Files
continuum-backend/tests/frame_task_contract.test.ts
octoturge b42cbac299 Fix the frame producer's field names, and pipeline the batch
The other end of the `stream:camera_frames` drift. This side wrote
`farmId`, `printerId`, `printJobId`, `r2ObjectKey` and `capturedAt`;
`continuum-ai-worker` read `printer_id` and nothing else. Every task was
delivered and acknowledged, and every frame was attributed to a printer
called "unknown", with no error on either side - a stream entry is a
flat string map, so there is nothing to parse and nothing to fail.

The field names are now snake_case, from
`schemas/continuum/v1/ai/frame_task.schema.json`. That is the convention
this repo already follows for anything shared with a Rust consumer (see
`/ws/edge/v1`), and it is not an inconsistency with the camelCase HTTP
routes: this is not an HTTP route.

`print_job_id` is omitted from the XADD when there is no job rather than
written as `""`. An empty string is a value: a consumer that believes it
looks up job id `""`, or puts it in a uuid column. A stream entry has no
null, so "no value" has to mean "no field".

The producer checks each task against the shared schema before writing
it. The check is not for the database's sake - a stream accepts any
field name - it is to make this exact class of drift loud. It earned its
keep immediately by failing on every task with `Unknown format 'uuid'`:
TypeBox ships no format validators and treats an *unregistered* format
as a failure rather than skipping it, so a schema using `format: "uuid"`
checked outside Elysia rejects everything. `src/validation/formats.ts`
registers the two this codebase uses, guarded by `Has` so Elysia's own
registrations still win for the route validators. (The HTTP routes were
never affected - importing Elysia registers ~24 formats including
`uuid`, which I confirmed by asserting a non-uuid body gets a 422.)

A `frame_batch` now goes onto the stream in one pipelined round trip
instead of one per frame, which was a round trip each while holding up
the WebSocket message handler - and a farm produces roughly a frame per
printer per second. ioredis reports per-command pipeline errors in the
result tuple rather than throwing, so those are checked explicitly; a
pipeline where one XADD failed otherwise looks like success.

`frameTaskToStreamFields` and the compiled check live in their own
module because `src/queue/index.ts` opens a Valkey connection on import.
Keeping the wire format pure means `tests/frame_task_contract.test.ts`
asserts it with nothing running.

Tests, mirroring the worker's own so neither side can agree only with
itself: the exact key set, values carried through unchanged,
`print_job_id` present when there is a job and the key absent when there
is not, no camelCase key ever emitted, and the uuid and date-time
formats actually enforced - that last one fails if the format
registration is lost (everything rejected) or becomes a no-op
(nothing rejected). Plus `PrintAlertSchema`, which this repo will have
to parse once the fan-out route exists.

`scripts/e2e-ai-frames.sh` runs this producer against the real worker
binary over real Valkey and asserts the field names by reading them back
out of Valkey - rather than from either side's source, which is what
made the original bug invisible.

Two faults in the first draft of that script, both worth naming because
a test that passes for the wrong reason is worse than no test: it
stranded *every* entry on a dead consumer, so the ordinary delivery path
was never exercised at all; and it grepped the subscriber output for
"print_alert", which matched `redis-cli`'s own subscription
confirmation line and reported a published alert that did not exist.
2026-10-11 16:59:46 +00:00

