Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c290513ed1 | ||
|
|
41a17eba6d | ||
|
|
169c294e62 | ||
|
|
e2df60d112 | ||
|
|
5f816bb0f5 | ||
|
|
67b970df9b |
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const showModal = ref(false)
|
||||
const feeds = ref([])
|
||||
const error = ref('')
|
||||
|
||||
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
|
||||
<template>
|
||||
<div class="admin">
|
||||
<h1 class="admin__heading">Admin</h1>
|
||||
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
|
||||
<p v-if="error" class="admin__error">{{ error }}</p>
|
||||
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
||||
<ul v-else class="admin__list">
|
||||
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
|
||||
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Teleport to="body">
|
||||
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
|
||||
<template #header>
|
||||
<h3>Add RSS Feed</h3>
|
||||
</template>
|
||||
</Modal>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.admin__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.5rem 1.1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin__add:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.admin__error,
|
||||
.admin__empty {
|
||||
opacity: 0.6;
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||
import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
|
||||
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
|
||||
|
||||
const headerRef = ref(null)
|
||||
|
||||
@@ -147,11 +146,6 @@ function handleMarkAllRead() {
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
showModal.value = true
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function handleToggleViewMode() {
|
||||
toggleViewMode()
|
||||
closeMenu()
|
||||
@@ -212,20 +206,11 @@ function handleToggleLayout() {
|
||||
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
||||
</template>
|
||||
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
||||
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
|
||||
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
||||
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
</Transition>
|
||||
|
||||
<Teleport to="body">
|
||||
<Modal :show="showModal" @close="showModal = false">
|
||||
<template #header>
|
||||
<h3>Add RSS Feed</h3>
|
||||
</template>
|
||||
</Modal>
|
||||
</Teleport>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
disconnectObserver,
|
||||
setInitialLoad,
|
||||
showMessageForXSeconds,
|
||||
markCurrentArticleRead,
|
||||
} = useFeeds()
|
||||
|
||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||
@@ -122,6 +123,11 @@ onBeforeUnmount(() => {
|
||||
onMounted(async () => {
|
||||
setInitialLoad(false)
|
||||
await fetchData()
|
||||
// viewMode persists across reloads (see useFeeds.js) — if we're booting
|
||||
// straight into article view, the article now on screen needs the same
|
||||
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
|
||||
// give it when reached by in-session navigation.
|
||||
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||
sync(true)
|
||||
setTimeout(function () {
|
||||
setInitialLoad(true)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import axios from 'axios'
|
||||
import AdminFeeds from '../AdminFeeds.vue'
|
||||
import AddUrl from '../modal/AddUrl.vue'
|
||||
|
||||
vi.mock('axios')
|
||||
|
||||
describe('AdminFeeds', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
localStorage.setItem('user-id', '7')
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Each mount renders the AddUrl modal via <Teleport to="body">, which
|
||||
// survives outside the wrapper's own element — unmount every mounted
|
||||
// instance so a leftover doesn't bleed into a later test's document.body.
|
||||
let mountedWrappers = []
|
||||
function mountAdmin() {
|
||||
const wrapper = mount(AdminFeeds)
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mountedWrappers) {
|
||||
try {
|
||||
wrapper.unmount()
|
||||
} catch {
|
||||
// already unmounted by the test itself — fine
|
||||
}
|
||||
}
|
||||
mountedWrappers = []
|
||||
})
|
||||
|
||||
it('fetches and renders the feed list on mount', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||
})
|
||||
|
||||
const wrapper = mountAdmin()
|
||||
await flushPromises()
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
|
||||
expect(wrapper.text()).toContain('Feed A')
|
||||
})
|
||||
|
||||
it('shows the empty state when there are no feeds', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
|
||||
const wrapper = mountAdmin()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||
})
|
||||
|
||||
it('shows an error message when the feed list fails to load', async () => {
|
||||
axios.get.mockRejectedValueOnce(new Error('Network Error'))
|
||||
|
||||
const wrapper = mountAdmin()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to load feeds.')
|
||||
})
|
||||
|
||||
it('deletes a feed after confirmation', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||
})
|
||||
axios.delete.mockResolvedValueOnce({ status: 204 })
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
const wrapper = mountAdmin()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.admin__delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
|
||||
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens the add-feed modal from the Add RSS button', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
|
||||
const wrapper = mountAdmin()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.admin__add').trigger('click')
|
||||
|
||||
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
|
||||
})
|
||||
|
||||
it('reloads the feed list when the modal reports a feed was added', async () => {
|
||||
axios.get
|
||||
.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
|
||||
|
||||
const wrapper = mountAdmin()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||
|
||||
await wrapper.findComponent(AddUrl).vm.$emit('added')
|
||||
await flushPromises()
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('Feed B')
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,7 @@ describe('AppNav', () => {
|
||||
localStorage.setItem('user-id', '7')
|
||||
vi.clearAllMocks()
|
||||
|
||||
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
||||
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
||||
feeds.value = []
|
||||
allItems.value = []
|
||||
feedFilter.value = null
|
||||
@@ -34,7 +34,6 @@ describe('AppNav', () => {
|
||||
lastProgrammaticScroll.value = 0
|
||||
showMessage.value = false
|
||||
message.value = ''
|
||||
showModal.value = false
|
||||
viewMode.value = 'list'
|
||||
currentIndex.value = 0
|
||||
layout.value = 'list'
|
||||
@@ -130,16 +129,6 @@ describe('AppNav', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
it('switches the view mode from the menu and closes it', async () => {
|
||||
const wrapper = await mountWithMenuOpen()
|
||||
const { viewMode } = useFeeds()
|
||||
|
||||
@@ -37,11 +37,10 @@ describe('RssFeeds', () => {
|
||||
|
||||
// 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()
|
||||
const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
||||
feeds.value = []
|
||||
showMessage.value = false
|
||||
message.value = ''
|
||||
showModal.value = false
|
||||
viewMode.value = 'list'
|
||||
currentIndex.value = 0
|
||||
layout.value = 'list'
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import '@/assets/modal.css';
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
show: Boolean
|
||||
})
|
||||
const emit = defineEmits(['close', 'added'])
|
||||
const submitted = ref(false)
|
||||
const url = ref('')
|
||||
const title = ref('')
|
||||
@@ -30,6 +31,7 @@ async function save() {
|
||||
|
||||
console.log(response)
|
||||
output.value = 'saved successfully'
|
||||
emit('added')
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
output.value = error.message
|
||||
@@ -57,7 +59,7 @@ async function save() {
|
||||
<div class="modal-footer">
|
||||
<slot name="footer">
|
||||
<button type="submit">Save</button>
|
||||
<button class="modal-default-button" @click="$emit('close')">Close</button>
|
||||
<button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
|
||||
</slot>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
|
||||
expect.anything(),
|
||||
)
|
||||
expect(wrapper.text()).toContain('saved successfully')
|
||||
expect(wrapper.emitted('added')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces the error message when the request fails', async () => {
|
||||
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Network Error')
|
||||
expect(wrapper.emitted('added')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
|
||||
const wrapper = mount(AddUrl, { props: { show: true } })
|
||||
await wrapper.find('#url').setValue('https://example.test/feed.xml')
|
||||
await wrapper.find('#title').setValue('Example feed')
|
||||
|
||||
await wrapper.find('.modal-default-button').trigger('click')
|
||||
|
||||
expect(axios.post).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import axios from 'axios'
|
||||
import { useFeeds } from '../useFeeds'
|
||||
|
||||
@@ -13,7 +12,7 @@ class FakeIntersectionObserver {
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
describe('useFeeds', () => {
|
||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
|
||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver } = useFeeds()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
@@ -25,7 +24,7 @@ describe('useFeeds', () => {
|
||||
feedFilter.value = null
|
||||
showMessage.value = false
|
||||
message.value = ''
|
||||
showModal.value = false
|
||||
viewMode.value = 'list'
|
||||
})
|
||||
|
||||
it('fetches and flattens articles for the current user', async () => {
|
||||
@@ -178,6 +177,193 @@ describe('useFeeds', () => {
|
||||
setInitialLoad(false)
|
||||
})
|
||||
|
||||
describe('markRead resilience', () => {
|
||||
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
|
||||
// sync via this comment: if that backoff changes, update this too — too
|
||||
// small a value just makes the affected test's `await` hang until it
|
||||
// times out, rather than pass incorrectly.
|
||||
const RETRY_BACKOFF_SUM_MS = 300 + 1000
|
||||
|
||||
// A failed assertion before a test's own vi.useRealTimers() call would
|
||||
// otherwise leak fake timers into every later test in this file.
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('retries a failed mark-read PUT before giving up, with a request timeout set', async () => {
|
||||
vi.useFakeTimers()
|
||||
axios.put
|
||||
.mockRejectedValueOnce(new Error('network'))
|
||||
.mockRejectedValueOnce(new Error('network'))
|
||||
.mockResolvedValueOnce({ status: 200 })
|
||||
|
||||
const done = markRead(601)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(await done).toBe(true)
|
||||
expect(axios.put).toHaveBeenCalledTimes(3)
|
||||
expect(axios.put).toHaveBeenCalledWith(
|
||||
'/api/v1/article/read/601',
|
||||
null,
|
||||
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||
)
|
||||
expect(showMessage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reports failure once retries are exhausted, and stops withholding the item from a refetch', async () => {
|
||||
vi.useFakeTimers()
|
||||
axios.put.mockRejectedValue(new Error('network'))
|
||||
|
||||
const done = markRead(602)
|
||||
// Advance past just the two retry backoffs — not runAllTimersAsync(),
|
||||
// which would also fire showMessageForXSeconds's own 5s auto-hide timer
|
||||
// and reset showMessage before the assertion below.
|
||||
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
|
||||
|
||||
expect(await done).toBe(false)
|
||||
expect(axios.put).toHaveBeenCalledTimes(3)
|
||||
expect(showMessage.value).toBe(true)
|
||||
expect(message.value).toMatch(/could not mark/i)
|
||||
|
||||
// The mark never committed, so the item genuinely is still unread
|
||||
// server-side — a refetch must be free to show it again, not hide it
|
||||
// forever as "pending".
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
feeds: [{
|
||||
title: 'Feed A',
|
||||
items: [{ id: 602, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
await fetchData()
|
||||
expect(feeds.value.map(f => f.id)).toEqual([602])
|
||||
})
|
||||
|
||||
it('does not retry a non-retryable 4xx mark-read failure', async () => {
|
||||
const error = new Error('Not Found')
|
||||
error.response = { status: 404 }
|
||||
axios.put.mockRejectedValueOnce(error)
|
||||
|
||||
const result = await markRead(605)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(axios.put).toHaveBeenCalledTimes(1)
|
||||
expect(showMessage.value).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a still-unconfirmed read article out of a concurrent background refetch', async () => {
|
||||
let resolvePut
|
||||
axios.put.mockReturnValueOnce(new Promise(resolve => { resolvePut = resolve }))
|
||||
|
||||
const done = markRead(603) // simulates a page-turn's fire-and-forget mark-read, still in flight
|
||||
|
||||
// A background sync's trailing fetchData() resolves while that PUT is
|
||||
// still pending — the server hasn't committed the read yet, so it's
|
||||
// still in the response.
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
feeds: [{
|
||||
title: 'Feed A',
|
||||
items: [{ id: 603, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
await fetchData()
|
||||
|
||||
expect(feeds.value).toEqual([])
|
||||
|
||||
resolvePut({ status: 200 })
|
||||
await done
|
||||
})
|
||||
|
||||
it('excludes an item whose mark-read PUT settles between a refetch\'s request and its response', async () => {
|
||||
// The PUT commits (and clears the pending id) while the GET below is
|
||||
// still in flight — the GET's response nonetheless reflects the DB as
|
||||
// of before that commit, still reporting the item unread. A snapshot
|
||||
// taken only once the GET resolves would miss this; one taken before
|
||||
// it's dispatched (unioned with the live state) catches it too.
|
||||
axios.put.mockResolvedValueOnce({ status: 200 })
|
||||
const markDone = markRead(604)
|
||||
|
||||
let resolveGet
|
||||
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||
const fetchPromise = fetchData()
|
||||
|
||||
await markDone // the PUT (and pending-id cleanup) settles first...
|
||||
|
||||
resolveGet({ // ...then the already-in-flight GET resolves, still unread.
|
||||
data: {
|
||||
feeds: [{
|
||||
title: 'Feed A',
|
||||
items: [{ id: 604, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
await fetchPromise
|
||||
|
||||
expect(feeds.value).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an item withheld until every overlapping markRead() call for it settles', async () => {
|
||||
// nextArticle()/prevArticle() can both re-mark the same article (page
|
||||
// forward, back, forward again) — two overlapping calls for one id.
|
||||
let resolveFirst
|
||||
let resolveSecond
|
||||
axios.put
|
||||
.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve }))
|
||||
.mockReturnValueOnce(new Promise(resolve => { resolveSecond = resolve }))
|
||||
|
||||
const first = markRead(606)
|
||||
const second = markRead(606)
|
||||
|
||||
resolveSecond({ status: 200 }) // the second (later) call settles first
|
||||
await second
|
||||
|
||||
// The first call is still in flight — a refetch must still withhold it.
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
feeds: [{
|
||||
title: 'Feed A',
|
||||
items: [{ id: 606, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
await fetchData()
|
||||
expect(feeds.value).toEqual([])
|
||||
|
||||
resolveFirst({ status: 200 })
|
||||
await first
|
||||
})
|
||||
})
|
||||
|
||||
describe('markAllRead resilience', () => {
|
||||
const RETRY_BACKOFF_SUM_MS = 300 + 1000
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reports a partial failure instead of claiming full success', async () => {
|
||||
feeds.value = [
|
||||
{ id: 701, title: 'First' },
|
||||
{ id: 702, title: 'Second' },
|
||||
]
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
axios.put
|
||||
.mockResolvedValueOnce({ status: 200 }) // 701 succeeds
|
||||
.mockRejectedValue(new Error('network')) // 702 fails every attempt
|
||||
|
||||
vi.useFakeTimers()
|
||||
const done = markAllRead()
|
||||
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
|
||||
await done
|
||||
|
||||
expect(message.value).toContain('1 of 2')
|
||||
expect(message.value).not.toBe('All articles marked as read.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('feed filter', () => {
|
||||
const twoFeedsResponse = {
|
||||
data: {
|
||||
@@ -237,6 +423,60 @@ describe('useFeeds', () => {
|
||||
expect(feeds.value.map(f => f.id)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('does not resurrect an article-view read-in-place article when re-selecting its feed', 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])
|
||||
|
||||
// Article view marks the displayed article read in place (feed.read =
|
||||
// true) without removing it, so currentIndex stays valid while paging —
|
||||
// see markCurrentArticleRead. It's still in the list at this point.
|
||||
markCurrentArticleRead()
|
||||
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||
|
||||
// Switching to another feed and back must not resurrect it as if it
|
||||
// were still unread.
|
||||
await setFeedFilter('Feed A')
|
||||
await setFeedFilter('Feed B')
|
||||
|
||||
expect(feeds.value).toEqual([])
|
||||
})
|
||||
|
||||
it('marks the newly displayed article read when the filter is switched while in article view', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
await fetchData()
|
||||
viewMode.value = 'article'
|
||||
|
||||
// Switching into Feed B while in article view immediately displays its
|
||||
// one article's full content — that counts as "viewed", so it's marked
|
||||
// read the same way toggleViewMode()/nextArticle()/prevArticle() do.
|
||||
await setFeedFilter('Feed B')
|
||||
|
||||
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
|
||||
expect(feeds.value[0].read).toBe(true)
|
||||
|
||||
// It doesn't resurface either, once dropped on the next switch.
|
||||
await setFeedFilter('Feed A')
|
||||
await setFeedFilter('Feed B')
|
||||
expect(feeds.value).toEqual([])
|
||||
})
|
||||
|
||||
it('does not mark the newly displayed article read when the filter is switched in list view', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
await fetchData()
|
||||
|
||||
// List view only shows titles — switching filters there must not mark
|
||||
// anything read; that's still driven solely by scroll (handleIntersection).
|
||||
await setFeedFilter('Feed B')
|
||||
|
||||
expect(axios.put).not.toHaveBeenCalled()
|
||||
expect(feeds.value[0].read).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
@@ -57,6 +57,10 @@ describe('viewMode boot persistence', () => {
|
||||
expect(wrapper.find('.article-single').exists()).toBe(true)
|
||||
expect(wrapper.find('#article').exists()).toBe(false)
|
||||
|
||||
// The article shown right after reload must be marked read immediately —
|
||||
// not only once the user pages forward/back at least once.
|
||||
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
+118
-14
@@ -59,7 +59,6 @@ function refreshUnreadDisplay() {
|
||||
displayedFeedUnreadCounts.value = feedUnreadCounts.value
|
||||
}
|
||||
const message = ref('')
|
||||
const showModal = ref(false)
|
||||
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||
const currentIndex = ref(0)
|
||||
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
||||
@@ -228,12 +227,74 @@ async function getReadable(feed, index) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ids with a mark-read PUT in flight (or being retried) — see markRead() and
|
||||
// fetchData() below. A refcount rather than a plain Set: nextArticle()/
|
||||
// prevArticle() can both re-mark the same article (page forward, back,
|
||||
// forward again), so two overlapping markRead() calls for one id must both
|
||||
// finish before fetchData() is allowed to trust the server's word on it —
|
||||
// otherwise the first call to settle would clear the id out from under the
|
||||
// second, still-in-flight one.
|
||||
const pendingReadCounts = new Map()
|
||||
|
||||
function addPendingRead(id) {
|
||||
pendingReadCounts.set(id, (pendingReadCounts.get(id) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function removePendingRead(id) {
|
||||
const count = pendingReadCounts.get(id) ?? 0
|
||||
if (count <= 1) {
|
||||
pendingReadCounts.delete(id)
|
||||
} else {
|
||||
pendingReadCounts.set(id, count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Short backoff between retries of a failed mark-read PUT — each entry is the
|
||||
// wait before that retry attempt.
|
||||
const MARK_READ_RETRY_DELAYS_MS = [300, 1000]
|
||||
// Bounds how long a hung request (dead connection, captive portal — axios has
|
||||
// no default timeout) can keep an id "pending": without this, a request that
|
||||
// never settles would never leave pendingReadCounts, silently withholding
|
||||
// that article from every fetchData() for the rest of the session.
|
||||
const MARK_READ_TIMEOUT_MS = 10000
|
||||
|
||||
// 4xx responses (expired token, item not found/not owned — see
|
||||
// src/reader/mark_read.rs) mean the request itself is wrong and won't
|
||||
// succeed on retry; only a missing response (network error, timeout) or a
|
||||
// server-side/rate-limit status is worth retrying.
|
||||
function isRetryableMarkReadError(error) {
|
||||
const status = error.response?.status
|
||||
return status === undefined || status >= 500 || status === 429
|
||||
}
|
||||
|
||||
// Resolves to true once the article is confirmed read server-side, or false
|
||||
// once retries (if any) are exhausted / the error isn't retryable.
|
||||
async function markRead(id) {
|
||||
addPendingRead(id)
|
||||
try {
|
||||
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
|
||||
console.log(response.status)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error marking article read:', error)
|
||||
if (attempt >= MARK_READ_RETRY_DELAYS_MS.length || !isRetryableMarkReadError(error)) {
|
||||
// Out of retries, or a retry can't help — this mark never committed
|
||||
// server-side, so the item genuinely is still unread there. Surface
|
||||
// it rather than letting it silently vanish from the local list now
|
||||
// only to mysteriously reappear as unread later.
|
||||
showMessageForXSeconds('Could not mark an article as read. It may reappear as unread.', 5)
|
||||
return false
|
||||
}
|
||||
await delay(MARK_READ_RETRY_DELAYS_MS[attempt])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
removePendingRead(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +307,22 @@ function applyFilter() {
|
||||
: allItems.value.slice()
|
||||
}
|
||||
|
||||
// Article view marks an article read in place (feed.read = true) without
|
||||
// removing it — see markCurrentArticleRead — so currentIndex stays valid while
|
||||
// paging within that same list. Anything switches the list out from under
|
||||
// that index (leaving article view, or re-filtering while still in it) has to
|
||||
// drop those read-in-place articles from the master here, or they resurface:
|
||||
// re-selecting their feed would otherwise re-project them from allItems as if
|
||||
// still unread.
|
||||
function dropReadArticles() {
|
||||
// The feeds.value filter here is redundant for the setFeedFilter() caller
|
||||
// (applyFilter() overwrites feeds.value again right after) but is what
|
||||
// leaveArticleView() actually needs — kept shared rather than split into two
|
||||
// near-identical branches.
|
||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||
}
|
||||
|
||||
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
||||
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
||||
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
||||
@@ -254,9 +331,16 @@ function applyFilter() {
|
||||
async function setFeedFilter(title) {
|
||||
disconnectObserver()
|
||||
window.scrollTo(0, 0)
|
||||
dropReadArticles()
|
||||
feedFilter.value = title // null for "All feeds"
|
||||
currentIndex.value = 0
|
||||
applyFilter()
|
||||
// In article view the item at currentIndex is fully displayed, not just
|
||||
// listed — unlike a plain currentIndex reset from paging, this one really is
|
||||
// "shown" to the user, so mark it the same way toggleViewMode()/nextArticle()/
|
||||
// prevArticle() do. List view only shows titles here; its own read-marking
|
||||
// happens via scroll (handleIntersection), so this must not fire there.
|
||||
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||
refreshUnreadDisplay()
|
||||
await nextTick()
|
||||
setupIntersectionObserver()
|
||||
@@ -265,15 +349,28 @@ async function setFeedFilter(title) {
|
||||
const fetchData = async () => {
|
||||
const user_id = localStorage.getItem("user-id")
|
||||
try {
|
||||
// Snapshot ids pending *before* the GET goes out, not just when its
|
||||
// response comes back. A markRead() PUT that commits while this GET is
|
||||
// in flight is invisible to the check below (its id has already been
|
||||
// cleared by the time the response arrives) even though the server read
|
||||
// the DB before that commit and so still reported the item unread — this
|
||||
// snapshot catches that case too.
|
||||
const pendingBeforeFetch = new Set(pendingReadCounts.keys())
|
||||
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
|
||||
const items = [];
|
||||
response.data.feeds.forEach(feed => {
|
||||
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
|
||||
});
|
||||
// An item pending at either end of this request may not have committed
|
||||
// server-side yet, so the server can still report it unread here. Drop it
|
||||
// from this snapshot too — otherwise a concurrent background sync's
|
||||
// refetch (see sync()) would resurrect it as unread out from under the
|
||||
// user while markRead() is still confirming or retrying it.
|
||||
const freshItems = items.filter(item => !pendingBeforeFetch.has(item.id) && !pendingReadCounts.has(item.id));
|
||||
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
||||
// lexicographic comparison sorts them chronologically.
|
||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
allItems.value = items;
|
||||
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
allItems.value = freshItems;
|
||||
applyFilter();
|
||||
refreshUnreadDisplay();
|
||||
await nextTick();
|
||||
@@ -411,9 +508,18 @@ async function markAllRead() {
|
||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||
currentIndex.value = 0
|
||||
refreshUnreadDisplay()
|
||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
||||
await Promise.all(ids.map(id => markRead(id)))
|
||||
showMessageForXSeconds('All articles marked as read.', 5)
|
||||
// markRead() resolves to true/false rather than rejecting, so Promise.all
|
||||
// can't reject here — but a false means that article is still unread
|
||||
// server-side, which the message below must not contradict.
|
||||
const results = await Promise.all(ids.map(id => markRead(id)))
|
||||
const failedCount = results.filter(ok => !ok).length
|
||||
if (failedCount === 0) {
|
||||
showMessageForXSeconds('All articles marked as read.', 5)
|
||||
} else {
|
||||
// markRead() already surfaced each individual failure — this summarizes
|
||||
// the batch outcome instead of claiming full success over it.
|
||||
showMessageForXSeconds(`Marked ${ids.length - failedCount} of ${ids.length} as read; ${failedCount} failed and may reappear.`, 5)
|
||||
}
|
||||
}
|
||||
|
||||
function markCurrentArticleRead() {
|
||||
@@ -431,9 +537,7 @@ async function leaveArticleView() {
|
||||
// Articles paged past in article view were marked read but deliberately kept
|
||||
// in place so currentIndex stayed valid — drop them now so they don't keep
|
||||
// showing up in the list view.
|
||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||
// Shared references — the paged-past objects carry .read on the master too.
|
||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||
dropReadArticles()
|
||||
currentIndex.value = 0
|
||||
viewMode.value = 'list'
|
||||
localStorage.setItem('viewMode', viewMode.value)
|
||||
@@ -504,11 +608,11 @@ export function useFeeds() {
|
||||
setFeedFilter,
|
||||
showMessage,
|
||||
message,
|
||||
showModal,
|
||||
viewMode,
|
||||
currentIndex,
|
||||
toggleViewMode,
|
||||
leaveArticleView,
|
||||
markCurrentArticleRead,
|
||||
layout,
|
||||
toggleLayout,
|
||||
nextArticle,
|
||||
|
||||
Reference in New Issue
Block a user