mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
84291a05d7
commit
cbb7b613f7
8 files changed
+2469
-3
No files matched your search
@@ -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<typeof servedFrom>;
|
||||
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {},
|
||||
): 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<StateWritePlan, "rules" | "body">;
|
||||
|
||||
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<StateWritePlan, "merge" | "observation"> => ({
|
||||
merge,
|
||||
observation: pointer === undefined ? null : { ordinal: 1, pointer },
|
||||
});
|
||||
|
||||
it.each<[string, ReturnType<typeof documentPlan>, 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,
|
||||
);
|
||||
});
|
||||
@@ -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<CreateEvidence> & {
|
||||
readonly path?: Record<string, string>;
|
||||
} = {},
|
||||
): 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> = {},
|
||||
): 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<string, string> | 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);
|
||||
});
|
||||
@@ -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<typeof readClock>]>([
|
||||
[
|
||||
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<JsonValue>([
|
||||
"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"),
|
||||
);
|
||||
});
|
||||
@@ -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<typeof environmentStateViewOf>;
|
||||
|
||||
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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Vendored
+15
-3
@@ -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: {
|
||||
|
||||
+163
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user