import { describe, it, expect, vi, beforeEach } from 'vitest' import { flushPromises } from '@vue/test-utils' import axios from 'axios' import { useFeeds } from '../useFeeds' vi.mock('axios') class FakeIntersectionObserver { observe() {} unobserve() {} disconnect() {} } vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) describe('useFeeds', () => { const { feeds, allItems, feedFilter, feedTitles, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead } = useFeeds() beforeEach(() => { localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-id', '7') vi.clearAllMocks() feeds.value = [] allItems.value = [] feedFilter.value = null showMessage.value = false message.value = '' showModal.value = false }) it('fetches and flattens articles for the current user', 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 10:00:00', }, ], }, ], }, }) await fetchData() expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything()) expect(feeds.value).toHaveLength(1) expect(feeds.value[0]).toMatchObject({ title: 'Article one', feedTitle: 'My Feed' }) }) it('sorts articles by timestamp across feeds, newest first', async () => { axios.get.mockResolvedValueOnce({ data: { feeds: [ { title: 'Old Feed', items: [ { id: 1, title: 'Older article', content: '', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' }, ], }, { title: 'New Feed', items: [ { id: 2, title: 'Newer article', content: '', url: 'https://example.test/2', timestamp: '2026-02-01 10:00:00' }, ], }, ], }, }) await fetchData() expect(feeds.value.map(f => f.title)).toEqual(['Newer article', 'Older article']) }) it('syncs feeds for the current user and refetches', async () => { axios.post.mockResolvedValueOnce({ status: 200 }) axios.get.mockResolvedValue({ data: { feeds: [] } }) await sync() expect(axios.post).toHaveBeenCalledWith( '/api/v1/article/sync', { user_id: 7 }, expect.anything(), ) expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything()) }) it('marks the correct articles read when several scroll out of view in one batch', async () => { feeds.value = [ { id: 101, title: 'First' }, { id: 102, title: 'Second' }, { id: 103, title: 'Third' }, ] setInitialLoad(true) axios.put.mockResolvedValue({ status: 200 }) // Both the first and second articles scrolled above the viewport in the // same IntersectionObserver callback — their `target.id` reflects their // original render-time indices (0 and 1). await handleIntersection([ { isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } }, { isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } }, ]) expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/101', null, expect.anything()) expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/102', null, expect.anything()) expect(axios.put).not.toHaveBeenCalledWith('/api/v1/article/read/103', null, expect.anything()) expect(feeds.value).toEqual([{ id: 103, title: 'Third' }]) setInitialLoad(false) }) describe('feed filter', () => { const twoFeedsResponse = { data: { feeds: [ { title: 'Feed A', items: [ { id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }, { id: 3, title: 'A2', content: '', url: 'https://example.test/a2', timestamp: '2026-01-03 10:00:00' }, ], }, { title: 'Feed B', items: [ { id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' }, ], }, ], }, } it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => { axios.get.mockResolvedValueOnce(twoFeedsResponse) await fetchData() // Options are the distinct feed titles, sorted. expect(feedTitles.value).toEqual(['Feed A', 'Feed B']) // Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1). expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1]) await setFeedFilter('Feed A') expect(feeds.value.map(f => f.id)).toEqual([3, 1]) await setFeedFilter(null) expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1]) }) it('does not resurrect read articles when the filter is cleared', async () => { axios.get.mockResolvedValueOnce(twoFeedsResponse) axios.put.mockResolvedValue({ status: 200 }) await fetchData() await setFeedFilter('Feed A') setInitialLoad(true) // The first Feed A article scrolls above the viewport → marked read and // dropped from both the filtered list and the master. await handleIntersection([ { isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } }, ]) setInitialLoad(false) expect(feeds.value.map(f => f.id)).toEqual([1]) await setFeedFilter(null) // Article 3 stays gone; the still-unread articles remain, newest first. expect(feeds.value.map(f => f.id)).toEqual([2, 1]) }) it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => { axios.get.mockResolvedValueOnce(twoFeedsResponse) axios.put.mockResolvedValue({ status: 200 }) await fetchData() await setFeedFilter('Feed B') expect(feeds.value.map(f => f.id)).toEqual([2]) // Mark all (visible = just Feed B) read — Feed B has no unread items left. vi.spyOn(window, 'confirm').mockReturnValue(true) await markAllRead() // The list empties, but the filter stays put and the feed remains a // selectable option (so the