Files
rss-reader/vue/src/composables/__tests__/useFeeds.spec.js
T
2026-06-10 19:22:24 +02:00

249 lines
9.7 KiB
JavaScript

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