The Add RSS modal is now opened from a button directly above the feed list in AdminFeeds.vue instead of the hamburger menu, so feed creation lives alongside the rest of feed management. AppNav.vue no longer renders it. AddUrl.vue emits an `added` event on a successful save; AdminFeeds.vue listens for it and reloads its feed list, so a newly added feed shows up immediately without leaving the admin page. Also fixes a bug this move surfaced: the modal's "Close" button had no `type`, so inside the form it defaulted to type="submit" — clicking Close on a filled-in form silently created the feed.
537 lines
17 KiB
JavaScript
537 lines
17 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import axios from 'axios'
|
|
import RssFeeds from '../RssFeeds.vue'
|
|
import { useFeeds } from '../../composables/useFeeds'
|
|
|
|
vi.mock('axios')
|
|
|
|
// jsdom does not implement IntersectionObserver, but the component sets one up
|
|
// once the feed list has rendered.
|
|
class FakeIntersectionObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
|
|
|
// Shared by the swipe-navigation tests below, which all start from the same
|
|
// two-article article view.
|
|
function twoArticleFeed() {
|
|
return [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{ id: 1, title: 'Article one', content: '<p>one</p>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
|
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
|
|
],
|
|
},
|
|
]
|
|
}
|
|
|
|
describe('RssFeeds', () => {
|
|
beforeEach(() => {
|
|
localStorage.setItem('user-token', 'test-token')
|
|
localStorage.setItem('user-id', '7')
|
|
vi.clearAllMocks()
|
|
|
|
// useFeeds() returns module-level singleton refs shared across the whole
|
|
// app (and this spec file) — reset them so state doesn't leak between tests.
|
|
const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
|
feeds.value = []
|
|
showMessage.value = false
|
|
message.value = ''
|
|
viewMode.value = 'list'
|
|
currentIndex.value = 0
|
|
layout.value = 'list'
|
|
})
|
|
|
|
it('fetches the current user articles and shows the empty state', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
|
|
expect(wrapper.text()).toContain('All caught up')
|
|
})
|
|
|
|
it('renders the fetched feed items', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>hello</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Article one')
|
|
expect(wrapper.text()).toContain('My Feed')
|
|
expect(wrapper.text()).not.toContain('All caught up')
|
|
})
|
|
|
|
it('renders the list as cards when the card layout is selected', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>hello</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const { layout } = useFeeds()
|
|
layout.value = 'cards'
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article').classes()).toContain('article--cards')
|
|
})
|
|
|
|
it('shows the full article content in cards with no truncation, growing the card to fit', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<img src="https://example.test/photo.jpg" alt="photo"><br>short summary',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
// axios.post is also hit by the sync triggered on mount, so branch on the
|
|
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
|
axios.post.mockImplementation((url) => {
|
|
if (url === '/api/v1/article/sync') {
|
|
return Promise.resolve({ status: 200 })
|
|
}
|
|
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
|
})
|
|
|
|
const { layout } = useFeeds()
|
|
layout.value = 'cards'
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
// Preview images are shown (not hidden/truncated) and the snippet isn't clamped.
|
|
expect(wrapper.find('.feed-content img').exists()).toBe(true)
|
|
expect(wrapper.find('.feed-content').classes()).not.toContain('feed-content--clamped')
|
|
expect(wrapper.text()).toContain('short summary')
|
|
|
|
await wrapper.find('.feed-title').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('full text')
|
|
})
|
|
|
|
it('sorts articles by date across feeds, newest first', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'Old Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Older article',
|
|
content: '<p>old</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
title: 'New Feed',
|
|
items: [
|
|
{
|
|
id: 2,
|
|
title: 'Newer article',
|
|
content: '<p>new</p>',
|
|
url: 'https://example.test/2',
|
|
timestamp: '2026-02-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
|
expect(titles).toEqual(['Newer article', 'Older article'])
|
|
})
|
|
|
|
it('keeps a link to the original article visible after the readable version is loaded', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>short summary</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
// axios.post is also hit by the sync triggered on mount, so branch on the
|
|
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
|
axios.post.mockImplementation((url) => {
|
|
if (url === '/api/v1/article/sync') {
|
|
return Promise.resolve({ status: 200 })
|
|
}
|
|
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
|
})
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
const link = wrapper.find('.feed-original-link a')
|
|
expect(link.exists()).toBe(true)
|
|
expect(link.attributes('href')).toBe('https://example.test/1')
|
|
expect(link.attributes('target')).toBe('_blank')
|
|
|
|
await wrapper.find('.feed-title').trigger('click')
|
|
await flushPromises()
|
|
|
|
const linkAfter = wrapper.find('.feed-original-link a')
|
|
expect(linkAfter.exists()).toBe(true)
|
|
expect(linkAfter.attributes('href')).toBe('https://example.test/1')
|
|
})
|
|
|
|
it('switches to article view and navigates between articles', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>one</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-02-01 10:00:00',
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Article two',
|
|
content: '<p>two</p>',
|
|
url: 'https://example.test/2',
|
|
timestamp: '2026-01-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
axios.post.mockResolvedValue({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
// The view-toggle button now lives in AppNav's hamburger menu, not here —
|
|
// switch modes directly through the shared composable, as AppNav would.
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
// Same as in list view: the readable content is loaded on demand by
|
|
// clicking the headline, not fetched automatically on entering the view.
|
|
// (axios.post is also hit by the sync triggered on mount.)
|
|
expect(axios.post).not.toHaveBeenCalledWith('/api/v1/article/read', expect.anything(), expect.anything())
|
|
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
|
|
|
await wrapper.find('.article-single .article-feature__title').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
|
|
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
|
|
|
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
|
|
|
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
|
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
|
|
|
|
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
})
|
|
|
|
it('drops articles read while paging through article view once back in the list', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>one</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-03-01 10:00:00',
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Article two',
|
|
content: '<p>two</p>',
|
|
url: 'https://example.test/2',
|
|
timestamp: '2026-02-01 10:00:00',
|
|
},
|
|
{
|
|
id: 3,
|
|
title: 'Article three',
|
|
content: '<p>three</p>',
|
|
url: 'https://example.test/3',
|
|
timestamp: '2026-01-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
const { toggleViewMode, leaveArticleView } = useFeeds()
|
|
|
|
// Enter article view (marks "Article one" read), page forward to "Article
|
|
// two" (marks it read too), then leave without visiting "Article three".
|
|
toggleViewMode()
|
|
await flushPromises()
|
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
|
await flushPromises()
|
|
|
|
leaveArticleView()
|
|
await flushPromises()
|
|
|
|
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
|
expect(titles).toEqual(['Article three'])
|
|
})
|
|
|
|
it('swipes left to navigate to the next article', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
const article = wrapper.find('.article-single')
|
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('swipes right to navigate to the previous article', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
|
|
|
const article = wrapper.find('.article-single')
|
|
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
|
|
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
const article = wrapper.find('.article-single')
|
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
|
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not navigate on a mostly-vertical drag', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
const article = wrapper.find('.article-single')
|
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
|
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
const article = wrapper.find('.article-single')
|
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
const article = wrapper.find('.article-single')
|
|
await article.trigger('touchstart', {
|
|
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
|
|
})
|
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not navigate when a swipe starts inside a code block', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
|
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
|
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not throw when swiping with an empty feed list', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
const article = wrapper.find('.article-single')
|
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('All caught up')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
})
|