Files
rss-reader/vue/src/composables/__tests__/useFeeds.spec.js
T
2026-07-12 12:28:32 +02:00

354 lines
14 KiB
JavaScript

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: '<p>hello</p>',
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 <select> never dangles) even though it no
// longer has unread items.
expect(feeds.value).toEqual([])
expect(feedFilter.value).toBe('Feed B')
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
// Switching away drops the now-empty feed from the options.
await setFeedFilter(null)
expect(feedTitles.value).toEqual(['Feed A'])
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
})
it('counts unread items in the selected feed when a filter is active, else the global total', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
// No filter: global unread total across both feeds.
expect(unreadCount.value).toBe(3)
// Filtered: only the selected feed's unread items.
await setFeedFilter('Feed B')
expect(unreadCount.value).toBe(1)
await setFeedFilter('Feed A')
expect(unreadCount.value).toBe(2)
await setFeedFilter(null)
expect(unreadCount.value).toBe(3)
})
})
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: `<html><body><article>
<h2 aria-label="Eingebettetes Video — Iran-Krieg belastet Wirtschaft und Märkte in Deutschland">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
Iran-Krieg belastet Wirtschaft und Märkte in Deutschland
</h2>
<p>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.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('Eingebettetes Video')
expect(feeds.value[0].content).not.toContain('<svg')
})
it('strips leftover embedded-audio 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: `<html><body><article>
<h2 aria-label="Eingebetteter Audio-Beitrag — Der Gender Pay Gap existiert noch immer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
Der Gender Pay Gap existiert noch immer
</h2>
<p>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.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('Eingebetteter Audio-Beitrag')
expect(feeds.value[0].content).not.toContain('<svg')
})
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.dw.com/en/article-one/a-1',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<img data-format="MASTER_LANDSCAPE" data-id="76212061"
data-url="https://static.dw.com/image/76212061_\${formatId}.jpg"
data-aspect-ratio="16/9" alt="Merz and Trump"
src="https://static.dw.com/image/76212061_$%7BformatId%7D.jpg">
<p>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.</p>
</article></body></html>`,
},
})
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: `<html><body><article>
<img data-format="MASTER_LANDSCAPE" data-id="76212061"
data-url="https://static.dw.com/image/76212061_\${size}.jpg"
data-src="https://static.dw.com/image/76212061_\${size}.jpg"
alt="Merz and Trump" src="data:image/gif;base64,placeholder">
<p>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.</p>
</article></body></html>`,
},
})
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: `<html><body><article>
<img data-url="https://example.test/img_\${size}.jpg" src="https://example.test/img_%7Bsize%7D.jpg">
<p>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.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('%7Bsize%7D')
expect(feeds.value[0].content).not.toContain('<img')
})
})