From b8da071b4e966f6890fca8a93da70b19f07b9ff5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:00:19 +0100 Subject: [PATCH 01/10] Add captureStore integration tests for API 500 error, query params, cancelItem, and triage polling lifecycle --- .../store/captureStore.integration.spec.ts | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts index f43ea297d..80af6cbc4 100644 --- a/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts @@ -5,7 +5,7 @@ * captureApi) catches any shape mismatches introduced between what the API * module returns and what the store state accepts. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import http from '../../api/http' import { useCaptureStore } from '../../store/captureStore' @@ -63,6 +63,10 @@ describe('captureStore — integration (real captureApi, mocked HTTP)', () => { vi.clearAllMocks() }) + afterEach(() => { + vi.useRealTimers() + }) + // ── listItems → store.items ─────────────────────────────────────────────── describe('fetchItems', () => { @@ -265,6 +269,128 @@ describe('captureStore — integration (real captureApi, mocked HTTP)', () => { }) }) + // ── createItem — API 500 error ───────────────────────────────────────── + + describe('createItem — server error', () => { + it('does not add a phantom summary when the API rejects with 500', async () => { + vi.mocked(http.post).mockRejectedValue({ response: { status: 500, data: { message: 'Internal Server Error' } } }) + + const store = useCaptureStore() + await expect(store.createItem({ boardId: null, text: 'will fail' })).rejects.toBeDefined() + + expect(store.items).toHaveLength(0) + // getErrorDisplay mock returns the fallback when error lacks .message + expect(store.actionError).toBe('Failed to capture item') + }) + }) + + // ── fetchItems — sorting query forwarding ──────────────────────────────── + + describe('fetchItems — query parameters', () => { + it('forwards boardId filter to the API URL', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + const store = useCaptureStore() + await store.fetchItems({ boardId: 'board-abc' }) + + const calledUrl: string = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('boardId=board-abc') + }) + + it('combines multiple query parameters in the URL', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + const store = useCaptureStore() + await store.fetchItems({ status: 'New', limit: 25 }) + + const calledUrl: string = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('status=New') + expect(calledUrl).toContain('limit=25') + }) + }) + + // ── cancelItem ──────────────────────────────────────────────────────────── + + describe('cancelItem', () => { + it('posts to cancel endpoint then refreshes detail', async () => { + vi.mocked(http.post).mockResolvedValue({ data: undefined }) + const cancelled = makeDetailPayload({ id: 'c-cancel', status: 'Failed' }) + vi.mocked(http.get).mockResolvedValue({ data: cancelled }) + + const store = useCaptureStore() + await store.cancelItem('c-cancel') + + expect(http.post).toHaveBeenCalledWith(expect.stringContaining('/capture/items/c-cancel/cancel')) + expect(store.detailById['c-cancel']?.status).toBe('Failed') + }) + + it('sets actionError when cancel POST fails', async () => { + vi.mocked(http.post).mockRejectedValue(new Error('cancel failed')) + + const store = useCaptureStore() + await expect(store.cancelItem('c-bad')).rejects.toBeInstanceOf(Error) + + expect(store.actionError).toBe('Failed to cancel capture item') + }) + }) + + // ── pollTriageCompletion — triage polling lifecycle ─────────────────────── + + describe('pollTriageCompletion', () => { + it('polls until the item reaches a terminal status, then clears triagePollingItemId', async () => { + vi.useFakeTimers() + + const store = useCaptureStore() + let callCount = 0 + vi.mocked(http.get).mockImplementation(async () => { + callCount++ + if (callCount < 3) { + return { data: makeDetailPayload({ id: 'c-poll', status: 'Triaging' }) } + } + return { data: makeDetailPayload({ id: 'c-poll', status: 'Triaged' }) } + }) + + const stop = store.pollTriageCompletion('c-poll') + expect(store.triagePollingItemId).toBe('c-poll') + + // Advance through 3 poll intervals (2s each) + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(2_000) + + // After reaching 'Triaged' (terminal), polling should have stopped + expect(store.triagePollingItemId).toBeNull() + expect(store.detailById['c-poll']?.status).toBe('Triaged') + + stop() // cleanup + vi.useRealTimers() + }) + + it('stops polling when the stop function is called', async () => { + vi.useFakeTimers() + + const store = useCaptureStore() + vi.mocked(http.get).mockResolvedValue({ data: makeDetailPayload({ id: 'c-stop', status: 'Triaging' }) }) + + const stop = store.pollTriageCompletion('c-stop') + expect(store.triagePollingItemId).toBe('c-stop') + + // Advance one interval + await vi.advanceTimersByTimeAsync(2_000) + + // Stop the poll manually + stop() + expect(store.triagePollingItemId).toBeNull() + + // Clear mocks and advance time — no more calls should happen + vi.clearAllMocks() + await vi.advanceTimersByTimeAsync(4_000) + expect(http.get).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + }) + // ── batchTriage ─────────────────────────────────────────────────────────── describe('batchTriage', () => { From 32a539220c6d518f7b2d0259dfde94a228e9cb31 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:00:28 +0100 Subject: [PATCH 02/10] Add boardStore integration tests for card snap-back on 409, deleteCard, and column CRUD --- .../store/boardStore.integration.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts index 1722ab4d8..ca30cf725 100644 --- a/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts @@ -297,6 +297,22 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => { expect(store.loading).toBe(false) }) + + it('retains original column and position when move API rejects with 409 (snap-back)', async () => { + const store = useBoardStore() + const card = makeCardPayload({ id: 'card-snap', columnId: 'col-1', position: 0 }) + store.currentBoardCards = [card] + + vi.mocked(http.post).mockRejectedValue({ response: { status: 409, data: { message: 'Stale position' } } }) + + await expect(store.moveCard('board-1', 'card-snap', 'col-2', 3)).rejects.toBeDefined() + + // The card should still be in the store (not removed) and in the original column + const storedCard = store.currentBoardCards.find(c => c.id === 'card-snap') + expect(storedCard).toBeDefined() + expect(storedCard?.columnId).toBe('col-1') + expect(storedCard?.position).toBe(0) + }) }) describe('updateCard', () => { @@ -325,6 +341,97 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => { }) }) + // ── deleteCard ───────────────────────────────────────────────────────── + + describe('deleteCard', () => { + it('calls DELETE /boards/:id/cards/:id and removes the card from local state', async () => { + const store = useBoardStore() + store.currentBoardCards = [ + makeCardPayload({ id: 'card-del', columnId: 'col-1' }), + makeCardPayload({ id: 'card-keep', columnId: 'col-1' }), + ] + + vi.mocked(http.delete).mockResolvedValue({ data: undefined }) + await store.deleteCard('board-1', 'card-del') + + expect(store.currentBoardCards.some(c => c.id === 'card-del')).toBe(false) + expect(store.currentBoardCards.some(c => c.id === 'card-keep')).toBe(true) + expect(http.delete).toHaveBeenCalledWith(expect.stringContaining('/boards/board-1/cards/card-del')) + }) + + it('does not remove a card when DELETE fails', async () => { + const store = useBoardStore() + store.currentBoardCards = [makeCardPayload({ id: 'card-fail' })] + + vi.mocked(http.delete).mockRejectedValue({ response: { status: 500, data: { message: 'Server error' } } }) + await expect(store.deleteCard('board-1', 'card-fail')).rejects.toBeDefined() + + expect(store.currentBoardCards.some(c => c.id === 'card-fail')).toBe(true) + }) + }) + + // ── column CRUD ─────────────────────────────────────────────────────────── + + describe('createColumn', () => { + it('posts to /boards/:id/columns and appends the returned column to currentBoard', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'My Board', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [], + } + + const newColumn = { id: 'col-new', name: 'Done', position: 0, wipLimit: null, cardCount: 0 } + vi.mocked(http.post).mockResolvedValue({ data: newColumn }) + + await store.createColumn('board-1', { name: 'Done', position: 0 }) + + expect(store.currentBoard?.columns).toHaveLength(1) + expect(store.currentBoard?.columns[0].name).toBe('Done') + expect(http.post).toHaveBeenCalledWith( + expect.stringContaining('/boards/board-1/columns'), + expect.objectContaining({ name: 'Done' }), + ) + }) + }) + + describe('deleteColumn', () => { + it('removes the column and its cards from local state', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'My Board', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [ + { id: 'col-1', name: 'Todo', position: 0, wipLimit: null, cardCount: 2 }, + { id: 'col-2', name: 'Done', position: 1, wipLimit: null, cardCount: 0 }, + ], + } + store.currentBoardCards = [ + makeCardPayload({ id: 'card-a', columnId: 'col-1' }), + makeCardPayload({ id: 'card-b', columnId: 'col-1' }), + makeCardPayload({ id: 'card-c', columnId: 'col-2' }), + ] + + vi.mocked(http.delete).mockResolvedValue({ data: undefined }) + await store.deleteColumn('board-1', 'col-1') + + // Column removed + expect(store.currentBoard?.columns).toHaveLength(1) + expect(store.currentBoard?.columns[0].id).toBe('col-2') + // Cards in deleted column removed + expect(store.currentBoardCards).toHaveLength(1) + expect(store.currentBoardCards[0].id).toBe('card-c') + }) + }) + // ── cardsByColumn getter ─────────────────────────────────────────────────── describe('cardsByColumn getter', () => { From 4d13bbff760a76a1f2158c3836f55f40de14fc79 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:00:34 +0100 Subject: [PATCH 03/10] Add notificationStore integration tests for unread count behavior and board-scoped filtering --- .../notificationStore.integration.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts index f67329334..b51360f49 100644 --- a/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts @@ -209,6 +209,90 @@ describe('notificationStore — integration (real notificationsApi, mocked HTTP) }) }) + // ── unread count behavior ────────────────────────────────────────────── + + describe('unread count behavior', () => { + it('unread count derives from isRead=false notifications after fetch', async () => { + const items = [ + makeNotification({ id: 'n-1', isRead: false }), + makeNotification({ id: 'n-2', isRead: false }), + makeNotification({ id: 'n-3', isRead: true }), + ] + vi.mocked(http.get).mockResolvedValue({ data: items }) + + const store = useNotificationStore() + await store.fetchNotifications() + + const unreadCount = store.notifications.filter(n => !n.isRead).length + expect(unreadCount).toBe(2) + }) + + it('unread count drops to 0 after markAllRead', async () => { + const store = useNotificationStore() + store.notifications = [ + makeNotification({ id: 'n-1', isRead: false }), + makeNotification({ id: 'n-2', isRead: false }), + makeNotification({ id: 'n-3', isRead: false }), + ] + + vi.mocked(http.post).mockResolvedValue({ data: { markedCount: 3 } }) + await store.markAllRead() + + const unreadCount = store.notifications.filter(n => !n.isRead).length + expect(unreadCount).toBe(0) + }) + + it('unread count decrements by 1 after marking a single notification as read', async () => { + const store = useNotificationStore() + store.notifications = [ + makeNotification({ id: 'n-1', isRead: false }), + makeNotification({ id: 'n-2', isRead: false }), + ] + + const readResponse = makeNotification({ id: 'n-1', isRead: true, readAt: '2026-02-01T00:00:00Z' }) + vi.mocked(http.post).mockResolvedValue({ data: readResponse }) + + await store.markAsRead('n-1') + + const unreadCount = store.notifications.filter(n => !n.isRead).length + expect(unreadCount).toBe(1) + }) + + it('adding a new unread notification locally increases the unread count', () => { + const store = useNotificationStore() + store.notifications = [ + makeNotification({ id: 'n-1', isRead: true }), + ] + + // Simulate a real-time notification arriving + store.notifications.unshift(makeNotification({ id: 'n-realtime', isRead: false })) + + const unreadCount = store.notifications.filter(n => !n.isRead).length + expect(unreadCount).toBe(1) + }) + }) + + // ── board-scoped mark all read and unread count ────────────────────────── + + describe('board-scoped unread count', () => { + it('only marks notifications for the specified board as read, preserving others unread', async () => { + const store = useNotificationStore() + store.notifications = [ + makeNotification({ id: 'n-1', boardId: 'board-A', isRead: false }), + makeNotification({ id: 'n-2', boardId: 'board-B', isRead: false }), + makeNotification({ id: 'n-3', boardId: 'board-A', isRead: false }), + ] + + vi.mocked(http.post).mockResolvedValue({ data: { markedCount: 2 } }) + await store.markAllRead('board-A') + + const boardAUnread = store.notifications.filter(n => n.boardId === 'board-A' && !n.isRead).length + const boardBUnread = store.notifications.filter(n => n.boardId === 'board-B' && !n.isRead).length + expect(boardAUnread).toBe(0) + expect(boardBUnread).toBe(1) + }) + }) + // ── preferences ─────────────────────────────────────────────────────────── describe('fetchPreferences', () => { From 93420a89c6ee49a8dd608a5f4ab6da9726d0128e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:00:42 +0100 Subject: [PATCH 04/10] Add sessionStore integration tests for changePassword and token expiry detection --- .../store/sessionStore.integration.spec.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/sessionStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/sessionStore.integration.spec.ts index 22b563e52..7740a4b10 100644 --- a/frontend/taskdeck-web/src/tests/store/sessionStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/sessionStore.integration.spec.ts @@ -240,6 +240,60 @@ describe('sessionStore — integration (real authApi, mocked HTTP)', () => { }) }) + // ── changePassword ──────────────────────────────────────────────────────── + + describe('changePassword', () => { + it('posts to /auth/change-password and clears loading on success', async () => { + vi.mocked(http.post).mockResolvedValue({ data: undefined }) + + const store = useSessionStore() + await store.changePassword({ currentPassword: 'old', newPassword: 'new123' }) + + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(http.post).toHaveBeenCalledWith( + '/auth/change-password', + expect.objectContaining({ currentPassword: 'old', newPassword: 'new123' }), + ) + }) + + it('sets error when POST /auth/change-password fails', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { data: { message: 'Current password is incorrect' } }, + }) + + const store = useSessionStore() + await expect(store.changePassword({ currentPassword: 'wrong', newPassword: 'new' })).rejects.toBeDefined() + + expect(store.error).toBe('Current password is incorrect') + expect(store.loading).toBe(false) + }) + }) + + // ── token expiry detection ─────────────────────────────────────────────── + + describe('token expiry', () => { + it('isAuthenticated returns false when the token has expired', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeAuthResponse(-60) }) + + const store = useSessionStore() + // Login with an already-expired token + await store.login({ usernameOrEmail: 'testuser', password: 'pass' }) + + // The token is expired, so isAuthenticated should be false + expect(store.isAuthenticated).toBe(false) + }) + + it('isAuthenticated returns true when the token is still valid', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeAuthResponse(3600) }) + + const store = useSessionStore() + await store.login({ usernameOrEmail: 'testuser', password: 'pass' }) + + expect(store.isAuthenticated).toBe(true) + }) + }) + // ── requireUserId ───────────────────────────────────────────────────────── describe('requireUserId', () => { From eff278875eb4c57d7ac511fe524e1dc0ac338b4e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:00:53 +0100 Subject: [PATCH 05/10] Add queueStore integration tests for loading state transitions and cancel isolation --- .../store/queueStore.integration.spec.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/queueStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStore.integration.spec.ts index d36924882..53a3fb905 100644 --- a/frontend/taskdeck-web/src/tests/store/queueStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/queueStore.integration.spec.ts @@ -254,6 +254,63 @@ describe('queueStore — integration (real queueApi, mocked HTTP)', () => { }) }) + // ── loading state transitions ────────────────────────────────────────── + + describe('loading state transitions', () => { + it('sets loading=true during fetchUserRequests and clears it after', async () => { + let loadingDuringRequest = false + vi.mocked(http.get).mockImplementation(async () => { + // Check loading state during the request + const store = useQueueStore() + loadingDuringRequest = store.loading + return { data: [] } + }) + + const store = useQueueStore() + await store.fetchUserRequests() + + expect(loadingDuringRequest).toBe(true) + expect(store.loading).toBe(false) + }) + + it('clears loading even when fetchUserRequests fails', async () => { + vi.mocked(http.get).mockRejectedValue(new Error('oops')) + + const store = useQueueStore() + await expect(store.fetchUserRequests()).rejects.toBeInstanceOf(Error) + + expect(store.loading).toBe(false) + }) + + it('sets loading=true during submitRequest and clears it after', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeRawRequest({ id: 'r-load' }) }) + + const store = useQueueStore() + await store.submitRequest({ requestType: 'Instruction', payload: 'test' }) + + expect(store.loading).toBe(false) + }) + }) + + // ── cancelRequest preserves other items ────────────────────────────────── + + describe('cancelRequest isolation', () => { + it('only removes the cancelled item and preserves all others', async () => { + const store = useQueueStore() + store.requests = [ + makeRawRequest({ id: 'req-1' }), + makeRawRequest({ id: 'req-2' }), + makeRawRequest({ id: 'req-3' }), + ] + + vi.mocked(http.post).mockResolvedValue({ data: undefined }) + await store.cancelRequest('req-2') + + expect(store.requests).toHaveLength(2) + expect(store.requests.map(r => r.id)).toEqual(['req-1', 'req-3']) + }) + }) + // ── fetchStats ──────────────────────────────────────────────────────────── describe('fetchStats', () => { From bc2182d02b28547a129f31a139520d81ece41ab2 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:01:04 +0100 Subject: [PATCH 06/10] Add archiveApi integration tests for listing, restore, URL encoding, and error handling --- .../store/archiveApi.integration.spec.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts new file mode 100644 index 000000000..8e2d87ea8 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts @@ -0,0 +1,196 @@ +/** + * archiveApi integration tests — verifies the archive API module boundary. + * + * No archiveStore exists; the archiveApi is consumed directly by ArchiveView. + * These tests exercise the archiveApi → http chain including error handling, + * query parameter construction, and response shape validation. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from '../../api/http' +import { archiveApi } from '../../api/archiveApi' +import type { ArchiveItem, RestoreArchiveResult } from '../../types/archive' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +function makeArchiveItem(overrides: Partial = {}): ArchiveItem { + return { + id: 'arch-1', + entityType: 'card', + entityId: 'card-1', + boardId: 'board-1', + name: 'Archived Card', + archivedByUserId: 'user-1', + archivedAt: '2026-01-01T00:00:00Z', + reason: null, + restoreStatus: 'Available', + restoredAt: null, + restoredByUserId: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +describe('archiveApi — integration (mocked HTTP)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ── getItems ────────────────────────────────────────────────────────────── + + describe('getItems', () => { + it('calls GET /archive/items and returns the response array', async () => { + const items = [makeArchiveItem(), makeArchiveItem({ id: 'arch-2', name: 'Second' })] + vi.mocked(http.get).mockResolvedValue({ data: items }) + + const result = await archiveApi.getItems() + + expect(result).toHaveLength(2) + expect(result[0].id).toBe('arch-1') + expect(result[1].id).toBe('arch-2') + expect(http.get).toHaveBeenCalledWith('/archive/items') + }) + + it('appends entityType filter to the query string', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + await archiveApi.getItems({ entityType: 'card' }) + + const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('entityType=card') + }) + + it('appends boardId filter to the query string', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + await archiveApi.getItems({ boardId: 'board-xyz' }) + + const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('boardId=board-xyz') + }) + + it('appends status filter to the query string', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + await archiveApi.getItems({ status: 'Available' }) + + const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('status=Available') + }) + + it('combines multiple filters in the query string', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + await archiveApi.getItems({ entityType: 'card', boardId: 'board-1', limit: 50 }) + + const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('entityType=card') + expect(calledUrl).toContain('boardId=board-1') + expect(calledUrl).toContain('limit=50') + }) + + it('propagates errors from the HTTP layer', async () => { + vi.mocked(http.get).mockRejectedValue(new Error('Network Error')) + + await expect(archiveApi.getItems()).rejects.toThrow('Network Error') + }) + }) + + // ── restoreItem ─────────────────────────────────────────────────────────── + + describe('restoreItem', () => { + it('posts to /archive/:entityType/:entityId/restore and returns the result', async () => { + const result: RestoreArchiveResult = { + success: true, + restoredEntityId: 'card-restored', + errorMessage: null, + resolvedName: 'My Card', + } + vi.mocked(http.post).mockResolvedValue({ data: result }) + + const response = await archiveApi.restoreItem('card', 'card-1', { + targetBoardId: 'board-1', + restoreMode: 0, + conflictStrategy: 0, + }) + + expect(response.success).toBe(true) + expect(response.restoredEntityId).toBe('card-restored') + expect(http.post).toHaveBeenCalledWith( + '/archive/card/card-1/restore', + expect.objectContaining({ targetBoardId: 'board-1' }), + ) + }) + + it('URL-encodes special characters in entityType and entityId', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { success: true, restoredEntityId: null, errorMessage: null, resolvedName: null }, + }) + + await archiveApi.restoreItem('card/type', 'id+special', { + targetBoardId: null, + restoreMode: 0, + conflictStrategy: 0, + }) + + const calledUrl = vi.mocked(http.post).mock.calls[0][0] as string + expect(calledUrl).toContain('card%2Ftype') + expect(calledUrl).toContain('id%2Bspecial') + }) + + it('returns failure result when the backend rejects the restore', async () => { + const failResult: RestoreArchiveResult = { + success: false, + restoredEntityId: null, + errorMessage: 'Board no longer exists', + resolvedName: null, + } + vi.mocked(http.post).mockResolvedValue({ data: failResult }) + + const response = await archiveApi.restoreItem('card', 'card-1', { + targetBoardId: 'deleted-board', + restoreMode: 0, + conflictStrategy: 0, + }) + + expect(response.success).toBe(false) + expect(response.errorMessage).toBe('Board no longer exists') + }) + + it('propagates HTTP errors from the restore endpoint', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { status: 404, data: { message: 'Archive item not found' } }, + }) + + await expect( + archiveApi.restoreItem('card', 'missing', { + targetBoardId: null, + restoreMode: 0, + conflictStrategy: 0, + }), + ).rejects.toBeDefined() + }) + + it('propagates 409 Conflict when restoring with name collision', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { status: 409, data: { message: 'Name conflict' } }, + }) + + await expect( + archiveApi.restoreItem('card', 'card-1', { + targetBoardId: 'board-1', + restoreMode: 0, + conflictStrategy: 0, + }), + ).rejects.toBeDefined() + }) + }) +}) From faa2d3a99927fddb1e758000dd1db2fcb0dbf5a5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:47:45 +0100 Subject: [PATCH 07/10] Use idiomatic toHaveBeenCalledWith assertions in archiveApi tests Replace manual mock.calls[0][0] URL extraction with expect(http.get).toHaveBeenCalledWith(expect.stringContaining(...)) for query param and URL encoding assertions. More idiomatic Vitest and less brittle. --- .../store/archiveApi.integration.spec.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts index 8e2d87ea8..de52fb9d2 100644 --- a/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts @@ -64,8 +64,7 @@ describe('archiveApi — integration (mocked HTTP)', () => { await archiveApi.getItems({ entityType: 'card' }) - const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string - expect(calledUrl).toContain('entityType=card') + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('entityType=card')) }) it('appends boardId filter to the query string', async () => { @@ -73,8 +72,7 @@ describe('archiveApi — integration (mocked HTTP)', () => { await archiveApi.getItems({ boardId: 'board-xyz' }) - const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string - expect(calledUrl).toContain('boardId=board-xyz') + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-xyz')) }) it('appends status filter to the query string', async () => { @@ -82,8 +80,7 @@ describe('archiveApi — integration (mocked HTTP)', () => { await archiveApi.getItems({ status: 'Available' }) - const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string - expect(calledUrl).toContain('status=Available') + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('status=Available')) }) it('combines multiple filters in the query string', async () => { @@ -91,10 +88,9 @@ describe('archiveApi — integration (mocked HTTP)', () => { await archiveApi.getItems({ entityType: 'card', boardId: 'board-1', limit: 50 }) - const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string - expect(calledUrl).toContain('entityType=card') - expect(calledUrl).toContain('boardId=board-1') - expect(calledUrl).toContain('limit=50') + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('entityType=card')) + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-1')) + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('limit=50')) }) it('propagates errors from the HTTP layer', async () => { @@ -141,9 +137,14 @@ describe('archiveApi — integration (mocked HTTP)', () => { conflictStrategy: 0, }) - const calledUrl = vi.mocked(http.post).mock.calls[0][0] as string - expect(calledUrl).toContain('card%2Ftype') - expect(calledUrl).toContain('id%2Bspecial') + expect(http.post).toHaveBeenCalledWith( + expect.stringContaining('card%2Ftype'), + expect.any(Object), + ) + expect(http.post).toHaveBeenCalledWith( + expect.stringContaining('id%2Bspecial'), + expect.any(Object), + ) }) it('returns failure result when the backend rejects the restore', async () => { From a1677f64a79f5b7efd8cd4979aa8915c82e5d0ee Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:48:37 +0100 Subject: [PATCH 08/10] Fix captureStore test comment, add poll count assertion, use idiomatic matchers - Fix misleading comment on 500 error test: getErrorDisplay mock always returns fallback, not conditionally - Add callCount assertion to pollTriageCompletion to verify expected number of poll attempts - Replace manual mock.calls URL extraction with toHaveBeenCalledWith(expect.stringContaining(...)) for query param tests --- .../src/tests/store/captureStore.integration.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts index 80af6cbc4..048e0c69d 100644 --- a/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/captureStore.integration.spec.ts @@ -279,7 +279,7 @@ describe('captureStore — integration (real captureApi, mocked HTTP)', () => { await expect(store.createItem({ boardId: null, text: 'will fail' })).rejects.toBeDefined() expect(store.items).toHaveLength(0) - // getErrorDisplay mock returns the fallback when error lacks .message + // getErrorDisplay is mocked to always return the fallback string expect(store.actionError).toBe('Failed to capture item') }) }) @@ -293,8 +293,7 @@ describe('captureStore — integration (real captureApi, mocked HTTP)', () => { const store = useCaptureStore() await store.fetchItems({ boardId: 'board-abc' }) - const calledUrl: string = vi.mocked(http.get).mock.calls[0][0] as string - expect(calledUrl).toContain('boardId=board-abc') + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-abc')) }) it('combines multiple query parameters in the URL', async () => { @@ -303,9 +302,8 @@ describe('captureStore — integration (real captureApi, mocked HTTP)', () => { const store = useCaptureStore() await store.fetchItems({ status: 'New', limit: 25 }) - const calledUrl: string = vi.mocked(http.get).mock.calls[0][0] as string - expect(calledUrl).toContain('status=New') - expect(calledUrl).toContain('limit=25') + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('status=New')) + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('limit=25')) }) }) @@ -361,6 +359,8 @@ describe('captureStore — integration (real captureApi, mocked HTTP)', () => { // After reaching 'Triaged' (terminal), polling should have stopped expect(store.triagePollingItemId).toBeNull() expect(store.detailById['c-poll']?.status).toBe('Triaged') + // Verify the expected number of poll attempts executed + expect(callCount).toBe(3) stop() // cleanup vi.useRealTimers() From 507277aa7dcc8254af0b265d6bb7cb2cf4e4bf35 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:49:01 +0100 Subject: [PATCH 09/10] Clarify boardStore moveCard 409 test: no optimistic mutation to snap back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store calls the API before updating local state, so on rejection the card is never modified. Updated test name and comment to reflect this — there is no snap-back because there was no optimistic mutation. --- .../src/tests/store/boardStore.integration.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts index ca30cf725..dbffd53e9 100644 --- a/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/boardStore.integration.spec.ts @@ -298,7 +298,7 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => { expect(store.loading).toBe(false) }) - it('retains original column and position when move API rejects with 409 (snap-back)', async () => { + it('retains original column and position when move API rejects with 409', async () => { const store = useBoardStore() const card = makeCardPayload({ id: 'card-snap', columnId: 'col-1', position: 0 }) store.currentBoardCards = [card] @@ -307,7 +307,8 @@ describe('boardStore — integration (real API module, mocked HTTP)', () => { await expect(store.moveCard('board-1', 'card-snap', 'col-2', 3)).rejects.toBeDefined() - // The card should still be in the store (not removed) and in the original column + // moveCard calls the API before updating local state (no optimistic mutation), + // so on rejection the card is never modified — verify it remains intact const storedCard = store.currentBoardCards.find(c => c.id === 'card-snap') expect(storedCard).toBeDefined() expect(storedCard?.columnId).toBe('col-1') From 10fcb3450b1c5dce1fa33e5a2a5c7bf0e8416190 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:49:17 +0100 Subject: [PATCH 10/10] Clarify notificationStore unread count tests document derived behavior The store does not expose an unreadCount computed, so these tests verify the expected derivation from the notifications array after store actions rather than testing a store getter. --- .../src/tests/store/notificationStore.integration.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts index b51360f49..140c5e1ed 100644 --- a/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/notificationStore.integration.spec.ts @@ -210,6 +210,8 @@ describe('notificationStore — integration (real notificationsApi, mocked HTTP) }) // ── unread count behavior ────────────────────────────────────────────── + // The store does not expose an unreadCount computed; these tests document + // the expected derivation from the notifications array after store actions. describe('unread count behavior', () => { it('unread count derives from isRead=false notifications after fetch', async () => {