diff --git a/docs/analysis/2026-09-21-notification-store-ownership.md b/docs/analysis/2026-09-21-notification-store-ownership.md new file mode 100644 index 0000000000..732a491aac --- /dev/null +++ b/docs/analysis/2026-09-21-notification-store-ownership.md @@ -0,0 +1,49 @@ +# Notification store request and credential ownership + +Status: draft PR #3340, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defects + +`notificationStore` previously let every inbox and preference read replace its shared surface. Confirmed mark-read and preference writes did not invalidate reads that began against an older snapshot. Inbox reads, preference reads and preference writes also assigned one loading Boolean directly, so the first settlement could clear another operation's busy state. + +The first lifecycle correction treated same-user token refresh as a full data reset and could blank an unchanged inbox or detach the mounted preference form from its store value. The preservation correction then exposed a second boundary: when refresh happened during an empty initial read, the old owner was retired but the unchanged route did not remount or refetch. + +## Contract + +Inbox and preferences have independent latest-read owners. Each owner carries a unique token, the current lifecycle epoch and the mutation generation observed at request start. A newer request retires only the previous read in the same lane. + +Successful `markAsRead` and `markAllRead` advance the inbox mutation generation and retire older inbox reads. A successful preference update does the same for preference reads. Loading is derived from active loading-owner tokens rather than whichever call settles first. + +User identity, authentication or demo-session replacement advances the epoch, retires work and clears notifications and preferences. + +A token-only rotation: + +- preserves settled notifications and preferences; +- suppresses old-token success, failure, toast and loading settlement; +- restarts an active inbox read only while the inbox is still empty; +- restarts an active preference read only while preferences are still null; +- retains the exact inbox query captured by the active read; +- never replays mark-read, mark-all or preference-update mutations. + +Mutation serialization, realtime arrival versus refresh, and reminder/email work in #2010 remain outside this slice. + +## Test-first evidence + +The initial supplemental actual-module suite changed from **0/10 passing on `main`** to **10/10 passing** after the first ownership correction. + +Review-regression head `10d48f732725ed8ee9a2557ac39ccf7b7a7958d5` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 4 failures, 0 errors**, all loaded-state preservation cases. + +Issue #3352 added test-only head `660362c9546b51f9996659be3382ac4b6d67f424`, covering token rotation while inbox and preferences are still empty. A dependency-free runner transpiled and executed the actual production module: + +- before the retry correction: each read API was called once and loading became false after rotation; +- after the correction: each read API was called twice, old-token settlement was suppressed and fresh-token inbox/preferences populated independently. + +Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it. + +## Remaining gates + +Current production correction: `2192edf4e0717998b7e1c24236546902d6a9229a` before this documentation commit. + +Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test and fresh-context review remain required. Existing notification-store, realtime, integration, demo and view suites must remain green. Stacked preference-order PR #3343 must later be reconciled to this corrected parent and requalified. + +This is client-state integrity, not a server-authorization claim or transport cancellation guarantee. No merge, release or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/notificationStore.ts b/frontend/taskdeck-web/src/store/notificationStore.ts index dbf2e0b738..a7d82a0ae3 100644 --- a/frontend/taskdeck-web/src/store/notificationStore.ts +++ b/frontend/taskdeck-web/src/store/notificationStore.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { notificationsApi } from '../api/notificationsApi' import { useToastStore } from './toastStore' +import { useSessionStore } from './sessionStore' import { isDemoMode, DemoModeError } from '../utils/demoMode' import { getErrorDisplay } from '../composables/useErrorMapper' import type { @@ -13,12 +14,176 @@ import type { export const useNotificationStore = defineStore('notifications', () => { const toast = useToastStore() + const session = useSessionStore() const notifications = ref([]) const preferences = ref(null) const loading = ref(false) const error = ref(null) + type ReadLane = 'notifications' | 'preferences' + type ReadRetry = () => Promise + + interface OperationOwner { + epoch: number + token: symbol + ownsLoading: boolean + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + } + + let sessionEpoch = 0 + let notificationMutationGeneration = 0 + let preferenceMutationGeneration = 0 + const activeLoadingOperations = new Set() + const readOwners = new Map() + const readRetries = new Map() + + function syncLoading(): void { + loading.value = activeLoadingOperations.size > 0 + } + + function clearError(): void { + error.value = null + } + + function beginOperation( + label: string, + options: { ownsLoading?: boolean; clearExistingError?: boolean } = {}, + ): OperationOwner { + const owner = { + epoch: sessionEpoch, + token: Symbol(label), + ownsLoading: options.ownsLoading ?? false, + } + if (owner.ownsLoading) activeLoadingOperations.add(owner.token) + if (options.clearExistingError ?? false) clearError() + syncLoading() + return owner + } + + function ownsSession(owner: OperationOwner): boolean { + return owner.epoch === sessionEpoch + } + + function finishOperation(owner: OperationOwner): void { + if (!ownsSession(owner)) return + if (owner.ownsLoading) activeLoadingOperations.delete(owner.token) + syncLoading() + } + + function mutationGeneration(lane: ReadLane): number { + return lane === 'notifications' + ? notificationMutationGeneration + : preferenceMutationGeneration + } + + function beginRead(lane: ReadLane, retry: ReadRetry): ReadOwner { + const previous = readOwners.get(lane) + if (previous?.epoch === sessionEpoch && previous.ownsLoading) { + activeLoadingOperations.delete(previous.token) + } + + const operation = beginOperation(`read:${lane}`, { + ownsLoading: true, + clearExistingError: true, + }) + const owner = { + ...operation, + observedMutationGeneration: mutationGeneration(lane), + } + readOwners.set(lane, owner) + readRetries.set(lane, retry) + return owner + } + + function ownsRead(lane: ReadLane, owner: ReadOwner): boolean { + const current = readOwners.get(lane) + return ownsSession(owner) + && current?.token === owner.token + && owner.observedMutationGeneration === mutationGeneration(lane) + } + + function finishRead(lane: ReadLane, owner: ReadOwner): void { + if (readOwners.get(lane)?.token === owner.token) { + readOwners.delete(lane) + readRetries.delete(lane) + } + finishOperation(owner) + } + + function invalidateRead(lane: ReadLane): void { + const owner = readOwners.get(lane) + if (owner?.epoch === sessionEpoch && owner.ownsLoading) { + activeLoadingOperations.delete(owner.token) + } + readOwners.delete(lane) + readRetries.delete(lane) + syncLoading() + } + + function recordNotificationMutation(): void { + notificationMutationGeneration += 1 + invalidateRead('notifications') + } + + function recordPreferenceMutation(): void { + preferenceMutationGeneration += 1 + invalidateRead('preferences') + } + + function invalidateOperations(): void { + sessionEpoch += 1 + notificationMutationGeneration = 0 + preferenceMutationGeneration = 0 + activeLoadingOperations.clear() + readOwners.clear() + readRetries.clear() + loading.value = false + clearError() + } + + function retryEmptyActiveReads(): void { + const notificationRetry = readOwners.has('notifications') && notifications.value.length === 0 + ? readRetries.get('notifications') + : undefined + const preferenceRetry = readOwners.has('preferences') && preferences.value === null + ? readRetries.get('preferences') + : undefined + + invalidateOperations() + if (notificationRetry) { + void notificationRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + if (preferenceRetry) { + void preferenceRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + + function resetForSession(): void { + invalidateOperations() + notifications.value = [] + preferences.value = null + } + + watch( + () => [session.userId, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + + watch( + () => session.token, + retryEmptyActiveReads, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -28,46 +193,59 @@ export const useNotificationStore = defineStore('notifications', () => { async function fetchNotifications(query?: NotificationQuery) { if (isDemoMode) { - loading.value = true - error.value = null + invalidateRead('notifications') + clearError() notifications.value = [] - loading.value = false return } + + const owner = beginRead('notifications', () => fetchNotifications(query)) try { - loading.value = true - error.value = null - notifications.value = await notificationsApi.getNotifications(query) + const result = await notificationsApi.getNotifications(query) + if (!ownsRead('notifications', owner)) return + notifications.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to load notifications').message - error.value = msg - toast.error(msg) + if (ownsRead('notifications', owner)) { + const msg = getErrorDisplay(e, 'Failed to load notifications').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead('notifications', owner) } } async function markAsRead(notificationId: string) { guardDemoMutation() + const owner = beginOperation(`mark-read:${notificationId}`) try { const updated = await notificationsApi.markAsRead(notificationId) + if (!ownsSession(owner)) return updated + + recordNotificationMutation() notifications.value = notifications.value.map((item) => ( item.id === notificationId ? updated : item )) return updated } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to mark notification as read').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to mark notification as read').message + error.value = msg + toast.error(msg) + } throw e } } async function markAllRead(boardId?: string) { guardDemoMutation() + const owner = beginOperation(`mark-all-read:${boardId ?? 'all'}`) try { const result = await notificationsApi.markAllRead(boardId) + if (!ownsSession(owner)) return result + + recordNotificationMutation() notifications.value = notifications.value.map((item) => { if (boardId && item.boardId !== boardId) return item return { @@ -78,51 +256,63 @@ export const useNotificationStore = defineStore('notifications', () => { }) return result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message + error.value = msg + toast.error(msg) + } throw e } } async function fetchPreferences() { if (isDemoMode) { - loading.value = true - error.value = null + invalidateRead('preferences') + clearError() preferences.value = null - loading.value = false return preferences.value } + + const owner = beginRead('preferences', fetchPreferences) try { - loading.value = true - error.value = null - preferences.value = await notificationsApi.getPreferences() - return preferences.value + const result = await notificationsApi.getPreferences() + if (ownsRead('preferences', owner)) preferences.value = result + return result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to load notification preferences').message - error.value = msg - toast.error(msg) + if (ownsRead('preferences', owner)) { + const msg = getErrorDisplay(e, 'Failed to load notification preferences').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead('preferences', owner) } } async function updatePreferences(dto: UpdateNotificationPreferenceRequest) { guardDemoMutation() + const owner = beginOperation('update-preferences', { + ownsLoading: true, + clearExistingError: true, + }) try { - loading.value = true - error.value = null - preferences.value = await notificationsApi.updatePreferences(dto) + const updated = await notificationsApi.updatePreferences(dto) + if (!ownsSession(owner)) return updated + + recordPreferenceMutation() + preferences.value = updated toast.success('Notification preferences saved') - return preferences.value + return updated } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to save notification preferences').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to save notification preferences').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } diff --git a/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts new file mode 100644 index 0000000000..7013958f0e --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { notificationsApi } from '../../api/notificationsApi' +import { useNotificationStore } from '../../store/notificationStore' +import { useSessionStore } from '../../store/sessionStore' +import type { + NotificationItem, + NotificationPreference, + UpdateNotificationPreferenceRequest, +} from '../../types/notifications' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +vi.mock('../../api/notificationsApi', () => ({ + notificationsApi: { + getNotifications: vi.fn(), + markAsRead: vi.fn(), + markAllRead: vi.fn(), + getPreferences: vi.fn(), + updatePreferences: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + refreshToken: vi.fn(), + exchangeOAuthCode: vi.fn(), + exchangeOidcCode: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (error: unknown, fallback: string) => ({ + message: error instanceof Error ? error.message : fallback, + }), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function token(suffix: string): string { + const body = btoa(JSON.stringify({ exp: 1893456000 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + return `header.${body}.${suffix}` +} + +function notification(id: string, isRead = false): NotificationItem { + return { + id, + userId: 'user-a', + boardId: null, + type: 'Mention', + cadence: 'Immediate', + title: id, + message: id, + sourceEntityType: 'card', + sourceEntityId: 'card-1', + isRead, + readAt: isRead ? '2026-09-21T00:00:01Z' : null, + createdAt: '2026-09-21T00:00:00Z', + updatedAt: '2026-09-21T00:00:01Z', + } +} + +function preferences( + mentionImmediateEnabled: boolean, +): NotificationPreference { + return { + userId: 'user-a', + inAppChannelEnabled: true, + mentionImmediateEnabled, + mentionDigestEnabled: !mentionImmediateEnabled, + assignmentImmediateEnabled: true, + assignmentDigestEnabled: false, + proposalOutcomeImmediateEnabled: true, + proposalOutcomeDigestEnabled: false, + createdAt: '2026-09-21T00:00:00Z', + updatedAt: '2026-09-21T00:00:01Z', + } +} + +function preferenceRequest( + mentionImmediateEnabled: boolean, +): UpdateNotificationPreferenceRequest { + const value = preferences(mentionImmediateEnabled) + return { + inAppChannelEnabled: value.inAppChannelEnabled, + mentionImmediateEnabled: value.mentionImmediateEnabled, + mentionDigestEnabled: value.mentionDigestEnabled, + assignmentImmediateEnabled: value.assignmentImmediateEnabled, + assignmentDigestEnabled: value.assignmentDigestEnabled, + proposalOutcomeImmediateEnabled: value.proposalOutcomeImmediateEnabled, + proposalOutcomeDigestEnabled: value.proposalOutcomeDigestEnabled, + } +} + +describe('notificationStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'user-a' + session.token = token('old') + store = useNotificationStore() + vi.clearAllMocks() + }) + + it('keeps the newest inbox read when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(notificationsApi.getNotifications) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchNotifications({ boardId: 'board-old' }) + const newRequest = store.fetchNotifications({ boardId: 'board-new' }) + newer.resolve([notification('new')]) + await newRequest + older.resolve([notification('old')]) + await oldRequest + + expect(store.notifications.map(item => item.id)).toEqual(['new']) + }) + + it('keeps the newest preference read when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(notificationsApi.getPreferences) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchPreferences() + const newRequest = store.fetchPreferences() + newer.resolve(preferences(false)) + await newRequest + older.resolve(preferences(true)) + await oldRequest + + expect(store.preferences?.mentionImmediateEnabled).toBe(false) + }) + + it('does not let an older inbox read undo a confirmed markAsRead', async () => { + store.notifications = [notification('n-1')] + const read = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(read.promise) + vi.mocked(notificationsApi.markAsRead).mockResolvedValue(notification('n-1', true)) + + const readRequest = store.fetchNotifications() + await store.markAsRead('n-1') + read.resolve([notification('n-1', false)]) + await readRequest + + expect(store.notifications[0]?.isRead).toBe(true) + }) + + it('does not let an older inbox read undo a confirmed markAllRead', async () => { + store.notifications = [notification('n-1'), notification('n-2')] + const read = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(read.promise) + vi.mocked(notificationsApi.markAllRead).mockResolvedValue({ markedCount: 2 }) + + const readRequest = store.fetchNotifications() + await store.markAllRead() + read.resolve([notification('n-1'), notification('n-2')]) + await readRequest + + expect(store.notifications.every(item => item.isRead)).toBe(true) + }) + + it('does not let an older preference read undo a confirmed update', async () => { + store.preferences = preferences(true) + const read = deferred() + vi.mocked(notificationsApi.getPreferences).mockReturnValue(read.promise) + vi.mocked(notificationsApi.updatePreferences).mockResolvedValue(preferences(false)) + + const readRequest = store.fetchPreferences() + await store.updatePreferences(preferenceRequest(false)) + read.resolve(preferences(true)) + await readRequest + + expect(store.preferences?.mentionImmediateEnabled).toBe(false) + }) + + it('keeps loading true while an independent notification operation remains pending', async () => { + const inbox = deferred() + const prefs = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(inbox.promise) + vi.mocked(notificationsApi.getPreferences).mockReturnValue(prefs.promise) + + const inboxRequest = store.fetchNotifications() + const prefsRequest = store.fetchPreferences() + inbox.resolve([]) + await inboxRequest + + expect(store.loading).toBe(true) + + prefs.resolve(preferences(true)) + await prefsRequest + expect(store.loading).toBe(false) + }) + + it('preserves both surfaces and rejects old read settlement after token rotation', async () => { + store.notifications = [notification('existing')] + store.preferences = preferences(true) + const inbox = deferred() + const prefs = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(inbox.promise) + vi.mocked(notificationsApi.getPreferences).mockReturnValue(prefs.promise) + + const inboxRequest = store.fetchNotifications() + const prefsRequest = store.fetchPreferences() + session.token = token('new') + + expect(store.notifications.map(item => item.id)).toEqual(['existing']) + expect(store.preferences?.mentionImmediateEnabled).toBe(true) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + + inbox.resolve([notification('old-token')]) + prefs.resolve(preferences(false)) + await Promise.all([inboxRequest, prefsRequest]) + + expect(store.notifications.map(item => item.id)).toEqual(['existing']) + expect(store.preferences?.mentionImmediateEnabled).toBe(true) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('suppresses a stale read failure after token rotation while preserving inbox data', async () => { + store.notifications = [notification('existing')] + const pending = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(pending.promise) + const request = store.fetchNotifications() + + session.token = token('new') + pending.reject(new Error('old credential read failed')) + await expect(request).rejects.toThrow('old credential read failed') + + expect(store.notifications.map(item => item.id)).toEqual(['existing']) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('suppresses a stale markAsRead success after token rotation while preserving inbox data', async () => { + store.notifications = [notification('n-1')] + const pending = deferred() + vi.mocked(notificationsApi.markAsRead).mockReturnValue(pending.promise) + const request = store.markAsRead('n-1') + + session.token = token('new') + pending.resolve(notification('n-1', true)) + await request + + expect(store.notifications.map(item => ({ id: item.id, isRead: item.isRead }))).toEqual([ + { id: 'n-1', isRead: false }, + ]) + expect(store.error).toBeNull() + }) + + it('suppresses a stale preference mutation failure after token rotation while preserving preferences', async () => { + store.preferences = preferences(true) + const pending = deferred() + vi.mocked(notificationsApi.updatePreferences).mockReturnValue(pending.promise) + const request = store.updatePreferences(preferenceRequest(false)) + + session.token = token('new') + pending.reject(new Error('old credential save failed')) + await expect(request).rejects.toThrow('old credential save failed') + + expect(store.preferences?.mentionImmediateEnabled).toBe(true) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('retries empty initial inbox and preference reads after same-user token rotation', async () => { + const oldInbox = deferred() + const freshInbox = deferred() + const oldPreferences = deferred() + const freshPreferences = deferred() + vi.mocked(notificationsApi.getNotifications) + .mockReturnValueOnce(oldInbox.promise) + .mockReturnValueOnce(freshInbox.promise) + vi.mocked(notificationsApi.getPreferences) + .mockReturnValueOnce(oldPreferences.promise) + .mockReturnValueOnce(freshPreferences.promise) + + const inboxRequest = store.fetchNotifications({ boardId: 'board-a' }) + const preferencesRequest = store.fetchPreferences() + session.token = token('new') + + expect(notificationsApi.getNotifications).toHaveBeenCalledTimes(2) + expect(notificationsApi.getPreferences).toHaveBeenCalledTimes(2) + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(true) + + oldInbox.resolve([notification('old-token')]) + oldPreferences.resolve(preferences(false)) + await Promise.all([inboxRequest, preferencesRequest]) + + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(true) + expect(store.error).toBeNull() + + freshInbox.resolve([notification('fresh-token')]) + freshPreferences.resolve(preferences(true)) + await vi.waitFor(() => { + expect(store.notifications.map(item => item.id)).toEqual(['fresh-token']) + expect(store.preferences?.mentionImmediateEnabled).toBe(true) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + }) + + it('clears both surfaces on identity replacement and suppresses old work', async () => { + store.notifications = [notification('existing')] + store.preferences = preferences(true) + const inbox = deferred() + const save = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(inbox.promise) + vi.mocked(notificationsApi.updatePreferences).mockReturnValue(save.promise) + + const inboxRequest = store.fetchNotifications() + const saveRequest = store.updatePreferences(preferenceRequest(false)) + session.userId = 'user-b' + + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + + inbox.resolve([notification('old-user')]) + save.resolve(preferences(false)) + await Promise.all([inboxRequest, saveRequest]) + + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.success).not.toHaveBeenCalled() + }) +})