diff --git a/frontend/src/stores/__tests__/announcements.fetch.spec.ts b/frontend/src/stores/__tests__/announcements.fetch.spec.ts new file mode 100644 index 0000000000..6edfa22fbc --- /dev/null +++ b/frontend/src/stores/__tests__/announcements.fetch.spec.ts @@ -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((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) + }) +}) diff --git a/frontend/src/stores/announcements.ts b/frontend/src/stores/announcements.ts index 90c6ad2385..e8dac58ad5 100644 --- a/frontend/src/stores/announcements.ts +++ b/frontend/src/stores/announcements.ts @@ -15,6 +15,7 @@ export const useAnnouncementStore = defineStore('announcements', () => { // Session-scoped dedup set — not reactive, used as plain lookup only let shownPopupIds = new Set() + 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()