From b5c6f4e331056269eaa831c444368f3fda911aa4 Mon Sep 17 00:00:00 2001 From: Kaito Date: Wed, 13 May 2026 20:34:31 +0700 Subject: [PATCH] test(api): cover task freshness helpers --- .../web/app/api/agent/tasks/[taskId]/route.ts | 28 +++---- apps/web/lib/api/task-freshness.test.ts | 76 +++++++++++++++++++ apps/web/lib/api/task-freshness.ts | 48 ++++++++++++ package.json | 1 + 4 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 apps/web/lib/api/task-freshness.test.ts create mode 100644 apps/web/lib/api/task-freshness.ts diff --git a/apps/web/app/api/agent/tasks/[taskId]/route.ts b/apps/web/app/api/agent/tasks/[taskId]/route.ts index b1d2ee2..c73a200 100644 --- a/apps/web/app/api/agent/tasks/[taskId]/route.ts +++ b/apps/web/app/api/agent/tasks/[taskId]/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server" import { Status } from "@/generated/prisma/enums" import { agentAuth } from "@/lib/agent-auth" import { createAuditLog, formatChangedFields } from "@/lib/api/audit-log" +import { getTaskFreshnessUpdate, parseReadByAgentIds } from "@/lib/api/task-freshness" import { serializeTaskReadMarkers } from "@/lib/api/task-read-markers" import { agentTaskUpdater } from "@/lib/api/task-updater" import { prisma } from "@/lib/prisma" @@ -352,15 +353,23 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return NextResponse.json({ statusCode: 400, error: "Read marker agent not found" }, { status: 400 }) } - const nextStatus = data.status ?? task.status - const statusChanged = nextStatus !== task.status - const noteChanged = data.note !== undefined && data.note !== task.note + const { + nextStatus, + noteChanged, + shouldClearNextStatusReads, + summaryUpdatedAt, + } = getTaskFreshnessUpdate({ + currentStatus: task.status, + currentNote: task.note, + nextStatus: data.status, + nextNote: data.note, + hasReadBy, + }) if (!noteChanged) { delete data.note } else { - data.summaryUpdatedAt = data.note ? new Date() : null + data.summaryUpdatedAt = summaryUpdatedAt } - const shouldClearNextStatusReads = !hasReadBy && (statusChanged || noteChanged) const updatedTask = await prisma.$transaction(async (tx: typeof prisma) => { if (hasReadBy) { await tx.taskReadMarker.deleteMany({ @@ -462,15 +471,6 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return NextResponse.json({ statusCode: 200, task: serializeTaskReadMarkers(updatedTask) }) } -function parseReadByAgentIds(value: unknown) { - if (value === undefined) return [] - if (!Array.isArray(value)) return null - - const agentIds = value.filter((item): item is string => typeof item === "string" && Boolean(item)) - - return agentIds.length === value.length ? Array.from(new Set(agentIds)) : null -} - /** * @openapi * /api/agent/tasks/{taskId}: diff --git a/apps/web/lib/api/task-freshness.test.ts b/apps/web/lib/api/task-freshness.test.ts new file mode 100644 index 0000000..94cb20e --- /dev/null +++ b/apps/web/lib/api/task-freshness.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { Status } from "@/generated/prisma/enums" +import { getTaskFreshnessUpdate, parseReadByAgentIds } from "./task-freshness" + +describe("Agent API task freshness updates", () => { + it("sets summaryUpdatedAt and clears current-status read markers when the note changes", () => { + const now = new Date("2026-05-13T13:00:00.000Z") + const result = getTaskFreshnessUpdate({ + currentStatus: Status.done, + currentNote: "old summary", + nextNote: "new summary", + hasReadBy: false, + now, + }) + + assert.equal(result.noteChanged, true) + assert.equal(result.statusChanged, false) + assert.equal(result.shouldClearNextStatusReads, true) + assert.equal(result.summaryUpdatedAt, now) + }) + + it("preserves summaryUpdatedAt and read markers when the note is unchanged", () => { + const result = getTaskFreshnessUpdate({ + currentStatus: Status.done, + currentNote: "same summary", + nextNote: "same summary", + hasReadBy: false, + now: new Date("2026-05-13T13:00:00.000Z"), + }) + + assert.equal(result.noteChanged, false) + assert.equal(result.shouldClearNextStatusReads, false) + assert.equal("summaryUpdatedAt" in result, false) + }) + + it("clears read markers for the resulting status when status changes and readBy is omitted", () => { + const result = getTaskFreshnessUpdate({ + currentStatus: Status.inprogress, + currentNote: null, + nextStatus: Status.done, + hasReadBy: false, + }) + + assert.equal(result.nextStatus, Status.done) + assert.equal(result.statusChanged, true) + assert.equal(result.shouldClearNextStatusReads, true) + }) + + it("does not clear resulting-status read markers when readBy is provided", () => { + const result = getTaskFreshnessUpdate({ + currentStatus: Status.inprogress, + currentNote: null, + nextStatus: Status.done, + nextNote: "finished", + hasReadBy: true, + }) + + assert.equal(result.noteChanged, true) + assert.equal(result.statusChanged, true) + assert.equal(result.shouldClearNextStatusReads, false) + }) +}) + +describe("Agent API readBy parsing", () => { + it("deduplicates valid AgentIds for replacement reads", () => { + assert.deepEqual(parseReadByAgentIds(["main", "main", "kaito"]), ["main", "kaito"]) + }) + + it("rejects invalid readBy values", () => { + assert.equal(parseReadByAgentIds("main"), null) + assert.equal(parseReadByAgentIds(["main", ""]), null) + assert.equal(parseReadByAgentIds(["main", 42]), null) + }) +}) diff --git a/apps/web/lib/api/task-freshness.ts b/apps/web/lib/api/task-freshness.ts new file mode 100644 index 0000000..84c0e3c --- /dev/null +++ b/apps/web/lib/api/task-freshness.ts @@ -0,0 +1,48 @@ +import { Status } from "@/generated/prisma/enums" + +export type TaskFreshnessInput = { + currentStatus: Status + currentNote: string | null + nextStatus?: Status + nextNote?: string | null + hasReadBy: boolean + now?: Date +} + +export type TaskFreshnessResult = { + nextStatus: Status + noteChanged: boolean + statusChanged: boolean + shouldClearNextStatusReads: boolean + summaryUpdatedAt?: Date | null +} + +export function getTaskFreshnessUpdate({ + currentStatus, + currentNote, + nextStatus, + nextNote, + hasReadBy, + now = new Date(), +}: TaskFreshnessInput): TaskFreshnessResult { + const resultingStatus = nextStatus ?? currentStatus + const statusChanged = resultingStatus !== currentStatus + const noteChanged = nextNote !== undefined && nextNote !== currentNote + + return { + nextStatus: resultingStatus, + noteChanged, + statusChanged, + shouldClearNextStatusReads: !hasReadBy && (statusChanged || noteChanged), + ...(noteChanged ? { summaryUpdatedAt: nextNote ? now : null } : {}), + } +} + +export function parseReadByAgentIds(value: unknown) { + if (value === undefined) return [] + if (!Array.isArray(value)) return null + + const agentIds = value.filter((item): item is string => typeof item === "string" && Boolean(item)) + + return agentIds.length === value.length ? Array.from(new Set(agentIds)) : null +} diff --git a/package.json b/package.json index 2f4b2ad..8a27279 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "prisma:studio": "prisma studio", "typecheck": "corepack pnpm prisma:generate && corepack pnpm --filter @agentbridge/web typecheck && corepack pnpm --filter agentbridge typecheck", "typecheck:web": "corepack pnpm prisma:generate && corepack pnpm --filter @agentbridge/web typecheck", + "test:task-freshness": "tsx --tsconfig apps/web/tsconfig.json --test apps/web/lib/api/task-freshness.test.ts", "cli:dev": "corepack pnpm --filter agentbridge dev", "cli:build": "corepack pnpm --filter agentbridge build", "cli:typecheck": "corepack pnpm --filter agentbridge typecheck",