From cbb7b613f7b86c2522b5de674eb8598df9c7c70c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 09:58:26 -0700 Subject: [PATCH] test(environment): cover write plans, the write gate and served writes Unit tests for clock, UUID, token, slug and delimited-token values, the field rules that explain created and updated entities, response shapes, insert rules and the shared apply functions; view tests for every plan reason, delete confirmation by a 400, 404 or null read, learned insert rules, unmodelled writes and observations from later reads; services tests for served creates, updates and deletes, refusals that leave the state unchanged, deterministic values across reset and load, missing entities, the catalog and paging-only list lines; and an MCP boundary test that serves a capture through the built CLI, applies a create and resets it. The capture fixture records request bodies. Co-Authored-By: Claude Opus 5.5 --- .../environmentStateWriteAnswer.test.ts | 399 +++++++++++++ .../environmentStateWriteApply.test.ts | 509 ++++++++++++++++ .../environmentStateWriteExplain.test.ts | 552 ++++++++++++++++++ .../environmentStateWriteValues.test.ts | 242 ++++++++ .../environmentStateWrites.test.ts | 492 ++++++++++++++++ .../mcp/environmentServeWritesMcp.test.ts | 97 +++ tests/fixtures/analysisView.ts | 18 +- tests/fixtures/environmentStateWrites.ts | 163 ++++++ 8 files changed, 2469 insertions(+), 3 deletions(-) create mode 100644 src/application/environment/environmentStateWriteAnswer.test.ts create mode 100644 src/domain/analysisView/environmentStateWriteApply.test.ts create mode 100644 src/domain/analysisView/environmentStateWriteExplain.test.ts create mode 100644 src/domain/analysisView/environmentStateWriteValues.test.ts create mode 100644 src/domain/analysisView/environmentStateWrites.test.ts create mode 100644 tests/boundary/mcp/environmentServeWritesMcp.test.ts create mode 100644 tests/fixtures/environmentStateWrites.ts diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts new file mode 100644 index 0000000000..aaee819cfb --- /dev/null +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from "vitest"; + +import { + timedCaptureEvidence, + type TimedCaptureRow, +} from "../../../tests/fixtures/analysisView.js"; +import { + environmentStateServeFixture, + environmentStateServeInputs, +} from "../../../tests/fixtures/environmentServe.js"; +import { + BOOK_INIT_ROWS, + BOOK_NOT_FOUND, + BOOK_ROWS, + DELETE_NOT_FOUND, + FIRST_BOOK, + SECOND_BOOK, + THIRD_BOOK, + book, + bookClock, + renamedBook, + type Book, +} from "../../../tests/fixtures/environmentStateWrites.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import type { ReplayArguments } from "./environmentReplay.js"; +import { servedCatalog } from "./environmentState.js"; +import { EnvironmentStateStore } from "./environmentStateStore.js"; +import { environmentToolAnswer } from "./environmentToolAnswer.js"; +import { environmentToolDefinitions } from "./environmentTools.js"; + +/** A served environment over the state derived from a capture of `rows`, answering tool calls as the server does. */ +const servedFrom = (rows: readonly TimedCaptureRow[] = BOOK_ROWS) => { + const { source, state } = environmentStateServeInputs( + environmentStateServeFixture(timedCaptureEvidence(rows)), + ); + const store = new EnvironmentStateStore(state); + const respond = environmentToolAnswer(source, store); + const call = (name: string, args: ReplayArguments) => { + const tool = source.manifest.tools.find( + (candidate) => candidate.name === name, + ); + if (tool === undefined) throw new Error(`The manifest lacks ${name}`); + return respond(tool, args); + }; + return { + source, + state, + store, + answer: (name: string, args: ReplayArguments = {}) => { + const result = call(name, args); + if (!result.ok) throw result.error; + return result.value; + }, + refusal: (name: string, args: ReplayArguments = {}) => { + const result = call(name, args); + if (result.ok) throw new Error(`Expected ${name} to be refused`); + return projectAnalysisError(result.error); + }, + /** What data/get reports, as text, so whole states compare byte for byte. */ + snapshot: () => JSON.stringify(store.snapshot()), + }; +}; + +/** The book the first served create of "New Book" makes: the next id, one second past the served clock's start. */ +const NEW_BOOK = book(3, "New Book", bookClock(13)); +const CREATE_NEW_BOOK = { body: { name: "New Book" } }; + +type Served = ReturnType; + +const entityOf = (books: Served, id: string) => + books.store.snapshot().rea_state.entities.find((entity) => entity.id === id); + +/** Load what data/get reports back through data/add: the state must come back byte for byte. */ +const expectReloadsUnchanged = (books: Served): void => { + const before = books.snapshot(); + expect(books.store.replace(JSON.parse(before)).ok).toBe(true); + expect(books.snapshot()).toBe(before); +}; + +describe("served creates", () => { + it("creates a book with the next id, the served clock and a derived slug, which later reads show", () => { + const books = servedFrom(); + const created = books.answer("post_books", CREATE_NEW_BOOK); + expect(created).toMatchObject({ + status: 201, + content_type: "application/json", + state: { + source: "state", + view: "create", + type: "/api/books", + collection: "/api/books", + id: "3", + }, + }); + expect(created.body).toEqual(NEW_BOOK); + expect(books.answer("get_books_by_id", { id: "3" })).toMatchObject({ + status: 200, + body: NEW_BOOK, + state: { source: "state", view: "item", id: "3" }, + }); + expect(books.answer("get_books")).toMatchObject({ + body: [FIRST_BOOK, SECOND_BOOK, NEW_BOOK], + state: { source: "state", view: "list", membership: "all", listed: 3 }, + }); + }); + + it("reports a created book in data/get without record citations and loads it back unchanged", () => { + const books = servedFrom(); + books.answer("post_books", CREATE_NEW_BOOK); + expect(entityOf(books, "3")).toEqual({ + type: "/api/books", + collection: "/api/books", + id: "3", + fields: NEW_BOOK, + view_fields: [], + record_ordinals: [], + }); + expectReloadsUnchanged(books); + expect(books.answer("get_books_by_id", { id: "3" }).body).toEqual(NEW_BOOK); + }); +}); + +describe("served updates and deletes", () => { + const SECOND_EDITION = renamedBook(SECOND_BOOK, "Second Edition", 13); + const RENAME_SECOND = { id: "2", body: { name: "Second Edition" } }; + + it("changes only the sent members, the slug that follows them and the clock", () => { + const books = servedFrom(); + expect(books.answer("patch_books_by_id", RENAME_SECOND)).toMatchObject({ + status: 200, + body: SECOND_EDITION, + state: { source: "state", view: "update", id: "2" }, + }); + expect(books.answer("get_books_by_id", { id: "2" }).body).toEqual( + SECOND_EDITION, + ); + expect(books.answer("get_books").body).toEqual([ + FIRST_BOOK, + SECOND_EDITION, + ]); + }); + + it("cites no records for an updated book in data/get, so it loads back unchanged", () => { + const books = servedFrom(); + books.answer("patch_books_by_id", RENAME_SECOND); + expect(entityOf(books, "2")).toMatchObject({ + fields: SECOND_EDITION, + record_ordinals: [], + }); + expectReloadsUnchanged(books); + }); + + it("removes a deleted book; a later read answers the recorded 404 as a missing entity", () => { + const books = servedFrom(); + expect(books.answer("delete_books_by_id", { id: "2" })).toMatchObject({ + status: 204, + body: null, + state: { source: "state", view: "delete", id: "2" }, + }); + expect(books.answer("get_books_by_id", { id: "2" })).toMatchObject({ + status: 404, + body: BOOK_NOT_FOUND, + state: { + source: "state", + view: "missing", + type: "/api/books", + collection: "/api/books", + id: "2", + }, + }); + expect(books.answer("get_books").body).toEqual([FIRST_BOOK]); + }); + + it("answers an update or delete of a missing book with a recorded 404, else refuses it", () => { + const books = servedFrom(); + const before = books.snapshot(); + // The update recorded no absence, so it answers the item view's; the delete answers its own. + expect( + books.answer("patch_books_by_id", { id: "99", body: { name: "Ghost" } }), + ).toMatchObject({ + status: 404, + body: BOOK_NOT_FOUND, + state: { view: "missing", id: "99" }, + }); + expect(books.answer("delete_books_by_id", { id: "99" })).toMatchObject({ + status: 404, + body: DELETE_NOT_FOUND, + state: { view: "missing", id: "99" }, + }); + expect(books.snapshot()).toBe(before); + const unrecorded = servedFrom( + BOOK_ROWS.filter(([, , , status]) => status !== 404), + ); + for (const [name, args] of [ + ["patch_books_by_id", { id: "99", body: { name: "Ghost" } }], + ["delete_books_by_id", { id: "99" }], + ] as const) + expect(unrecorded.refusal(name, args)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining( + "holds no /api/books entity 99 in /api/books", + ), + }); + }); +}); + +describe("refusals", () => { + it("refuses a body the capture does not support and leaves the state unchanged", () => { + const books = servedFrom(); + const before = books.snapshot(); + const refused: [string, ReplayArguments, string][] = [ + [ + "post_books", + { body: { name: "Extra", color: "red" } }, + "Member color is not one the capture showed stored", + ], + ["post_books", { body: {} }, "lacks required members name"], + [ + "post_books", + { body: { name: 42 } }, + "Member name must be a JSON string", + ], + ["post_books", { body: ["New Book"] }, "takes a JSON object body"], + [ + "patch_books_by_id", + { id: "1", body: "New Book" }, + "takes a JSON object body", + ], + ["patch_books_by_id", { id: "1", body: {} }, "sends none of the members"], + [ + "patch_books_by_id", + { id: "1", body: { title: "New" } }, + "Member title is not one", + ], + ]; + for (const [name, args, message] of refused) { + expect(books.refusal(name, args)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining(message), + }); + expect(books.snapshot()).toBe(before); + } + }); + + it("accepts a member the application stored differently only with the value the capture sent", () => { + const bound = (entry: Book, format: string): Book => ({ ...entry, format }); + const books = servedFrom([ + [ + "GET", + "/api/books", + [bound(FIRST_BOOK, "PAPERBACK"), bound(SECOND_BOOK, "PAPERBACK")], + ], + ["GET", "/api/books/1", bound(FIRST_BOOK, "PAPERBACK")], + ["GET", "/api/books/2", bound(SECOND_BOOK, "PAPERBACK")], + [ + "POST", + "/api/books", + bound(THIRD_BOOK, "HARDCOVER"), + 201, + { name: "Third Book", format: "hardcover" }, + ], + ["GET", "/api/books/3", bound(THIRD_BOOK, "HARDCOVER")], + ]); + const before = books.snapshot(); + expect( + books.refusal("post_books", { + body: { name: "Soft", format: "softcover" }, + }), + ).toMatchObject({ + message: expect.stringContaining( + "Member format is accepted only with the value the capture sent", + ), + }); + expect(books.snapshot()).toBe(before); + expect( + books.answer("post_books", { + body: { name: "Hard", format: "hardcover" }, + }).body, + ).toMatchObject({ id: 3, name: "Hard", format: "HARDCOVER" }); + }); + + it("generates the same values after a refused call as without it", () => { + const refusedFirst = servedFrom(); + refusedFirst.refusal("post_books", { body: {} }); + refusedFirst.refusal("patch_books_by_id", { + id: "1", + body: { title: "New" }, + }); + expect(refusedFirst.answer("post_books", CREATE_NEW_BOOK).body).toEqual( + servedFrom().answer("post_books", CREATE_NEW_BOOK).body, + ); + }); +}); + +describe("generated values", () => { + it("answers the same calls identically after a reset, never reusing a deleted id", () => { + const books = servedFrom(); + const session = () => { + const answers = [ + books.answer("post_books", CREATE_NEW_BOOK), + books.answer("patch_books_by_id", { + id: "3", + body: { name: "Newer Book" }, + }), + books.answer("delete_books_by_id", { id: "3" }), + books.answer("post_books", { body: { name: "Another Book" } }), + books.answer("get_books"), + ]; + return { answers, text: JSON.stringify([answers, books.snapshot()]) }; + }; + const first = session(); + books.store.reset(); + expect(session().text).toBe(first.text); + expect(first.answers[3]?.body).toMatchObject({ + id: 4, + name: "Another Book", + }); + }); + + it("restarts the clock and the sequences when data/add loads a state", () => { + const books = servedFrom(); + const initial: unknown = JSON.parse(books.snapshot()); + const created = books.answer("post_books", CREATE_NEW_BOOK); + books.answer("post_books", { body: { name: "Another Book" } }); + expect(books.store.replace(initial).ok).toBe(true); + // Had the sequence and clock run on, this create would take id 5 one second later. + expect(books.answer("post_books", CREATE_NEW_BOOK).body).toEqual( + created.body, + ); + }); +}); + +describe("served catalog", () => { + it("adds only the served write tools, saying what each does to the state", () => { + const { source, state } = servedFrom(); + const catalog = servedCatalog(source, state, "books"); + if (!catalog.ok) throw catalog.error; + const described = new Map( + catalog.value.tools.map((tool) => [tool.name, tool]), + ); + // The export write targets no state type, so it is not served. + expect([...described.keys()].sort()).toEqual([ + "delete_books_by_id", + "get_books", + "get_books_by_id", + "patch_books_by_id", + "post_books", + ]); + expect(described.get("post_books")?.description).toContain( + "creates an entity of /api/books in the served environment state (body members name; required: name), modelled on the recorded calls", + ); + expect(described.get("patch_books_by_id")?.description).toContain( + "changes the entity the path names in the served environment state (body members name; send at least one)", + ); + expect(described.get("delete_books_by_id")?.description).toContain( + "removes the entity the path names from the served environment state", + ); + expect(described.get("post_books")?.annotations.readOnlyHint).toBe(false); + expect(described.get("get_books")?.description).toContain( + "answers from the served environment state where the state backs this tool", + ); + }); + + it("serves every manifest tool unchanged in replay mode", () => { + const { source } = servedFrom(); + const replay = servedCatalog(source, undefined, "books"); + const definitions = environmentToolDefinitions(source, "books"); + if (!replay.ok || !definitions.ok) throw new Error("Expected a catalog"); + expect(replay.value).toEqual({ + tools: definitions.value, + dataPlane: "none", + }); + expect(replay.value.tools.map(({ name }) => name)).toContain( + "post_exports", + ); + }); +}); + +describe("paged list lines", () => { + it("answers an argument-less list from the state on a line that only pages, and another page from its recording", () => { + const paged = servedFrom([ + ["GET", "/api/books?limit=50", [FIRST_BOOK, SECOND_BOOK]], + ...BOOK_INIT_ROWS.slice(1), + ["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }], + ["GET", "/api/books/3", THIRD_BOOK], + ["GET", "/api/books?limit=50", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]], + ]); + const created = paged.answer("post_books", CREATE_NEW_BOOK).body; + expect(paged.answer("get_books")).toMatchObject({ + body: [FIRST_BOOK, SECOND_BOOK, created], + replay: { record_ordinal: 0, exact_match: false }, + state: { source: "state", view: "list", listed: 3 }, + }); + expect(paged.answer("get_books", { limit: "10" })).toMatchObject({ + body: [FIRST_BOOK, SECOND_BOOK], + state: { source: "recording", reason: "unrecorded-request" }, + }); + }); +}); diff --git a/src/domain/analysisView/environmentStateWriteApply.test.ts b/src/domain/analysisView/environmentStateWriteApply.test.ts new file mode 100644 index 0000000000..2260e530bb --- /dev/null +++ b/src/domain/analysisView/environmentStateWriteApply.test.ts @@ -0,0 +1,509 @@ +import { expect, it } from "vitest"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok } from "../result.js"; +import type { JsonObject, StateEntity } from "./environmentStateRules.js"; +import { + bodyProblem, + createdFields, + updatedEntity, + updatedFields, + valueAt, + writtenDocument, + writtenResponse, +} from "./environmentStateWriteApply.js"; +import type { + FieldRule, + GeneratedRule, + StateWritePlan, + WriteBody, + WriteCall, + WriteResponse, +} from "./environmentStateWriteRules.js"; +import type { ClockFormat } from "./environmentStateWriteValues.js"; + +const SECONDS: ClockFormat = { + kind: "iso", + separator: "T", + fraction: 0, + zone: "Z", +}; +const NOW = "2026-03-04T05:06:07Z"; + +const callWith = ( + body: JsonObject | undefined, + pathValues: Record = {}, +): WriteCall => ({ + path: "/items", + pathValues: new Map(Object.entries(pathValues)), + body, +}); + +/** Generated values the store would supply, recording which rules asked. */ +const generator = () => { + const asked: GeneratedRule[] = []; + const values = { identity: "id-9", sequence: 9, unique: "ref-9", clock: NOW }; + const generate = (rule: GeneratedRule): JsonValue => { + asked.push(rule); + return values[rule.rule]; + }; + return { asked, generate }; +}; + +const CONTRACT: WriteBody = { + accepted: ["title", "tags", "meta", "note"], + fixed: [ + { member: "priority", sent: "HIGH" }, + { member: "options", sent: { a: 1, b: 2 } }, + ], + required: ["title"], + identity: "id", +}; +const RECORDED = { title: "A", tags: ["x"], meta: null, priority: "high" }; +const LISTED = + "accepted members: title, tags, meta, note; members accepted only with their recorded value: priority, options; required: title"; + +const problemOf = (body: JsonObject | undefined): string | undefined => + bodyProblem(CONTRACT, callWith(body), { entity: RECORDED, id: "42" }); + +it.each<[string, JsonObject]>([ + [ + "accepted and fixed members", + { title: "B", priority: "HIGH", options: { b: 2, a: 1 } }, + ], + ["the identity repeated", { id: "42", title: "B" }], + ["the identity repeated as a number", { id: 42, title: "B" }], + ["any type where the recorded value is null", { title: "B", meta: { x: 1 } }], + ["any type where no value was recorded", { title: "B", note: 5 }], +])("accepts a body with %s", (_, body) => { + expect(problemOf(body)).toBeUndefined(); +}); + +it.each<[string, JsonObject | undefined, string]>([ + [ + "an unknown member", + { title: "B", color: "red" }, + `Member color is not one the capture showed stored (${LISTED}).`, + ], + [ + "a fixed member with another value", + { title: "B", priority: "LOW" }, + `Member priority is accepted only with the value the capture sent, because the application stored it differently (${LISTED}).`, + ], + [ + "a number for a string", + { title: 5 }, + "Member title must be a JSON string, as the capture stored it.", + ], + [ + "null for a string", + { title: null }, + "Member title must be a JSON string, as the capture stored it.", + ], + [ + "an object for an array", + { title: "B", tags: { x: true } }, + "Member tags must be a JSON array, as the capture stored it.", + ], + [ + "a missing required member", + { tags: ["y"] }, + `The call lacks required members title (${LISTED}).`, + ], + ["no body", undefined, `The call lacks required members title (${LISTED}).`], + [ + "another identity", + { id: "43", title: "B" }, + "Member id must repeat the target's identity 42.", + ], +])("refuses a body with %s", (_, body, message) => { + expect(problemOf(body)).toBe(message); +}); + +it.each<[string, JsonValue | undefined]>([ + ["", { a: [{ b: 1 }, { b: [2, 3] }], "x/y": { "m~n": true }, "": "blank" }], + ["/a/0/b", 1], + ["/a/1/b/1", 3], + ["/x~1y/m~0n", true], + ["/", "blank"], + ["/a/2/b", undefined], + ["/a/first", undefined], + ["/a/0/b/c", undefined], + ["/missing", undefined], +])("reads the body value at %j", (pointer, expected) => { + const body = { + a: [{ b: 1 }, { b: [2, 3] }], + "x/y": { "m~n": true }, + "": "blank", + }; + expect(valueAt(body, pointer)).toEqual(expected); +}); + +const CREATE_RULES: FieldRule[] = [ + { field: "id", rule: "identity", generator: "token" }, + { field: "number", rule: "sequence", scope: "collection" }, + { field: "createdAt", rule: "clock", format: SECONDS }, + { field: "ref", rule: "unique" }, + { field: "title", rule: "echo" }, + { field: "summary", rule: "copy", from: "/meta/notes/1" }, + { field: "projectId", rule: "path", from: "project" }, + { field: "projectKey", rule: "path", from: "project" }, + { field: "slug", rule: "slug", from: "title" }, + { field: "url", rule: "template", from: "id" }, +]; +type Plan = Pick; + +const CREATE_PLAN: Plan = { + rules: CREATE_RULES, + body: { + accepted: ["title", "meta"], + fixed: [], + required: ["title", "meta"], + identity: null, + }, +}; +const CREATE_OBSERVED: JsonObject = { + id: "id-1", + number: 1, + createdAt: "2026-01-01T00:00:00Z", + ref: "ref-1", + title: "Old title", + summary: "old summary", + projectId: 7, + projectKey: "7", + slug: "old-title", + url: "https://x.test/items/id-1?v=id-10", + status: "open", +}; +const NEW_ITEM = { + title: "New Title!", + meta: { notes: ["a", "the new summary"] }, +}; + +it("creates an entity from generated values, the call and the observed constants", () => { + const { asked, generate } = generator(); + const observed = structuredClone(CREATE_OBSERVED); + const result = createdFields( + CREATE_PLAN, + observed, + callWith(NEW_ITEM, { project: "42" }), + generate, + ); + expect(result).toEqual( + ok({ + id: "id-9", + number: 9, + createdAt: NOW, + ref: "ref-9", + title: "New Title!", + summary: "the new summary", + projectId: 42, + projectKey: "42", + slug: "new-title", + url: "https://x.test/items/id-9?v=id-10", + status: "open", + }), + ); + expect(asked).toEqual(CREATE_RULES.slice(0, 4)); + expect(observed).toEqual(CREATE_OBSERVED); +}); + +it.each<[string, Plan, WriteCall, string]>([ + [ + "no modelled body", + { rules: CREATE_RULES, body: null }, + callWith(NEW_ITEM, { project: "42" }), + "The tool has no modelled body.", + ], + [ + "a missing copy source", + CREATE_PLAN, + callWith({ title: "x", meta: { notes: ["only one"] } }, { project: "42" }), + "The call has no value at /meta/notes/1 in its body, which summary copies.", + ], + [ + "a missing path value", + CREATE_PLAN, + callWith(NEW_ITEM), + "The call names no project path value, which projectId takes.", + ], + [ + "a body problem", + CREATE_PLAN, + callWith({ ...NEW_ITEM, extra: 1 }, { project: "42" }), + "Member extra is not one the capture showed stored (accepted members: title, meta; members accepted only with their recorded value: none; required: title, meta).", + ], +])("refuses a create with %s", (_, plan, call, message) => { + expect( + createdFields(plan, CREATE_OBSERVED, call, generator().generate), + ).toEqual(err(message)); +}); + +const UPDATE_PLAN: Plan = { + rules: [ + { field: "title", rule: "echo" }, + { field: "notes", rule: "echo" }, + { field: "status", rule: "constant" }, + { field: "slug", rule: "slug", from: "title" }, + { field: "version", rule: "increment" }, + { field: "revision", rule: "increment" }, + { field: "updatedAt", rule: "clock", format: SECONDS }, + ], + body: { + accepted: ["title", "notes"], + fixed: [{ member: "status", sent: "CLOSED" }], + required: [], + identity: "id", + }, +}; +const BEFORE: JsonObject = { + id: "42", + title: "Old Title", + notes: "n", + status: "open", + slug: "old-title", + version: 3, + revision: "9", + updatedAt: "2026-01-01T00:00:00Z", + color: "red", +}; +const AFTER: JsonObject = { + ...BEFORE, + title: "Recorded Title", + status: "closed", + slug: "recorded-title", + version: 4, + revision: "10", +}; + +const update = (body: JsonObject | undefined, plan = UPDATE_PLAN) => { + const { asked, generate } = generator(); + const result = updatedFields( + plan, + { before: BEFORE, observed: AFTER }, + { ...callWith(body, { item: "42" }), id: "42" }, + generate, + ); + return { asked, result }; +}; + +const ADVANCED = { version: 4, revision: "10", updatedAt: NOW }; + +it("updates sent echoed members and derives the slug of a sent member", () => { + const { asked, result } = update({ id: "42", title: "New Title" }); + expect(result).toEqual( + ok({ ...BEFORE, ...ADVANCED, title: "New Title", slug: "new-title" }), + ); + expect(asked).toEqual([UPDATE_PLAN.rules[6]]); +}); + +it("applies a constant from the recorded update only when the call sends it", () => { + expect(update({ status: "CLOSED" }).result).toEqual( + ok({ ...BEFORE, ...ADVANCED, status: "closed" }), + ); + expect(update({ notes: "m" }).result).toEqual( + ok({ ...BEFORE, ...ADVANCED, notes: "m" }), + ); +}); + +it.each<[string, JsonObject | undefined, string]>([ + [ + "only the identity", + { id: "42" }, + "The call sends none of the members an update applies", + ], + ["no members", {}, "The call sends none of the members an update applies"], + [ + "no body", + undefined, + "The call sends none of the members an update applies", + ], + [ + "another identity", + { id: "43", title: "x" }, + "Member id must repeat the target's identity 42.", + ], + ["a number for a string", { title: 5 }, "Member title must be a JSON string"], + [ + "a fixed member with another value", + { status: "OPEN" }, + "Member status is accepted only with the value", + ], +])("refuses an update with %s", (_, body, message) => { + const { result } = update(body); + expect(result.ok ? undefined : result.error).toContain(message); +}); + +it("refuses an update without a modelled body", () => { + expect(update({ title: "x" }, { ...UPDATE_PLAN, body: null }).result).toEqual( + err("The tool has no modelled body."), + ); +}); + +const DOCUMENT = { settings: { theme: "dark", lang: "en" }, version: 1 }; + +const documentPlan = ( + merge: "merge" | "replace", + pointer?: string, +): Pick => ({ + merge, + observation: pointer === undefined ? null : { ordinal: 1, pointer }, +}); + +it.each<[string, ReturnType, JsonObject, JsonValue]>([ + [ + "merges into a member", + documentPlan("merge", "/settings"), + { theme: "light" }, + { settings: { theme: "light", lang: "en" }, version: 1 }, + ], + [ + "replaces a member", + documentPlan("replace", "/settings"), + { theme: "light" }, + { settings: { theme: "light" }, version: 1 }, + ], + [ + "merges into the root", + documentPlan("merge"), + { version: 2 }, + { ...DOCUMENT, version: 2 }, + ], + [ + "replaces the root", + documentPlan("replace", ""), + { theme: "x" }, + { theme: "x" }, + ], +])("%s of a document", (_, plan, body, expected) => { + expect(writtenDocument(plan, DOCUMENT, callWith(body))).toEqual(ok(expected)); + expect(DOCUMENT).toEqual({ + settings: { theme: "dark", lang: "en" }, + version: 1, + }); +}); + +it("refuses a document write that does not reach an object", () => { + const refused = err("The call's body does not fit the document."); + const plan = (pointer: string) => documentPlan("merge", pointer); + expect( + writtenDocument(plan("/version"), DOCUMENT, callWith({ a: 1 })), + ).toEqual(refused); + expect( + writtenDocument(plan("/settings"), DOCUMENT, callWith(undefined)), + ).toEqual(refused); +}); + +const answer = ( + shape: WriteResponse["shape"], + pointer: string | null, +): WriteResponse => ({ ordinal: 1, shape, pointer }); +const WRITTEN = { id: "8", title: "New", extra: true }; + +it.each<[string, WriteResponse, JsonValue | undefined, JsonValue | undefined]>([ + ["no body", answer("empty", null), { id: "1" }, undefined], + ["the recorded body", answer("recorded", null), { ok: true }, { ok: true }], + [ + "no body when none was recorded", + answer("entity", ""), + undefined, + undefined, + ], + ["a numeric identity at the root", answer("identity", ""), 7, 8], + ["a string identity at the root", answer("identity", ""), "7", "8"], + [ + "a numeric identity in a member", + answer("identity", "/id"), + { id: 7, ok: true }, + { id: 8, ok: true }, + ], + [ + "the recorded fields of the entity", + answer("entity", "/data"), + { data: { title: "Old", id: "1" }, page: 1 }, + { data: { title: "New", id: "8" }, page: 1 }, + ], + [ + "the entity in a one-element array", + answer("entity", "/0"), + [{ id: "1" }], + [{ id: "8" }], + ], +])("answers with %s", (_, response, recorded, expected) => { + expect( + writtenResponse(response, recorded, { fields: WRITTEN, id: "8" }), + ).toEqual(ok(expected)); +}); + +it("spells an identity that is not an integer as a string where a number was recorded", () => { + expect( + writtenResponse( + answer("identity", "/id"), + { id: 7 }, + { fields: undefined, id: "abc" }, + ), + ).toEqual(ok({ id: "abc" })); +}); + +it("refuses an entity answer the state cannot render", () => { + const recorded = { data: { id: "1", title: "Old", owner: "x" } }; + expect( + writtenResponse(answer("entity", "/data"), recorded, { + fields: WRITTEN, + id: "8", + }), + ).toEqual( + err( + "The state holds no complete entity for this response: it lacks owner.", + ), + ); + expect( + writtenResponse(answer("entity", "/data"), recorded, { + fields: undefined, + id: "8", + }), + ).toEqual(err("The recorded response does not carry the entity.")); + expect( + writtenResponse(answer("entity", "/missing"), recorded, { + fields: WRITTEN, + id: "8", + }), + ).toEqual(err("The recorded response does not carry the entity.")); +}); + +it("drops view-specific values of the fields an update changed and cites no records", () => { + const entity: StateEntity = { + type: "/items", + collection: "/items", + id: "1", + fields: { id: "1", title: "Old", status: "open", meta: { a: 1, b: 2 } }, + view_fields: [ + { + tool: "list_items", + fields: { title: "Old…", status: "OPEN", meta: "a,b" }, + }, + { tool: "get_item", fields: { title: "OLD" } }, + { tool: "search_items", fields: { priority: "HIGH" } }, + ], + record_ordinals: [3, 5], + }; + const snapshot = structuredClone(entity); + const fields = { + id: "1", + title: "New", + status: "open", + meta: { b: 2, a: 1 }, + priority: "low", + }; + expect(updatedEntity(entity, fields)).toEqual({ + ...snapshot, + fields, + record_ordinals: [], + view_fields: [ + { tool: "list_items", fields: { status: "OPEN", meta: "a,b" } }, + ], + }); + expect(entity).toEqual(snapshot); + expect(updatedEntity(entity, entity.fields).view_fields).toEqual( + entity.view_fields, + ); +}); diff --git a/src/domain/analysisView/environmentStateWriteExplain.test.ts b/src/domain/analysisView/environmentStateWriteExplain.test.ts new file mode 100644 index 0000000000..0b73e9eae4 --- /dev/null +++ b/src/domain/analysisView/environmentStateWriteExplain.test.ts @@ -0,0 +1,552 @@ +import { expect, it } from "vitest"; +import type { JsonValue } from "../jsonValue.js"; +import type { JsonObject } from "./environmentStateRules.js"; +import { + compareValues, + documentPointerOf, + explainCreate, + explainUpdate, + insertCandidates, + insertIndex, + responseShapeOf, + type CreateEvidence, + type UpdateEvidence, +} from "./environmentStateWriteExplain.js"; +import { + classifyWrite, + pathValuesOf, + prefixRelated, + type FieldRule, + type InsertRule, + type WriteEffect, + type WriteResponse, +} from "./environmentStateWriteRules.js"; + +const START = Date.UTC(2026, 2, 4, 5, 6, 0); +const MINUTE = 60_000; +const UUID = "123e4567-e89b-42d3-a456-426614174000"; +const OTHER_UUID = "00000000-0000-4000-8000-000000000000"; + +const created = ( + entity: JsonObject, + body: JsonObject, + context: Partial & { + readonly path?: Record; + } = {}, +): CreateEvidence => { + const { path = {}, ...rest } = context; + return { + entity, + call: { path: "/items", pathValues: new Map(Object.entries(path)), body }, + idField: "id", + startMs: START, + collection: [], + type: [], + ...rest, + }; +}; + +const updated = ( + before: JsonObject, + after: JsonObject, + body: JsonObject, + context: Partial = {}, +): UpdateEvidence => ({ + before, + after, + call: { path: "/items/42", pathValues: new Map([["item", "42"]]), body }, + method: "PATCH", + idField: "id", + id: "42", + startMs: START, + ...context, +}); + +const ruleFor = ( + rules: readonly FieldRule[], + field: string, +): FieldRule | undefined => rules.find((rule) => rule.field === field); + +it("explains created fields by echo, copy, path and slug, leaving constants unlisted", () => { + const notes = ["x", "drafted by the finance team"]; + const result = explainCreate( + created( + { + id: 7, + title: "Quarterly report", + heading: "Quarterly report", + summary: "drafted by the finance team", + projectId: 42, + slug: "quarterly-report", + code: "abc", + status: "open", + }, + { title: "Quarterly report", meta: { notes }, label: "abc" }, + { path: { project: "42" } }, + ), + ); + expect(result.rules).toEqual([ + { field: "id", rule: "identity", generator: "sequence" }, + { field: "title", rule: "echo" }, + { field: "heading", rule: "copy", from: "/title" }, + { field: "summary", rule: "copy", from: "/meta/notes/1" }, + { field: "projectId", rule: "path", from: "project" }, + { field: "slug", rule: "slug", from: "title" }, + ]); + expect(result.tied).toBe(false); +}); + +it("copies only body strings of eight or more characters", () => { + const short = explainCreate( + created({ id: 1, label: "seven77" }, { name: "seven77" }), + ); + expect(ruleFor(short.rules, "label")).toBeUndefined(); + const long = explainCreate( + created({ id: 1, label: "eight888" }, { name: { first: "eight888" } }), + ); + expect(ruleFor(long.rules, "label")).toEqual({ + field: "label", + rule: "copy", + from: "/name/first", + }); +}); + +it.each<[string, JsonValue, number | undefined, boolean]>([ + [ + "ten minutes after", + new Date(START + 10 * MINUTE).toISOString(), + START, + true, + ], + [ + "ten minutes before", + new Date(START - 10 * MINUTE).toISOString(), + START, + true, + ], + [ + "just outside", + new Date(START + 10 * MINUTE + 1).toISOString(), + START, + false, + ], + ["untimed", new Date(START).toISOString(), undefined, false], +])("explains a clock %s the request start", (_, value, startMs, clock) => { + const result = explainCreate(created({ id: 1, at: value }, {}, { startMs })); + expect(ruleFor(result.rules, "at")).toEqual( + clock + ? { + field: "at", + rule: "clock", + format: { kind: "iso", separator: "T", fraction: 3, zone: "Z" }, + } + : undefined, + ); +}); + +it("keeps an epoch clock's unit", () => { + const result = explainCreate(created({ id: 1, at: START / 1000 + 5 }, {})); + expect(ruleFor(result.rules, "at")).toEqual({ + field: "at", + rule: "clock", + format: { kind: "epoch", unit: "s" }, + }); +}); + +it.each<[JsonValue, FieldRule | undefined]>([ + [4, { field: "number", rule: "sequence", scope: "collection" }], + ["4", { field: "number", rule: "sequence", scope: "collection" }], + [10, { field: "number", rule: "sequence", scope: "type" }], + [5, undefined], +])("explains the number %j by the sequence it continues", (value, rule) => { + const collection = [{ number: 1 }, { number: "3" }]; + const evidence = created( + { id: 1, number: value }, + {}, + { + collection, + type: [...collection, { number: 9 }, { number: "x" }], + }, + ); + expect(ruleFor(explainCreate(evidence).rules, "number")).toEqual(rule); +}); + +it.each<[string, JsonObject[], boolean]>([ + ["no other entity shares", [{ ref: OTHER_UUID }, {}], true], + ["another entity shares", [{ ref: OTHER_UUID }, { ref: UUID }], false], + ["the type holds no other entity", [], false], +])("explains a UUID as unique when %s it", (_, type, unique) => { + const result = explainCreate(created({ id: 1, ref: UUID }, {}, { type })); + expect(ruleFor(result.rules, "ref")).toEqual( + unique ? { field: "ref", rule: "unique" } : undefined, + ); +}); + +it.each<[JsonValue, "sequence" | "uuid" | "token"]>([ + [17, "sequence"], + ["17", "sequence"], + [UUID, "uuid"], + ["tok_AbC9", "token"], +])("generates the identity %j as a %s", (id, generator) => { + const result = explainCreate( + created({ key: id, title: "x" }, {}, { idField: "key" }), + ); + expect(result.rules).toEqual([{ field: "key", rule: "identity", generator }]); +}); + +it("rebuilds strings that embed a generated value as a delimited token", () => { + const result = explainCreate( + created( + { + id: "abc123", + number: 4, + url: "https://x.test/items/abc123", + ref: "#4", + near: "abc1234 is not it", + year: "2024", + }, + {}, + { collection: [{ number: 3 }] }, + ), + ); + expect(result.rules).toEqual([ + { field: "id", rule: "identity", generator: "token" }, + { field: "number", rule: "sequence", scope: "collection" }, + { field: "url", rule: "template", from: "id" }, + { field: "ref", rule: "template", from: "number" }, + ]); +}); + +it("accepts echoed members and fixes the others at their sent values", () => { + const result = explainCreate( + created( + { id: 1, title: "A", priority: "high" }, + { title: "A", priority: "HIGH", notify: true }, + ), + ); + expect(result.body).toEqual({ + accepted: ["title"], + fixed: [ + { member: "priority", sent: "HIGH" }, + { member: "notify", sent: true }, + ], + required: ["title", "priority", "notify"], + identity: null, + }); +}); + +it.each<[string, JsonObject, boolean]>([ + [ + "a constant holds the path value delimited", + { team: "/orgs/acme/teams" }, + true, + ], + ["a constant holds it only inside a word", { team: "acmecorp" }, false], + ["only a path field holds it", { org: "acme" }, false], + [ + "a template keeps it beside the generated value", + { url: "/orgs/acme/items/1" }, + true, + ], +])("ties a create to its collection only when %s", (_, fields, tied) => { + const result = explainCreate( + created({ id: 1, ...fields }, {}, { path: { org: "acme", empty: "" } }), + ); + expect(result.tied).toBe(tied); +}); + +it("explains an update's sent members as echoes, constants or the identity", () => { + const result = explainUpdate( + updated( + { id: "42", title: "Old", status: "open" }, + { id: "42", title: "New", status: "closed" }, + { id: 42, title: "New", status: "CLOSED", comment: "done" }, + ), + ); + expect(result).toEqual({ + rules: [ + { field: "title", rule: "echo" }, + { field: "status", rule: "constant" }, + ], + body: { + accepted: ["title"], + fixed: [ + { member: "status", sent: "CLOSED" }, + { member: "comment", sent: "done" }, + ], + required: [], + identity: "id", + }, + unexplained: [], + }); +}); + +it("explains unsent changes as a clock, an increment or a slug of an echoed member", () => { + const result = explainUpdate( + updated( + { + id: "42", + title: "Old Title", + slug: "old-title", + version: 3, + revision: "9", + updatedAt: "2026-01-01T00:00:00Z", + color: "red", + }, + { + id: "42", + title: "New Title", + slug: "new-title", + version: 4, + revision: "10", + updatedAt: new Date(START + 1000).toISOString(), + color: "blue", + added: true, + }, + { title: "New Title" }, + ), + ); + expect(result.rules).toEqual([ + { field: "title", rule: "echo" }, + { field: "slug", rule: "slug", from: "title" }, + { field: "version", rule: "increment" }, + { field: "revision", rule: "increment" }, + { + field: "updatedAt", + rule: "clock", + format: { kind: "iso", separator: "T", fraction: 3, zone: "Z" }, + }, + ]); + expect(result.unexplained).toEqual(["color"]); +}); + +it("leaves a slug of a transformed member, an untimed clock and a jump unexplained", () => { + const result = explainUpdate( + updated( + { id: "42", title: "Old", slug: "old", at: "2026-01-01T00:00:00Z", n: 3 }, + { + id: "42", + title: "new title", + slug: "new-title", + at: new Date(START).toISOString(), + n: 5, + }, + { title: "New Title" }, + { startMs: undefined }, + ), + ); + expect(result.rules).toEqual([{ field: "title", rule: "constant" }]); + expect(result.unexplained).toEqual(["slug", "at", "n"]); +}); + +it.each<[string, string[]]>([ + ["PUT", ["title", "status"]], + ["PATCH", []], + ["POST", []], +])( + "requires the members a %s sent apart from the identity", + (method, required) => { + const after = { id: "42", title: "New", status: "closed" }; + const body = { id: "42", title: "New", status: "closed" }; + const result = explainUpdate(updated(after, after, body, { method })); + expect(result.body.required).toEqual(required); + expect(result.body.identity).toBe("id"); + }, +); + +type Shaped = [WriteResponse["shape"], string | null]; + +it.each<[string, JsonValue | undefined, boolean, Shaped]>([ + ["no body", undefined, true, ["empty", null]], + ["an empty response with a body", { id: "42" }, true, ["empty", null]], + ["no recorded body", undefined, false, ["recorded", null]], + ["the entity at the root", { id: "42", title: "x" }, false, ["entity", ""]], + [ + "the entity in one member", + { data: { id: 42 } }, + false, + ["entity", "/data"], + ], + [ + "the entity in a one-element array", + [{ id: "42" }], + false, + ["entity", "/0"], + ], + ["the identity at the root", "42", false, ["identity", ""]], + ["a numeric identity at the root", 42, false, ["identity", ""]], + [ + "the identity in one member", + { created: "42", ok: true }, + false, + ["identity", "/created"], + ], + [ + "the identity in an escaped member", + { "a/b": 42 }, + false, + ["identity", "/a~1b"], + ], + [ + "the identity in two members", + { a: "42", b: "42" }, + false, + ["recorded", null], + ], + ["no identity", { ok: true }, false, ["recorded", null]], +])("shapes a response carrying %s", (_, body, empty, [shape, pointer]) => { + expect(responseShapeOf(3, body, empty, { idField: "id", id: "42" })).toEqual({ + ordinal: 3, + shape, + pointer, + }); +}); + +it.each<[JsonValue | undefined, JsonValue | undefined, number | undefined]>([ + [2, 10, -1], + ["10", 9, 1], + ["7", 7, 0], + ["2026-03-04T05:00:00Z", "2026-03-04T06:30:00+02:00", 1], + [Date.UTC(2026, 2, 4, 6) / 1000, "2026-03-04T05:00:00Z", 1], + ["apple", "banana", -1], + ["Zebra", "apple", -1], + ["b", "b", 0], + [5, "apple", undefined], + [true, false, undefined], + [undefined, "x", undefined], + [{ a: 1 }, { a: 1 }, undefined], +])("orders %j against %j as %j", (left, right, sign) => { + const order = compareValues(left, right); + expect(order === undefined ? undefined : Math.sign(order)).toBe(sign); +}); + +it("offers each strictly ordered field in its direction, then last and first", () => { + const members = [ + { id: 1, at: "2026-03-02T00:00:00Z", name: "b", tag: "x" }, + { id: 2, at: "2026-03-01T12:00:00+02:00", name: "a", tag: "x" }, + { id: "3", at: "2026-02-28T00:00:00Z", name: "c" }, + ]; + expect(insertCandidates(members, ["id", "at", "name", "tag"])).toEqual([ + { rule: "sorted", field: "id", direction: "ascending" }, + { rule: "sorted", field: "at", direction: "descending" }, + { rule: "last" }, + { rule: "first" }, + ]); + const fallback = [{ rule: "last" }, { rule: "first" }]; + expect(insertCandidates([{ id: 1 }], ["id"])).toEqual(fallback); + expect(insertCandidates([], ["id"])).toEqual(fallback); +}); + +const ASCENDING = [{ n: 1 }, { n: 3 }, { n: 5 }]; +const DESCENDING = [{ n: 5 }, { n: 3 }, { n: 1 }]; +const up: InsertRule = { rule: "sorted", field: "n", direction: "ascending" }; +const down: InsertRule = { + rule: "sorted", + field: "n", + direction: "descending", +}; + +it.each<[string, InsertRule, JsonObject, number]>([ + ["first", { rule: "first" }, { n: 9 }, 0], + ["last", { rule: "last" }, { n: 0 }, 3], + ["ascending, between members", up, { n: 4 }, 2], + ["ascending, before every member", up, { n: 0 }, 0], + ["ascending, after every member", up, { n: 6 }, 3], + ["ascending, after an equal member", up, { n: 3 }, 2], + ["descending, between members", down, { n: 4 }, 1], + ["descending, after every member", down, { n: 0 }, 3], + ["sorted, with an incomparable value", up, { n: true }, 3], + ["sorted, without the field", up, {}, 3], +])("inserts %s", (_, rule, fields, index) => { + const members = rule === down ? DESCENDING : ASCENDING; + expect(insertIndex(rule, members, fields)).toBe(index); +}); + +it.each<[string, JsonValue, JsonObject, string | undefined]>([ + ["the root", { theme: "dark", lang: "en" }, { theme: "light" }, ""], + [ + "the member sharing most", + { theme: "dark", settings: { theme: "dark", lang: "en" } }, + { theme: "light", lang: "fr" }, + "/settings", + ], + [ + "the root on a tie", + { theme: "d", s: { lang: "en" } }, + { theme: "x", lang: "y" }, + "", + ], + ["an escaped member", { "a/b": { theme: "dark" } }, { theme: "x" }, "/a~1b"], + [ + "nothing when none is shared", + { a: 1, b: { c: 2 } }, + { theme: "x" }, + undefined, + ], + ["nothing in an array", [{ theme: "dark" }], { theme: "x" }, undefined], +])("points a document write at %s", (_, value, body, pointer) => { + expect(documentPointerOf(value, body)).toBe(pointer); +}); + +it.each<[string, string, WriteEffect, string | null]>([ + ["POST", "/users", "create", "/users"], + ["POST", "/users/", "create", "/users"], + ["POST", "/users/{userId}/posts", "create", "/users/{userId}/posts"], + ["PUT", "/users/{userId}", "update", "/users"], + ["PATCH", "/users/{id}", "update", "/users"], + ["POST", "/users/{id}", "update", "/users"], + ["DELETE", "/users/{id}", "delete", "/users"], + [ + "DELETE", + "/users/{userId}/posts/{postId}", + "delete", + "/users/{userId}/posts", + ], + ["GET", "/users/{id}", "none", null], + ["DELETE", "/users", "none", null], + ["PUT", "/users", "none", null], + ["PUT", "/users/me", "none", null], + ["POST", "/users/{id}/archive", "none", null], + ["PUT", "/settings", "document", "get_settings"], + ["PATCH", "/settings/", "document", "get_settings"], + ["POST", "/settings", "document", "get_settings"], + ["DELETE", "/settings", "none", null], +])("classifies %s %s as %s", (method, path, effect, target) => { + expect( + classifyWrite( + { method, path_template: path }, + new Set(["/users", "/users/{userId}/posts"]), + new Map([["/settings", "get_settings"]]), + ), + ).toEqual({ effect, target }); +}); + +it.each<[string, string, Record | undefined]>([ + [ + "/orgs/{org}/repos/{repo}", + "/orgs/acme/repos/rea", + { org: "acme", repo: "rea" }, + ], + ["/orgs/{org}", "/orgs/a%20b%2Fc/", { org: "a b/c" }], + ["/orgs/{org}", "/orgs/%E0%A4%A", { org: "%E0%A4%A" }], + ["/orgs", "/orgs", {}], + ["/orgs/{org}", "/teams/acme", undefined], + ["/orgs/{org}", "/orgs/acme/repos", undefined], +])("reads %s from %s", (template, path, values) => { + expect(pathValuesOf(template, path)).toEqual( + values === undefined ? undefined : new Map(Object.entries(values)), + ); +}); + +it.each<[string, string, boolean]>([ + ["/users", "/users/{id}", true], + ["/users/{id}/posts", "/users/42", true], + ["/users/42/posts", "/users/{id}/posts/7", true], + ["/", "/anything/at/all", true], + ["/users/42", "/users/43", false], + ["/users", "/teams/{id}", false], + ["/{tenant}/users", "/acme/teams", false], +])("relates %s and %s by prefix: %s", (left, right, related) => { + expect(prefixRelated(left, right)).toBe(related); + expect(prefixRelated(right, left)).toBe(related); +}); diff --git a/src/domain/analysisView/environmentStateWriteValues.test.ts b/src/domain/analysisView/environmentStateWriteValues.test.ts new file mode 100644 index 0000000000..775866ce66 --- /dev/null +++ b/src/domain/analysisView/environmentStateWriteValues.test.ts @@ -0,0 +1,242 @@ +import { expect, it } from "vitest"; +import type { JsonValue } from "../jsonValue.js"; +import { + containsDelimited, + delimitedOffsets, + formatClock, + integerOf, + isUuid, + readClock, + replaceDelimited, + seededToken, + seededUuid, + slugOf, + spelledLike, + type ClockFormat, +} from "./environmentStateWriteValues.js"; + +const WHOLE_SECOND = Date.UTC(2026, 2, 4, 5, 6, 7); +const WITH_MS = WHOLE_SECOND + 123; +const V4_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const UUID_EXEMPLAR = "123e4567-e89b-12d3-a456-426614174000"; + +const iso = ( + separator: "T" | " ", + fraction: number, + zone: string, +): ClockFormat => ({ kind: "iso", separator, fraction, zone }); + +const seeds = (count: number): string[] => + Array.from({ length: count }, (_, index) => `seed-${String(index)}`); + +it.each<[string, number, ClockFormat]>([ + ["2026-03-04T05:06:07Z", WHOLE_SECOND, iso("T", 0, "Z")], + ["2026-03-04 05:06:07.123Z", WITH_MS, iso(" ", 3, "Z")], + ["2026-03-04T05:06:07.123456Z", WITH_MS, iso("T", 6, "Z")], + ["2026-03-04T07:06:07.123+02:00", WITH_MS, iso("T", 3, "+02:00")], + ["2026-03-03T23:36:07-0530", WHOLE_SECOND, iso("T", 0, "-0530")], + ["2026-03-04T05:06:07", WHOLE_SECOND, iso("T", 0, "")], + ["2024-02-29 00:00:00Z", Date.UTC(2024, 1, 29), iso(" ", 0, "Z")], +])("reads the ISO clock %s with its spelling", (text, ms, format) => { + expect(readClock(text)).toEqual({ ms, format }); +}); + +it.each([ + "2026-02-30T00:00:00Z", + "2026-02-29T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-03-04T24:00:00Z", + "2026-03-04T05:60:00Z", + "2026-03-04T05:06:60Z", + "2026-03-04", + "2026-03-04T05:06Z", + "2026-03-04T05:06:07+05", + "2026-03-04T05:06:07.1234567890Z", + "2026-03-04t05:06:07Z", + "next tuesday", +])("refuses %s as a clock", (text) => { + expect(readClock(text)).toBeUndefined(); +}); + +it.each<[JsonValue | undefined, ReturnType]>([ + [ + WHOLE_SECOND / 1000, + { ms: WHOLE_SECOND, format: { kind: "epoch", unit: "s" } }, + ], + [WITH_MS, { ms: WITH_MS, format: { kind: "epoch", unit: "ms" } }], + [999_999_999, undefined], + [99_999_999_999, undefined], + [10_000_000_000_000, undefined], + [WHOLE_SECOND / 1000 + 0.5, undefined], + [String(WHOLE_SECOND / 1000), undefined], + [null, undefined], + [undefined, undefined], +])("reads the epoch value %j", (value, expected) => { + expect(readClock(value)).toEqual(expected); +}); + +it.each([ + "2026-03-04T05:06:07Z", + "2026-03-04 05:06:07.123Z", + "2026-03-04T07:06:07.123+02:00", + "2026-03-03T23:36:07-0530", + "2026-03-04T05:06:07.1", + "2026-03-04T05:06:07.123000000", + WHOLE_SECOND / 1000, + WITH_MS, +])("formats %j back to its own spelling", (value) => { + const clock = readClock(value)!; + expect(formatClock(clock.ms, clock.format)).toBe(value); +}); + +it.each<[ClockFormat, JsonValue]>([ + [iso("T", 6, "Z"), "2026-03-04T05:06:07.123000Z"], + [iso(" ", 9, ""), "2026-03-04 05:06:07.123000000"], + [iso("T", 1, "Z"), "2026-03-04T05:06:07.1Z"], + [iso("T", 0, "+02:00"), "2026-03-04T07:06:07+02:00"], + [iso("T", 3, "-0530"), "2026-03-03T23:36:07.123-0530"], + [{ kind: "epoch", unit: "s" }, WHOLE_SECOND / 1000], + [{ kind: "epoch", unit: "ms" }, WITH_MS], +])("formats an instant as %j", (format, expected) => { + expect(formatClock(WITH_MS, format)).toBe(expected); +}); + +it.each<[JsonValue | undefined, boolean]>([ + [UUID_EXEMPLAR, true], + [UUID_EXEMPLAR.toUpperCase(), true], + [UUID_EXEMPLAR.replaceAll("-", ""), false], + [UUID_EXEMPLAR.slice(1), false], + [`g${UUID_EXEMPLAR.slice(1)}`, false], + [` ${UUID_EXEMPLAR}`, false], + [42, false], + [undefined, false], +])("isUuid(%j) is %s", (value, expected) => { + expect(isUuid(value)).toBe(expected); +}); + +it.each<[JsonValue | undefined, number | undefined]>([ + [42, 42], + [-3, -3], + ["42", 42], + ["007", 7], + ["123456789012345", 123_456_789_012_345], + ["1234567890123456", undefined], + ["-3", undefined], + ["4.2", undefined], + [4.2, undefined], + [2 ** 53, undefined], + ["", undefined], + [" 4", undefined], + [true, undefined], + [null, undefined], + [undefined, undefined], +])("integerOf(%j) is %j", (value, expected) => { + expect(integerOf(value)).toBe(expected); +}); + +it("spells an integer as the recorded value spells integers", () => { + expect(spelledLike("7", 8)).toBe("8"); + expect(spelledLike(7, 8)).toBe(8); + expect(spelledLike(null, 8)).toBe(8); +}); + +it.each([ + ["Hello World", "hello-world"], + ["Héllo, Wörld!", "hello-world"], + [" Crème brûlée -- recipe__v2 ", "creme-brulee-recipe-v2"], + ["Fullwidth 2", "fullwidth-2"], + ["already-a-slug", "already-a-slug"], + ["--Edge--", "edge"], + ["!!!", ""], +])("slugOf(%j) is %j", (text, slug) => { + expect(slugOf(text)).toBe(slug); +}); + +it.each<[string, string, number[]]>([ + ["/issues/2", "2", [8]], + ["2026", "2", []], + ["2 of 22, page 2", "2", [0, 14]], + ["x2/2y", "2", []], + ["é2", "2", []], + ["https://x.test/items/abc123?ref=abc123", "abc123", [21, 32]], + ["anything", "", []], +])("finds %j's delimited %j at %j", (text, token, offsets) => { + expect(delimitedOffsets(text, token)).toEqual(offsets); +}); + +it("replaces only delimited occurrences", () => { + expect(replaceDelimited("/issues/2 in 2026", "2", "9")).toBe( + "/issues/9 in 2026", + ); + expect(replaceDelimited("a-2-b-2", "2", "10")).toBe("a-10-b-10"); + expect(replaceDelimited("2026", "2", "9")).toBe("2026"); +}); + +// Known bug: a rejected match skips its whole length, hiding an overlapping delimited one. +it("finds a delimited match that overlaps an undelimited one", () => { + expect(delimitedOffsets("11-1-1", "1-1")).toEqual([3]); +}); + +it("finds a delimited token in any string nested in objects and arrays", () => { + expect( + containsDelimited({ a: [1, { b: ["x", "see /issues/2"] }] }, "2"), + ).toBe(true); + expect(containsDelimited("2", "2")).toBe(true); + expect(containsDelimited({ c: "2026", n: 2, d: null }, "2")).toBe(false); + expect(containsDelimited(["2026", true, [{ e: "x2" }]], "2")).toBe(false); +}); + +it("derives a deterministic version 4 UUID per seed", () => { + const first = seededUuid("seed-a", UUID_EXEMPLAR); + expect(first).toMatch(V4_UUID); + expect(seededUuid("seed-a", UUID_EXEMPLAR)).toBe(first); + const derived = seeds(64).map((seed) => seededUuid(seed, UUID_EXEMPLAR)); + expect(new Set(derived).size).toBe(derived.length); + for (const uuid of derived) expect(uuid).toMatch(V4_UUID); +}); + +it("keeps upper case only when the recorded UUID is upper case", () => { + const upper = seededUuid("seed-a", UUID_EXEMPLAR.toUpperCase()); + expect(upper).toBe(seededUuid("seed-a", UUID_EXEMPLAR).toUpperCase()); + expect(upper).toMatch(/[A-F]/u); + const digits = "12345678-1234-4234-8234-123456789012"; + expect(seededUuid("seed-a", digits)).toMatch(V4_UUID); + const mixed = "123e4567-E89B-12d3-a456-426614174000"; + expect(seededUuid("seed-a", mixed)).toMatch(V4_UUID); +}); + +it.each<[string, RegExp]>([ + ["a1b2c3d4e5f6", /^[0-9a-f]{12}$/u], + ["A1B2C3D4E5F6", /^[0-9A-F]{12}$/u], + ["k9x2m7q4z1w8", /^[0-9a-z]{12}$/u], + ["AbCdEfGhIjKl", /^[A-Za-z]{12}$/u], + ["deadbeefcafe", /^[a-z]{12}$/u], + [ + "tok_9Xa-7bQ.2", + /^[0-9A-Za-z]{3}_[0-9A-Za-z]{3}-[0-9A-Za-z]{3}\.[0-9A-Za-z]$/u, + ], + ["____", /^____$/u], +])("keeps the length and character classes of %j", (exemplar, shape) => { + for (const seed of seeds(16)) { + const token = seededToken(seed, exemplar); + expect(token).toHaveLength(exemplar.length); + expect(token).toMatch(shape); + expect(seededToken(seed, exemplar)).toBe(token); + } +}); + +it("draws tokens from the whole alphabet the recorded token uses", () => { + const characters = (exemplar: string): string => + seeds(64) + .map((seed) => seededToken(seed, exemplar)) + .join(""); + expect(characters("a1b2c3d4e5f6")).toMatch(/^[0-9a-f]+$/u); + expect(characters("k9x2m7q4z1w8")).toMatch(/[g-z]/u); + const mixed = characters("AbCdEfGhIjKl"); + expect(mixed).toMatch(/[a-z]/u); + expect(mixed).toMatch(/[A-Z]/u); + expect(seededToken("seed-a", "k9x2m7q4z1w8")).not.toBe( + seededToken("seed-b", "k9x2m7q4z1w8"), + ); +}); diff --git a/src/domain/analysisView/environmentStateWrites.test.ts b/src/domain/analysisView/environmentStateWrites.test.ts new file mode 100644 index 0000000000..1a19e968cb --- /dev/null +++ b/src/domain/analysisView/environmentStateWrites.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, it } from "vitest"; + +import type { JsonValue } from "../jsonValue.js"; +import { + timedCaptureEvidence, + type TimedCaptureRow, +} from "../../../tests/fixtures/analysisView.js"; +import { + BOOK_INIT_ROWS, + BOOK_NOT_FOUND, + BOOK_ROWS, + BOOK_WRITE_CLOCK, + FIRST_BOOK, + SECOND_BOOK, + THIRD_BOOK, + book, + bookClock, + environmentStateViewOf, + rawCaptureEvidence, + renamedBook, + type Book, +} from "../../../tests/fixtures/environmentStateWrites.js"; + +type StateView = ReturnType; + +const ISO_SECONDS = { + kind: "iso", + separator: "T", + fraction: 0, + zone: "Z", +} as const; + +const stateOf = (rows: readonly TimedCaptureRow[]): StateView => + environmentStateViewOf(timedCaptureEvidence(rows)); + +const planOf = (state: StateView, tool: string) => { + const plan = state.writes.find((candidate) => candidate.tool === tool); + if (plan === undefined) throw new Error(`No write plan for ${tool}`); + return plan; +}; + +/** Each write tool's verdict: "served", else the reason it is not. */ +const verdicts = (rows: readonly TimedCaptureRow[]) => + Object.fromEntries( + stateOf(rows).writes.map((plan) => [ + plan.tool, + plan.served ? "served" : plan.reason, + ]), + ); + +/** A create of the third book whose response is `response`, then the given later exchanges. */ +const created = ( + response: JsonValue, + ...later: TimedCaptureRow[] +): TimedCaptureRow[] => [ + ...BOOK_INIT_ROWS, + ["POST", "/api/books", response, 201, { name: "Third Book" }], + ...later, +]; + +const RENAMED_AT_3 = renamedBook(FIRST_BOOK, "Renamed Book", 3); +/** An update renaming the first book whose response is `response`, then the given later exchanges. */ +const rename = ( + response: Book = RENAMED_AT_3, + ...later: TimedCaptureRow[] +): TimedCaptureRow[] => [ + ...BOOK_INIT_ROWS, + ["PATCH", "/api/books/1", response, 200, { name: "Renamed Book" }], + ...later, +]; + +describe("served write plans", () => { + it("serves the create, update and delete that later reads confirm, citing those reads", () => { + const state = stateOf(BOOK_ROWS); + expect(state.write_clock).toBe(BOOK_WRITE_CLOCK); + expect(planOf(state, "post_books")).toEqual({ + tool: "post_books", + effect: "create", + target: "/api/books", + served: true, + reason: null, + exemplar_ordinal: 3, + observation: { ordinal: 3, pointer: "" }, + response: { ordinal: 3, shape: "entity", pointer: "" }, + body: { + accepted: ["name"], + fixed: [], + required: ["name"], + identity: null, + }, + rules: [ + { field: "id", rule: "identity", generator: "sequence" }, + { field: "name", rule: "echo" }, + { field: "slug", rule: "slug", from: "name" }, + { field: "created_at", rule: "clock", format: ISO_SECONDS }, + { field: "updated_at", rule: "clock", format: ISO_SECONDS }, + ], + collection: null, + insert: { rule: "sorted", field: "id", direction: "ascending" }, + merge: null, + checked_record_ordinals: [4, 9], + contradicted_by: null, + }); + // PATCH requires no member; the slug follows the sent name and the update stamps its clock. + expect(planOf(state, "patch_books_by_id")).toMatchObject({ + effect: "update", + served: true, + exemplar_ordinal: 5, + body: { accepted: ["name"], required: [] }, + rules: [ + { field: "name", rule: "echo" }, + { field: "slug", rule: "slug", from: "name" }, + { field: "updated_at", rule: "clock", format: ISO_SECONDS }, + ], + checked_record_ordinals: [6, 9], + }); + expect(planOf(state, "delete_books_by_id")).toMatchObject({ + effect: "delete", + served: true, + exemplar_ordinal: 7, + response: { ordinal: 7, shape: "empty", pointer: null }, + checked_record_ordinals: [8, 9], + }); + expect(planOf(state, "post_exports")).toMatchObject({ + effect: "none", + target: null, + served: false, + reason: "no-state-target", + }); + }); + + it("starts no clock on a record-order timeline, where no value is generated by a clock", () => { + const state = environmentStateViewOf( + rawCaptureEvidence( + created(THIRD_BOOK, ["GET", "/api/books/3", THIRD_BOOK]), + { + mimeType: "application/json", + timed: false, + }, + ), + ); + expect(state.timeline).toBe("record-order"); + expect(state.write_clock).toBeNull(); + const plan = planOf(state, "post_books"); + expect(plan).toMatchObject({ served: true, checked_record_ordinals: [4] }); + // Without start times the clock fields keep the recorded values as constants. + expect(plan.rules.map(({ field }) => field)).toEqual([ + "id", + "name", + "slug", + ]); + }); +}); + +describe("unserved write plans", () => { + it("names a write to no state type and a tool with no successful call", () => { + expect( + verdicts([ + ...BOOK_INIT_ROWS, + ["POST", "/api/session", { token: "t0" }, 200, { user: "ada" }], + ["POST", "/api/books", { errors: ["name"] }, 422, { name: "" }], + ["DELETE", "/api/books/9", BOOK_NOT_FOUND, 404], + ]), + ).toEqual({ + post_session: "no-state-target", + post_books: "no-call", + delete_books_by_id: "no-call", + }); + }); + + it("refuses a create whose request body cannot enter the state", () => { + /** The create of the third book sending `sent`, or no body, then a read that shows it. */ + const sending = (...sent: [] | [JsonValue]): TimedCaptureRow[] => [ + ...BOOK_INIT_ROWS, + ["POST", "/api/books", THIRD_BOOK, 201, ...sent], + ["GET", "/api/books/3", THIRD_BOOK], + ]; + const recorded = (mimeType: string, text: string) => + environmentStateViewOf( + rawCaptureEvidence(sending(text), { mimeType, timed: true }), + ); + for (const state of [ + stateOf(sending()), + stateOf(sending([{ name: "Third Book" }])), + recorded("application/x-www-form-urlencoded", "name=Third+Book"), + recorded("application/json", '{"__proto__":{},"name":"Third Book"}'), + recorded( + "application/json", + '{"name":"Third Book","copies":12345678901234567890}', + ), + ]) + expect(planOf(state, "post_books")).toMatchObject({ + served: false, + reason: "body-unusable", + exemplar_ordinal: null, + }); + }); + + it("reports no observation when neither the response nor a read before the next write shows the entity", () => { + expect(verdicts(created({ ok: true }))).toEqual({ + post_books: "no-observation", + }); + // The read of the created book comes after the next write; the update names a book never held. + expect( + verdicts( + created( + "", + [ + "PATCH", + "/api/books/7", + { id: 7, name: "Seventh" }, + 200, + { name: "Seventh" }, + ], + ["GET", "/api/books/3", THIRD_BOOK], + ), + ), + ).toEqual({ + post_books: "no-observation", + patch_books_by_id: "no-observation", + }); + }); + + it("reports an update that changed an unsent field no clock, increment or slug explains", () => { + const changed = { ...RENAMED_AT_3, slug: "first-book-v2" }; + expect(verdicts(rename(changed, ["GET", "/api/books/1", changed]))).toEqual( + { + patch_books_by_id: "unexplained-change", + }, + ); + }); + + it("names the first later read that contradicts a write", () => { + const other = renamedBook(RENAMED_AT_3, "Other Name", 3); + const plan = planOf( + stateOf( + rename( + RENAMED_AT_3, + ["GET", "/api/books/1", other], + ["GET", "/api/books/1", other], + ), + ), + "patch_books_by_id", + ); + expect(plan).toMatchObject({ + served: false, + reason: "contradicted", + contradicted_by: 4, + checked_record_ordinals: [], + }); + }); + + it("blames a related write when a book no write touched changed after it", () => { + const plan = planOf( + stateOf( + created( + THIRD_BOOK, + ["GET", "/api/books/3", THIRD_BOOK], + [ + "GET", + "/api/books/2", + renamedBook(SECOND_BOOK, "Second Edition", 5), + ], + ), + ), + "post_books", + ); + // The create's own read confirmed it, but the changed sibling outweighs that. + expect(plan).toMatchObject({ + served: false, + reason: "side-effect", + checked_record_ordinals: [4], + contradicted_by: null, + }); + }); + + it("leaves a write that no later read checks unconfirmed", () => { + expect(planOf(stateOf(created(THIRD_BOOK)), "post_books")).toMatchObject({ + served: false, + reason: "unconfirmed", + exemplar_ordinal: 3, + checked_record_ordinals: [], + }); + }); +}); + +describe("delete confirmation", () => { + it("confirms a delete by a later 404, 400 or JSON null item read", () => { + const absences: [body: JsonValue, status: number][] = [ + [BOOK_NOT_FOUND, 404], + [{ detail: "Invalid book id." }, 400], + [null, 200], + ]; + for (const [body, status] of absences) + expect( + planOf( + stateOf([ + ...BOOK_INIT_ROWS, + ["DELETE", "/api/books/2", "", 204], + ["GET", "/api/books/2", body, status], + ]), + "delete_books_by_id", + ), + ).toMatchObject({ served: true, checked_record_ordinals: [4] }); + }); + it("withholds a delete whose recorded answer shows fields no initial entity holds", () => { + const answered = (previous: JsonValue) => + planOf( + stateOf([ + ...BOOK_INIT_ROWS, + ["DELETE", "/api/books/2", { deleted: true, previous }, 200], + ["GET", "/api/books/2", BOOK_NOT_FOUND, 404], + ]), + "delete_books_by_id", + ); + // An edit view of the book: the read views never showed its draft field. + expect(answered({ ...SECOND_BOOK, draft: false })).toMatchObject({ + served: false, + reason: "incomplete-entities", + checked_record_ordinals: [4], + }); + expect(answered(SECOND_BOOK)).toMatchObject({ + served: true, + response: { shape: "entity", pointer: "/previous" }, + }); + }); +}); + +describe("insert rules", () => { + it("learns where a create inserts from the recorded collection order", () => { + // Listed by name, while neither identities nor clocks are in order. + const shelf = [ + book(3, "Alpha", "2026-10-01T10:00:00Z"), + book(1, "Delta", "2026-10-01T09:00:00Z"), + book(2, "Zulu", "2026-10-01T11:00:00Z"), + ]; + const mike = book(4, "Mike", bookClock(6)); + const state = stateOf([ + ["GET", "/api/books", shelf], + ...shelf.map((entry): TimedCaptureRow => [ + "GET", + `/api/books/${String(entry.id)}`, + entry, + ]), + ["GET", "/api/books", shelf], + ["GET", "/api/books", shelf], + ["POST", "/api/books", mike, 201, { name: "Mike" }], + ["GET", "/api/books", [shelf[0]!, shelf[1]!, mike, shelf[2]!]], + ]); + expect(planOf(state, "post_books")).toMatchObject({ + served: true, + insert: { rule: "sorted", field: "name", direction: "ascending" }, + checked_record_ordinals: [7], + }); + }); + + it("takes the position a later list shows when the recorded order admits several", () => { + const zed = book(3, "Zed", bookClock(3)); + const plan = planOf( + stateOf( + created(zed, ["GET", "/api/books", [zed, FIRST_BOOK, SECOND_BOOK]]), + ), + "post_books", + ); + expect(plan).toMatchObject({ + served: true, + insert: { rule: "first" }, + checked_record_ordinals: [4], + }); + }); +}); + +describe("unmodelled writes", () => { + const shelved = (entry: Book, archived = false): Book => ({ + ...entry, + archived, + }); + /** The rename, an archive the gate does not model when `archive` is set, then a read of both changes. */ + const archivedAfterRename = (archive: boolean) => + stateOf([ + ["GET", "/api/books", [shelved(FIRST_BOOK), shelved(SECOND_BOOK)]], + ["GET", "/api/books/1", shelved(FIRST_BOOK)], + ["GET", "/api/books/2", shelved(SECOND_BOOK)], + [ + "PATCH", + "/api/books/1", + shelved(RENAMED_AT_3), + 200, + { name: "Renamed Book" }, + ], + ...(archive + ? [ + [ + "POST", + "/api/books/1/archive", + { archived: true }, + 200, + {}, + ] as TimedCaptureRow, + ] + : []), + ["GET", "/api/books/1", shelved(RENAMED_AT_3, true)], + ]); + + it("confirms an update on the members it sent when an unmodelled write also changed the entity", () => { + const state = archivedAfterRename(true); + expect(planOf(state, "patch_books_by_id")).toMatchObject({ + served: true, + checked_record_ordinals: [5], + contradicted_by: null, + }); + expect(planOf(state, "post_books_archive_by_id")).toMatchObject({ + effect: "none", + reason: "no-state-target", + }); + // With no unmodelled write the same read contradicts the update. + expect( + planOf(archivedAfterRename(false), "patch_books_by_id"), + ).toMatchObject({ + reason: "contradicted", + contradicted_by: 4, + }); + }); + + it("does not blame a modelled write for a check that fails after an unmodelled write", () => { + const changedSibling = renamedBook(SECOND_BOOK, "Second Edition", 4); + const reindex: TimedCaptureRow = [ + "POST", + "/api/jobs/reindex", + { queued: true }, + 202, + {}, + ]; + const list: TimedCaptureRow = [ + "GET", + "/api/books", + [RENAMED_AT_3, changedSibling], + ]; + expect( + planOf(stateOf(rename(RENAMED_AT_3, list)), "patch_books_by_id"), + ).toMatchObject({ + reason: "contradicted", + contradicted_by: 4, + }); + expect( + planOf(stateOf(rename(RENAMED_AT_3, reindex, list)), "patch_books_by_id"), + ).toMatchObject({ reason: "unconfirmed", contradicted_by: null }); + // A create wrote every field, so after an unmodelled write a differing read checks none of them. + const reslugged = { ...THIRD_BOOK, slug: "third-book-1" }; + const read: TimedCaptureRow = ["GET", "/api/books/3", reslugged]; + expect( + planOf(stateOf(created(THIRD_BOOK, read)), "post_books"), + ).toMatchObject({ + reason: "contradicted", + contradicted_by: 4, + }); + expect( + planOf(stateOf(created(THIRD_BOOK, reindex, read)), "post_books"), + ).toMatchObject({ + reason: "unconfirmed", + contradicted_by: null, + }); + }); +}); + +describe("observation from a later read", () => { + it("observes a created entity in the first later item read when the response names only its identity", () => { + const later: TimedCaptureRow[] = [ + ["GET", "/api/books/3", THIRD_BOOK], + ["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]], + ]; + const bare = planOf(stateOf(created({ id: 3 }, ...later)), "post_books"); + // The read that showed the entity does not check the create; the later list does. + expect(bare).toMatchObject({ + served: true, + exemplar_ordinal: 3, + observation: { ordinal: 4, pointer: "" }, + response: { ordinal: 3, shape: "entity", pointer: "" }, + checked_record_ordinals: [5], + }); + const named = planOf( + stateOf(created({ created_id: 3 }, ...later)), + "post_books", + ); + expect(named).toMatchObject({ + served: true, + observation: { ordinal: 4, pointer: "" }, + response: { ordinal: 3, shape: "identity", pointer: "/created_id" }, + checked_record_ordinals: [5], + }); + }); +}); diff --git a/tests/boundary/mcp/environmentServeWritesMcp.test.ts b/tests/boundary/mcp/environmentServeWritesMcp.test.ts new file mode 100644 index 0000000000..046b0cc85c --- /dev/null +++ b/tests/boundary/mcp/environmentServeWritesMcp.test.ts @@ -0,0 +1,97 @@ +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { describe, expect } from "vitest"; + +import { timedCaptureEvidence } from "../../fixtures/analysisView.js"; +import { + environmentStateServeFixture, + writeEnvironmentServeFiles, +} from "../../fixtures/environmentServe.js"; +import { + BOOK_NOT_FOUND, + BOOK_ROWS, + book, + bookClock, +} from "../../fixtures/environmentStateWrites.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { startListeningProcess } from "../../support/mcp/streamableHttpProcess.js"; +import { processTest } from "../../support/process/processFixture.js"; + +const HTTP_BOUNDARY_TIMEOUT_MS = 90_000; + +/** The book the first served create of "Boundary Book" makes: one second past the served clock's start. */ +const BOUNDARY_BOOK = book(3, "Boundary Book", bookClock(13)); + +describe("rea environment-serve write tools", () => { + processTest( + "applies a served create to the state the next read answers from, until data/reset", + async ({ processes }) => { + const root = await createTestTempDirectory("rea-environment-writes-mcp-"); + const files = await writeEnvironmentServeFiles( + root, + environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS)), + ); + const serve = [files.manifestPath, "--evidence", files.capturePath]; + const started = await startListeningProcess(processes, [ + "environment-serve", + ...serve, + "--port", + "0", + ]); + const client = new Client({ name: "environment-writes", version: "1" }); + await client.connect(new StreamableHTTPClientTransport(started.url)); + try { + const { tools } = await client.listTools(); + const names = tools.map(({ name }) => name); + expect(names).toEqual( + expect.arrayContaining(["post_books", "patch_books_by_id"]), + ); + // The capture's export write targets no state type, so only confirmed writes are listed. + expect(names).not.toContain("post_exports"); + expect( + tools.find(({ name }) => name === "post_books")?.annotations, + ).toMatchObject({ + readOnlyHint: false, + }); + const created = await client.callTool({ + name: "post_books", + arguments: { body: { name: "Boundary Book" } }, + }); + expect(created.isError).toBeFalsy(); + expect(created.structuredContent).toMatchObject({ + status: 201, + body: BOUNDARY_BOOK, + state: { source: "state", view: "create", id: "3" }, + }); + const read = () => + client.callTool({ name: "get_books_by_id", arguments: { id: "3" } }); + expect((await read()).structuredContent).toMatchObject({ + status: 200, + body: BOUNDARY_BOOK, + state: { source: "state", view: "item", id: "3" }, + }); + const reset = await fetch(new URL("/agentenv", started.url.origin), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "reset", + method: "data/reset", + }), + }); + await expect(reset.json()).resolves.toMatchObject({ result: {} }); + // The initial state holds no third book, so the read answers the recorded absence. + expect((await read()).structuredContent).toMatchObject({ + status: 404, + body: BOOK_NOT_FOUND, + state: { source: "state", view: "missing", id: "3" }, + }); + } finally { + await client.close(); + } + }, + HTTP_BOUNDARY_TIMEOUT_MS, + ); +}); diff --git a/tests/fixtures/analysisView.ts b/tests/fixtures/analysisView.ts index 30ac1fe239..5123de9d96 100644 --- a/tests/fixtures/analysisView.ts +++ b/tests/fixtures/analysisView.ts @@ -314,12 +314,13 @@ export const environmentManifestCaptureFixture = ( export const environmentStateCaptureFixture = () => environmentManifestCaptureFixture(environmentStateHar().log.entries); -/** One synthetic exchange: method, path, JSON response body (a string is recorded as written) and status. */ +/** One synthetic exchange: method, path, JSON response body (a string is recorded as written), status and JSON request body. */ export type TimedCaptureRow = readonly [ method: string, path: string, body?: JsonValue, status?: number, + request?: JsonValue, ]; /** Capture Evidence of HAR entries one second apart on one origin, as the environment-state view orders them. */ @@ -328,12 +329,23 @@ export const timedCaptureEvidence = ( ): Evidence => analysisViewCaptureEvidence( environmentManifestCaptureFixture( - rows.map(([method, path, body = null, status = 200], index) => ({ + rows.map(([method, path, body = null, status = 200, sent], index) => ({ startedDateTime: new Date( Date.parse("2026-10-17T18:00:00.000Z") + index * 1000, ).toISOString(), time: 5, - request: { method, url: `http://app.example${path}` }, + request: { + method, + url: `http://app.example${path}`, + ...(sent === undefined + ? {} + : { + postData: { + mimeType: "application/json", + text: JSON.stringify(sent), + }, + }), + }, response: { status, content: { diff --git a/tests/fixtures/environmentStateWrites.ts b/tests/fixtures/environmentStateWrites.ts new file mode 100644 index 0000000000..a2c1afc840 --- /dev/null +++ b/tests/fixtures/environmentStateWrites.ts @@ -0,0 +1,163 @@ +import { + projectAnalysisView, + type AnalysisViewResult, +} from "../../src/domain/analysisView/analysisView.js"; +import type { Evidence } from "../../src/domain/evidence.js"; +import type { JsonValue } from "../../src/domain/jsonValue.js"; +import { + analysisViewCaptureEvidence, + environmentManifestCaptureFixture, + type TimedCaptureRow, +} from "./analysisView.js"; + +/** When the first synthetic exchange starts; exchange `i` starts `i` seconds later, as in `timedCaptureEvidence`. */ +const CAPTURE_START_MS = Date.parse("2026-10-17T18:00:00.000Z"); + +/** The instant `seconds` after the first exchange, spelled as the books application spells clock values. */ +export const bookClock = (seconds: number): string => + new Date(CAPTURE_START_MS + seconds * 1000) + .toISOString() + .replace(".000Z", "Z"); + +/** Lower case with every run of other characters joined by one `-`, as the books application slugs names. */ +const slugOf = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-|-$/gu, ""); + +/** A book as the application shows it. */ +export type Book = { [field: string]: JsonValue }; + +/** A book whose slug follows its name. */ +export const book = ( + id: number, + name: string, + created: string, + updated = created, +): Book => ({ + id, + name, + slug: slugOf(name), + created_at: created, + updated_at: updated, +}); + +/** A book renamed by an update recorded at exchange `seconds`: its slug and update clock follow. */ +export const renamedBook = ( + entity: Book, + name: string, + seconds: number, +): Book => ({ + ...entity, + name, + slug: slugOf(name), + updated_at: bookClock(seconds), +}); + +export const FIRST_BOOK = book(1, "First Book", "2026-10-01T09:00:00Z"); +export const SECOND_BOOK = book(2, "Second Book", "2026-10-01T10:00:00Z"); +/** The book the recorded create at exchange 3 made. */ +export const THIRD_BOOK = book(3, "Third Book", bookClock(3)); +/** The first book after the recorded update at exchange 5. */ +export const RENAMED_BOOK = renamedBook(FIRST_BOOK, "Renamed Book", 5); + +/** What the item read answered for a book the application does not hold. */ +export const BOOK_NOT_FOUND = { detail: "Not found." }; +/** What the delete answered for a book the application does not hold. */ +export const DELETE_NOT_FOUND = { detail: "No Book matches the given query." }; + +/** Reads before any write: the whole list, then each book; the initial state comes from these. */ +export const BOOK_INIT_ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]], + ["GET", "/api/books/1", FIRST_BOOK], + ["GET", "/api/books/2", SECOND_BOOK], +]; + +/** + * A create, an update and a delete, each confirmed by later reads; a delete of a book the + * application did not hold; and a write to no state type. + */ +export const BOOK_ROWS: readonly TimedCaptureRow[] = [ + ...BOOK_INIT_ROWS, + ["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }], + ["GET", "/api/books/3", THIRD_BOOK], + ["PATCH", "/api/books/1", RENAMED_BOOK, 200, { name: "Renamed Book" }], + ["GET", "/api/books/1", RENAMED_BOOK], + ["DELETE", "/api/books/3", "", 204], + ["GET", "/api/books/3", BOOK_NOT_FOUND, 404], + ["GET", "/api/books", [RENAMED_BOOK, SECOND_BOOK]], + ["DELETE", "/api/books/9", DELETE_NOT_FOUND, 404], + ["POST", "/api/exports", { queued: true }, 202, { format: "csv" }], +]; + +/** The end of the last exchange of BOOK_ROWS, to the next whole second: where the served clock starts. */ +export const BOOK_WRITE_CLOCK = "2026-10-17T18:00:12.000Z"; + +/** + * Capture Evidence like `timedCaptureEvidence`, but each request body is recorded under the + * given media type (a string as written, anything else as JSON), and the exchanges may carry + * no start times. + */ +export const rawCaptureEvidence = ( + rows: readonly TimedCaptureRow[], + request: { readonly mimeType: string; readonly timed: boolean }, +): Evidence => + analysisViewCaptureEvidence( + environmentManifestCaptureFixture( + rows.map(([method, path, body = null, status = 200, sent], index) => ({ + ...(request.timed + ? { + startedDateTime: new Date( + CAPTURE_START_MS + index * 1000, + ).toISOString(), + time: 5, + } + : {}), + request: { + method, + url: `http://app.example${path}`, + ...(sent === undefined + ? {} + : { + postData: { + mimeType: request.mimeType, + text: typeof sent === "string" ? sent : JSON.stringify(sent), + }, + }), + }, + response: { + status, + content: { + mimeType: "application/json", + text: typeof body === "string" ? body : JSON.stringify(body), + }, + }, + })), + ), + ); + +type EnvironmentStateView = Extract< + AnalysisViewResult, + { kind: "environment-state" } +>; + +/** The environment-state view a capture projects with no opt-in template rules. */ +export const environmentStateViewOf = ( + capture: Evidence, +): EnvironmentStateView => { + const result = projectAnalysisView( + { + evidenceId: capture.evidence_id, + operation: capture.operation, + normalizedResult: capture.normalized_result, + subject: capture.subject, + limitations: [], + parameters: capture.parameters, + }, + { kind: "environment-state", template_rules: [] }, + ); + if (!result.ok || result.value.kind !== "environment-state") + throw new Error("Expected an environment state"); + return result.value; +};