Files
kandev/apps/web/components/task/task-page-content-workflow.test.ts
2026-09-25 15:54:32 +01:00

264 lines
8.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { repositoryId, taskId, workflowId, type Task } from "@/lib/types/http";
import type { KanbanState } from "@/lib/state/slices";
import {
buildTaskFromKanban,
resolveEffectiveTask,
resolveLatestTaskProjection,
resolveWorkflowCurrentStepId,
} from "./task-page-content-helpers";
type KanbanTask = KanbanState["tasks"][number];
const TASK_ID = "task-1";
const SOURCE_WORKFLOW = "workflow-source";
const DESTINATION_WORKFLOW = "workflow-destination";
const SOURCE_STEP = "step-source";
const DESTINATION_STEP = "step-destination";
const SOURCE_UPDATED_AT = "2026-07-18T00:00:00Z";
const DESTINATION_UPDATED_AT = "2026-07-19T00:00:00Z";
function makeTaskDetails(overrides: Partial<Task> = {}): Task {
return {
id: taskId(TASK_ID),
title: "Workflow migration task",
description: "Task details",
workflow_step_id: SOURCE_STEP,
position: 0,
state: "TODO",
workspace_id: "workspace-1",
workflow_id: workflowId(SOURCE_WORKFLOW),
priority: "medium",
repositories: [],
created_at: "",
updated_at: SOURCE_UPDATED_AT,
...overrides,
} as Task;
}
function makeKanbanTask(overrides: Partial<KanbanTask> = {}): KanbanTask {
return {
id: TASK_ID,
title: "Workflow migration task",
workflowId: SOURCE_WORKFLOW,
workflowStepId: SOURCE_STEP,
position: 0,
state: "TODO",
...overrides,
} as KanbanTask;
}
// eslint-disable-next-line max-lines-per-function -- placement cases share fixtures and freshness assertions.
describe("task workflow placement", () => {
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
it("applies a newer destination placement and retains task-only details", () => {
const repositories: Task["repositories"] = [
{
id: "task-repo-1",
task_id: taskId(TASK_ID),
repository_id: repositoryId("repo-1"),
base_branch: "main",
position: 0,
created_at: "",
updated_at: "",
},
];
const details = makeTaskDetails({ repositories });
const destination = makeKanbanTask({
workflowId: DESTINATION_WORKFLOW,
workflowStepId: DESTINATION_STEP,
updatedAt: DESTINATION_UPDATED_AT,
});
const resolved = resolveEffectiveTask(details, null, destination, TASK_ID);
expect(resolved).toMatchObject({
workflow_id: DESTINATION_WORKFLOW,
workflow_step_id: DESTINATION_STEP,
repositories,
});
});
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
it("rejects an older source placement as one workflow and step pair", () => {
const details = makeTaskDetails({
workflow_id: workflowId(DESTINATION_WORKFLOW),
workflow_step_id: DESTINATION_STEP,
updated_at: DESTINATION_UPDATED_AT,
});
const staleSource = makeKanbanTask({ updatedAt: SOURCE_UPDATED_AT });
const resolved = resolveEffectiveTask(details, null, staleSource, TASK_ID);
expect(resolved).toMatchObject({
workflow_id: DESTINATION_WORKFLOW,
workflow_step_id: DESTINATION_STEP,
});
});
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
it("preserves the known placement when a live row omits its destination step", () => {
const details = makeTaskDetails();
const partial = makeKanbanTask({
workflowId: DESTINATION_WORKFLOW,
workflowStepId: "",
});
const resolved = resolveEffectiveTask(details, null, partial, TASK_ID);
expect(resolved).toMatchObject({
workflow_id: SOURCE_WORKFLOW,
workflow_step_id: SOURCE_STEP,
});
});
it("does not merge an older projection's freshness-sensitive details", () => {
const details = makeTaskDetails({
title: "Fresh title",
description: "Fresh description",
state: "IN_PROGRESS",
position: 9,
metadata: { source: "http" },
workflow_id: workflowId(DESTINATION_WORKFLOW),
workflow_step_id: DESTINATION_STEP,
updated_at: DESTINATION_UPDATED_AT,
});
const staleProjection = makeKanbanTask({
title: "Stale title",
description: "Stale description",
state: "TODO",
position: 1,
metadata: { source: "cache" },
workflowId: SOURCE_WORKFLOW,
workflowStepId: SOURCE_STEP,
updatedAt: SOURCE_UPDATED_AT,
});
const resolved = resolveEffectiveTask(details, null, staleProjection, TASK_ID);
expect(resolved).toMatchObject({
title: "Fresh title",
description: "Fresh description",
state: "IN_PROGRESS",
position: 9,
metadata: { source: "http" },
workflow_id: DESTINATION_WORKFLOW,
workflow_step_id: DESTINATION_STEP,
});
});
it.each([
["missing", undefined],
["malformed", "not-a-timestamp"],
["date-normalized malformed", "2026-02-30T00:00:00Z"],
])("rejects a %s projection timestamp for placement", (_label, updatedAt) => {
const details = makeTaskDetails({
workflow_id: workflowId(DESTINATION_WORKFLOW),
workflow_step_id: DESTINATION_STEP,
updated_at: SOURCE_UPDATED_AT,
});
const untrustedProjection = makeKanbanTask({
workflowId: SOURCE_WORKFLOW,
workflowStepId: SOURCE_STEP,
updatedAt,
});
const resolved = resolveEffectiveTask(details, null, untrustedProjection, TASK_ID);
expect(resolved).toMatchObject({
workflow_id: DESTINATION_WORKFLOW,
workflow_step_id: DESTINATION_STEP,
});
});
});
describe("task workflow projection selection", () => {
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
it("selects the newer destination row when an older source snapshot remains", () => {
const source = makeKanbanTask({ updatedAt: SOURCE_UPDATED_AT });
const destination = makeKanbanTask({
workflowId: DESTINATION_WORKFLOW,
workflowStepId: DESTINATION_STEP,
updatedAt: DESTINATION_UPDATED_AT,
});
const selected = resolveLatestTaskProjection(TASK_ID, [source], {
[SOURCE_WORKFLOW]: { tasks: [source] },
[DESTINATION_WORKFLOW]: { tasks: [destination] },
});
expect(selected).toBe(destination);
});
it("keeps a nanosecond-newer active row over an older cached snapshot", () => {
const freshDestination = makeKanbanTask({
workflowId: DESTINATION_WORKFLOW,
workflowStepId: DESTINATION_STEP,
updatedAt: "2026-07-19T00:00:00.123456790Z",
});
const staleSource = makeKanbanTask({
updatedAt: "2026-07-19T00:00:00.123456789Z",
});
const selected = resolveLatestTaskProjection(TASK_ID, [freshDestination], {
[SOURCE_WORKFLOW]: { tasks: [staleSource] },
});
expect(selected).toBe(freshDestination);
});
it("ignores a date-normalized malformed cache timestamp", () => {
const validTask = makeKanbanTask({
workflowId: DESTINATION_WORKFLOW,
workflowStepId: DESTINATION_STEP,
updatedAt: "2026-02-28T00:00:00Z",
});
const malformedTask = makeKanbanTask({
updatedAt: "2026-02-30T00:00:00Z",
});
const selected = resolveLatestTaskProjection(TASK_ID, [validTask], {
[SOURCE_WORKFLOW]: { tasks: [malformedTask] },
});
expect(selected).toBe(validTask);
});
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
it("uses a task step when the session step belongs to the source workflow", () => {
expect(
resolveWorkflowCurrentStepId(SOURCE_STEP, DESTINATION_STEP, [DESTINATION_STEP, "step-next"]),
).toBe(DESTINATION_STEP);
});
it("uses the fresh task-detail step over an older cached step in the same workflow", () => {
const details = makeTaskDetails({
workflow_id: workflowId(SOURCE_WORKFLOW),
workflow_step_id: DESTINATION_STEP,
updated_at: DESTINATION_UPDATED_AT,
});
const staleCachedTask = makeKanbanTask({
workflowId: SOURCE_WORKFLOW,
workflowStepId: SOURCE_STEP,
updatedAt: SOURCE_UPDATED_AT,
});
const resolvedTask = resolveEffectiveTask(details, null, staleCachedTask, TASK_ID);
expect(
resolveWorkflowCurrentStepId(
staleCachedTask.workflowStepId,
resolvedTask?.workflow_step_id ?? null,
[SOURCE_STEP, DESTINATION_STEP],
),
).toBe(DESTINATION_STEP);
});
it("includes the workflow ID when building a task from a projection", () => {
const task = buildTaskFromKanban(
makeKanbanTask({ workflowId: DESTINATION_WORKFLOW, workflowStepId: DESTINATION_STEP }),
);
expect(task.workflow_id).toBe(DESTINATION_WORKFLOW);
});
});