From 6a4938bdfb23606a0d319205ca54cd9a357e5cc0 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 15 Sep 2026 20:06:33 +0800 Subject: [PATCH] fix(announcements): preserve successful bulk read results --- .../__tests__/announcements.markAll.spec.ts | 67 +++++++++++++++++++ frontend/src/stores/announcements.ts | 12 ++-- 2 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 frontend/src/stores/__tests__/announcements.markAll.spec.ts diff --git a/frontend/src/stores/__tests__/announcements.markAll.spec.ts b/frontend/src/stores/__tests__/announcements.markAll.spec.ts new file mode 100644 index 0000000000..06b5538c9a --- /dev/null +++ b/frontend/src/stores/__tests__/announcements.markAll.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises } from '@vue/test-utils' +import { useAnnouncementStore } from '../announcements' +import type { UserAnnouncement } from '@/types' + +const markRead = vi.hoisted(() => vi.fn()) +vi.mock('@/api', () => ({ announcementsAPI: { markRead } })) + +const announcement = (id: number): UserAnnouncement => ({ + id, title: `Notice ${id}`, content: 'Content', notify_mode: 'silent', + created_at: '2026-09-15T00:00:00Z', updated_at: '2026-09-15T00:00:00Z' +}) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.resetAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => { vi.restoreAllMocks() }) + +describe('mark all announcements read', () => { + it('keeps successful results and retries only failed announcements', async () => { + const store = useAnnouncementStore() + store.announcements = [announcement(1), announcement(2)] + const error = new Error('offline') + markRead.mockImplementation((id: number) => id === 1 ? Promise.resolve() : Promise.reject(error)) + await expect(store.markAllAsRead()).rejects.toBe(error) + expect(store.announcements[0].read_at).toBeTruthy() + expect(store.announcements[1].read_at).toBeFalsy() + expect(store.unreadCount).toBe(1) + markRead.mockClear().mockResolvedValue(undefined) + await store.markAllAsRead() + expect(markRead.mock.calls).toEqual([[2]]) + expect(store.unreadCount).toBe(0) + }) + + it('keeps loading until all submitted requests finish, even after a rejection', async () => { + const store = useAnnouncementStore() + store.announcements = [announcement(1), announcement(2)] + let finish!: () => void + const error = new Error('offline') + markRead.mockImplementation((id: number) => id === 1 + ? Promise.reject(error) : new Promise(resolve => { finish = resolve })) + const result = store.markAllAsRead().catch(err => err) + await flushPromises() + expect(store.loading).toBe(true) + finish() + expect(await result).toBe(error) + expect(store.loading).toBe(false) + expect(store.announcements[1].read_at).toBeTruthy() + }) + + it('does not mark a newly arrived announcement that was not submitted', async () => { + const store = useAnnouncementStore() + store.announcements = [announcement(1)] + let finish!: () => void + markRead.mockImplementation(() => new Promise(resolve => { finish = resolve })) + const result = store.markAllAsRead() + store.announcements.push(announcement(2)) + finish() + await result + expect(markRead.mock.calls).toEqual([[1]]) + expect(store.announcements[0].read_at).toBeTruthy() + expect(store.announcements[1].read_at).toBeFalsy() + }) +}) diff --git a/frontend/src/stores/announcements.ts b/frontend/src/stores/announcements.ts index 6f636d93e4..90c6ad2385 100644 --- a/frontend/src/stores/announcements.ts +++ b/frontend/src/stores/announcements.ts @@ -103,12 +103,12 @@ export const useAnnouncementStore = defineStore('announcements', () => { try { loading.value = true - await Promise.all(unread.map((a) => announcementsAPI.markRead(a.id))) - announcements.value.forEach((a) => { - if (!a.read_at) { - a.read_at = new Date().toISOString() - } - }) + const results = await Promise.allSettled(unread.map(async (a) => { + await announcementsAPI.markRead(a.id) + a.read_at = new Date().toISOString() + })) + const failure = results.find((result) => result.status === 'rejected') + if (failure) throw failure.reason } catch (err: any) { console.error('Failed to mark all as read:', err) throw err