diff --git a/e2e/board-drag-atomic.spec.ts b/e2e/board-drag-atomic.spec.ts index 762e108f..ea358b8b 100644 --- a/e2e/board-drag-atomic.spec.ts +++ b/e2e/board-drag-atomic.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import type { Task } from '@veritas-kanban/shared'; import { bypassAuth, cleanupRoutes, deleteTask, seedTestTask, unwrapApiData } from './helpers/auth'; const API_BASE = process.env.API_BASE_URL || 'http://127.0.0.1:3001'; @@ -309,6 +310,96 @@ test.describe('Atomic board drag', () => { .toEqual({ status: 'todo', revision: 2, title: 'Atomic stale drag source updated' }); }); + test('moves two tasks consecutively when one detail cache trails the live board', async ({ + page, + }) => { + const destination = await seedTestTask(page, { + title: 'Consecutive drag destination', + status: 'blocked', + }); + const first = await seedTestTask(page, { + title: 'Consecutive drag first', + status: 'todo', + }); + const second = await seedTestTask(page, { + title: 'Consecutive drag second', + status: 'todo', + }); + const destinationId = destination.id as string; + const firstId = first.id as string; + const secondId = second.id as string; + taskIds.push(destinationId, firstId, secondId); + + const moveHeaders: Array<{ taskId: string; revision: string | undefined }> = []; + page.on('request', (request) => { + const match = new URL(request.url()).pathname.match(/^\/api\/tasks\/([^/]+)\/move$/); + if (request.method() !== 'POST' || !match) return; + moveHeaders.push({ taskId: match[1], revision: request.headers()['if-match'] }); + }); + + await page.goto('/'); + await page.getByLabel('Search tasks').fill('Consecutive drag'); + await page.getByLabel('Search tasks').blur(); + const secondCard = page.locator(`[data-task-id="${secondId}"]`); + await secondCard.click(); + const detail = page.locator('[role="dialog"]'); + await expect(detail).toBeVisible(); + const titleInput = detail.locator('input').first(); + await expect(titleInput).toHaveValue('Consecutive drag second'); + const titleSave = page.waitForResponse( + (response) => + new URL(response.url()).pathname === `/api/tasks/${secondId}` && + response.request().method() === 'PATCH' + ); + await titleInput.fill('Consecutive drag second edited'); + const titleResponse = await titleSave; + expect(titleResponse.ok()).toBe(true); + const edited = unwrapApiData(await titleResponse.json()); + await detail.getByRole('button', { name: 'Close task workspace' }).click(); + await expect(detail).not.toBeVisible(); + + const externalResponse = await page.request.patch(`${API_BASE}/api/tasks/${secondId}`, { + headers: { 'If-Match': `"task:${secondId}:${edited.revision}"` }, + data: { description: 'The live board has a newer revision than the inactive detail cache.' }, + }); + expect(externalResponse.ok()).toBe(true); + const externallyUpdated = unwrapApiData(await externalResponse.json()); + await expect(secondCard).toContainText('The live board has a newer revision'); + + const blocked = page.getByRole('region', { name: 'Blocked' }); + const destinationCard = page.locator(`[data-task-id="${destinationId}"]`); + const dragToDestination = async (taskId: string) => { + const moveResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname === `/api/tasks/${taskId}/move` && + response.request().method() === 'POST' + ); + const source = page.locator(`[data-task-id="${taskId}"]`); + const sourceBox = await source.boundingBox(); + const destinationBox = await destinationCard.boundingBox(); + expect(sourceBox).not.toBeNull(); + expect(destinationBox).not.toBeNull(); + await page.mouse.move(sourceBox!.x + sourceBox!.width / 2, sourceBox!.y + 18); + await page.mouse.down(); + await page.mouse.move(destinationBox!.x + destinationBox!.width / 2, destinationBox!.y + 18, { + steps: 8, + }); + await page.mouse.up(); + expect((await moveResponse).ok()).toBe(true); + await expect(page.locator('[data-board-drag-overlay]')).toHaveCount(0); + await expect(blocked.locator(`[data-task-id="${taskId}"]`)).toBeVisible(); + }; + + await dragToDestination(firstId); + await dragToDestination(secondId); + + expect(moveHeaders).toEqual([ + { taskId: firstId, revision: `"task:${firstId}:${first.revision}"` }, + { taskId: secondId, revision: `"task:${secondId}:${externallyUpdated.revision}"` }, + ]); + await expect(page.getByText('Move not saved')).toHaveCount(0); + }); + test('routes a keyboard column move through the same move endpoint', async ({ page }) => { const moving = await seedTestTask(page, { title: 'Atomic keyboard source', diff --git a/web/src/__tests__/api-tasks.test.ts b/web/src/__tests__/api-tasks.test.ts index 62029dda..afea5502 100644 --- a/web/src/__tests__/api-tasks.test.ts +++ b/web/src/__tests__/api-tasks.test.ts @@ -57,7 +57,10 @@ describe('tasksApi', () => { } as Response); const result = await tasksApi.list(); - expect(fetch).toHaveBeenCalledWith('http://test-api/tasks', { credentials: 'include' }); + expect(fetch).toHaveBeenCalledWith('http://test-api/tasks', { + credentials: 'include', + cache: 'no-store', + }); expect(result).toHaveLength(2); expect(result[0].id).toBe('t1'); }); diff --git a/web/src/__tests__/board-move-query.test.tsx b/web/src/__tests__/board-move-query.test.tsx index 6fad7c10..6f395dd9 100644 --- a/web/src/__tests__/board-move-query.test.tsx +++ b/web/src/__tests__/board-move-query.test.tsx @@ -170,6 +170,42 @@ describe('board move QueryClient and WebSocket convergence', () => { expect(mocks.toast).not.toHaveBeenCalled(); }); + it('uses the newest revision when an inactive detail cache trails the board', async () => { + const movedAgain: Task = { + ...moved, + status: 'todo', + position: 1, + revision: 5, + }; + queryClient.setQueryData(['tasks'], [moved]); + queryClient.setQueryData(['tasks', original.id], original); + mocks.move.mockResolvedValue({ + task: movedAgain, + operationId: '00000000-0000-4000-8000-000000000018', + orderedTaskIds: [movedAgain.id], + replayed: false, + }); + const { result } = renderHook(() => useMoveTask(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + id: original.id, + input: { + operationId: '00000000-0000-4000-8000-000000000018', + sourceStatus: 'blocked', + sourcePosition: 0.5, + destinationStatus: 'todo', + destinationIndex: 0, + }, + }); + }); + + expect(mocks.move).toHaveBeenCalledOnce(); + expect(mocks.move.mock.calls[0]?.[2]).toBe(4); + expect(queryClient.getQueryData(['tasks'])?.[0]).toEqual(movedAgain); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + it('loads the authoritative task and reports one task-specific stale-move message', async () => { const current = { ...original, title: 'Edited elsewhere', revision: 4 }; const conflict = Object.assign(new Error('stale revision'), { diff --git a/web/src/hooks/useTasks.ts b/web/src/hooks/useTasks.ts index be932c2e..d1e3aa62 100644 --- a/web/src/hooks/useTasks.ts +++ b/web/src/hooks/useTasks.ts @@ -46,12 +46,11 @@ type ApiMutationError = Error & { code?: string; details?: unknown }; function cachedTaskRevision(queryClient: QueryClient, taskId: string): number | undefined { const detailTask = queryClient.getQueryData(['tasks', taskId]); - if (typeof detailTask?.revision === 'number') { - return detailTask.revision; - } - const listTask = queryClient.getQueryData(['tasks'])?.find((task) => task.id === taskId); - return typeof listTask?.revision === 'number' ? listTask.revision : undefined; + const revisions = [detailTask?.revision, listTask?.revision].filter( + (revision): revision is number => typeof revision === 'number' + ); + return revisions.length > 0 ? Math.max(...revisions) : undefined; } function cachedTask(queryClient: QueryClient, taskId: string): Task | undefined { diff --git a/web/src/lib/api/tasks.ts b/web/src/lib/api/tasks.ts index d18147d3..833e90a0 100644 --- a/web/src/lib/api/tasks.ts +++ b/web/src/lib/api/tasks.ts @@ -12,7 +12,7 @@ import { API_BASE, apiFetch } from './helpers'; export const tasksApi = { list: async (): Promise => { - return apiFetch(`${API_BASE}/tasks`); + return apiFetch(`${API_BASE}/tasks`, { cache: 'no-store' }); }, listArchived: async (): Promise => {