mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-06 11:47:51 +00:00
fix: keep board task revisions current
This commit is contained in:
parent
20d5d46444
commit
0aa7bc8fbe
5 changed files with 136 additions and 7 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'), {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[]> => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue