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..de52fb9d2 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/archiveApi.integration.spec.ts @@ -0,0 +1,197 @@ +/** + * 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' }) + + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('entityType=card')) + }) + + it('appends boardId filter to the query string', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + await archiveApi.getItems({ boardId: 'board-xyz' }) + + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('boardId=board-xyz')) + }) + + it('appends status filter to the query string', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + await archiveApi.getItems({ status: 'Available' }) + + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('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 }) + + 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 () => { + 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, + }) + + 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 () => { + 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() + }) + }) +}) 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..dbffd53e9 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,23 @@ 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', 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() + + // 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') + expect(storedCard?.position).toBe(0) + }) }) describe('updateCard', () => { @@ -325,6 +342,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', () => { 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..048e0c69d 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 is mocked to always return the fallback string + 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' }) + + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('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 }) + + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('status=New')) + expect(http.get).toHaveBeenCalledWith(expect.stringContaining('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') + // Verify the expected number of poll attempts executed + expect(callCount).toBe(3) + + 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', () => { 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..140c5e1ed 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,92 @@ 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 () => { + 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', () => { 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', () => { 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', () => {