335 lines
10 KiB
JavaScript
335 lines
10 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import axios from 'axios'
|
|
import RssFeeds from '../RssFeeds.vue'
|
|
import { useFeeds } from '../../composables/useFeeds'
|
|
|
|
vi.mock('axios')
|
|
|
|
// jsdom does not implement IntersectionObserver, but the component sets one up
|
|
// once the feed list has rendered.
|
|
class FakeIntersectionObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
|
|
|
describe('RssFeeds', () => {
|
|
beforeEach(() => {
|
|
localStorage.setItem('user-token', 'test-token')
|
|
localStorage.setItem('user-id', '7')
|
|
vi.clearAllMocks()
|
|
|
|
// useFeeds() returns module-level singleton refs shared across the whole
|
|
// app (and this spec file) — reset them so state doesn't leak between tests.
|
|
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
|
feeds.value = []
|
|
showMessage.value = false
|
|
message.value = ''
|
|
showModal.value = false
|
|
viewMode.value = 'list'
|
|
currentIndex.value = 0
|
|
layout.value = 'list'
|
|
})
|
|
|
|
it('fetches the current user articles and shows the empty state', async () => {
|
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
|
|
expect(wrapper.text()).toContain('All caught up')
|
|
})
|
|
|
|
it('renders the fetched feed items', 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',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Article one')
|
|
expect(wrapper.text()).toContain('My Feed')
|
|
expect(wrapper.text()).not.toContain('All caught up')
|
|
})
|
|
|
|
it('renders the list as cards when the card layout is selected', 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',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const { layout } = useFeeds()
|
|
layout.value = 'cards'
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article').classes()).toContain('article--cards')
|
|
})
|
|
|
|
it('shows the full article content in cards with no truncation, growing the card to fit', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<img src="https://example.test/photo.jpg" alt="photo"><br>short summary',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
|
|
|
const { layout } = useFeeds()
|
|
layout.value = 'cards'
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
// Preview images are shown (not hidden/truncated) and the snippet isn't clamped.
|
|
expect(wrapper.find('.feed-content img').exists()).toBe(true)
|
|
expect(wrapper.find('.feed-content').classes()).not.toContain('feed-content--clamped')
|
|
expect(wrapper.text()).toContain('short summary')
|
|
|
|
await wrapper.find('.feed-title').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('full text')
|
|
})
|
|
|
|
it('sorts articles by date across feeds, newest first', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'Old Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Older article',
|
|
content: '<p>old</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
title: 'New Feed',
|
|
items: [
|
|
{
|
|
id: 2,
|
|
title: 'Newer article',
|
|
content: '<p>new</p>',
|
|
url: 'https://example.test/2',
|
|
timestamp: '2026-02-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
|
expect(titles).toEqual(['Newer article', 'Older article'])
|
|
})
|
|
|
|
it('shows a link to the original article until the readable version is loaded', async () => {
|
|
// The API returns each item with a short summary already in `content` —
|
|
// the link must key off the `readable` flag (set once Readability has
|
|
// parsed the full article), not off `content` truthiness.
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>short summary</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-01-01',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
const link = wrapper.find('.feed-original-link a')
|
|
expect(link.exists()).toBe(true)
|
|
expect(link.attributes('href')).toBe('https://example.test/1')
|
|
expect(link.attributes('target')).toBe('_blank')
|
|
|
|
await wrapper.find('.feed-title').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.feed-original-link a').exists()).toBe(false)
|
|
})
|
|
|
|
it('switches to article view and navigates between articles', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>one</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-02-01 10:00:00',
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Article two',
|
|
content: '<p>two</p>',
|
|
url: 'https://example.test/2',
|
|
timestamp: '2026-01-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
axios.post.mockResolvedValue({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
// The view-toggle button now lives in AppNav's hamburger menu, not here —
|
|
// switch modes directly through the shared composable, as AppNav would.
|
|
useFeeds().toggleViewMode()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
// Same as in list view: the readable content is loaded on demand by
|
|
// clicking the headline, not fetched automatically on entering the view.
|
|
expect(axios.post).not.toHaveBeenCalled()
|
|
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
|
|
|
await wrapper.find('.article-single .article-feature__title').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
|
|
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(false)
|
|
|
|
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
|
|
|
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
|
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
|
|
|
|
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
|
})
|
|
|
|
it('drops articles read while paging through article view once back in the list', async () => {
|
|
axios.get.mockResolvedValueOnce({
|
|
data: {
|
|
feeds: [
|
|
{
|
|
title: 'My Feed',
|
|
items: [
|
|
{
|
|
id: 1,
|
|
title: 'Article one',
|
|
content: '<p>one</p>',
|
|
url: 'https://example.test/1',
|
|
timestamp: '2026-03-01 10:00:00',
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Article two',
|
|
content: '<p>two</p>',
|
|
url: 'https://example.test/2',
|
|
timestamp: '2026-02-01 10:00:00',
|
|
},
|
|
{
|
|
id: 3,
|
|
title: 'Article three',
|
|
content: '<p>three</p>',
|
|
url: 'https://example.test/3',
|
|
timestamp: '2026-01-01 10:00:00',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
axios.put.mockResolvedValue({ status: 200 })
|
|
|
|
const wrapper = mount(RssFeeds)
|
|
await flushPromises()
|
|
|
|
const { toggleViewMode, leaveArticleView } = useFeeds()
|
|
|
|
// Enter article view (marks "Article one" read), page forward to "Article
|
|
// two" (marks it read too), then leave without visiting "Article three".
|
|
toggleViewMode()
|
|
await flushPromises()
|
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
|
await flushPromises()
|
|
|
|
leaveArticleView()
|
|
await flushPromises()
|
|
|
|
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
|
expect(titles).toEqual(['Article three'])
|
|
})
|
|
})
|