From d0ad1caec5bc62906b77d6d4f6afe2c3114b03aa Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 2 Feb 2026 21:27:21 -0800 Subject: [PATCH] fix: preserve capture overrides on partial updates --- .../[id]/override-capture-ts/handlers.ts | 40 ++++-- .../asset-overrides-admin-auth.test.ts | 117 ++++++++++++++++++ 2 files changed, 146 insertions(+), 11 deletions(-) diff --git a/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts b/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts index 9bbc3b5..faca7e1 100644 --- a/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts +++ b/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts @@ -11,7 +11,7 @@ const paramsSchema = z.object({ const bodySchema = z .object({ captureTsUtcOverride: z.string().datetime().nullable().optional(), - captureOffsetMinutesOverride: z.coerce.number().int().nullable().optional(), + captureOffsetMinutesOverride: z.number().int().nullable().optional(), }) .strict(); @@ -48,15 +48,23 @@ export async function handleSetCaptureOverride(input: { }; } - const db = (input.db ?? getDb()) as DbLike; const data = bodyParsed.data; - const captureTs = data.captureTsUtcOverride - ? new Date(data.captureTsUtcOverride) + const hasCaptureTs = "captureTsUtcOverride" in data; + const hasCaptureOffset = "captureOffsetMinutesOverride" in data; + if (!hasCaptureTs && !hasCaptureOffset) { + return { status: 400, body: { error: "invalid_body" } }; + } + + const db = (input.db ?? getDb()) as DbLike; + + const captureTs = hasCaptureTs + ? data.captureTsUtcOverride + ? new Date(data.captureTsUtcOverride) + : null + : null; + const captureOffset = hasCaptureOffset + ? data.captureOffsetMinutesOverride ?? null : null; - const captureOffset = - data.captureOffsetMinutesOverride !== undefined - ? data.captureOffsetMinutesOverride - : null; const rows = await db< { @@ -71,11 +79,21 @@ export async function handleSetCaptureOverride(input: { capture_ts_utc_override, capture_offset_minutes_override ) - values (${paramsParsed.data.id}, ${captureTs}, ${captureOffset}) + values ( + ${paramsParsed.data.id}, + ${captureTs}, + ${captureOffset} + ) on conflict (asset_id) do update set - capture_ts_utc_override = excluded.capture_ts_utc_override, - capture_offset_minutes_override = excluded.capture_offset_minutes_override + capture_ts_utc_override = case + when ${hasCaptureTs} then excluded.capture_ts_utc_override + else asset_overrides.capture_ts_utc_override + end, + capture_offset_minutes_override = case + when ${hasCaptureOffset} then excluded.capture_offset_minutes_override + else asset_overrides.capture_offset_minutes_override + end returning asset_id, capture_ts_utc_override, capture_offset_minutes_override, created_at `; diff --git a/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts b/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts index 1762206..9fe0fb4 100644 --- a/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts +++ b/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts @@ -1,4 +1,63 @@ import { test, expect } from "bun:test"; +import type { getDb } from "@tline/db"; + +type DbRow = { + asset_id: string; + capture_ts_utc_override: string | null; + capture_offset_minutes_override: number | null; + created_at: string; +}; + +const createDbStub = (initial: DbRow) => { + let current = { ...initial }; + + const db = async ( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise => { + const query = strings.join(""); + if (query.includes("insert into asset_overrides")) { + const [assetId, captureTs, captureOffset, tsProvided, offsetProvided] = + values; + const hasFlags = + typeof tsProvided === "boolean" && typeof offsetProvided === "boolean"; + const updateTs = hasFlags ? (tsProvided as boolean) : true; + const updateOffset = hasFlags ? (offsetProvided as boolean) : true; + + if (updateTs) { + if (captureTs instanceof Date) { + current.capture_ts_utc_override = captureTs.toISOString(); + } else if (captureTs === null) { + current.capture_ts_utc_override = null; + } else { + current.capture_ts_utc_override = String(captureTs ?? ""); + } + } + + if (updateOffset) { + current.capture_offset_minutes_override = + captureOffset as number | null; + } + + return [ + { + asset_id: String(assetId), + capture_ts_utc_override: current.capture_ts_utc_override, + capture_offset_minutes_override: current.capture_offset_minutes_override, + created_at: current.created_at, + }, + ] as T; + } + + if (query.includes("insert into audit_log")) { + return [] as T; + } + + throw new Error(`Unexpected query: ${query}`); + }; + + return db as unknown as ReturnType; +}; test("asset overrides POST rejects when missing admin token", async () => { const { handleSetCaptureOverride } = await import( @@ -26,3 +85,61 @@ test("asset overrides POST rejects invalid body", async () => { expect(res.body).toMatchObject({ error: "invalid_body" }); expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true); }); + +test("asset overrides POST rejects empty body", async () => { + const { handleSetCaptureOverride } = await import( + "../../app/api/assets/[id]/override-capture-ts/handlers" + ); + const res = await handleSetCaptureOverride({ + adminOk: true, + params: { id: "00000000-0000-4000-8000-000000000000" }, + body: {}, + }); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: "invalid_body" }); +}); + +test("asset overrides POST preserves omitted fields", async () => { + const { handleSetCaptureOverride } = await import( + "../../app/api/assets/[id]/override-capture-ts/handlers" + ); + const db = createDbStub({ + asset_id: "00000000-0000-4000-8000-000000000000", + capture_ts_utc_override: "2026-01-01T00:00:00.000Z", + capture_offset_minutes_override: 90, + created_at: "2026-02-01T00:00:00.000Z", + }); + const res = await handleSetCaptureOverride({ + adminOk: true, + params: { id: "00000000-0000-4000-8000-000000000000" }, + body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" }, + db, + }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + capture_ts_utc_override: "2026-02-01T00:00:00.000Z", + capture_offset_minutes_override: 90, + }); +}); + +test("asset overrides POST allows explicit null clearing", async () => { + const { handleSetCaptureOverride } = await import( + "../../app/api/assets/[id]/override-capture-ts/handlers" + ); + const db = createDbStub({ + asset_id: "00000000-0000-4000-8000-000000000000", + capture_ts_utc_override: "2026-01-01T00:00:00.000Z", + capture_offset_minutes_override: 90, + created_at: "2026-02-01T00:00:00.000Z", + }); + const res = await handleSetCaptureOverride({ + adminOk: true, + params: { id: "00000000-0000-4000-8000-000000000000" }, + body: { captureOffsetMinutesOverride: null }, + db, + }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + capture_offset_minutes_override: null, + }); +});