diff --git a/frontend/taskdeck-web/src/tests/store/boardStore.columnReorder.spec.ts b/frontend/taskdeck-web/src/tests/store/boardStore.columnReorder.spec.ts new file mode 100644 index 00000000..23d93306 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/boardStore.columnReorder.spec.ts @@ -0,0 +1,399 @@ +/** + * boardStore column reorder and stale reconciliation integration tests. + * + * These tests exercise column reorder → API confirms → all cards maintain + * correct column association, card update conflict (409) handling, and + * board data refresh while a card edit is in flight. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import http from '../../api/http' +import { useBoardStore } from '../../store/boardStore' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +function makeColumn(overrides: Partial> = {}) { + return { + id: 'col-1', + name: 'Todo', + position: 0, + wipLimit: null, + cardCount: 0, + ...overrides, + } +} + +function makeCard(overrides: Partial> = {}) { + return { + id: 'card-1', + boardId: 'board-1', + columnId: 'col-1', + title: 'Task', + description: '', + position: 0, + dueDate: null, + isBlocked: false, + blockReason: null, + labels: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +describe('boardStore — column reorder and stale reconciliation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // ── column reorder ─────────────────────────────────────────────────────── + + describe('reorderColumns', () => { + it('sends POST /boards/:id/columns/reorder and updates local column order', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'Test Board', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [ + makeColumn({ id: 'col-a', name: 'Todo', position: 0 }), + makeColumn({ id: 'col-b', name: 'In Progress', position: 1 }), + makeColumn({ id: 'col-c', name: 'Done', position: 2 }), + ], + } + + // API returns the columns in the new order + const reorderedColumns = [ + makeColumn({ id: 'col-c', name: 'Done', position: 0 }), + makeColumn({ id: 'col-a', name: 'Todo', position: 1 }), + makeColumn({ id: 'col-b', name: 'In Progress', position: 2 }), + ] + vi.mocked(http.post).mockResolvedValue({ data: reorderedColumns }) + + await store.reorderColumns('board-1', ['col-c', 'col-a', 'col-b']) + + expect(store.currentBoard?.columns).toHaveLength(3) + expect(store.currentBoard?.columns[0].id).toBe('col-c') + expect(store.currentBoard?.columns[1].id).toBe('col-a') + expect(store.currentBoard?.columns[2].id).toBe('col-b') + expect(http.post).toHaveBeenCalledWith( + '/boards/board-1/columns/reorder', + expect.objectContaining({ columnIds: ['col-c', 'col-a', 'col-b'] }), + ) + }) + + it('preserves cards in their correct columns after column reorder', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'Test Board', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [ + makeColumn({ id: 'col-a', position: 0 }), + makeColumn({ id: 'col-b', position: 1 }), + ], + } + store.currentBoardCards = [ + makeCard({ id: 'card-1', columnId: 'col-a', position: 0 }), + makeCard({ id: 'card-2', columnId: 'col-a', position: 1 }), + makeCard({ id: 'card-3', columnId: 'col-b', position: 0 }), + ] + + const reorderedColumns = [ + makeColumn({ id: 'col-b', position: 0 }), + makeColumn({ id: 'col-a', position: 1 }), + ] + vi.mocked(http.post).mockResolvedValue({ data: reorderedColumns }) + + await store.reorderColumns('board-1', ['col-b', 'col-a']) + + // Cards must remain in their original columns + const colACards = store.currentBoardCards.filter(c => c.columnId === 'col-a') + const colBCards = store.currentBoardCards.filter(c => c.columnId === 'col-b') + expect(colACards).toHaveLength(2) + expect(colBCards).toHaveLength(1) + expect(colACards.map(c => c.id)).toEqual(expect.arrayContaining(['card-1', 'card-2'])) + expect(colBCards[0].id).toBe('card-3') + }) + + it('does not corrupt column state when reorder API fails', async () => { + const store = useBoardStore() + const originalColumns = [ + makeColumn({ id: 'col-a', position: 0 }), + makeColumn({ id: 'col-b', position: 1 }), + ] + store.currentBoard = { + id: 'board-1', + name: 'Test Board', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [...originalColumns], + } + + vi.mocked(http.post).mockRejectedValue({ + response: { status: 409, data: { message: 'Stale board state' } }, + }) + + await expect( + store.reorderColumns('board-1', ['col-b', 'col-a']), + ).rejects.toMatchObject({ + response: { status: 409 }, + }) + + // On failure, columns must not be mutated to the new order + // (the API call is atomic — either it succeeds and we update, or it fails and we keep original) + expect(store.currentBoard?.columns).toHaveLength(2) + expect(store.currentBoard?.columns[0].id).toBe('col-a') + expect(store.currentBoard?.columns[1].id).toBe('col-b') + }) + }) + + // ── updateCard 409 Conflict ─────────────────────────────────────────────── + + describe('updateCard — 409 Conflict', () => { + it('does not corrupt card state when PATCH /boards/:id/cards/:id returns 409', async () => { + const store = useBoardStore() + const original = makeCard({ + id: 'card-conflict', + title: 'Original Title', + updatedAt: '2026-01-01T00:00:00Z', + }) + store.currentBoardCards = [original] + + vi.mocked(http.patch).mockRejectedValue({ + response: { status: 409, data: { message: 'Card was modified by another user' } }, + }) + + await expect( + store.updateCard('board-1', 'card-conflict', { + title: 'Updated Title', + description: null, + dueDate: null, + isBlocked: null, + blockReason: null, + labelIds: null, + }), + ).rejects.toMatchObject({ + response: { status: 409 }, + }) + + // Card must retain its original title — no partial update applied + const stored = store.currentBoardCards.find(c => c.id === 'card-conflict') + expect(stored?.title).toBe('Original Title') + }) + + it('passes expectedUpdatedAt from existing card to detect stale edits', async () => { + const store = useBoardStore() + const original = makeCard({ + id: 'card-stale', + title: 'Original', + updatedAt: '2026-01-15T10:30:00Z', + }) + store.currentBoardCards = [original] + + const updated = makeCard({ + id: 'card-stale', + title: 'Updated', + updatedAt: '2026-01-15T11:00:00Z', + }) + vi.mocked(http.patch).mockResolvedValue({ data: updated }) + + await store.updateCard('board-1', 'card-stale', { + title: 'Updated', + description: null, + dueDate: null, + isBlocked: null, + blockReason: null, + labelIds: null, + }) + + // The PATCH body should include the expectedUpdatedAt from the existing card + const patchBody = vi.mocked(http.patch).mock.calls[0][1] as Record + expect(patchBody.expectedUpdatedAt).toBe('2026-01-15T10:30:00Z') + }) + }) + + // ── moveCard column card count tracking ────────────────────────────────── + + describe('moveCard — column card count tracking', () => { + it('decrements source column count and increments target column count on successful move', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'Test', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [ + makeColumn({ id: 'col-src', name: 'Source', position: 0, cardCount: 2 }), + makeColumn({ id: 'col-dst', name: 'Destination', position: 1, cardCount: 1 }), + ], + } + store.currentBoardCards = [ + makeCard({ id: 'card-move', columnId: 'col-src', position: 0 }), + makeCard({ id: 'card-stay', columnId: 'col-src', position: 1 }), + makeCard({ id: 'card-existing', columnId: 'col-dst', position: 0 }), + ] + + const moved = makeCard({ id: 'card-move', columnId: 'col-dst', position: 1 }) + vi.mocked(http.post).mockResolvedValue({ data: moved }) + + await store.moveCard('board-1', 'card-move', 'col-dst', 1) + + const srcCol = store.currentBoard?.columns.find(c => c.id === 'col-src') + const dstCol = store.currentBoard?.columns.find(c => c.id === 'col-dst') + expect(srcCol?.cardCount).toBe(1) // was 2, now 1 + expect(dstCol?.cardCount).toBe(2) // was 1, now 2 + }) + + it('does not change card counts when move within the same column', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'Test', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [ + makeColumn({ id: 'col-1', name: 'Todo', position: 0, cardCount: 3 }), + ], + } + store.currentBoardCards = [ + makeCard({ id: 'card-a', columnId: 'col-1', position: 0 }), + makeCard({ id: 'card-b', columnId: 'col-1', position: 1 }), + makeCard({ id: 'card-c', columnId: 'col-1', position: 2 }), + ] + + // Move within same column — just reposition + const moved = makeCard({ id: 'card-a', columnId: 'col-1', position: 2 }) + vi.mocked(http.post).mockResolvedValue({ data: moved }) + + await store.moveCard('board-1', 'card-a', 'col-1', 2) + + const col = store.currentBoard?.columns.find(c => c.id === 'col-1') + expect(col?.cardCount).toBe(3) // unchanged + }) + }) + + // ── updateColumn ────────────────────────────────────────────────────────── + + describe('updateColumn', () => { + it('sends PATCH /boards/:id/columns/:id and updates the local column record', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'Test', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [ + makeColumn({ id: 'col-rename', name: 'Old Name', wipLimit: null }), + ], + } + + const updated = makeColumn({ id: 'col-rename', name: 'New Name', wipLimit: 5 }) + vi.mocked(http.patch).mockResolvedValue({ data: updated }) + + await store.updateColumn('board-1', 'col-rename', { name: 'New Name', wipLimit: 5 }) + + expect(store.currentBoard?.columns[0].name).toBe('New Name') + expect(store.currentBoard?.columns[0].wipLimit).toBe(5) + expect(http.patch).toHaveBeenCalledWith( + '/boards/board-1/columns/col-rename', + expect.objectContaining({ name: 'New Name', wipLimit: 5 }), + ) + }) + + it('does not corrupt column when update fails', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'Test', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [ + makeColumn({ id: 'col-safe', name: 'Original' }), + ], + } + + vi.mocked(http.patch).mockRejectedValue({ + response: { status: 500, data: { message: 'Server error' } }, + }) + + await expect( + store.updateColumn('board-1', 'col-safe', { name: 'Will fail' }), + ).rejects.toMatchObject({ + response: { status: 500 }, + }) + + // Column must retain original name + expect(store.currentBoard?.columns[0].name).toBe('Original') + }) + }) + + // ── board data refresh preserves editing card ───────────────────────────── + + describe('editingCardId preservation', () => { + it('board operations do not clear editingCardId', async () => { + const store = useBoardStore() + store.currentBoard = { + id: 'board-1', + name: 'Test', + description: '', + isArchived: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + columns: [makeColumn({ id: 'col-1' })], + } + store.currentBoardCards = [ + makeCard({ id: 'card-editing', columnId: 'col-1' }), + ] + + // User starts editing a card + store.setEditingCard('card-editing') + expect(store.editingCardId).toBe('card-editing') + + // Another card is created — this should not clear editingCardId + const newCard = makeCard({ id: 'card-new', columnId: 'col-1', position: 1 }) + vi.mocked(http.post).mockResolvedValue({ data: newCard }) + await store.createCard('board-1', { columnId: 'col-1', title: 'New Card', description: '' }) + + expect(store.editingCardId).toBe('card-editing') + }) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/chatApi.integration.spec.ts b/frontend/taskdeck-web/src/tests/store/chatApi.integration.spec.ts new file mode 100644 index 00000000..0f2713a9 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/chatApi.integration.spec.ts @@ -0,0 +1,402 @@ +/** + * chatApi integration tests — full API module boundary with mocked HTTP. + * + * No chatStore exists; the chatApi is consumed directly by views and composables. + * These tests exercise the chatApi → http chain covering the full chat lifecycle: + * session creation, message accumulation, session listing, health checks, and + * error propagation for each endpoint. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from '../../api/http' +import { chatApi } from '../../api/chatApi' +import type { ChatMessage, ChatProviderHealth, ChatSession } from '../../types/chat' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +function makeChatSession(overrides: Partial = {}): ChatSession { + return { + id: 'session-1', + userId: 'user-1', + boardId: null, + title: 'Test Session', + status: 'Active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + recentMessages: [], + ...overrides, + } +} + +function makeChatMessage(overrides: Partial = {}): ChatMessage { + return { + id: 'msg-1', + sessionId: 'session-1', + role: 'User', + content: 'Hello', + messageType: 'text', + proposalId: null, + tokenUsage: null, + createdAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +function makeHealthPayload(overrides: Partial = {}): ChatProviderHealth { + return { + isAvailable: true, + providerName: 'Mock', + errorMessage: null, + model: 'mock-default', + isMock: true, + isProbed: false, + verificationStatus: 'unverified', + ...overrides, + } +} + +describe('chatApi — integration (mocked HTTP)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ── createSession ────────────────────────────────────────────────────────── + + describe('createSession', () => { + it('posts to /llm/chat/sessions with title and boardId', async () => { + const session = makeChatSession() + vi.mocked(http.post).mockResolvedValue({ data: session }) + + const result = await chatApi.createSession({ title: 'Test Session', boardId: null }) + + expect(result.id).toBe('session-1') + expect(result.title).toBe('Test Session') + expect(http.post).toHaveBeenCalledWith('/llm/chat/sessions', { + title: 'Test Session', + boardId: null, + }) + }) + + it('associates session with a board when boardId is provided', async () => { + const session = makeChatSession({ boardId: 'board-42' }) + vi.mocked(http.post).mockResolvedValue({ data: session }) + + const result = await chatApi.createSession({ title: 'Board Chat', boardId: 'board-42' }) + + expect(result.boardId).toBe('board-42') + expect(http.post).toHaveBeenCalledWith('/llm/chat/sessions', { + title: 'Board Chat', + boardId: 'board-42', + }) + }) + + it('propagates errors from the create session endpoint', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { status: 503, data: { message: 'Provider unavailable' } }, + }) + + await expect(chatApi.createSession({ title: 'Fail' })).rejects.toMatchObject({ + response: { status: 503 }, + }) + }) + }) + + // ── getMySessions ────────────────────────────────────────────────────────── + + describe('getMySessions', () => { + it('calls GET /llm/chat/sessions and returns the session list', async () => { + const sessions = [ + makeChatSession({ id: 'session-1' }), + makeChatSession({ id: 'session-2', title: 'Second' }), + ] + vi.mocked(http.get).mockResolvedValue({ data: sessions }) + + const result = await chatApi.getMySessions() + + expect(result).toHaveLength(2) + expect(result[0].id).toBe('session-1') + expect(result[1].id).toBe('session-2') + expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions') + }) + + it('returns empty array when user has no sessions', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + const result = await chatApi.getMySessions() + + expect(result).toHaveLength(0) + }) + + it('propagates network errors from session listing', async () => { + vi.mocked(http.get).mockRejectedValue(new Error('Network Error')) + + await expect(chatApi.getMySessions()).rejects.toThrow('Network Error') + }) + }) + + // ── getSession ───────────────────────────────────────────────────────────── + + describe('getSession', () => { + it('calls GET /llm/chat/sessions/:id and returns session with recent messages', async () => { + const session = makeChatSession({ + recentMessages: [ + makeChatMessage({ id: 'msg-1', role: 'User', content: 'Hello' }), + makeChatMessage({ id: 'msg-2', role: 'Assistant', content: 'Hi there' }), + ], + }) + vi.mocked(http.get).mockResolvedValue({ data: session }) + + const result = await chatApi.getSession('session-1') + + expect(result.recentMessages).toHaveLength(2) + expect(result.recentMessages[0].role).toBe('User') + expect(result.recentMessages[1].role).toBe('Assistant') + expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions/session-1') + }) + + it('URL-encodes special characters in the session ID', async () => { + vi.mocked(http.get).mockResolvedValue({ data: makeChatSession({ id: 'session/special' }) }) + + await chatApi.getSession('session/special') + + expect(http.get).toHaveBeenCalledWith('/llm/chat/sessions/session%2Fspecial') + }) + + it('propagates 404 when session does not exist', async () => { + vi.mocked(http.get).mockRejectedValue({ + response: { status: 404, data: { message: 'Session not found' } }, + }) + + await expect(chatApi.getSession('missing')).rejects.toMatchObject({ + response: { status: 404 }, + }) + }) + }) + + // ── sendMessage ──────────────────────────────────────────────────────────── + + describe('sendMessage', () => { + it('posts to /llm/chat/sessions/:id/messages with content', async () => { + const message = makeChatMessage({ id: 'msg-new', content: 'Create a task', role: 'User' }) + vi.mocked(http.post).mockResolvedValue({ data: message }) + + const result = await chatApi.sendMessage('session-1', { + content: 'Create a task', + requestProposal: false, + }) + + expect(result.id).toBe('msg-new') + expect(result.content).toBe('Create a task') + expect(http.post).toHaveBeenCalledWith('/llm/chat/sessions/session-1/messages', { + content: 'Create a task', + requestProposal: false, + }) + }) + + it('includes requestProposal flag for proposal-generating messages', async () => { + const message = makeChatMessage({ + id: 'msg-proposal', + content: 'Add card "Fix login"', + messageType: 'text', + }) + vi.mocked(http.post).mockResolvedValue({ data: message }) + + await chatApi.sendMessage('session-1', { + content: 'Add card "Fix login"', + requestProposal: true, + }) + + expect(http.post).toHaveBeenCalledWith( + '/llm/chat/sessions/session-1/messages', + expect.objectContaining({ requestProposal: true }), + ) + }) + + it('URL-encodes special characters in the session ID for message posting', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeChatMessage() }) + + await chatApi.sendMessage('session/id+special', { content: 'test' }) + + const calledUrl = vi.mocked(http.post).mock.calls[0][0] as string + expect(calledUrl).toContain('session%2Fid%2Bspecial') + }) + + it('returns messages with proposal references when the assistant generates a proposal', async () => { + const assistantMsg = makeChatMessage({ + id: 'msg-assistant', + role: 'Assistant', + content: 'I created a proposal to add card "Fix login"', + messageType: 'proposal-reference', + proposalId: 'proposal-abc', + }) + vi.mocked(http.post).mockResolvedValue({ data: assistantMsg }) + + const result = await chatApi.sendMessage('session-1', { + content: 'Add card', + requestProposal: true, + }) + + expect(result.messageType).toBe('proposal-reference') + expect(result.proposalId).toBe('proposal-abc') + }) + + it('returns degraded messages when the provider is partially available', async () => { + const degradedMsg = makeChatMessage({ + id: 'msg-degraded', + role: 'Assistant', + content: 'I understood your request but could not fully process it.', + messageType: 'degraded', + degradedReason: 'Rate limit exceeded', + }) + vi.mocked(http.post).mockResolvedValue({ data: degradedMsg }) + + const result = await chatApi.sendMessage('session-1', { content: 'test' }) + + expect(result.messageType).toBe('degraded') + expect(result.degradedReason).toBe('Rate limit exceeded') + }) + + it('propagates 503 when the LLM provider is unavailable', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { status: 503, data: { message: 'LLM provider unavailable' } }, + }) + + await expect( + chatApi.sendMessage('session-1', { content: 'test' }), + ).rejects.toMatchObject({ + response: { status: 503 }, + }) + }) + }) + + // ── getHealth ────────────────────────────────────────────────────────────── + + describe('getHealth', () => { + it('calls GET /llm/chat/health without probe by default', async () => { + vi.mocked(http.get).mockResolvedValue({ data: makeHealthPayload() }) + + const result = await chatApi.getHealth() + + expect(result.isAvailable).toBe(true) + expect(result.providerName).toBe('Mock') + expect(http.get).toHaveBeenCalledWith('/llm/chat/health') + }) + + it('appends probe=true when probe option is set', async () => { + vi.mocked(http.get).mockResolvedValue({ data: makeHealthPayload({ isProbed: true }) }) + + const result = await chatApi.getHealth({ probe: true }) + + expect(result.isProbed).toBe(true) + expect(http.get).toHaveBeenCalledWith('/llm/chat/health?probe=true') + }) + + it('does not append probe parameter when probe is false', async () => { + vi.mocked(http.get).mockResolvedValue({ data: makeHealthPayload() }) + + await chatApi.getHealth({ probe: false }) + + expect(http.get).toHaveBeenCalledWith('/llm/chat/health') + }) + + it('reports provider unavailability from health response', async () => { + const unhealthy = makeHealthPayload({ + isAvailable: false, + errorMessage: 'API key expired', + providerName: 'OpenAI', + isMock: false, + }) + vi.mocked(http.get).mockResolvedValue({ data: unhealthy }) + + const result = await chatApi.getHealth() + + expect(result.isAvailable).toBe(false) + expect(result.errorMessage).toBe('API key expired') + }) + + it('propagates errors from the health endpoint', async () => { + vi.mocked(http.get).mockRejectedValue(new Error('Timeout')) + + await expect(chatApi.getHealth()).rejects.toThrow('Timeout') + }) + }) + + // ── full lifecycle: session → messages ───────────────────────────────────── + + describe('full lifecycle', () => { + it('creates a session and accumulates multiple messages', async () => { + const session = makeChatSession({ id: 'lifecycle-session' }) + vi.mocked(http.post).mockResolvedValueOnce({ data: session }) + + const created = await chatApi.createSession({ title: 'Lifecycle Test' }) + expect(created.id).toBe('lifecycle-session') + + // Send first user message + const userMsg = makeChatMessage({ id: 'msg-1', sessionId: 'lifecycle-session', role: 'User', content: 'Hello' }) + vi.mocked(http.post).mockResolvedValueOnce({ data: userMsg }) + + const msg1 = await chatApi.sendMessage('lifecycle-session', { content: 'Hello' }) + expect(msg1.sessionId).toBe('lifecycle-session') + + // Send second message with proposal + const proposalMsg = makeChatMessage({ + id: 'msg-2', + sessionId: 'lifecycle-session', + role: 'Assistant', + content: 'Created proposal', + messageType: 'proposal-reference', + proposalId: 'prop-1', + }) + vi.mocked(http.post).mockResolvedValueOnce({ data: proposalMsg }) + + const msg2 = await chatApi.sendMessage('lifecycle-session', { + content: 'Create card "Fix bug"', + requestProposal: true, + }) + expect(msg2.proposalId).toBe('prop-1') + + // Reload session with accumulated messages + const reloadedSession = makeChatSession({ + id: 'lifecycle-session', + recentMessages: [userMsg, proposalMsg], + }) + vi.mocked(http.get).mockResolvedValueOnce({ data: reloadedSession }) + + const reloaded = await chatApi.getSession('lifecycle-session') + expect(reloaded.recentMessages).toHaveLength(2) + expect(reloaded.recentMessages[1].proposalId).toBe('prop-1') + }) + + it('handles tool call metadata in assistant messages', async () => { + const toolCallJson = JSON.stringify({ + rounds: 2, + total_tokens: 150, + tool_calls: [ + { round: 1, tool: 'create_card', args: { title: 'Fix bug' }, result_summary: 'Card created', is_error: false }, + ], + }) + const assistantMsg = makeChatMessage({ + id: 'msg-tool', + role: 'Assistant', + content: 'Done', + toolCallMetadataJson: toolCallJson, + }) + vi.mocked(http.post).mockResolvedValue({ data: assistantMsg }) + + const result = await chatApi.sendMessage('session-1', { content: 'Create card' }) + + expect(result.toolCallMetadataJson).toBe(toolCallJson) + // Verify the JSON can be parsed to the expected shape + const parsed = JSON.parse(result.toolCallMetadataJson!) + expect(parsed.rounds).toBe(2) + expect(parsed.tool_calls[0].tool).toBe('create_card') + }) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/notificationStore.realtime.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStore.realtime.spec.ts new file mode 100644 index 00000000..b038dec1 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/notificationStore.realtime.spec.ts @@ -0,0 +1,341 @@ +/** + * notificationStore — realtime arrival, stale reconciliation, and loading state tests. + * + * These tests exercise: + * - Simulated real-time notification arrival (push to local state) + * - Stale state reconciliation on re-fetch (server has newer data) + * - Loading state transitions for all async operations + * - Concurrent operations (markAsRead during fetch) + * - Error recovery (error cleared on successful retry) + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import http from '../../api/http' +import { useNotificationStore } from '../../store/notificationStore' +import type { NotificationItem } from '../../types/notifications' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }), +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (_error: unknown, fallback: string) => ({ message: fallback }), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +function makeNotification(overrides: Partial = {}): NotificationItem { + return { + id: 'n-1', + userId: 'u-1', + boardId: null, + type: 'Mention', + cadence: 'Immediate', + title: 'You were mentioned', + message: 'Someone mentioned you in a comment', + sourceEntityType: 'card', + sourceEntityId: 'card-1', + isRead: false, + readAt: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +describe('notificationStore — realtime and extended scenarios', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + // ── simulated real-time notification arrival ───────────────────────────── + + describe('real-time notification arrival', () => { + it('unshift of a new notification increments derived unread count', async () => { + const store = useNotificationStore() + + // Initial state: one read notification + vi.mocked(http.get).mockResolvedValue({ + data: [makeNotification({ id: 'n-existing', isRead: true })], + }) + await store.fetchNotifications() + expect(store.notifications.filter(n => !n.isRead)).toHaveLength(0) + + // Simulate real-time arrival + store.notifications.unshift( + makeNotification({ id: 'n-realtime-1', isRead: false, title: 'New mention' }), + ) + + const unread = store.notifications.filter(n => !n.isRead) + expect(unread).toHaveLength(1) + expect(unread[0].id).toBe('n-realtime-1') + }) + + it('multiple real-time arrivals accumulate correctly', () => { + const store = useNotificationStore() + store.notifications = [ + makeNotification({ id: 'n-1', isRead: true }), + ] + + // Three new notifications arrive + store.notifications.unshift( + makeNotification({ id: 'n-rt-1', isRead: false }), + ) + store.notifications.unshift( + makeNotification({ id: 'n-rt-2', isRead: false }), + ) + store.notifications.unshift( + makeNotification({ id: 'n-rt-3', isRead: false }), + ) + + expect(store.notifications).toHaveLength(4) + const unread = store.notifications.filter(n => !n.isRead) + expect(unread).toHaveLength(3) + }) + + it('markAllRead clears all real-time arrivals', async () => { + const store = useNotificationStore() + store.notifications = [ + makeNotification({ id: 'n-old', isRead: false }), + ] + + // Simulate two real-time arrivals + store.notifications.unshift( + makeNotification({ id: 'n-rt-1', isRead: false }), + ) + store.notifications.unshift( + makeNotification({ id: 'n-rt-2', isRead: false }), + ) + expect(store.notifications.filter(n => !n.isRead)).toHaveLength(3) + + vi.mocked(http.post).mockResolvedValue({ data: { markedCount: 3 } }) + await store.markAllRead() + + expect(store.notifications.filter(n => !n.isRead)).toHaveLength(0) + expect(store.notifications).toHaveLength(3) // items are still there, just marked read + }) + }) + + // ── stale reconciliation on re-fetch ────────────────────────────────────── + + describe('stale state reconciliation', () => { + it('replaces local state with fresh server data on re-fetch', async () => { + const store = useNotificationStore() + + // Initial fetch + vi.mocked(http.get).mockResolvedValueOnce({ + data: [ + makeNotification({ id: 'n-1', isRead: false }), + makeNotification({ id: 'n-2', isRead: false }), + ], + }) + await store.fetchNotifications() + expect(store.notifications).toHaveLength(2) + + // Re-fetch: server has new notifications and one was read server-side + vi.mocked(http.get).mockResolvedValueOnce({ + data: [ + makeNotification({ id: 'n-3', isRead: false, title: 'New' }), + makeNotification({ id: 'n-1', isRead: true, readAt: '2026-02-01T00:00:00Z' }), + makeNotification({ id: 'n-2', isRead: false }), + ], + }) + await store.fetchNotifications() + + expect(store.notifications).toHaveLength(3) + const n1 = store.notifications.find(n => n.id === 'n-1') + expect(n1?.isRead).toBe(true) + expect(n1?.readAt).toBe('2026-02-01T00:00:00Z') + }) + + it('removes server-deleted notifications on re-fetch', async () => { + const store = useNotificationStore() + + vi.mocked(http.get).mockResolvedValueOnce({ + data: [ + makeNotification({ id: 'n-1' }), + makeNotification({ id: 'n-2' }), + ], + }) + await store.fetchNotifications() + + // Re-fetch: n-1 was deleted server-side + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeNotification({ id: 'n-2' })], + }) + await store.fetchNotifications() + + expect(store.notifications).toHaveLength(1) + expect(store.notifications[0].id).toBe('n-2') + }) + }) + + // ── loading state transitions ────────────────────────────────────────── + + describe('loading state transitions', () => { + it('sets loading=true during fetchNotifications and clears after', async () => { + let loadingDuringFetch = false + vi.mocked(http.get).mockImplementation(async () => { + const store = useNotificationStore() + loadingDuringFetch = store.loading + return { data: [] } + }) + + const store = useNotificationStore() + await store.fetchNotifications() + + expect(loadingDuringFetch).toBe(true) + expect(store.loading).toBe(false) + }) + + it('clears loading even when fetchNotifications fails', async () => { + vi.mocked(http.get).mockRejectedValue(new Error('timeout')) + + const store = useNotificationStore() + await expect(store.fetchNotifications()).rejects.toBeInstanceOf(Error) + + expect(store.loading).toBe(false) + }) + + it('sets loading=true during fetchPreferences and clears after', async () => { + let loadingDuringFetch = false + vi.mocked(http.get).mockImplementation(async () => { + const store = useNotificationStore() + loadingDuringFetch = store.loading + return { data: { userId: 'u-1', inAppChannelEnabled: true } } + }) + + const store = useNotificationStore() + await store.fetchPreferences() + + expect(loadingDuringFetch).toBe(true) + expect(store.loading).toBe(false) + }) + + it('sets loading=true during updatePreferences and clears after', async () => { + vi.mocked(http.put).mockResolvedValue({ + data: { userId: 'u-1', mentionImmediateEnabled: false }, + }) + + const store = useNotificationStore() + await store.updatePreferences({ + inAppChannelEnabled: true, + mentionImmediateEnabled: false, + mentionDigestEnabled: true, + assignmentImmediateEnabled: true, + assignmentDigestEnabled: false, + proposalOutcomeImmediateEnabled: true, + proposalOutcomeDigestEnabled: false, + }) + + expect(store.loading).toBe(false) + }) + }) + + // ── error recovery ─────────────────────────────────────────────────────── + + describe('error recovery', () => { + it('clears error on successful retry after a failed fetch', async () => { + const store = useNotificationStore() + + // First attempt fails + vi.mocked(http.get).mockRejectedValueOnce(new Error('network')) + await expect(store.fetchNotifications()).rejects.toBeInstanceOf(Error) + expect(store.error).toBe('Failed to load notifications') + + // Retry succeeds + vi.mocked(http.get).mockResolvedValueOnce({ data: [makeNotification()] }) + await store.fetchNotifications() + + expect(store.error).toBeNull() + expect(store.notifications).toHaveLength(1) + }) + + it('updates notification state on successful retry even when error from prior failure persists', async () => { + const store = useNotificationStore() + store.notifications = [makeNotification({ id: 'n-err', isRead: false })] + + // First markAsRead fails — error is set + vi.mocked(http.post).mockRejectedValueOnce(new Error('server error')) + await expect(store.markAsRead('n-err')).rejects.toBeInstanceOf(Error) + expect(store.error).toBe('Failed to mark notification as read') + + // Retry succeeds — notification is updated even though error persists + // (markAsRead only sets error on failure; it does not clear it on success) + const readNotification = makeNotification({ id: 'n-err', isRead: true, readAt: '2026-02-01T00:00:00Z' }) + vi.mocked(http.post).mockResolvedValueOnce({ data: readNotification }) + await store.markAsRead('n-err') + + expect(store.notifications[0].isRead).toBe(true) + // Error from the prior failure is still set — not a bug, just markAsRead's current behavior + expect(store.error).toBe('Failed to mark notification as read') + }) + }) + + // ── board-scoped notification filtering ────────────────────────────────── + + describe('board-scoped notification filtering', () => { + it('fetches only board-specific notifications with boardId filter', async () => { + vi.mocked(http.get).mockResolvedValue({ + data: [ + makeNotification({ id: 'n-board-a', boardId: 'board-A' }), + ], + }) + + const store = useNotificationStore() + await store.fetchNotifications({ boardId: 'board-A' }) + + expect(store.notifications).toHaveLength(1) + expect(store.notifications[0].boardId).toBe('board-A') + const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('boardId=board-A') + }) + + it('combines unreadOnly and boardId filters', async () => { + vi.mocked(http.get).mockResolvedValue({ data: [] }) + + const store = useNotificationStore() + await store.fetchNotifications({ boardId: 'board-X', unreadOnly: true }) + + const calledUrl = vi.mocked(http.get).mock.calls[0][0] as string + expect(calledUrl).toContain('boardId=board-X') + expect(calledUrl).toContain('unreadOnly=true') + }) + }) + + // ── markAsRead idempotency ─────────────────────────────────────────────── + + describe('markAsRead idempotency', () => { + it('marking an already-read notification does not change its readAt timestamp', async () => { + const store = useNotificationStore() + store.notifications = [ + makeNotification({ id: 'n-already-read', isRead: true, readAt: '2026-01-15T00:00:00Z' }), + ] + + const sameNotification = makeNotification({ + id: 'n-already-read', + isRead: true, + readAt: '2026-01-15T00:00:00Z', + }) + vi.mocked(http.post).mockResolvedValue({ data: sameNotification }) + + await store.markAsRead('n-already-read') + + expect(store.notifications[0].readAt).toBe('2026-01-15T00:00:00Z') + }) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/queueStore.polling.spec.ts b/frontend/taskdeck-web/src/tests/store/queueStore.polling.spec.ts new file mode 100644 index 00000000..a689c7d5 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/queueStore.polling.spec.ts @@ -0,0 +1,329 @@ +/** + * queueStore polling and state transition integration tests. + * + * These tests exercise: + * - Queue item state transitions (Pending → Processing → Completed) + * - Server-side item deletion (phantom entry removal) + * - Stale state reconciliation on re-fetch + * - Concurrent operations (submit while fetch in flight) + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import http from '../../api/http' +import { useQueueStore } from '../../store/queueStore' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }), +})) + +vi.mock('../../store/sessionStore', () => ({ + useSessionStore: () => ({ + userId: 'user-1', + requireUserId: vi.fn().mockReturnValue('user-1'), + }), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (error: unknown, fallback: string) => { + if (error instanceof Error && error.message.trim().length > 0) { + return { message: error.message, code: null } + } + return { message: fallback, code: null } + }, +})) + +function makeRequest(overrides: Partial> = {}) { + return { + id: 'req-1', + userId: 'user-1', + boardId: 'board-1', + requestType: 'Instruction', + status: 'Pending', + errorMessage: null, + createdAt: '2026-01-01T00:00:00Z', + processedAt: null, + retryCount: 0, + ...overrides, + } +} + +describe('queueStore — polling and state transitions', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + // ── state transitions via re-fetch ──────────────────────────────────────── + + describe('state transitions via sequential fetches', () => { + it('reflects Pending → Processing → Completed transition across re-fetches', async () => { + const store = useQueueStore() + + // First fetch: item is Pending + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-transition', status: 'Pending' })], + }) + await store.fetchUserRequests() + expect(store.requests[0].status).toBe('Pending') + + // Second fetch: item is Processing + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-transition', status: 'Processing' })], + }) + await store.fetchUserRequests() + expect(store.requests[0].status).toBe('Processing') + + // Third fetch: item is Completed + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-transition', status: 'Completed', processedAt: '2026-01-02T00:00:00Z' })], + }) + await store.fetchUserRequests() + expect(store.requests[0].status).toBe('Completed') + expect(store.requests[0].processedAt).toBe('2026-01-02T00:00:00Z') + }) + + it('reflects Pending → Failed transition with error message', async () => { + const store = useQueueStore() + + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-fail', status: 'Pending' })], + }) + await store.fetchUserRequests() + expect(store.requests[0].status).toBe('Pending') + + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-fail', status: 'Failed', errorMessage: 'Provider timeout' })], + }) + await store.fetchUserRequests() + expect(store.requests[0].status).toBe('Failed') + expect(store.requests[0].errorMessage).toBe('Provider timeout') + }) + }) + + // ── server-side deletion (phantom entry) ────────────────────────────────── + + describe('server-side deletion', () => { + it('removes items from store that no longer exist on the server', async () => { + const store = useQueueStore() + + // Initial fetch: two items + vi.mocked(http.get).mockResolvedValueOnce({ + data: [ + makeRequest({ id: 'req-alive' }), + makeRequest({ id: 'req-ghost' }), + ], + }) + await store.fetchUserRequests() + expect(store.requests).toHaveLength(2) + + // Re-fetch: server deleted req-ghost + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-alive' })], + }) + await store.fetchUserRequests() + expect(store.requests).toHaveLength(1) + expect(store.requests[0].id).toBe('req-alive') + }) + + it('handles empty list when all items are deleted server-side', async () => { + const store = useQueueStore() + + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-temp' })], + }) + await store.fetchUserRequests() + expect(store.requests).toHaveLength(1) + + vi.mocked(http.get).mockResolvedValueOnce({ data: [] }) + await store.fetchUserRequests() + expect(store.requests).toHaveLength(0) + }) + }) + + // ── stale state reconciliation ──────────────────────────────────────────── + + describe('stale state reconciliation', () => { + it('replaces cached items with fresh data on re-fetch', async () => { + const store = useQueueStore() + + // Initial fetch: old data + vi.mocked(http.get).mockResolvedValueOnce({ + data: [ + makeRequest({ id: 'req-stale', status: 'Pending', retryCount: 0 }), + ], + }) + await store.fetchUserRequests() + + // Re-fetch: server returns updated data (retried) + vi.mocked(http.get).mockResolvedValueOnce({ + data: [ + makeRequest({ id: 'req-stale', status: 'Processing', retryCount: 2 }), + ], + }) + await store.fetchUserRequests() + + expect(store.requests[0].retryCount).toBe(2) + expect(store.requests[0].status).toBe('Processing') + }) + + it('adds new server-side items that were not in local state', async () => { + const store = useQueueStore() + + // Initial fetch: one item + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-1' })], + }) + await store.fetchUserRequests() + + // Re-fetch: server has a new item (e.g., submitted from another device) + vi.mocked(http.get).mockResolvedValueOnce({ + data: [ + makeRequest({ id: 'req-1' }), + makeRequest({ id: 'req-2', status: 'Processing' }), + ], + }) + await store.fetchUserRequests() + + expect(store.requests).toHaveLength(2) + expect(store.requests.find(r => r.id === 'req-2')?.status).toBe('Processing') + }) + }) + + // ── submit while existing items present ────────────────────────────────── + + describe('submit preserves existing items', () => { + it('appends new request to the end of the existing list', async () => { + const store = useQueueStore() + + // Start with one existing request + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-existing', status: 'Processing' })], + }) + await store.fetchUserRequests() + + // Submit a new request + const newRequest = makeRequest({ id: 'req-submitted', status: 'Pending' }) + vi.mocked(http.post).mockResolvedValue({ data: newRequest }) + + await store.submitRequest({ + requestType: 'Instruction', + payload: 'new task', + boardId: 'board-1', + }) + + expect(store.requests).toHaveLength(2) + expect(store.requests[0].id).toBe('req-existing') + expect(store.requests[1].id).toBe('req-submitted') + }) + }) + + // ── cancel while other items in-flight ──────────────────────────────────── + + describe('cancel isolation', () => { + it('cancel only removes the targeted item, leaving others in their current state', async () => { + const store = useQueueStore() + store.requests = [ + makeRequest({ id: 'req-cancel', status: 'Pending' }), + makeRequest({ id: 'req-processing', status: 'Processing' }), + makeRequest({ id: 'req-completed', status: 'Completed' }), + ] + + vi.mocked(http.post).mockResolvedValue({ data: undefined }) + await store.cancelRequest('req-cancel') + + expect(store.requests).toHaveLength(2) + expect(store.requests.map(r => r.id)).toEqual(['req-processing', 'req-completed']) + // Other items must retain their original status + expect(store.requests[0].status).toBe('Processing') + expect(store.requests[1].status).toBe('Completed') + }) + + it('keeps the item in the list when cancel API fails', async () => { + const store = useQueueStore() + store.requests = [makeRequest({ id: 'req-cant-cancel', status: 'Processing' })] + + vi.mocked(http.post).mockRejectedValue({ + response: { status: 409, data: { message: 'Cannot cancel a processing request' } }, + }) + + await expect(store.cancelRequest('req-cant-cancel')).rejects.toMatchObject({ + response: { status: 409 }, + }) + + // Item must still be in the list + expect(store.requests).toHaveLength(1) + expect(store.requests[0].id).toBe('req-cant-cancel') + }) + }) + + // ── processNext interaction with existing state ────────────────────────── + + describe('processNext', () => { + it('returns the processed request with normalized status', async () => { + const processed = makeRequest({ + id: 'req-processed', + status: 'Completed', + processedAt: '2026-02-01T00:00:00Z', + }) + vi.mocked(http.post).mockResolvedValue({ data: processed }) + + const store = useQueueStore() + const result = await store.processNext() + + expect(result?.status).toBe('Completed') + expect(result?.processedAt).toBe('2026-02-01T00:00:00Z') + }) + + it('normalizes numeric status from processNext response', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: makeRequest({ id: 'req-numeric', status: 2 }), + }) + + const store = useQueueStore() + const result = await store.processNext() + + expect(result?.status).toBe('Completed') + }) + }) + + // ── fetchStats updates stats independently from requests ────────────────── + + describe('fetchStats independence', () => { + it('updates stats without affecting the requests list', async () => { + const store = useQueueStore() + + // Load requests + vi.mocked(http.get).mockResolvedValueOnce({ + data: [makeRequest({ id: 'req-1' })], + }) + await store.fetchUserRequests() + expect(store.requests).toHaveLength(1) + + // Fetch stats separately + vi.mocked(http.get).mockResolvedValueOnce({ + data: { pendingCount: 10, processingCount: 3, completedCount: 50, failedCount: 2 }, + }) + await store.fetchStats() + + // Both should coexist + expect(store.requests).toHaveLength(1) + expect(store.stats?.pendingCount).toBe(10) + expect(store.stats?.completedCount).toBe(50) + }) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/sessionStore.oidc.spec.ts b/frontend/taskdeck-web/src/tests/store/sessionStore.oidc.spec.ts new file mode 100644 index 00000000..d24cf63c --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/sessionStore.oidc.spec.ts @@ -0,0 +1,304 @@ +/** + * sessionStore — OIDC/SSO exchange and extended session lifecycle tests. + * + * These tests cover: + * - OIDC code exchange flow (exchangeOidcCode) + * - Token structure validation + * - Session state consistency after login and logout + * - Error message mapping from API responses + * - Loading state transitions + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import http from '../../api/http' +import { useSessionStore } from '../../store/sessionStore' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }), +})) + +// Helper: build a compact base64url-encoded JWT with the given claims. +function toBase64Url(value: string): string { + return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +function fakeJwt(expOffsetSeconds = 3600): string { + const header = toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const exp = Math.floor(Date.now() / 1000) + expOffsetSeconds + const payload = toBase64Url(JSON.stringify({ exp })) + return `${header}.${payload}.fakesig` +} + +function makeAuthResponse(expOffsetSeconds = 3600) { + return { + token: fakeJwt(expOffsetSeconds), + user: { + id: 'user-oidc', + username: 'oidcuser', + email: 'oidc@example.com', + defaultRole: 2, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + } +} + +describe('sessionStore — OIDC exchange and extended lifecycle', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + }) + + // ── exchangeOidcCode ───────────────────────────────────────────────────── + + describe('exchangeOidcCode', () => { + it('posts to /auth/oidc/exchange and establishes a session on success', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeAuthResponse() }) + + const store = useSessionStore() + await store.exchangeOidcCode('oidc-auth-code-xyz') + + expect(store.isAuthenticated).toBe(true) + expect(store.userId).toBe('user-oidc') + expect(store.username).toBe('oidcuser') + expect(store.email).toBe('oidc@example.com') + expect(store.token).not.toBeNull() + expect(http.post).toHaveBeenCalledWith('/auth/oidc/exchange', { code: 'oidc-auth-code-xyz' }) + }) + + it('persists the OIDC session to localStorage', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeAuthResponse() }) + + const store = useSessionStore() + await store.exchangeOidcCode('oidc-code') + + expect(localStorage.getItem('taskdeck_token')).toBe(store.token) + const session = JSON.parse(localStorage.getItem('taskdeck_session') ?? '{}') + expect(session.userId).toBe('user-oidc') + expect(session.username).toBe('oidcuser') + }) + + it('sets error when OIDC exchange fails with expired code', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { data: { message: 'OIDC code expired or invalid' } }, + }) + + const store = useSessionStore() + await expect(store.exchangeOidcCode('expired-code')).rejects.toMatchObject({ + response: { data: { message: 'OIDC code expired or invalid' } }, + }) + + expect(store.isAuthenticated).toBe(false) + expect(store.error).toBe('OIDC code expired or invalid') + }) + + it('uses generic fallback when OIDC exchange fails without a message', async () => { + vi.mocked(http.post).mockRejectedValue(new Error('Network timeout')) + + const store = useSessionStore() + await expect(store.exchangeOidcCode('bad-code')).rejects.toThrow('Network timeout') + + expect(store.isAuthenticated).toBe(false) + expect(store.error).toBe('Network timeout') + }) + + it('clears loading state after OIDC exchange success', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeAuthResponse() }) + + const store = useSessionStore() + await store.exchangeOidcCode('oidc-code') + + expect(store.loading).toBe(false) + }) + + it('clears loading state after OIDC exchange failure', async () => { + vi.mocked(http.post).mockRejectedValue(new Error('fail')) + + const store = useSessionStore() + await expect(store.exchangeOidcCode('bad')).rejects.toThrow('fail') + + expect(store.loading).toBe(false) + }) + }) + + // ── logout clears all session state ────────────────────────────────────── + + describe('logout clears comprehensive state', () => { + it('clears token, claims, expiresAt, and localStorage after OIDC session', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeAuthResponse() }) + + const store = useSessionStore() + await store.exchangeOidcCode('oidc-code') + expect(store.isAuthenticated).toBe(true) + expect(store.expiresAt).not.toBeNull() + + store.logout() + + expect(store.token).toBeNull() + expect(store.userId).toBeNull() + expect(store.username).toBeNull() + expect(store.email).toBeNull() + expect(store.defaultRole).toBeNull() + expect(store.expiresAt).toBeNull() + expect(store.isAuthenticated).toBe(false) + expect(localStorage.getItem('taskdeck_token')).toBeNull() + expect(localStorage.getItem('taskdeck_session')).toBeNull() + }) + + it('does not leave authentication artifacts after logout following a failed login', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { data: { message: 'Login failed' } }, + }) + + const store = useSessionStore() + await expect(store.login({ usernameOrEmail: 'bad', password: 'bad' })).rejects.toMatchObject({ + response: { data: { message: 'Login failed' } }, + }) + expect(store.error).toBe('Login failed') + + store.logout() + + // clearSession does not explicitly null out error, but all auth artifacts must be gone + expect(store.isAuthenticated).toBe(false) + expect(store.token).toBeNull() + expect(store.userId).toBeNull() + // Note: store.error persists through logout — this is current behavior, not a bug. + // The error is from the login attempt and will be cleared on the next login/register call. + }) + }) + + // ── sequential login → logout → login ────────────────────────────────── + + describe('sequential session lifecycle', () => { + it('fully resets between login → logout → re-login with different user', async () => { + const store = useSessionStore() + + // Login as user A + const userA = makeAuthResponse() + userA.user.id = 'user-a' + userA.user.username = 'alice' + vi.mocked(http.post).mockResolvedValueOnce({ data: userA }) + await store.login({ usernameOrEmail: 'alice', password: 'pass' }) + expect(store.userId).toBe('user-a') + + // Logout + store.logout() + expect(store.userId).toBeNull() + + // Login as user B + const userB = makeAuthResponse() + userB.user.id = 'user-b' + userB.user.username = 'bob' + vi.mocked(http.post).mockResolvedValueOnce({ data: userB }) + await store.login({ usernameOrEmail: 'bob', password: 'pass' }) + + expect(store.userId).toBe('user-b') + expect(store.username).toBe('bob') + // No residual data from user A + const session = JSON.parse(localStorage.getItem('taskdeck_session') ?? '{}') + expect(session.userId).toBe('user-b') + }) + }) + + // ── sessionState computed consistency ───────────────────────────────────── + + describe('sessionState computed', () => { + it('reflects current state as a snapshot object', async () => { + vi.mocked(http.post).mockResolvedValue({ data: makeAuthResponse() }) + + const store = useSessionStore() + await store.exchangeOidcCode('code') + + const snapshot = store.sessionState + expect(snapshot.userId).toBe('user-oidc') + expect(snapshot.username).toBe('oidcuser') + expect(snapshot.email).toBe('oidc@example.com') + expect(snapshot.isAuthenticated).toBe(true) + expect(snapshot.token).not.toBeNull() + expect(snapshot.expiresAt).not.toBeNull() + }) + + it('reflects unauthenticated state before login', () => { + const store = useSessionStore() + + const snapshot = store.sessionState + expect(snapshot.userId).toBeNull() + expect(snapshot.isAuthenticated).toBe(false) + expect(snapshot.token).toBeNull() + }) + }) + + // ── token validation ────────────────────────────────────────────────────── + + describe('token validation edge cases', () => { + it('does not establish a session when token has invalid JWT structure', async () => { + const badAuth = { + token: 'not-a-jwt', + user: { + id: 'user-bad', + username: 'baduser', + email: 'bad@example.com', + defaultRole: 2, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + } + vi.mocked(http.post).mockResolvedValue({ data: badAuth }) + + const store = useSessionStore() + await store.login({ usernameOrEmail: 'user', password: 'pass' }) + + // setSession returns early before assigning any in-memory state when the + // token has an invalid JWT structure, so the store stays unauthenticated. + expect(store.isAuthenticated).toBe(false) + expect(store.token).toBeNull() + expect(store.userId).toBeNull() + expect(localStorage.getItem('taskdeck_token')).toBeNull() + }) + }) + + // ── loading state during operations ─────────────────────────────────────── + + describe('loading transitions', () => { + it('sets loading=true during login and clears after success', async () => { + let loadingDuringRequest = false + vi.mocked(http.post).mockImplementation(async () => { + const store = useSessionStore() + loadingDuringRequest = store.loading + return { data: makeAuthResponse() } + }) + + const store = useSessionStore() + await store.login({ usernameOrEmail: 'test', password: 'pass' }) + + expect(loadingDuringRequest).toBe(true) + expect(store.loading).toBe(false) + }) + + it('sets loading=true during register and clears after failure', async () => { + vi.mocked(http.post).mockRejectedValue({ + response: { data: { message: 'Email already taken' } }, + }) + + const store = useSessionStore() + await expect(store.register({ username: 'dup', email: 'dup@example.com', password: 'pass' })).rejects.toMatchObject({ + response: { data: { message: 'Email already taken' } }, + }) + + expect(store.loading).toBe(false) + }) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/workspaceStore.modePersistence.spec.ts b/frontend/taskdeck-web/src/tests/store/workspaceStore.modePersistence.spec.ts new file mode 100644 index 00000000..aa53e963 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/workspaceStore.modePersistence.spec.ts @@ -0,0 +1,387 @@ +/** + * workspaceStore — mode persistence, concurrent preference requests, and + * summary clearing integration tests. + * + * These tests exercise: + * - localStorage persistence of workspace mode + * - Concurrent/overlapping preference requests (version guards) + * - clearHomeSummary / clearTodaySummary behavior + * - Mode fallback when localStorage contains invalid values + * - Loading state during multiple overlapping requests + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import http from '../../api/http' +import { useWorkspaceStore } from '../../store/workspaceStore' +import { WORKSPACE_MODE_STORAGE_KEY } from '../../utils/storageKeys' +import type { HomeSummary, TodaySummary, WorkspaceOnboarding } from '../../types/workspace' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }), +})) + +vi.mock('../../store/sessionStore', () => ({ + useSessionStore: () => ({ isAuthenticated: true }), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +function makeOnboarding(overrides: Partial = {}): WorkspaceOnboarding { + return { + visibility: 'active', + isComplete: false, + currentStepId: 'create-first-board', + dismissedAt: null, + completedAt: null, + steps: [], + ...overrides, + } +} + +function makePreferencePayload(mode: 'guided' | 'workbench' | 'agent' = 'guided') { + return { + userId: 'u-1', + workspaceMode: mode, + onboarding: makeOnboarding(), + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } +} + +function makeHomeSummary(overrides: Partial = {}): HomeSummary { + return { + workspaceMode: 'guided', + isFirstRun: false, + onboarding: makeOnboarding(), + workload: { + capturesNeedingTriage: 0, + capturesInProgress: 0, + capturesReadyForFollowUp: 0, + proposalsPendingReview: 0, + }, + boards: { + totalBoards: 1, + recentBoardsCount: 1, + recentBoards: [], + }, + recommendedActions: [], + ...overrides, + } +} + +function makeTodaySummary(overrides: Partial = {}): TodaySummary { + return { + workspaceMode: 'guided', + onboarding: makeOnboarding(), + summary: { + capturesNeedingTriage: 0, + proposalsPendingReview: 0, + overdueCards: 0, + dueTodayCards: 0, + blockedCards: 0, + }, + overdueCards: [], + dueTodayCards: [], + blockedCards: [], + recommendedActions: [], + ...overrides, + } +} + +describe('workspaceStore — mode persistence and extended scenarios', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + }) + + // ── localStorage mode persistence ───────────────────────────────────────── + + describe('mode persistence in localStorage', () => { + it('persists mode to localStorage when updateMode is called', async () => { + vi.mocked(http.put).mockResolvedValue({ data: makePreferencePayload('workbench') }) + + const store = useWorkspaceStore() + await store.updateMode('workbench') + + expect(localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY)).toBe('workbench') + }) + + it('reads mode from localStorage on store initialization', () => { + localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, 'agent') + + const store = useWorkspaceStore() + + expect(store.mode).toBe('agent') + }) + + it('defaults to guided when localStorage contains an invalid mode string', () => { + localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, 'invalid-mode') + + const store = useWorkspaceStore() + + expect(store.mode).toBe('guided') + }) + + it('defaults to guided when localStorage is empty', () => { + const store = useWorkspaceStore() + + expect(store.mode).toBe('guided') + }) + + it('persists mode from hydratePreferences response', async () => { + vi.mocked(http.get).mockResolvedValue({ data: makePreferencePayload('agent') }) + + const store = useWorkspaceStore() + await store.hydratePreferences() + + expect(localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY)).toBe('agent') + }) + }) + + // ── concurrent preference requests (version guard) ──────────────────────── + + describe('concurrent preference requests', () => { + it('only applies the most recent updateMode response when calls overlap', async () => { + const store = useWorkspaceStore() + let firstResolve!: (val: unknown) => void + let secondResolve!: (val: unknown) => void + + vi.mocked(http.put) + .mockReturnValueOnce( + new Promise((resolve) => { firstResolve = resolve }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { secondResolve = resolve }), + ) + + // Fire two concurrent updateMode calls + const first = store.updateMode('workbench') + const second = store.updateMode('agent') + + // Local mode should reflect the latest call immediately + expect(store.mode).toBe('agent') + + // Resolve the second (latest) first + secondResolve({ data: makePreferencePayload('agent') }) + await Promise.resolve() + await Promise.resolve() + + // Resolve the first (stale) after + firstResolve({ data: makePreferencePayload('workbench') }) + + await first + await second + + // The stale response should be discarded by the version guard + expect(store.mode).toBe('agent') + expect(localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY)).toBe('agent') + }) + }) + + // ── clearHomeSummary / clearTodaySummary ────────────────────────────────── + + describe('clearHomeSummary', () => { + it('resets homeSummary and homeError to null', async () => { + vi.mocked(http.get).mockResolvedValue({ data: makeHomeSummary() }) + + const store = useWorkspaceStore() + await store.fetchHomeSummary() + expect(store.homeSummary).not.toBeNull() + + store.clearHomeSummary() + + expect(store.homeSummary).toBeNull() + expect(store.homeError).toBeNull() + }) + + it('resets badge counts to zero after clearing home summary', async () => { + vi.mocked(http.get).mockResolvedValue({ + data: makeHomeSummary({ + workload: { + capturesNeedingTriage: 5, + capturesInProgress: 0, + capturesReadyForFollowUp: 0, + proposalsPendingReview: 3, + }, + }), + }) + + const store = useWorkspaceStore() + await store.fetchHomeSummary() + expect(store.inboxBadgeCount).toBe(5) + + store.clearHomeSummary() + + expect(store.inboxBadgeCount).toBe(0) + expect(store.reviewBadgeCount).toBe(0) + }) + }) + + describe('clearTodaySummary', () => { + it('resets todaySummary and todayError to null', async () => { + vi.mocked(http.get).mockResolvedValue({ data: makeTodaySummary() }) + + const store = useWorkspaceStore() + await store.fetchTodaySummary() + expect(store.todaySummary).not.toBeNull() + + store.clearTodaySummary() + + expect(store.todaySummary).toBeNull() + expect(store.todayError).toBeNull() + }) + }) + + // ── home/today loading state ────────────────────────────────────────────── + + describe('homeLoading transitions', () => { + it('sets homeLoading=true during fetchHomeSummary and clears after', async () => { + let loadingDuringFetch = false + vi.mocked(http.get).mockImplementation(async () => { + const store = useWorkspaceStore() + loadingDuringFetch = store.homeLoading + return { data: makeHomeSummary() } + }) + + const store = useWorkspaceStore() + await store.fetchHomeSummary() + + expect(loadingDuringFetch).toBe(true) + expect(store.homeLoading).toBe(false) + }) + + it('clears homeLoading even when fetchHomeSummary fails', async () => { + vi.mocked(http.get).mockRejectedValue(new Error('timeout')) + + const store = useWorkspaceStore() + await expect(store.fetchHomeSummary()).rejects.toBeInstanceOf(Error) + + expect(store.homeLoading).toBe(false) + }) + }) + + describe('todayLoading transitions', () => { + it('sets todayLoading=true during fetchTodaySummary and clears after', async () => { + let loadingDuringFetch = false + vi.mocked(http.get).mockImplementation(async () => { + const store = useWorkspaceStore() + loadingDuringFetch = store.todayLoading + return { data: makeTodaySummary() } + }) + + const store = useWorkspaceStore() + await store.fetchTodaySummary() + + expect(loadingDuringFetch).toBe(true) + expect(store.todayLoading).toBe(false) + }) + }) + + // ── fetchHomeSummary syncs mode from server ───────────────────────────── + + describe('fetchHomeSummary mode sync', () => { + it('overrides local mode with the mode from home summary', async () => { + localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, 'guided') + + vi.mocked(http.get).mockResolvedValue({ + data: makeHomeSummary({ workspaceMode: 'agent' }), + }) + + const store = useWorkspaceStore() + expect(store.mode).toBe('guided') + + await store.fetchHomeSummary() + + expect(store.mode).toBe('agent') + expect(localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY)).toBe('agent') + }) + }) + + // ── resetForLogout ──────────────────────────────────────────────────────── + + describe('resetForLogout preserves localStorage mode', () => { + it('restores mode from localStorage after reset', async () => { + localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, 'workbench') + + vi.mocked(http.get).mockResolvedValue({ + data: makeHomeSummary({ workspaceMode: 'agent' }), + }) + + const store = useWorkspaceStore() + await store.fetchHomeSummary() + expect(store.mode).toBe('agent') + + // fetchHomeSummary calls applyMode('agent') which persists 'agent' to localStorage, + // overwriting the original 'workbench' value. So after resetForLogout, mode reads + // from localStorage which now contains 'agent'. + store.resetForLogout() + + expect(store.mode).toBe('agent') + expect(store.homeSummary).toBeNull() + expect(store.todaySummary).toBeNull() + expect(store.preferencesHydrated).toBe(false) + }) + + it('falls back to localStorage mode that was not overwritten by server sync', async () => { + localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, 'workbench') + + const store = useWorkspaceStore() + expect(store.mode).toBe('workbench') + + // Directly change in-memory mode without persisting (simulating a partial state) + // Actually, updateMode persists, so we test the normal flow: + // the store initializes from localStorage, and resetForLogout re-reads it. + store.resetForLogout() + + expect(store.mode).toBe('workbench') + expect(localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY)).toBe('workbench') + }) + }) + + // ── onboarding sync across summaries ────────────────────────────────────── + + describe('onboarding sync across home and today summaries', () => { + it('updateOnboarding patches both home and today summaries simultaneously', async () => { + const store = useWorkspaceStore() + store.homeSummary = makeHomeSummary() + store.todaySummary = makeTodaySummary() + + const dismissed = makeOnboarding({ visibility: 'dismissed' }) + vi.mocked(http.put).mockResolvedValue({ data: dismissed }) + + await store.updateOnboarding('dismiss') + + expect(store.onboarding?.visibility).toBe('dismissed') + expect(store.homeSummary?.onboarding.visibility).toBe('dismissed') + expect(store.todaySummary?.onboarding.visibility).toBe('dismissed') + }) + + it('updateOnboarding does not throw when home summary is null', async () => { + const store = useWorkspaceStore() + store.homeSummary = null + store.todaySummary = makeTodaySummary() + + const replayed = makeOnboarding({ visibility: 'active', currentStepId: 'step-2' }) + vi.mocked(http.put).mockResolvedValue({ data: replayed }) + + await store.updateOnboarding('replay') + + expect(store.onboarding?.currentStepId).toBe('step-2') + expect(store.todaySummary?.onboarding.currentStepId).toBe('step-2') + }) + }) +})