diff --git a/frontend/taskdeck-web/src/store/queueStore.ts b/frontend/taskdeck-web/src/store/queueStore.ts index aabc8bc8a..ebabed5f6 100644 --- a/frontend/taskdeck-web/src/store/queueStore.ts +++ b/frontend/taskdeck-web/src/store/queueStore.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { queueApi } from '../api/queueApi' import { useToastStore } from './toastStore' import { useSessionStore } from './sessionStore' @@ -16,6 +16,214 @@ export const useQueueStore = defineStore('queue', () => { const loading = ref(false) const error = ref(null) + type ReadLane = 'requests' | 'stats' + type OperationScope = ReadLane | 'mutation' + type ReadRetry = () => Promise + + interface OperationOwner { + epoch: number + token: symbol + scope: OperationScope + userId: string | null + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + } + + let credentialEpoch = 0 + let mutationGeneration = 0 + let errorOwner: OperationOwner | null = null + const activeOperations = new Set() + const readOwners = new Map() + const readRetries = new Map() + const successorReads = new Map>() + let activeRequestRetry: ReadRetry | null = null + + function syncLoading(): void { + loading.value = activeOperations.size > 0 + } + + function clearErrorForScope(scope: OperationScope): void { + if (errorOwner && errorOwner.epoch === credentialEpoch && errorOwner.scope !== scope) return + errorOwner = null + error.value = null + } + + function publishError(owner: OperationOwner, message: string): void { + errorOwner = owner + error.value = message + } + + function beginOperation(label: string, scope: OperationScope): OperationOwner { + const owner = { epoch: credentialEpoch, token: Symbol(label), scope, userId: session.userId } + activeOperations.add(owner.token) + clearErrorForScope(scope) + syncLoading() + return owner + } + + function ownsCredential(owner: OperationOwner): boolean { + return owner.epoch === credentialEpoch + } + + function finishOperation(owner: OperationOwner): void { + if (!ownsCredential(owner)) return + activeOperations.delete(owner.token) + syncLoading() + } + + function beginRead(lane: ReadLane, retry: ReadRetry): ReadOwner { + const previous = readOwners.get(lane) + if (previous?.epoch === credentialEpoch) { + activeOperations.delete(previous.token) + } + + const operation = beginOperation(`read:${lane}`, lane) + const owner = { + ...operation, + observedMutationGeneration: mutationGeneration, + } + readOwners.set(lane, owner) + readRetries.set(lane, retry) + return owner + } + + function ownsRead(lane: ReadLane, owner: ReadOwner): boolean { + return ownsCredential(owner) + && readOwners.get(lane)?.token === owner.token + && owner.observedMutationGeneration === mutationGeneration + } + + 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 === credentialEpoch) { + activeOperations.delete(owner.token) + } + readOwners.delete(lane) + readRetries.delete(lane) + syncLoading() + } + + function recordMutation(): void { + mutationGeneration += 1 + invalidateRead('requests') + invalidateRead('stats') + } + + function invalidateOperations(): void { + credentialEpoch += 1 + activeOperations.clear() + readOwners.clear() + readRetries.clear() + errorOwner = null + loading.value = false + error.value = null + } + + function canReportMutationFailure(owner: OperationOwner): boolean { + return ownsCredential(owner) + || (owner.userId !== null + && owner.userId === session.userId + && session.isAuthenticated + && !session.isDemo) + } + + async function awaitSuccessor(owner: ReadOwner): Promise { + const successor = successorReads.get(owner.token) + if (!successor) return false + + try { + await successor + } finally { + successorReads.delete(owner.token) + } + return true + } + + function retryActiveReads(): void { + if (!session.isAuthenticated || session.userId === null || session.isDemo) { + $reset() + return + } + + const requestRetry = readOwners.has('requests') + ? { owner: readOwners.get('requests')!, retry: readRetries.get('requests') } + : undefined + const statsRetry = readOwners.has('stats') + ? { owner: readOwners.get('stats')!, retry: readRetries.get('stats') } + : undefined + + invalidateOperations() + for (const entry of [requestRetry, statsRetry]) { + if (!entry?.retry) continue + const successor = entry.retry() + successorReads.set(entry.owner.token, successor) + void successor.catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + + async function reconcileStaleMutation(owner: OperationOwner): Promise { + // A same-user token rotation can let the server commit a mutation after this + // store retires its operation owner. Refresh both affected read lanes under + // the replacement credential so a successful write is not hidden by stale + // request rows or counts. Identity changes and logout must not read for the + // retired session. + if (ownsCredential(owner) + || owner.userId === null + || owner.userId !== session.userId + || !session.isAuthenticated + || session.isDemo) { + return + } + + try { + const refreshes: Promise[] = [] + if (!readOwners.has('requests')) { + const refreshRequests = activeRequestRetry ?? fetchUserRequests + refreshes.push(refreshRequests()) + } + if (!readOwners.has('stats')) { + refreshes.push(fetchStats()) + } + await Promise.all(refreshes) + } catch { + // Each read owns its error/toast state. The mutation already succeeded, + // so a reconciliation failure must not turn it into a false write error. + } + } + + function $reset(): void { + invalidateOperations() + successorReads.clear() + activeRequestRetry = null + mutationGeneration = 0 + requests.value = [] + stats.value = null + } + + watch( + () => [session.userId, session.isAuthenticated, session.isDemo], + $reset, + { flush: 'sync' }, + ) + + watch( + () => session.token, + retryActiveReads, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -25,131 +233,181 @@ export const useQueueStore = defineStore('queue', () => { async function fetchUserRequests() { if (isDemoMode) { - loading.value = true - error.value = null + invalidateRead('requests') + clearErrorForScope('requests') requests.value = [] - loading.value = false return } + + const retry = fetchUserRequests + activeRequestRetry = retry + const owner = beginRead('requests', retry) try { - loading.value = true - error.value = null session.requireUserId('queue operations') - requests.value = await queueApi.getUserRequests() + const result = await queueApi.getUserRequests() + if (ownsRead('requests', owner)) { + requests.value = result + } else { + await awaitSuccessor(owner) + } } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch queue requests').message - error.value = msg - toast.error(msg) + if (ownsRead('requests', owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch queue requests').message + publishError(owner, msg) + toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return + } throw e } finally { - loading.value = false + finishRead('requests', owner) } } async function fetchByStatus(status: string) { if (isDemoMode) { - loading.value = true - error.value = null + invalidateRead('requests') + clearErrorForScope('requests') requests.value = [] - loading.value = false return } + + const retry = () => fetchByStatus(status) + activeRequestRetry = retry + const owner = beginRead('requests', retry) try { - loading.value = true - error.value = null - requests.value = await queueApi.getRequestsByStatus(status) + const result = await queueApi.getRequestsByStatus(status) + if (ownsRead('requests', owner)) { + requests.value = result + } else { + await awaitSuccessor(owner) + } } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch requests by status').message - error.value = msg - toast.error(msg) + if (ownsRead('requests', owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch requests by status').message + publishError(owner, msg) + toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return + } throw e } finally { - loading.value = false + finishRead('requests', owner) } } async function submitRequest(dto: CreateQueueRequestDto) { guardDemoMutation() + const owner = beginOperation('submit-request', 'mutation') try { - loading.value = true - error.value = null session.requireUserId('queue operations') const request = await queueApi.createRequest(dto) + if (!ownsCredential(owner)) { + await reconcileStaleMutation(owner) + return request + } + + recordMutation() requests.value.push(request) toast.success('Request submitted') return request } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to submit request').message - error.value = msg - toast.error(msg) + if (canReportMutationFailure(owner)) { + const msg = getErrorDisplay(e, 'Failed to submit request').message + publishError(owner, msg) + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function cancelRequest(requestId: string) { guardDemoMutation() + const owner = beginOperation(`cancel-request:${requestId}`, 'mutation') try { - loading.value = true - error.value = null session.requireUserId('queue operations') await queueApi.cancelRequest(requestId) + if (!ownsCredential(owner)) { + await reconcileStaleMutation(owner) + return + } + + recordMutation() requests.value = requests.value.filter(r => r.id !== requestId) toast.success('Request cancelled') } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to cancel request').message - error.value = msg - toast.error(msg) + if (canReportMutationFailure(owner)) { + const msg = getErrorDisplay(e, 'Failed to cancel request').message + publishError(owner, msg) + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function processNext() { guardDemoMutation() + const owner = beginOperation('process-next', 'mutation') try { - loading.value = true - error.value = null const result = await queueApi.processNext() + if (!ownsCredential(owner)) { + if (result) await reconcileStaleMutation(owner) + return result + } + if (result) { + recordMutation() toast.success('Request processed') } else { toast.info('No pending requests') } return result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to process request').message - error.value = msg - toast.error(msg) + if (canReportMutationFailure(owner)) { + const msg = getErrorDisplay(e, 'Failed to process request').message + publishError(owner, msg) + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function fetchStats() { if (isDemoMode) { - loading.value = true - error.value = null + invalidateRead('stats') + clearErrorForScope('stats') stats.value = { pendingCount: 0, processingCount: 0, completedCount: 0, failedCount: 0 } - loading.value = false return } + + const owner = beginRead('stats', fetchStats) try { - loading.value = true - error.value = null - stats.value = await queueApi.getStats() + const result = await queueApi.getStats() + if (ownsRead('stats', owner)) { + stats.value = result + } else { + await awaitSuccessor(owner) + } } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch queue stats').message - error.value = msg - toast.error(msg) + if (ownsRead('stats', owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch queue stats').message + publishError(owner, msg) + toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return + } throw e } finally { - loading.value = false + finishRead('stats', owner) } } + return { requests, stats, @@ -161,5 +419,6 @@ export const useQueueStore = defineStore('queue', () => { cancelRequest, processNext, fetchStats, + $reset, } }) diff --git a/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts new file mode 100644 index 000000000..92918b0a0 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/queueStoreOwnership.spec.ts @@ -0,0 +1,517 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { queueApi } from '../../api/queueApi' +import { useQueueStore } from '../../store/queueStore' +import { useSessionStore } from '../../store/sessionStore' +import type { QueueRequest, QueueStats } from '../../types/queue' + +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/queueApi', () => ({ + queueApi: { + getUserRequests: vi.fn(), + getRequestsByStatus: vi.fn(), + createRequest: vi.fn(), + cancelRequest: vi.fn(), + processNext: vi.fn(), + getStats: 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 request(id: string, status: QueueRequest['status'] = 'Pending'): QueueRequest { + return { + id, + userId: 'user-a', + boardId: 'board-a', + requestType: 'Instruction', + status, + errorMessage: null, + createdAt: '2026-09-21T00:00:00Z', + processedAt: status === 'Pending' ? null : '2026-09-21T00:01:00Z', + retryCount: 0, + } +} + +function stats(pendingCount: number): QueueStats { + return { + pendingCount, + processingCount: 1, + completedCount: 2, + failedCount: 3, + } +} + +describe('queueStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'user-a' + session.token = token('old') + store = useQueueStore() + vi.clearAllMocks() + }) + + it('keeps the newest request-list query when user and status reads settle in reverse order', async () => { + const oldUser = deferred() + const newStatus = deferred() + vi.mocked(queueApi.getUserRequests).mockReturnValue(oldUser.promise) + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(newStatus.promise) + + const oldRequest = store.fetchUserRequests() + const newRequest = store.fetchByStatus('Failed') + + newStatus.resolve([request('new-status', 'Failed')]) + await newRequest + oldUser.resolve([request('old-user')]) + await oldRequest + + expect(store.requests.map(item => item.id)).toEqual(['new-status']) + expect(store.error).toBeNull() + }) + + it('suppresses an older request-list failure after a newer query succeeds', async () => { + const oldUser = deferred() + const newStatus = deferred() + vi.mocked(queueApi.getUserRequests).mockReturnValue(oldUser.promise) + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(newStatus.promise) + + const oldRequest = store.fetchUserRequests() + const newRequest = store.fetchByStatus('Completed') + + newStatus.resolve([request('new-status', 'Completed')]) + await newRequest + oldUser.reject(new Error('stale user queue failure')) + await expect(oldRequest).rejects.toThrow('stale user queue failure') + + expect(store.requests.map(item => item.id)).toEqual(['new-status']) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not let a stats refresh erase the current request-list failure', async () => { + const queueStats = deferred() + vi.mocked(queueApi.getRequestsByStatus).mockRejectedValue(new Error('request list failed')) + vi.mocked(queueApi.getStats).mockReturnValue(queueStats.promise) + + await expect(store.fetchByStatus('Failed')).rejects.toThrow('request list failed') + expect(store.error).toBe('request list failed') + + const statsRequest = store.fetchStats() + const errorAfterStatsStarted = store.error + queueStats.resolve(stats(2)) + await statsRequest + + expect(errorAfterStatsStarted).toBe('request list failed') + expect(store.error).toBe('request list failed') + }) + + it('does not let an older request read erase a confirmed submission', async () => { + const oldRead = deferred() + const create = deferred() + vi.mocked(queueApi.getUserRequests).mockReturnValue(oldRead.promise) + vi.mocked(queueApi.createRequest).mockReturnValue(create.promise) + + const readRequest = store.fetchUserRequests() + const submitRequest = store.submitRequest({ requestType: 'Instruction', payload: 'Do the work' }) + + create.resolve(request('created')) + await submitRequest + oldRead.resolve([request('existing')]) + await readRequest + + expect(store.requests.map(item => item.id)).toEqual(['created']) + }) + + it('does not let an older request read reinsert a confirmed cancellation', async () => { + const snapshot = [request('cancelled-later'), request('kept')] + const oldRead = deferred() + const cancel = deferred() + store.requests = [...snapshot] + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(oldRead.promise) + vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) + + const readRequest = store.fetchByStatus('Pending') + const cancelRequest = store.cancelRequest('cancelled-later') + + cancel.resolve() + await cancelRequest + oldRead.resolve(snapshot) + await readRequest + + expect(store.requests.map(item => item.id)).toEqual(['kept']) + }) + + it('does not install stats captured before a confirmed queue mutation', async () => { + const oldStats = deferred() + const create = deferred() + store.stats = stats(99) + vi.mocked(queueApi.getStats).mockReturnValue(oldStats.promise) + vi.mocked(queueApi.createRequest).mockReturnValue(create.promise) + + const statsRequest = store.fetchStats() + const submitRequest = store.submitRequest({ requestType: 'Instruction', payload: 'Queue it' }) + + create.resolve(request('created')) + await submitRequest + oldStats.resolve(stats(1)) + await statsRequest + + expect(store.stats?.pendingCount).toBe(99) + }) + + it('keeps loading true until independent request and stats operations both settle', async () => { + const requests = deferred() + const queueStats = deferred() + vi.mocked(queueApi.getRequestsByStatus).mockReturnValue(requests.promise) + vi.mocked(queueApi.getStats).mockReturnValue(queueStats.promise) + + const requestsOperation = store.fetchByStatus('Pending') + const statsOperation = store.fetchStats() + expect(store.loading).toBe(true) + + requests.resolve([request('pending')]) + await requestsOperation + expect(store.loading).toBe(true) + + queueStats.resolve(stats(1)) + await statsOperation + expect(store.loading).toBe(false) + }) + + it('preserves loaded queue data on token rotation and ignores late read and submit successes', async () => { + const oldRead = deferred() + const freshRead = deferred() + const oldSubmit = deferred() + store.requests = [request('existing')] + store.stats = stats(4) + store.error = 'existing error' + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + vi.mocked(queueApi.getUserRequests).mockResolvedValue([request('existing')]) + vi.mocked(queueApi.getStats).mockResolvedValue(stats(4)) + vi.mocked(queueApi.createRequest).mockReturnValue(oldSubmit.promise) + + const readOperation = store.fetchByStatus('Pending') + const submitOperation = store.submitRequest({ requestType: 'Instruction', payload: 'Old session' }) + + session.token = token('new') + const immediate = { + requestIds: store.requests.map(item => item.id), + stats: store.stats, + loading: store.loading, + error: store.error, + } + + oldRead.resolve([request('old-read')]) + oldSubmit.resolve(request('old-submit')) + freshRead.resolve([request('existing')]) + await Promise.all([readOperation, submitOperation]) + + expect(immediate).toEqual({ requestIds: ['existing'], stats: stats(4), loading: true, error: null }) + expect(store.requests.map(item => item.id)).toEqual(['existing']) + expect(store.stats).toEqual(stats(4)) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('retries empty initial request and stats reads after same-user token rotation', async () => { + const oldRequests = deferred() + const freshRequests = deferred() + const oldStats = deferred() + const freshStats = deferred() + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRequests.promise) + .mockReturnValueOnce(freshRequests.promise) + vi.mocked(queueApi.getStats) + .mockReturnValueOnce(oldStats.promise) + .mockReturnValueOnce(freshStats.promise) + + const requestsOperation = store.fetchByStatus('Failed') + const statsOperation = store.fetchStats() + session.token = token('new') + + expect(queueApi.getRequestsByStatus).toHaveBeenCalledTimes(2) + expect(queueApi.getRequestsByStatus).toHaveBeenNthCalledWith(2, 'Failed') + expect(queueApi.getStats).toHaveBeenCalledTimes(2) + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(true) + + oldRequests.resolve([request('old-token', 'Failed')]) + oldStats.resolve(stats(1)) + freshRequests.resolve([request('fresh-token', 'Failed')]) + freshStats.resolve(stats(7)) + await Promise.all([requestsOperation, statsOperation]) + + expect(store.requests.map(item => item.id)).toEqual(['fresh-token']) + expect(store.stats?.pendingCount).toBe(7) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('retries an active status read with cached rows and joins the replacement', async () => { + const oldRead = deferred() + const freshRead = deferred() + store.requests = [request('previous', 'Completed')] + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const operation = store.fetchByStatus('Pending') + session.token = token('new') + + expect(queueApi.getRequestsByStatus).toHaveBeenCalledTimes(2) + expect(queueApi.getRequestsByStatus).toHaveBeenNthCalledWith(2, 'Pending') + + oldRead.reject(new Error('old-token failure')) + freshRead.resolve([request('fresh', 'Pending')]) + + await expect(operation).resolves.toBeUndefined() + expect(store.requests.map(item => item.id)).toEqual(['fresh']) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('surfaces a replacement read failure and clears loading after token rotation', async () => { + const oldRead = deferred() + const freshRead = deferred() + store.requests = [request('previous', 'Completed')] + vi.mocked(queueApi.getRequestsByStatus) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const operation = store.fetchByStatus('Pending') + session.token = token('new') + oldRead.resolve([request('old-token', 'Pending')]) + freshRead.reject(new Error('replacement failed')) + + await expect(operation).rejects.toThrow('replacement failed') + expect(store.loading).toBe(false) + expect(store.error).toBe('replacement failed') + expect(toastMocks.error).toHaveBeenCalledTimes(1) + }) + + it('reports a same-user mutation failure after token rotation', async () => { + const cancel = deferred() + store.requests = [request('old-request')] + vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) + + const operation = store.cancelRequest('old-request') + session.token = token('new') + cancel.reject(new Error('old-session cancellation failed')) + await expect(operation).rejects.toThrow('old-session cancellation failed') + + expect(store.requests.map(item => item.id)).toEqual(['old-request']) + expect(store.error).toBe('old-session cancellation failed') + expect(store.loading).toBe(false) + expect(toastMocks.error).toHaveBeenCalledWith('old-session cancellation failed') + }) + + it('suppresses a mutation failure after identity replacement', async () => { + const cancel = deferred() + store.requests = [request('old-request')] + vi.mocked(queueApi.cancelRequest).mockReturnValue(cancel.promise) + + const operation = store.cancelRequest('old-request') + session.userId = 'other-user' + cancel.reject(new Error('old-session cancellation failed')) + await expect(operation).rejects.toThrow('old-session cancellation failed') + + expect(store.requests).toEqual([]) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('reconciles a successful stale submission after same-user token rotation', async () => { + const pendingSubmit = deferred() + const freshRequests = deferred() + const freshStats = deferred() + const existing = request('existing') + const created = request('created') + store.requests = [existing] + store.stats = stats(1) + vi.mocked(queueApi.createRequest).mockReturnValue(pendingSubmit.promise) + vi.mocked(queueApi.getUserRequests).mockReturnValue(freshRequests.promise) + vi.mocked(queueApi.getStats).mockReturnValue(freshStats.promise) + + const operation = store.submitRequest({ requestType: 'Instruction', payload: 'Queue it' }) + session.token = token('new') + pendingSubmit.resolve(created) + + await vi.waitFor(() => { + expect(queueApi.getUserRequests).toHaveBeenCalledTimes(1) + expect(queueApi.getStats).toHaveBeenCalledTimes(1) + }) + + freshRequests.resolve([existing, created]) + freshStats.resolve(stats(2)) + await expect(operation).resolves.toEqual(created) + + expect(store.requests.map(item => item.id)).toEqual(['existing', 'created']) + expect(store.stats?.pendingCount).toBe(2) + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('reconciles a successful stale cancellation after same-user token rotation', async () => { + const pendingCancel = deferred() + const freshRequests = deferred() + const freshStats = deferred() + const existing = request('existing') + store.requests = [existing] + store.stats = stats(1) + vi.mocked(queueApi.cancelRequest).mockReturnValue(pendingCancel.promise) + vi.mocked(queueApi.getUserRequests).mockReturnValue(freshRequests.promise) + vi.mocked(queueApi.getStats).mockReturnValue(freshStats.promise) + + const operation = store.cancelRequest('existing') + session.token = token('new') + pendingCancel.resolve() + + await vi.waitFor(() => { + expect(queueApi.getUserRequests).toHaveBeenCalledTimes(1) + expect(queueApi.getStats).toHaveBeenCalledTimes(1) + }) + + freshRequests.resolve([]) + freshStats.resolve(stats(0)) + await expect(operation).resolves.toBeUndefined() + + expect(store.requests).toEqual([]) + expect(store.stats?.pendingCount).toBe(0) + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('reconciles a stale mutation using the active status query', async () => { + const pendingSubmit = deferred() + const existing = request('existing', 'Failed') + const created = request('created', 'Failed') + const freshStats = stats(2) + store.requests = [existing] + store.stats = stats(1) + vi.mocked(queueApi.getRequestsByStatus) + .mockResolvedValueOnce([existing]) + .mockResolvedValueOnce([existing, created]) + vi.mocked(queueApi.getStats).mockResolvedValue(freshStats) + vi.mocked(queueApi.createRequest).mockReturnValue(pendingSubmit.promise) + + await store.fetchByStatus('Failed') + const operation = store.submitRequest({ requestType: 'Instruction', payload: 'Queue it' }) + session.token = token('new') + pendingSubmit.resolve(created) + + await expect(operation).resolves.toEqual(created) + expect(queueApi.getRequestsByStatus).toHaveBeenNthCalledWith(2, 'Failed') + expect(queueApi.getUserRequests).not.toHaveBeenCalled() + expect(store.requests.map(item => item.id)).toEqual(['existing', 'created']) + expect(store.stats).toEqual(freshStats) + }) + + it('resets rather than retrying a read under a cleared identity', async () => { + const pendingRead = deferred() + store.requests = [request('existing')] + vi.mocked(queueApi.getUserRequests).mockReturnValue(pendingRead.promise) + + const operation = store.fetchUserRequests() + session.token = null + session.userId = null + + expect(queueApi.getUserRequests).toHaveBeenCalledTimes(1) + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(false) + + pendingRead.resolve([request('late')]) + await operation + expect(store.requests).toEqual([]) + }) + + it('does not emit a process result toast after credential replacement', async () => { + const processing = deferred() + vi.mocked(queueApi.processNext).mockReturnValue(processing.promise) + + const operation = store.processNext() + session.token = token('new') + processing.resolve(request('processed', 'Completed')) + await operation + + expect(toastMocks.success).not.toHaveBeenCalled() + expect(toastMocks.info).not.toHaveBeenCalled() + expect(store.loading).toBe(false) + }) + + it('exposes a reset boundary that invalidates an in-flight request read', async () => { + const pending = deferred() + store.requests = [request('existing')] + store.stats = stats(3) + vi.mocked(queueApi.getUserRequests).mockReturnValue(pending.promise) + + const operation = store.fetchUserRequests() + const reset = (store as unknown as { $reset?: () => void }).$reset + const hasReset = typeof reset === 'function' + reset?.() + + pending.resolve([request('late')]) + await operation + + expect(hasReset).toBe(true) + expect(store.requests).toEqual([]) + expect(store.stats).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) +})