fix: keep board task revisions current

This commit is contained in:
Brad Groux 2026-09-03 02:54:53 -05:00
parent 20d5d46444
commit 0aa7bc8fbe
5 changed files with 136 additions and 7 deletions

View file

@ -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<Task>(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<Task>(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',

View file

@ -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');
});

View file

@ -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<Task[]>(['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'), {

View file

@ -46,12 +46,11 @@ type ApiMutationError = Error & { code?: string; details?: unknown };
function cachedTaskRevision(queryClient: QueryClient, taskId: string): number | undefined {
const detailTask = queryClient.getQueryData<Task>(['tasks', taskId]);
if (typeof detailTask?.revision === 'number') {
return detailTask.revision;
}
const listTask = queryClient.getQueryData<Task[]>(['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 {

View file

@ -12,7 +12,7 @@ import { API_BASE, apiFetch } from './helpers';
export const tasksApi = {
list: async (): Promise<Task[]> => {
return apiFetch<Task[]>(`${API_BASE}/tasks`);
return apiFetch<Task[]>(`${API_BASE}/tasks`, { cache: 'no-store' });
},
listArchived: async (): Promise<Task[]> => {