import { describe, it, expect, vi, beforeEach } from 'vitest' import axios from 'axios' import { useFeeds } from '../useFeeds' vi.mock('axios') class FakeIntersectionObserver { observe() {} unobserve() {} disconnect() {} } vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) describe('useFeeds', () => { const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds() beforeEach(() => { localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-id', '7') vi.clearAllMocks() feeds.value = [] 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) }) it('strips leftover embedded-video placeholder headings', async () => { feeds.value = [{ id: 1, title: 'Article one', url: 'https://www.dw.com/en/article-one/a-1', content: '', }] axios.post.mockResolvedValueOnce({ data: { content: `

Iran-Krieg belastet Wirtschaft und Märkte in Deutschland

some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.

`, }, }) await getReadable(feeds.value[0], 0) expect(feeds.value[0].content).not.toContain('Eingebettetes Video') expect(feeds.value[0].content).not.toContain(' { feeds.value = [{ id: 1, title: 'Article one', url: 'https://www.dw.com/en/article-one/a-1', content: '', }] axios.post.mockResolvedValueOnce({ data: { content: `

Der Gender Pay Gap existiert noch immer

some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.

`, }, }) await getReadable(feeds.value[0], 0) expect(feeds.value[0].content).not.toContain('Eingebetteter Audio-Beitrag') expect(feeds.value[0].content).not.toContain(' { feeds.value = [{ id: 1, title: 'Article one', url: 'https://www.dw.com/en/article-one/a-1', content: '', }] axios.post.mockResolvedValueOnce({ data: { content: `
Merz and Trump

some article text long enough for readability to keep the image and paragraph together in the parsed output, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes for the main article body.

`, }, }) await getReadable(feeds.value[0], 0) // "MASTER_LANDSCAPE" is a symbolic name from DW's CMS, not a valid value // for the CDN's numeric `formatId` — it must be mapped to "6" or the // resulting URL 400s and the image fails to load. expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_6.jpg"') // The rendered `src` is what matters — `data-url` retaining the raw // template is harmless since browsers don't load images from data-* attrs. expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/) }) it('resolves templated images regardless of the placeholder name and scrubs other lazy-load attributes', async () => { feeds.value = [{ id: 1, title: 'Article one', url: 'https://www.dw.com/en/article-one/a-1', content: '', }] axios.post.mockResolvedValueOnce({ data: { content: `
Merz and Trump

some article text long enough for readability to keep the image and paragraph together in the parsed output, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes for the main article body.

`, }, }) await getReadable(feeds.value[0], 0) // `${size}` isn't the hardcoded `${formatId}` placeholder, but `data-format` // is still the right substitution — and lazy-load attributes like `data-src` // (which Readability may promote into `src`) must be cleaned too, not just // `data-url`, so a stale template can't resurface in the rendered output. expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/) }) it('drops unresolvable templated images instead of leaving a broken src', async () => { feeds.value = [{ id: 1, title: 'Article one', url: 'https://example.test/article-one', content: '', }] axios.post.mockResolvedValueOnce({ data: { content: `

some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.

`, }, }) await getReadable(feeds.value[0], 0) expect(feeds.value[0].content).not.toContain('%7Bsize%7D') expect(feeds.value[0].content).not.toContain('