mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7323 from ouchihao/codex/bug-announcement-stale-fetch
fix(announcements): ignore superseded fetch results
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useAnnouncementStore } from '../announcements'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
|
||||
const list = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/api', () => ({ announcementsAPI: { list } }))
|
||||
|
||||
const notice = (id: number): UserAnnouncement => ({
|
||||
id, title: `Notice ${id}`, content: 'Content', notify_mode: 'popup',
|
||||
created_at: '2026-09-18T00:00:00Z', updated_at: '2026-09-18T00:00:00Z'
|
||||
})
|
||||
|
||||
function pendingList() {
|
||||
let resolve!: (value: UserAnnouncement[]) => void
|
||||
let reject!: (reason: Error) => void
|
||||
const promise = new Promise<UserAnnouncement[]>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('announcement fetch ownership', () => {
|
||||
it('does not restore announcements or popups after logout resets the store', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
const old = pendingList()
|
||||
list.mockReturnValueOnce(old.promise)
|
||||
const request = store.fetchAnnouncements()
|
||||
store.reset()
|
||||
old.resolve([notice(1)])
|
||||
await request
|
||||
expect(store.announcements).toEqual([])
|
||||
expect(store.currentPopup).toBeNull()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the new session loading when an old request finishes', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
const old = pendingList()
|
||||
const current = pendingList()
|
||||
list.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise)
|
||||
const oldRequest = store.fetchAnnouncements()
|
||||
store.reset()
|
||||
const currentRequest = store.fetchAnnouncements()
|
||||
old.resolve([])
|
||||
await oldRequest
|
||||
expect(store.loading).toBe(true)
|
||||
current.resolve([notice(2)])
|
||||
await currentRequest
|
||||
expect(store.announcements.map(a => a.id)).toEqual([2])
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the latest forced refresh when responses arrive out of order', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
const old = pendingList()
|
||||
list.mockReturnValueOnce(old.promise).mockResolvedValueOnce([notice(2)])
|
||||
const oldRequest = store.fetchAnnouncements()
|
||||
await store.fetchAnnouncements(true)
|
||||
old.resolve([notice(1)])
|
||||
await oldRequest
|
||||
expect(store.announcements.map(a => a.id)).toEqual([2])
|
||||
expect(store.currentPopup?.id).toBe(2)
|
||||
})
|
||||
|
||||
it('does not clear the new session throttle when an old request rejects', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
const old = pendingList()
|
||||
list.mockReturnValueOnce(old.promise).mockResolvedValue([notice(2)])
|
||||
const oldRequest = store.fetchAnnouncements()
|
||||
store.reset()
|
||||
await store.fetchAnnouncements()
|
||||
old.reject(new Error('old request failed'))
|
||||
await oldRequest
|
||||
await store.fetchAnnouncements()
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(store.announcements.map(a => a.id)).toEqual([2])
|
||||
})
|
||||
|
||||
it('still retries a failed current request', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
list.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce([notice(1)])
|
||||
await store.fetchAnnouncements()
|
||||
await store.fetchAnnouncements()
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(store.currentPopup?.id).toBe(1)
|
||||
})
|
||||
|
||||
it('still throttles ordinary concurrent fetches', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
const current = pendingList()
|
||||
list.mockReturnValueOnce(current.promise)
|
||||
const request = store.fetchAnnouncements()
|
||||
await store.fetchAnnouncements()
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(store.loading).toBe(true)
|
||||
current.resolve([])
|
||||
await request
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ export const useAnnouncementStore = defineStore('announcements', () => {
|
||||
|
||||
// Session-scoped dedup set — not reactive, used as plain lookup only
|
||||
let shownPopupIds = new Set<number>()
|
||||
let fetchGeneration = 0
|
||||
|
||||
// Getters
|
||||
const unreadCount = computed(() =>
|
||||
@@ -30,18 +31,21 @@ export const useAnnouncementStore = defineStore('announcements', () => {
|
||||
|
||||
// Set immediately to prevent concurrent duplicate requests
|
||||
lastFetchTime.value = now
|
||||
const generation = ++fetchGeneration
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const all = await announcementsAPI.list(false)
|
||||
if (generation !== fetchGeneration) return
|
||||
announcements.value = all.slice(0, 20)
|
||||
enqueueNewPopups()
|
||||
} catch (err: any) {
|
||||
if (generation !== fetchGeneration) return
|
||||
// Revert throttle timestamp on failure so retry is allowed
|
||||
lastFetchTime.value = 0
|
||||
console.error('Failed to fetch announcements:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (generation === fetchGeneration) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +122,7 @@ export const useAnnouncementStore = defineStore('announcements', () => {
|
||||
}
|
||||
|
||||
function reset() {
|
||||
fetchGeneration++
|
||||
announcements.value = []
|
||||
lastFetchTime.value = 0
|
||||
shownPopupIds = new Set()
|
||||
|
||||
Reference in New Issue
Block a user