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) 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, showModal, viewMode, currentIndex, layout } = useFeeds() feeds.value = [] showMessage.value = false message.value = '' showModal.value = false 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: '

hello

', 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: '

hello

', 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: 'photo
short summary', url: 'https://example.test/1', timestamp: '2026-01-01', }, ], }, ], }, }) axios.post.mockResolvedValueOnce({ data: { content: '

full text

' } }) 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: '

old

', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00', }, ], }, { title: 'New Feed', items: [ { id: 2, title: 'Newer article', content: '

new

', 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('shows a link to the original article until the readable version is loaded', async () => { // The API returns each item with a short summary already in `content` — // the link must key off the `readable` flag (set once Readability has // parsed the full article), not off `content` truthiness. axios.get.mockResolvedValueOnce({ data: { feeds: [ { title: 'My Feed', items: [ { id: 1, title: 'Article one', content: '

short summary

', url: 'https://example.test/1', timestamp: '2026-01-01', }, ], }, ], }, }) axios.post.mockResolvedValueOnce({ data: { content: '

full text

' } }) 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() expect(wrapper.find('.feed-original-link a').exists()).toBe(false) }) 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: '

one

', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00', }, { id: 2, title: 'Article two', content: '

two

', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00', }, ], }, ], }, }) axios.put.mockResolvedValue({ status: 200 }) axios.post.mockResolvedValue({ data: { content: '

full text

' } }) 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 .feed-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. expect(axios.post).not.toHaveBeenCalled() expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true) await wrapper.find('.article-single .feed-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(false) 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 .feed-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 .feed-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: '

one

', url: 'https://example.test/1', timestamp: '2026-03-01 10:00:00', }, { id: 2, title: 'Article two', content: '

two

', url: 'https://example.test/2', timestamp: '2026-02-01 10:00:00', }, { id: 3, title: 'Article three', content: '

three

', 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']) }) })