hamburger menu, article view

This commit is contained in:
2026-06-08 06:39:47 +02:00
parent 39f08c7218
commit b4fc86302f
8 changed files with 784 additions and 203 deletions
+61 -2
View File
@@ -1,7 +1,11 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import AppNav from '../AppNav.vue'
import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios')
describe('AppNav', () => {
let router
@@ -9,6 +13,13 @@ describe('AppNav', () => {
beforeEach(async () => {
localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
const { feeds, showMessage, message, showModal } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
router = createRouter({
history: createWebHistory(),
@@ -21,8 +32,27 @@ describe('AppNav', () => {
await router.isReady()
})
it('clears stored credentials and redirects to login on logout', async () => {
async function mountWithMenuOpen() {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
await wrapper.find('.app-nav__hamburger').trigger('click')
await flushPromises()
return wrapper
}
it('toggles the menu open and closed via the hamburger button', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
await wrapper.find('.app-nav__hamburger').trigger('click')
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
await wrapper.find('.app-nav__hamburger').trigger('click')
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('clears stored credentials and redirects to login on logout', async () => {
const wrapper = await mountWithMenuOpen()
await wrapper.find('.app-nav__logout').trigger('click')
await flushPromises()
@@ -31,4 +61,33 @@ describe('AppNav', () => {
expect(localStorage.getItem('user-id')).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
it('triggers a sync from the menu', async () => {
axios.get.mockResolvedValue({ data: { feeds: [] } })
axios.post.mockResolvedValueOnce({ status: 200 })
const wrapper = await mountWithMenuOpen()
const syncButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Sync')
await syncButton.trigger('click')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/article/sync',
{ user_id: 7 },
expect.anything(),
)
// Menu auto-closes after an action
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('opens the add-feed modal from the menu', async () => {
const wrapper = await mountWithMenuOpen()
const { showModal } = useFeeds()
const addButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Add RSS')
await addButton.trigger('click')
expect(showModal.value).toBe(true)
})
})
+100 -9
View File
@@ -2,6 +2,7 @@ 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')
@@ -19,6 +20,14 @@ describe('RssFeeds', () => {
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 } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
})
it('fetches the current user articles and shows the empty state', async () => {
@@ -98,20 +107,102 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Newer article', 'Older article'])
})
it('syncs feeds for the current user', async () => {
axios.get.mockResolvedValue({ data: { feeds: [] } })
axios.post.mockResolvedValueOnce({ status: 200 })
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()
await wrapper.find('.feed-actions p').trigger('click')
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(axios.post).toHaveBeenCalledWith(
'/api/v1/article/sync',
{ user_id: 7 },
expect.anything(),
)
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()
await wrapper.find('.view-toggle__btn').trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .feed-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 .feed-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 .feed-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 .feed-title').text()).toBe('Article one')
})
})