177 lines
6.9 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { Value } from "@sinclair/typebox/value";
import { FrameTaskSchema, PrintAlertSchema, type FrameTask } from "@continuum/ts-types";
import { checkFrameTask, frameTaskToStreamFields } from "../src/queue/frame_task";
/**
* The mirror of `continuum-ai-worker/src/task.rs`'s test module. Both sides
* assert the same field names against the same canonical schema
* (`schemas/continuum/v1/ai/frame_task.schema.json`), because the bug this
* replaced was invisible from either side alone: the producer wrote
* `printerId` and `r2ObjectKey`, the consumer read `printer_id` and nothing
* else, every task was delivered and acknowledged, and every frame was
* attributed to a printer called "unknown".
*
* A stream entry is a flat string map, so there is nothing to parse and
* nothing to fail - which is exactly why the field names need a test rather
* than a type.
*/
const FARM = "11111111-1111-1111-1111-111111111111";
const PRINTER = "22222222-2222-2222-2222-222222222222";
const JOB = "33333333-3333-3333-3333-333333333333";
const task: FrameTask = {
farm_id: FARM,
printer_id: PRINTER,
r2_object_key: "farms/x/frames/1.jpg",
captured_at: "2026-10-11T12:00:00Z",
};
/** The flat list XADD takes, as the `{field: value}` map a consumer sees. */
function asMap(fields: string[]): Record<string, string> {
const map: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) map[fields[i]!] = fields[i + 1]!;
return map;
}
describe("the stream:camera_frames wire form", () => {
test("is snake_case, and exactly the keys the schema names", () => {
// Written out rather than derived from the schema: a test that reads the
// field names from the same place the implementation does cannot catch
// them both being wrong.
expect(Object.keys(asMap(frameTaskToStreamFields(task))).sort()).toEqual([
"captured_at",
"farm_id",
"printer_id",
"r2_object_key",
]);
});
test("carries the values through unchanged", () => {
expect(asMap(frameTaskToStreamFields(task))).toEqual({
farm_id: FARM,
printer_id: PRINTER,
r2_object_key: "farms/x/frames/1.jpg",
captured_at: "2026-10-11T12:00:00Z",
});
});
test("includes print_job_id when there is one", () => {
const fields = asMap(frameTaskToStreamFields({ ...task, print_job_id: JOB }));
expect(fields.print_job_id).toBe(JOB);
});
/**
* The counterpart of the Rust `an_empty_job_id_is_no_job_id` test. The old
* producer wrote `""`; a stream entry has no null, so the only way to say
* "no job" is to leave the field out.
*/
test("omits print_job_id entirely rather than writing an empty string", () => {
const fields = frameTaskToStreamFields(task);
expect(fields).not.toContain("print_job_id");
expect(fields).not.toContain("");
expect("print_job_id" in asMap(fields)).toBe(false);
});
test("emits an even number of field/value entries", () => {
// XADD takes pairs; an odd count is a wrong-arity error at the server
// rather than at the type level.
expect(frameTaskToStreamFields(task).length % 2).toBe(0);
expect(frameTaskToStreamFields({ ...task, print_job_id: JOB }).length % 2).toBe(0);
});
test("never emits a camelCase key", () => {
// The actual regression. Both field name sets, side by side, so the
// failure message names the one that came back.
const keys = Object.keys(asMap(frameTaskToStreamFields({ ...task, print_job_id: JOB })));
for (const camel of ["farmId", "printerId", "printJobId", "r2ObjectKey", "capturedAt"]) {
expect(keys).not.toContain(camel);
}
});
});
describe("the producer's schema check", () => {
test("accepts a well-formed task", () => {
expect(checkFrameTask.Check(task)).toBe(true);
});
test("rejects a task with a camelCase field instead of the real one", () => {
const { printer_id, ...rest } = task;
expect(checkFrameTask.Check({ ...rest, printerId: printer_id })).toBe(false);
});
test("rejects a task missing any required field", () => {
for (const key of Object.keys(task) as Array<keyof FrameTask>) {
const partial = { ...task };
delete partial[key];
expect(checkFrameTask.Check(partial)).toBe(false);
}
});
test("rejects an empty object key", () => {
// `minLength: 1`: an empty key would send the worker looking for the
// object named "".
expect(checkFrameTask.Check({ ...task, r2_object_key: "" })).toBe(false);
});
/**
* Guards `src/validation/formats.ts`. TypeBox treats an unregistered
* format as a failure, not as "skip", so this check started life rejecting
* every task with `Unknown format 'uuid'`. These two assertions fail if
* the registration is lost (everything rejected) *or* if it became a no-op
* that accepts anything (nothing rejected).
*/
test("the uuid format is actually enforced, not merely declared", () => {
expect(checkFrameTask.Check(task)).toBe(true);
expect(checkFrameTask.Check({ ...task, printer_id: "unknown" })).toBe(false);
expect(checkFrameTask.Check({ ...task, farm_id: "11111111-1111-1111-1111-11111111111" })).toBe(
false,
);
});
test("the date-time format is actually enforced", () => {
expect(checkFrameTask.Check({ ...task, captured_at: "yesterday" })).toBe(false);
});
test("rejects a print_job_id that is an empty string", () => {
// Belt and braces with the omit-don't-empty rule: if a caller does pass
// `""`, the producer refuses rather than writing it.
expect(checkFrameTask.Check({ ...task, print_job_id: "" })).toBe(false);
});
});
describe("the channel:print_alerts payload", () => {
// Asserted here as well as in the worker because the backend is the side
// that will have to parse these once the fan-out route exists, and this is
// where that expectation should be recorded.
const alert = {
printer_id: PRINTER,
label: "spaghetti",
confidence: 0.91,
captured_at: "2026-10-11T12:00:00Z",
r2_object_key: "farms/x/frames/1.jpg",
};
test("the shape the worker publishes validates", () => {
expect(Value.Check(PrintAlertSchema, alert)).toBe(true);
});
test("print_job_id is optional, and null is not the same as absent", () => {
expect(Value.Check(PrintAlertSchema, { ...alert, print_job_id: JOB })).toBe(true);
expect(Value.Check(PrintAlertSchema, { ...alert, print_job_id: null })).toBe(false);
});
test("confidence outside 0..1 is refused", () => {
expect(Value.Check(PrintAlertSchema, { ...alert, confidence: 1.5 })).toBe(false);
expect(Value.Check(PrintAlertSchema, { ...alert, confidence: -0.1 })).toBe(false);
});
test("an alert carries the frame it came from, not just a label", () => {
// Without `r2_object_key` an operator gets "printer X is failing" and no
// way to look at the evidence.
const { r2_object_key, ...withoutFrame } = alert;
expect(Value.Check(PrintAlertSchema, withoutFrame)).toBe(false);
});
});