249 lines
9.7 KiB
JavaScript
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')
|
|
})
|
|
})
|