mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
2288 lines
82 KiB
TypeScript
2288 lines
82 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
analysisViewCaptureEvidence,
|
|
environmentManifestCaptureFixture,
|
|
type TimedCaptureRow,
|
|
} from "../../../tests/fixtures/analysisView.js";
|
|
import {
|
|
environmentStateServeFixture,
|
|
environmentStateServeInputs,
|
|
} from "../../../tests/fixtures/environmentServe.js";
|
|
import {
|
|
servedFrom,
|
|
type Served,
|
|
} from "../../../tests/fixtures/environmentStateServing.js";
|
|
import {
|
|
ASSET_ROWS,
|
|
BOOK_INIT_ROWS,
|
|
BOOK_NOT_FOUND,
|
|
BOOK_ROWS,
|
|
DELETE_NOT_FOUND,
|
|
DETAILED_LIST_ROWS,
|
|
EXPANDED_LIST_ROWS,
|
|
FIRST_BOOK,
|
|
OMITTED_FIELD_ROWS,
|
|
PROJECTED_ROWS,
|
|
AUTHORED_ROWS,
|
|
CHAPTER_AUTHOR,
|
|
CONTENTS_ROWS,
|
|
HANDLE_ROWS,
|
|
LIST_LINE_ROWS,
|
|
QUERY_ROWS,
|
|
OFFSET_LIST_ROWS,
|
|
SECOND_BOOK,
|
|
THIRD_BOOK,
|
|
assetBook,
|
|
book,
|
|
bookClock,
|
|
detailedBook,
|
|
expandedBook,
|
|
UNCHAPTERED_ROWS,
|
|
VERSIONED_ROWS,
|
|
numberedUuid,
|
|
bookPage,
|
|
offsetListed,
|
|
renamedBook,
|
|
type Book,
|
|
} from "../../../tests/fixtures/environmentStateWrites.js";
|
|
import { seededUuid } from "../../domain/analysisView/environmentStateWriteValues.js";
|
|
import type { StateWritePlan } from "../../domain/analysisView/environmentStateWriteRules.js";
|
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
|
import type { ReplayArguments } from "./environmentReplay.js";
|
|
import { servedCatalog } from "./environmentState.js";
|
|
import { describeWrite } from "./environmentStateWriteDescription.js";
|
|
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
|
import { environmentToolAnswer } from "./environmentToolAnswer.js";
|
|
import {
|
|
environmentToolArguments,
|
|
environmentToolDefinitions,
|
|
} from "./environmentTools.js";
|
|
|
|
/** 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" } };
|
|
|
|
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("confirmed write response bodies", () => {
|
|
it.each<{
|
|
name: string;
|
|
status?: number;
|
|
content: Record<string, JsonValue>;
|
|
shape: "recorded" | "empty";
|
|
bodyRecorded: boolean;
|
|
}>([
|
|
{
|
|
name: "unsupported encoding with empty text",
|
|
content: {
|
|
mimeType: "application/json",
|
|
text: "",
|
|
encoding: "custom-json",
|
|
},
|
|
shape: "recorded",
|
|
bodyRecorded: false,
|
|
},
|
|
{
|
|
name: "omitted text with nonzero content size",
|
|
content: { mimeType: "application/json", size: 25 },
|
|
shape: "recorded",
|
|
bodyRecorded: false,
|
|
},
|
|
{
|
|
name: "205 without retained text",
|
|
status: 205,
|
|
content: { mimeType: "text/html" },
|
|
shape: "recorded",
|
|
bodyRecorded: false,
|
|
},
|
|
...[undefined, null].map((text) => ({
|
|
name: `zero-size HTML with ${text === null ? "null" : "omitted"} text`,
|
|
content: {
|
|
mimeType: "text/html",
|
|
size: 0,
|
|
...(text === null ? { text } : {}),
|
|
},
|
|
shape: "recorded" as const,
|
|
bodyRecorded: false,
|
|
})),
|
|
{
|
|
name: "known empty text",
|
|
content: { mimeType: "application/json", size: 0, text: "" },
|
|
shape: "empty",
|
|
bodyRecorded: true,
|
|
},
|
|
])(
|
|
"preserves $name when serving a confirmed document write",
|
|
({ content, shape, bodyRecorded, status = 200 }) => {
|
|
const entries: Record<string, JsonValue>[] = [
|
|
{
|
|
request: { method: "GET", url: "http://app.example/api/settings" },
|
|
response: {
|
|
status: 200,
|
|
content: {
|
|
mimeType: "application/json",
|
|
text: '{"theme":"light","lang":"en"}',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
request: {
|
|
method: "PATCH",
|
|
url: "http://app.example/api/settings",
|
|
postData: {
|
|
mimeType: "application/json",
|
|
text: '{"theme":"dark"}',
|
|
},
|
|
},
|
|
response: { status, content },
|
|
},
|
|
{
|
|
request: { method: "GET", url: "http://app.example/api/settings" },
|
|
response: {
|
|
status: 200,
|
|
content: {
|
|
mimeType: "application/json",
|
|
text: '{"theme":"dark","lang":"en"}',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
const capture = analysisViewCaptureEvidence(
|
|
environmentManifestCaptureFixture(
|
|
entries.map((entry, index) => ({
|
|
...entry,
|
|
startedDateTime: `2026-10-17T18:00:0${String(index)}.000Z`,
|
|
time: 5,
|
|
})),
|
|
),
|
|
);
|
|
const { source, state } = environmentStateServeInputs(
|
|
environmentStateServeFixture(capture),
|
|
);
|
|
const store = new EnvironmentStateStore(state);
|
|
expect(store.state.writes.get("patch_settings")).toMatchObject({
|
|
effect: "document",
|
|
served: true,
|
|
response: { ordinal: 1, shape, pointer: null },
|
|
checked_record_ordinals: [2],
|
|
});
|
|
const respond = environmentToolAnswer(source, store);
|
|
const patch = source.manifest.tools.find(
|
|
(tool) => tool.name === "patch_settings",
|
|
);
|
|
const get = source.manifest.tools.find(
|
|
(tool) => tool.name === "get_settings",
|
|
);
|
|
if (patch === undefined || get === undefined)
|
|
throw new Error("Expected settings tools");
|
|
const write = respond(patch, { body: { theme: "blue" } });
|
|
if (!write.ok) throw write.error;
|
|
expect(write.value).toMatchObject({
|
|
status,
|
|
body: null,
|
|
replay: {
|
|
record_ordinal: 1,
|
|
evidence_id: source.evidenceId,
|
|
body_recorded: bodyRecorded,
|
|
},
|
|
state: { source: "state", view: "document-write" },
|
|
});
|
|
const read = respond(get, {});
|
|
if (!read.ok) throw read.error;
|
|
expect(read.value.body).toEqual({ theme: "blue", lang: "en" });
|
|
},
|
|
);
|
|
});
|
|
|
|
it("refuses an unrepresentable generated clock without changing the held state", () => {
|
|
const books = servedFrom();
|
|
const pending = books.store.pendingWrite();
|
|
const pendingWrite = books.store.pendingWrite.bind(books.store);
|
|
vi.spyOn(books.store, "pendingWrite").mockImplementation(() => ({
|
|
...pendingWrite(),
|
|
clockMs: Date.parse("0000-01-01T00:00:00+01:00"),
|
|
}));
|
|
const before = books.snapshot();
|
|
const refusal = books.refusal("post_books", CREATE_NEW_BOOK);
|
|
expect(JSON.stringify(refusal)).toContain(
|
|
"outside its recorded four-digit year format",
|
|
);
|
|
expect(books.snapshot()).toBe(before);
|
|
expect(books.store.pendingWrite().count).toBe(pending.count);
|
|
});
|
|
|
|
describe("served creates", () => {
|
|
it("refuses an exhausted generated-value collision search without changing state", () => {
|
|
const existing = {
|
|
id: "123e4567-e89b-42d3-a456-426614174000",
|
|
ref: "223e4567-e89b-42d3-a456-426614174000",
|
|
name: "Existing",
|
|
};
|
|
const created = {
|
|
id: "323e4567-e89b-42d3-a456-426614174000",
|
|
ref: "423e4567-e89b-42d3-a456-426614174000",
|
|
name: "Created",
|
|
};
|
|
const books = servedFrom([
|
|
["GET", "/api/items", [existing]],
|
|
["GET", `/api/items/${existing.id}`, existing],
|
|
["POST", "/api/items", created, 201, { name: "Created" }],
|
|
["GET", `/api/items/${created.id}`, created],
|
|
["GET", "/api/items", [existing, created]],
|
|
]);
|
|
const plan = books.store.state.writes.get("post_items");
|
|
if (plan?.served !== true)
|
|
throw new Error("Expected post_items to be served");
|
|
const rule = plan.rules.find((candidate) => candidate.rule === "unique");
|
|
expect(rule).toMatchObject({ field: "ref", rule: "unique" });
|
|
if (rule?.rule !== "unique") throw new Error("Expected a unique ref rule");
|
|
const candidates = Array.from({ length: 65 }, (_, attempt) =>
|
|
seededUuid(
|
|
`${books.state.evidenceId}:${plan.tool}:${rule.field}:1:${String(attempt)}`,
|
|
created.ref,
|
|
),
|
|
);
|
|
const snapshot = books.store.snapshot();
|
|
const collisions = candidates.map((ref, index) => ({
|
|
type: "/api/items",
|
|
collection: "/api/items",
|
|
id: `collision-${String(index)}`,
|
|
fields: { id: `collision-${String(index)}`, ref, name: "Collision" },
|
|
view_fields: [],
|
|
record_ordinals: [],
|
|
}));
|
|
const loaded = {
|
|
...snapshot,
|
|
rea_state: {
|
|
...snapshot.rea_state,
|
|
entities: [...snapshot.rea_state.entities, ...collisions],
|
|
},
|
|
};
|
|
const replaced = books.store.replace(loaded);
|
|
if (!replaced.ok) throw new Error(JSON.stringify(replaced.error));
|
|
const before = books.snapshot();
|
|
const pending = books.store.pendingWrite();
|
|
|
|
const refusal = books.refusal(plan.tool, { body: { name: "Created" } });
|
|
|
|
expect(JSON.stringify(refusal)).toContain(
|
|
"Could not generate a unique value for ref after 64 attempts",
|
|
);
|
|
expect(books.snapshot()).toBe(before);
|
|
expect(books.store.pendingWrite()).toEqual(pending);
|
|
});
|
|
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", () => {
|
|
it("keeps an entity's identity when the application ignored the id an update sent", () => {
|
|
const renamed = renamedBook(FIRST_BOOK, "Renamed Book", 3);
|
|
const books = servedFrom([
|
|
...BOOK_INIT_ROWS,
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
renamed,
|
|
200,
|
|
{ id: 999, name: "Renamed Book" },
|
|
],
|
|
["GET", "/api/books/1", renamed],
|
|
]);
|
|
const answer = books.answer("patch_books_by_id", {
|
|
id: "2",
|
|
body: { id: 999, name: "Second Edition" },
|
|
});
|
|
expect(answer.body).toMatchObject({ id: 2, name: "Second Edition" });
|
|
expect(books.answer("get_books_by_id", { id: "2" }).body).toMatchObject({
|
|
id: 2,
|
|
});
|
|
});
|
|
|
|
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("wider write coverage", () => {
|
|
it("serves a created book without the field the application leaves out of it, and keeps it on the seeded ones", () => {
|
|
const books = servedFrom(OMITTED_FIELD_ROWS);
|
|
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
|
expect(created).not.toHaveProperty("detail");
|
|
expect(books.answer("get_books_by_id", { id: "3" }).body).toEqual(created);
|
|
expect(books.answer("get_books_by_id", { id: "1" }).body).toEqual(
|
|
detailedBook(FIRST_BOOK),
|
|
);
|
|
});
|
|
|
|
it("replays a call that asks for some fields only, and answers the rest from the state", () => {
|
|
const books = servedFrom(PROJECTED_ROWS);
|
|
expect(
|
|
books.answer("get_books_by_id", { id: "2", fields: "name" }),
|
|
).toMatchObject({
|
|
body: { name: "Second Book" },
|
|
state: { source: "recording", reason: "projection" },
|
|
});
|
|
expect(books.answer("get_books_by_id", { id: "2" })).toMatchObject({
|
|
body: SECOND_BOOK,
|
|
state: { source: "state", view: "item" },
|
|
});
|
|
});
|
|
|
|
it("replays a call naming a parameter only the expanded line names, and lists a served create on the plain one", () => {
|
|
const books = servedFrom(EXPANDED_LIST_ROWS);
|
|
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
|
expect(books.answer("get_books")).toMatchObject({
|
|
body: [FIRST_BOOK, SECOND_BOOK, created],
|
|
state: { source: "state", view: "list" },
|
|
});
|
|
expect(books.answer("get_books", { expand: "shelf" })).toMatchObject({
|
|
body: [FIRST_BOOK, SECOND_BOOK].map(expandedBook),
|
|
state: { source: "recording", reason: "variant" },
|
|
});
|
|
});
|
|
|
|
it("numbers created books after the largest held asset number, in its digit groups", () => {
|
|
const books = servedFrom(ASSET_ROWS);
|
|
const create = (name: string) =>
|
|
books.answer("post_books", { body: { name } }).body as Book;
|
|
expect(create("Fifth Book")).toMatchObject({ asset: "000-003" });
|
|
expect(create("Sixth Book")).toMatchObject({ asset: "000-004" });
|
|
expect(books.answer("get_books_by_id", { id: "1" }).body).toEqual(
|
|
assetBook(FIRST_BOOK, "000-001"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("items only a replayed line showed", () => {
|
|
it("replays the recorded read of an item only a variant line showed, and still finds an unrecorded one missing", () => {
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [FIRST_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/1?expand=shelf", expandedBook(FIRST_BOOK)],
|
|
["GET", "/api/books/2?expand=shelf", expandedBook(SECOND_BOOK)],
|
|
]);
|
|
expect(
|
|
books.answer("get_books_by_id", { id: "2", expand: "shelf" }),
|
|
).toMatchObject({
|
|
status: 200,
|
|
body: expandedBook(SECOND_BOOK),
|
|
state: { source: "recording", reason: "variant" },
|
|
});
|
|
expect(
|
|
books.call("get_books_by_id", { id: "3", expand: "shelf" }),
|
|
).not.toMatchObject({ ok: true, value: { status: 200 } });
|
|
});
|
|
});
|
|
|
|
describe("items a served write made and removed", () => {
|
|
it("finds an item a served create made and a served delete removed missing, though a variant line recorded it", () => {
|
|
const created = book(3, "Third Book", bookClock(4));
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["GET", "/api/books/1?expand=shelf", expandedBook(FIRST_BOOK)],
|
|
["POST", "/api/books", created, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", created],
|
|
["GET", "/api/books/3?expand=shelf", expandedBook(created)],
|
|
["DELETE", "/api/books/3", "", 204],
|
|
["GET", "/api/books/3", BOOK_NOT_FOUND, 404],
|
|
]);
|
|
expect(books.state.writes.get("post_books")).toMatchObject({
|
|
served: true,
|
|
});
|
|
expect(books.state.writes.get("delete_books_by_id")).toMatchObject({
|
|
served: true,
|
|
});
|
|
books.answer("post_books", { body: { name: "Third Book" } });
|
|
books.answer("delete_books_by_id", { id: "3" });
|
|
const read = books.call("get_books_by_id", { id: "3", expand: "shelf" });
|
|
expect(read.ok && read.value.status === 200).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("field selections beside replayed reads", () => {
|
|
it("replays a field selection no checked line gave, and answers a selection of every field from the state", () => {
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1?fields=*", FIRST_BOOK],
|
|
["GET", "/api/books/2?fields=*", SECOND_BOOK],
|
|
["GET", "/api/books/2?fields=name", { name: "Second Book" }],
|
|
]);
|
|
expect(
|
|
books.answer("get_books_by_id", { id: "1", fields: "id" }),
|
|
).toMatchObject({ state: { source: "recording", reason: "projection" } });
|
|
expect(
|
|
books.answer("get_books_by_id", { id: "1", fields: "*" }),
|
|
).toMatchObject({
|
|
body: FIRST_BOOK,
|
|
state: { source: "state", view: "item" },
|
|
});
|
|
});
|
|
|
|
it("replays a field selection a replayed read gave, beside a variant key and a checked line giving it too", () => {
|
|
// One read asking for a book's name answered it whole; another left the id out.
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/1?fields=name", FIRST_BOOK],
|
|
["GET", "/api/books/2?fields=name", { name: "Second Book" }],
|
|
["GET", "/api/books/1?expand=shelf", expandedBook(FIRST_BOOK)],
|
|
]);
|
|
expect(
|
|
books.answer("get_books_by_id", { id: "2", fields: "name" }),
|
|
).toMatchObject({
|
|
body: { name: "Second Book" },
|
|
state: { source: "recording", reason: "projection" },
|
|
});
|
|
expect(
|
|
books.answer("get_books_by_id", { id: "1", expand: "shelf" }),
|
|
).toMatchObject({ state: { source: "recording", reason: "variant" } });
|
|
expect(books.answer("get_books_by_id", { id: "2" })).toMatchObject({
|
|
body: SECOND_BOOK,
|
|
state: { source: "state", view: "item" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("write coverage limits", () => {
|
|
it("numbers past a mask's width again after the state is saved and loaded", () => {
|
|
const books = servedFrom([
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[assetBook(FIRST_BOOK, "99-97"), assetBook(SECOND_BOOK, "99-98")],
|
|
],
|
|
["GET", "/api/books/1", assetBook(FIRST_BOOK, "99-97")],
|
|
["GET", "/api/books/2", assetBook(SECOND_BOOK, "99-98")],
|
|
[
|
|
"POST",
|
|
"/api/books",
|
|
assetBook(THIRD_BOOK, "99-99"),
|
|
201,
|
|
{ name: "Third Book" },
|
|
],
|
|
["GET", "/api/books/3", assetBook(THIRD_BOOK, "99-99")],
|
|
]);
|
|
const create = (name: string) =>
|
|
books.answer("post_books", { body: { name } }).body;
|
|
expect(create("Fifth Book")).toMatchObject({ asset: "99-99" });
|
|
expect(create("Sixth Book")).toMatchObject({ asset: "100-00" });
|
|
// Loading what data/get wrote clears the sequence counter, so the held numbers alone carry it on.
|
|
const { rea_state } = books.store.snapshot();
|
|
expect(books.store.replace({ rea_state }).ok).toBe(true);
|
|
expect(create("Seventh Book")).toMatchObject({ asset: "100-01" });
|
|
});
|
|
|
|
it("replays a call naming a projection parameter, even an empty one", () => {
|
|
expect(
|
|
servedFrom(PROJECTED_ROWS).answer("get_books_by_id", {
|
|
id: "2",
|
|
fields: "",
|
|
}),
|
|
).toMatchObject({ state: { source: "recording", reason: "projection" } });
|
|
});
|
|
|
|
it("answers a projection of a book the state no longer holds as a missing one", () => {
|
|
const books = servedFrom([
|
|
...PROJECTED_ROWS,
|
|
...BOOK_ROWS.slice(BOOK_INIT_ROWS.length),
|
|
]);
|
|
books.answer("delete_books_by_id", { id: "2" });
|
|
expect(
|
|
books.answer("get_books_by_id", { id: "2", fields: "name" }),
|
|
).toMatchObject({
|
|
status: 404,
|
|
body: BOOK_NOT_FOUND,
|
|
state: { source: "state", view: "missing", id: "2" },
|
|
});
|
|
});
|
|
|
|
it("lists a created book without the field an item read showed the application leaves out", () => {
|
|
const books = servedFrom(DETAILED_LIST_ROWS);
|
|
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
|
expect(created).not.toHaveProperty("detail");
|
|
expect(books.answer("get_books").body).toEqual([
|
|
detailedBook(FIRST_BOOK),
|
|
detailedBook(SECOND_BOOK),
|
|
created,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("values the application assigns", () => {
|
|
it("gives each update a fresh version and new chapter ids, keeping the chapters' other values", () => {
|
|
const books = servedFrom(VERSIONED_ROWS);
|
|
const rename = (name: string) =>
|
|
books.answer("patch_books_by_id", {
|
|
id: "2",
|
|
body: { name, version_id: "any version" },
|
|
}).body as Book;
|
|
const first = rename("Second Edition");
|
|
const second = rename("Third Edition");
|
|
const held = [1, 2, 3, 4, 5, 6, 7, 8].map(numberedUuid);
|
|
for (const answer of [first, second]) {
|
|
expect(held).not.toContain(answer.version_id);
|
|
expect(answer.chapters).toEqual([
|
|
{ id: expect.not.stringMatching(numberedUuid(4)), title: "Opening" },
|
|
]);
|
|
}
|
|
expect(second.version_id).not.toBe(first.version_id);
|
|
expect(books.answer("get_books_by_id", { id: "2" }).body).toEqual(second);
|
|
});
|
|
|
|
it("renews chapter ids alone, keeping the author each chapter names", () => {
|
|
const answer = servedFrom(AUTHORED_ROWS).answer("patch_books_by_id", {
|
|
id: "2",
|
|
body: { name: "Second Edition", version_id: "any version" },
|
|
}).body as Book;
|
|
expect(answer.chapters).toEqual([
|
|
{
|
|
id: expect.not.stringMatching(numberedUuid(4)),
|
|
title: "Opening",
|
|
author_id: CHAPTER_AUTHOR,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("renews a chapter id alike wherever an update renews it", () => {
|
|
const answer = servedFrom(CONTENTS_ROWS).answer("patch_books_by_id", {
|
|
id: "2",
|
|
body: { name: "Second Edition", version_id: "any version" },
|
|
}).body as {
|
|
chapters: [{ id: string }];
|
|
contents: [{ chapter_id: string }];
|
|
};
|
|
expect(answer.chapters[0].id).not.toBe(numberedUuid(4));
|
|
expect(answer.contents[0].chapter_id).toBe(answer.chapters[0].id);
|
|
});
|
|
|
|
it("gives no chapter rows to a book the state holds without them", () => {
|
|
const books = servedFrom(UNCHAPTERED_ROWS);
|
|
const before = books.snapshot();
|
|
expect(
|
|
books.refusal("patch_books_by_id", {
|
|
id: "2",
|
|
body: { name: "Second Edition", version_id: "any version" },
|
|
}).message,
|
|
).toContain("lacks chapters");
|
|
expect(books.snapshot()).toBe(before);
|
|
});
|
|
|
|
it("refuses a handle of another JSON type than the code it is stored as", () => {
|
|
const books = servedFrom(HANDLE_ROWS);
|
|
for (const handle of [{ not: "a string" }, null])
|
|
expect(
|
|
books.refusal("post_books", { body: { name: "Fifth Book", handle } })
|
|
.message,
|
|
).toContain("Member handle must be a JSON string");
|
|
});
|
|
|
|
it("stores a sent handle as the code and rebuilds the link around it", () => {
|
|
const books = servedFrom(HANDLE_ROWS);
|
|
expect(
|
|
books.answer("post_books", {
|
|
body: { name: "Fifth Book", handle: "fifth-handle" },
|
|
}).body,
|
|
).toMatchObject({
|
|
id: 3,
|
|
code: "fifth-handle",
|
|
link: "https://books.example.test/b/fifth-handle",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clock spellings per view", () => {
|
|
it("lists a created book with the list's clock spelling and reads it back with the item's", () => {
|
|
const books = servedFrom(OFFSET_LIST_ROWS);
|
|
// The served clock starts after the sixth recorded exchange.
|
|
const created = book(3, "New Book", bookClock(7));
|
|
books.answer("post_books", CREATE_NEW_BOOK);
|
|
expect(books.answer("get_books").body).toEqual(
|
|
[FIRST_BOOK, SECOND_BOOK, created].map(offsetListed),
|
|
);
|
|
expect(books.answer("get_books_by_id", { id: "3" }).body).toEqual(created);
|
|
});
|
|
});
|
|
|
|
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("holds a member a later update first stored to the JSON type it stored", () => {
|
|
const shelved = { ...SECOND_BOOK, tags: ["a"] };
|
|
const renamed = renamedBook(FIRST_BOOK, "Renamed Book", 3);
|
|
const retagged = { ...shelved, tags: ["x"], updated_at: bookClock(5) };
|
|
// The rename the plan was built from shows no tags, which the first book never had.
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [FIRST_BOOK, shelved]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", shelved],
|
|
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed Book" }],
|
|
["GET", "/api/books/1", renamed],
|
|
["PATCH", "/api/books/2", retagged, 200, { tags: ["x"] }],
|
|
["GET", "/api/books/2", retagged],
|
|
]);
|
|
expect(books.state.writes.get("patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
body: { kinds: [{ member: "tags", kind: "array" }] },
|
|
});
|
|
const before = books.snapshot();
|
|
expect(
|
|
books.refusal("patch_books_by_id", { id: "1", body: { tags: "x" } }),
|
|
).toMatchObject({
|
|
message: expect.stringContaining("Member tags must be a JSON array"),
|
|
});
|
|
expect(books.snapshot()).toBe(before);
|
|
books.answer("patch_books_by_id", { id: "1", body: { tags: ["y"] } });
|
|
expect(entityOf(books, "1")?.fields).toMatchObject({ tags: ["y"] });
|
|
});
|
|
|
|
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,
|
|
);
|
|
});
|
|
});
|
|
|
|
/** Items whose `rank` is a collection sequence, with a recorded create at `recorded`. */
|
|
const rankedItems = (initial: readonly [number, number], recorded: number) => {
|
|
const item = (id: number, rank: number) => ({
|
|
id,
|
|
name: `n${String(id)}`,
|
|
rank,
|
|
});
|
|
const [first, second] = initial;
|
|
return servedFrom([
|
|
["GET", "/api/items", [item(1, first), item(2, second)]],
|
|
["GET", "/api/items/1", item(1, first)],
|
|
["GET", "/api/items/2", item(2, second)],
|
|
["POST", "/api/items", item(3, recorded), 201, { name: "n3" }],
|
|
["GET", "/api/items/3", item(3, recorded)],
|
|
["GET", "/api/items", [item(1, first), item(2, second), item(3, recorded)]],
|
|
]);
|
|
};
|
|
|
|
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("serves the next rank when a collection sequence runs through negative values", () => {
|
|
const acrossZero = rankedItems([-2, -1], 0);
|
|
expect(
|
|
acrossZero.answer("post_items", { body: { name: "n4" } }).body,
|
|
).toMatchObject({ id: 3, rank: 0 });
|
|
expect(
|
|
acrossZero.answer("post_items", { body: { name: "n5" } }).body,
|
|
).toMatchObject({ id: 4, rank: 1 });
|
|
// The value after a stored negative rank stays on that sequence.
|
|
const negative = rankedItems([-4, -3], -2);
|
|
expect(
|
|
negative.answer("post_items", { body: { name: "n4" } }).body,
|
|
).toMatchObject({ id: 3, rank: -2 });
|
|
expect(
|
|
negative.answer("post_items", { body: { name: "n5" } }).body,
|
|
).toMatchObject({ id: 4, rank: -1 });
|
|
});
|
|
|
|
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).toMatch(
|
|
/creates an entity.*\/api\/books.*"name".*required.*"name"/iu,
|
|
);
|
|
expect(described.get("patch_books_by_id")?.description).toMatch(
|
|
/changes the entity.*path.*"name".*send at least one/iu,
|
|
);
|
|
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).toMatch(
|
|
/answers.*served environment state.*state backs this tool/iu,
|
|
);
|
|
});
|
|
|
|
it("quotes body member names and omits one that exceeds the sample budget", () => {
|
|
const plan: StateWritePlan = {
|
|
tool: "post_books",
|
|
effect: "create",
|
|
target: "/api/books",
|
|
served: true,
|
|
reason: null,
|
|
exemplar_ordinal: null,
|
|
observation: null,
|
|
response: null,
|
|
body: {
|
|
accepted: ["name, title", "note\nline"],
|
|
fixed: [],
|
|
required: [],
|
|
identity: null,
|
|
kinds: [],
|
|
},
|
|
rules: [],
|
|
collection: null,
|
|
insert: null,
|
|
merge: null,
|
|
checked_record_ordinals: [],
|
|
contradicted_by: null,
|
|
};
|
|
expect(describeWrite(plan)).toContain(
|
|
'body members "name, title", "note\\nline"',
|
|
);
|
|
const long = describeWrite({
|
|
...plan,
|
|
body: { ...plan.body!, accepted: ["m".repeat(2000)] },
|
|
});
|
|
expect(long).not.toContain("m".repeat(2000));
|
|
expect(Buffer.byteLength(long ?? "", "utf8")).toBeLessThan(1200);
|
|
});
|
|
|
|
it("quotes the member names a write's description lists and samples a long one", () => {
|
|
const long = "n".repeat(90_000);
|
|
const item = (id: number, name: string) => ({
|
|
id,
|
|
name,
|
|
"x, y": name,
|
|
[long]: name,
|
|
});
|
|
const { source, state } = servedFrom([
|
|
["GET", "/api/books", [item(1, "A")]],
|
|
["GET", "/api/books/1", item(1, "A")],
|
|
[
|
|
"POST",
|
|
"/api/books",
|
|
item(2, "B"),
|
|
201,
|
|
{ name: "B", "x, y": "B", [long]: "B" },
|
|
],
|
|
["GET", "/api/books/2", item(2, "B")],
|
|
["GET", "/api/books", [item(1, "A"), item(2, "B")]],
|
|
]);
|
|
const catalog = servedCatalog(source, state, "books");
|
|
if (!catalog.ok) throw catalog.error;
|
|
const created = catalog.value.tools.find(
|
|
(tool) => tool.name === "post_books",
|
|
);
|
|
expect(created?.description).toContain(
|
|
'body members "name", "x, y", … (2/3 shown; full list in state Evidence)',
|
|
);
|
|
});
|
|
|
|
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" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("list lines", () => {
|
|
it("keeps an empty later page recorded after data/add, while a first line lists the loaded entity", () => {
|
|
for (const [query, whole] of [
|
|
["offset=0", true],
|
|
["start=0", true],
|
|
["_start=0", true],
|
|
["page=0", true],
|
|
["offset=2", false],
|
|
["start=2", false],
|
|
["_start=2", false],
|
|
["page=1", false],
|
|
["offset=-1", false],
|
|
["offset=unknown", false],
|
|
] as const) {
|
|
const alpha = { id: 1, name: "Alpha", org: "1" };
|
|
const beta = { id: 2, name: "Beta", org: "2" };
|
|
const books = servedFrom([
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/1/books?name=Alpha", [alpha]],
|
|
["GET", `/api/orgs/2/books?${query}&limit=50`, []],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
]);
|
|
const held = books.store.snapshot().rea_state;
|
|
const loaded = books.store.replace({
|
|
rea_state: {
|
|
...held,
|
|
entities: [
|
|
...held.entities,
|
|
{
|
|
type: held.entities[0]!.type,
|
|
collection: "/api/orgs/2/books",
|
|
id: "2",
|
|
fields: beta,
|
|
view_fields: [],
|
|
record_ordinals: [],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
if (!loaded.ok) throw loaded.error;
|
|
const answer = books.answer("get_orgs_books_by_id", {
|
|
id: "2",
|
|
...Object.fromEntries(new URLSearchParams(query)),
|
|
limit: "50",
|
|
});
|
|
expect(answer.body, query).toEqual(whole ? [beta] : []);
|
|
if (whole)
|
|
expect(answer).toMatchObject({
|
|
state: { source: "state", membership: "all", listed: 1 },
|
|
});
|
|
else {
|
|
expect(answer).toMatchObject({
|
|
state: { source: expect.stringMatching(/^(state|recording)$/u) },
|
|
});
|
|
expect(answer).not.toMatchObject({ state: { membership: "all" } });
|
|
}
|
|
}
|
|
});
|
|
|
|
it("lists a created book on the line that lists every book, and a filtered line by its filter", () => {
|
|
const books = servedFrom(LIST_LINE_ROWS);
|
|
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
|
expect(books.answer("get_books")).toMatchObject({
|
|
body: bookPage([FIRST_BOOK, SECOND_BOOK, created]),
|
|
state: { source: "state", membership: "all", listed: 3 },
|
|
});
|
|
expect(books.answer("get_books", { name: "First Book" })).toMatchObject({
|
|
body: bookPage([FIRST_BOOK]),
|
|
state: { source: "state", membership: "query", listed: 1 },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("list queries", () => {
|
|
it.each([3, "3"])(
|
|
"preserves query total %j across omission, retaining its type and rejecting contradictions",
|
|
(initialTotal) => {
|
|
const alpha = { id: 1, name: "Alpha" },
|
|
beta = { id: 2, name: "Beta" },
|
|
gamma = { id: 3, name: "Gamma" };
|
|
for (const contradicts of [false, true]) {
|
|
const books = servedFrom([
|
|
["GET", "/api/books", { items: [alpha, beta, gamma] }],
|
|
[
|
|
"GET",
|
|
"/api/books?limit=1",
|
|
{ items: [alpha], total: initialTotal },
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/books?limit=2",
|
|
{ items: [alpha, beta], ...(contradicts ? { total: 20 } : {}) },
|
|
],
|
|
]);
|
|
expect(books.state.views.get("get_books")).toMatchObject({
|
|
query: { totals: contradicts ? [] : ["total"] },
|
|
});
|
|
const snapshot = books.store.snapshot();
|
|
const loaded = books.store.replace({
|
|
...snapshot,
|
|
rea_state: {
|
|
...snapshot.rea_state,
|
|
entities: snapshot.rea_state.entities.filter(
|
|
(entity) => entity.id !== "3",
|
|
),
|
|
},
|
|
});
|
|
if (!loaded.ok) throw loaded.error;
|
|
expect(books.answer("get_books", { limit: "1" })).toMatchObject({
|
|
body: {
|
|
items: [alpha],
|
|
total: contradicts
|
|
? initialTotal
|
|
: typeof initialTotal === "string"
|
|
? "2"
|
|
: 2,
|
|
},
|
|
state: { source: "state", membership: "query" },
|
|
});
|
|
if (!contradicts)
|
|
expect(
|
|
books.answer("get_books", { limit: "2" }).body,
|
|
).not.toHaveProperty("total");
|
|
}
|
|
},
|
|
);
|
|
|
|
it("replays unsupported search syntax instead of inventing an empty state-backed result", () => {
|
|
const books = servedFrom(QUERY_ROWS);
|
|
for (const q of ["title:Gamma", "#sea", '"First Book"']) {
|
|
expect(books.answer("get_books", { q }), q).toMatchObject({
|
|
state: { source: "recording", reason: "unrecorded-request" },
|
|
});
|
|
}
|
|
expect(books.answer("get_books", { q: "SECOND" })).toMatchObject({
|
|
state: { source: "state", membership: "query", listed: 1 },
|
|
});
|
|
});
|
|
|
|
it("updates a query-only page counter without adding it to an envelope that never recorded it", () => {
|
|
const alpha = { id: 1, name: "Alpha", shelf: "a" };
|
|
const beta = { id: 2, name: "Beta", shelf: "b" };
|
|
const gamma = { id: 3, name: "Gamma", shelf: "a" };
|
|
const books = servedFrom([
|
|
["GET", "/api/books", { items: [alpha, beta], total: 2 }],
|
|
[
|
|
"GET",
|
|
"/api/books?shelf=a",
|
|
{ items: [alpha], total: 1, numberOfElements: 1 },
|
|
],
|
|
["GET", "/api/books/1", alpha],
|
|
["GET", "/api/books/2", beta],
|
|
["POST", "/api/books", gamma, 201, { name: "Gamma", shelf: "a" }],
|
|
["GET", "/api/books/3", gamma],
|
|
["GET", "/api/books", { items: [alpha, beta, gamma], total: 3 }],
|
|
]);
|
|
const created = books.answer("post_books", {
|
|
body: { name: "Delta", shelf: "a" },
|
|
}).body;
|
|
expect(books.answer("get_books", { shelf: "a" })).toMatchObject({
|
|
body: { items: [alpha, created], total: 2, numberOfElements: 2 },
|
|
state: { source: "state", membership: "query", listed: 2 },
|
|
});
|
|
expect(books.answer("get_books").body).toEqual({
|
|
items: [alpha, beta, created],
|
|
total: 3,
|
|
});
|
|
});
|
|
|
|
it("filters, searches and sorts the state for values no line recorded, created books included", () => {
|
|
const books = servedFrom(QUERY_ROWS);
|
|
const created = books.answer("post_books", {
|
|
body: { name: "New Book", shelf: "b" },
|
|
}).body as Book;
|
|
const listed = (args: Record<string, string>) => {
|
|
const answer = books.answer("get_books", args);
|
|
return {
|
|
ids: (answer.body as Book[]).map((entry) => entry["id"]),
|
|
state: (answer as { readonly state?: unknown }).state,
|
|
};
|
|
};
|
|
expect(listed({ shelf: "b" })).toMatchObject({
|
|
ids: [2, created["id"]],
|
|
state: { source: "state", membership: "query", listed: 2 },
|
|
});
|
|
expect(listed({ q: "NEW" }).ids).toEqual([created["id"]]);
|
|
expect(listed({ sort: "-id" }).ids).toEqual([created["id"], 2, 1]);
|
|
expect(listed({ sort: "-id", shelf: "a" }).ids).toEqual([1]);
|
|
// A value the flag never recorded, or a parameter the query has no role for, replays as before.
|
|
expect(listed({ include: "none" }).state).toMatchObject({
|
|
source: "recording",
|
|
reason: "unrecorded-request",
|
|
});
|
|
});
|
|
|
|
it("keeps a whole line's total following the state beside a partial page, which its query now answers", () => {
|
|
const partial = {
|
|
content: [FIRST_BOOK],
|
|
totalElements: 2,
|
|
numberOfElements: 1,
|
|
};
|
|
const books = servedFrom([
|
|
["GET", "/api/books", bookPage([FIRST_BOOK, SECOND_BOOK])],
|
|
["GET", "/api/books?page=1&size=1", partial],
|
|
...LIST_LINE_ROWS.slice(2),
|
|
]);
|
|
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
|
expect(books.answer("get_books")).toMatchObject({
|
|
body: bookPage([FIRST_BOOK, SECOND_BOOK, created]),
|
|
});
|
|
// The partial page is a query line here: its page and total come from the state, which now holds three books.
|
|
expect(books.answer("get_books", { page: "1", size: "1" })).toMatchObject({
|
|
body: { ...partial, totalElements: 3 },
|
|
state: { membership: "query" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("path values", () => {
|
|
const orgBook = (id: number, org: string, name: string): Book => ({
|
|
id,
|
|
org,
|
|
name,
|
|
});
|
|
const ALPHA = orgBook(1, "my org1", "Alpha");
|
|
const BETA = orgBook(2, "acme2", "Beta");
|
|
const GAMMA = orgBook(3, "my org1", "Gamma");
|
|
const DELTA = orgBook(4, "acme2", "Delta");
|
|
/** Books under two organisations, one whose name the paths percent-encode, and a create under each. */
|
|
const ROWS: readonly TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/my%20org1/books", [ALPHA]],
|
|
["GET", "/api/orgs/acme2/books", [BETA]],
|
|
["GET", "/api/orgs/my%20org1/books/1", ALPHA],
|
|
["GET", "/api/orgs/acme2/books/2", BETA],
|
|
["POST", "/api/orgs/my%20org1/books", GAMMA, 201, { name: "Gamma" }],
|
|
["GET", "/api/orgs/my%20org1/books", [ALPHA, GAMMA]],
|
|
["POST", "/api/orgs/acme2/books", DELTA, 201, { name: "Delta" }],
|
|
["GET", "/api/orgs/acme2/books", [BETA, DELTA]],
|
|
];
|
|
|
|
it("stores a parent path value decoded, as the gate learned it, whichever spelling the call uses", () => {
|
|
for (const spelling of ["my%20org1", "my org1"]) {
|
|
const books = servedFrom(ROWS);
|
|
const tool = books.source.manifest.tools.find(
|
|
(candidate) => candidate.method === "POST",
|
|
)!;
|
|
expect(books.state.writes.get(tool.name)).toMatchObject({
|
|
served: true,
|
|
rules: expect.arrayContaining([
|
|
expect.objectContaining({ field: "org", rule: "path" }),
|
|
]),
|
|
});
|
|
const [org] = environmentToolArguments(tool).path.values();
|
|
books.answer(tool.name, { [org!]: spelling, body: { name: "Epsilon" } });
|
|
const created = books.store
|
|
.snapshot()
|
|
.rea_state.entities.find(
|
|
(entity) => entity.fields["name"] === "Epsilon",
|
|
);
|
|
expect(created).toMatchObject({
|
|
collection: "/api/orgs/my%20org1/books",
|
|
fields: { org: "my org1" },
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("collections a create chooses", () => {
|
|
const orgBook = (id: number, name: string): Book => ({ id, name });
|
|
/** One organisation listed under two spellings and another organisation, each with a create. */
|
|
const ROWS: readonly TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/%61cme1/books", [orgBook(1, "Alpha")]],
|
|
["GET", "/api/orgs/acme1/books", [orgBook(2, "Beta")]],
|
|
["GET", "/api/orgs/beta2/books", [orgBook(3, "Gamma")]],
|
|
[
|
|
"POST",
|
|
"/api/orgs/acme1/books",
|
|
orgBook(4, "Delta"),
|
|
201,
|
|
{ name: "Delta" },
|
|
],
|
|
["GET", "/api/orgs/acme1/books", [orgBook(2, "Beta"), orgBook(4, "Delta")]],
|
|
[
|
|
"POST",
|
|
"/api/orgs/beta2/books",
|
|
orgBook(5, "Epsilon"),
|
|
201,
|
|
{ name: "Epsilon" },
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/orgs/beta2/books",
|
|
[orgBook(3, "Gamma"), orgBook(5, "Epsilon")],
|
|
],
|
|
];
|
|
|
|
it("creates in the collection the call spells verbatim, as item calls choose their entity", () => {
|
|
const books = servedFrom(ROWS);
|
|
const tool = books.source.manifest.tools.find(
|
|
(candidate) => candidate.method === "POST",
|
|
)!;
|
|
expect(books.state.writes.get(tool.name)).toMatchObject({ served: true });
|
|
const [org] = environmentToolArguments(tool).path.values();
|
|
books.answer(tool.name, { [org!]: "acme1", body: { name: "Zeta" } });
|
|
expect(
|
|
books.store
|
|
.snapshot()
|
|
.rea_state.entities.find((entity) => entity.fields["name"] === "Zeta")
|
|
?.collection,
|
|
).toBe("/api/orgs/acme1/books");
|
|
});
|
|
|
|
it("refuses an empty parent segment before storing an unreadable collection", () => {
|
|
const books = servedFrom(ROWS);
|
|
const tool = books.source.manifest.tools.find(
|
|
(candidate) => candidate.method === "POST",
|
|
)!;
|
|
const [org] = environmentToolArguments(tool).path.values();
|
|
const before = books.snapshot();
|
|
expect(
|
|
books.refusal(tool.name, { [org!]: "", body: { name: "Invalid" } }),
|
|
).toMatchObject({
|
|
code: "invalid_request",
|
|
message: expect.stringContaining("path argument to be non-empty"),
|
|
});
|
|
expect(books.snapshot()).toBe(before);
|
|
expect(
|
|
books.store.knownCollectionsOf("/api/orgs/{org}/books"),
|
|
).not.toContain("/api/orgs//books");
|
|
});
|
|
});
|
|
|
|
describe("missing entities after a delete", () => {
|
|
it("answers with a recorded 2xx null, which the gate also took as an absence", () => {
|
|
const books = servedFrom([
|
|
...BOOK_INIT_ROWS,
|
|
["DELETE", "/api/books/2", "", 204],
|
|
["GET", "/api/books/2", null],
|
|
]);
|
|
expect(books.state.writes.get("delete_books_by_id")).toMatchObject({
|
|
served: true,
|
|
});
|
|
books.answer("delete_books_by_id", { id: "1" });
|
|
expect(books.answer("get_books_by_id", { id: "1" })).toMatchObject({
|
|
status: 200,
|
|
body: null,
|
|
state: { view: "missing", id: "1" },
|
|
});
|
|
});
|
|
|
|
it("never answers a write of a missing entity with success, even when the write recorded a 2xx null", () => {
|
|
const books = servedFrom([
|
|
...BOOK_INIT_ROWS,
|
|
["DELETE", "/api/books/2", null, 200],
|
|
["GET", "/api/books/2", BOOK_NOT_FOUND, 404],
|
|
]);
|
|
expect(books.state.writes.get("delete_books_by_id")).toMatchObject({
|
|
served: true,
|
|
});
|
|
expect(books.answer("delete_books_by_id", { id: "9" })).toMatchObject({
|
|
status: 404,
|
|
body: BOOK_NOT_FOUND,
|
|
state: { view: "missing", id: "9" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("values the state started with", () => {
|
|
it("never reuses a seeded id for a create, even after its entity is deleted first", () => {
|
|
const books = servedFrom();
|
|
books.answer("delete_books_by_id", { id: "2" });
|
|
books.answer("post_books", CREATE_NEW_BOOK);
|
|
expect(entityOf(books, "2")).toBeUndefined();
|
|
expect(entityOf(books, "3")?.fields).toMatchObject({ name: "New Book" });
|
|
});
|
|
|
|
const orgBook = (id: number, org: string, name: string): Book => ({
|
|
id,
|
|
org,
|
|
name,
|
|
});
|
|
/** Books under two organisations, one spelled `first` in its paths, with creates and a delete the lists confirm. */
|
|
const twoOrgs = (first: string, org: string) => {
|
|
const alpha = orgBook(1, org, "Alpha");
|
|
const beta = orgBook(2, "acme2", "Beta");
|
|
const gamma = orgBook(3, org, "Gamma");
|
|
const delta = orgBook(4, "acme2", "Delta");
|
|
const books = servedFrom([
|
|
["GET", `/api/orgs/${first}/books`, [alpha]],
|
|
["GET", "/api/orgs/acme2/books", [beta]],
|
|
["GET", `/api/orgs/${first}/books/1`, alpha],
|
|
["GET", "/api/orgs/acme2/books/2", beta],
|
|
["POST", `/api/orgs/${first}/books`, gamma, 201, { name: "Gamma" }],
|
|
["GET", `/api/orgs/${first}/books`, [alpha, gamma]],
|
|
["POST", "/api/orgs/acme2/books", delta, 201, { name: "Delta" }],
|
|
["GET", "/api/orgs/acme2/books", [beta, delta]],
|
|
["DELETE", `/api/orgs/${first}/books/3`, "", 204],
|
|
["GET", `/api/orgs/${first}/books`, [alpha]],
|
|
]);
|
|
const toolOf = (method: string) =>
|
|
books.source.manifest.tools.find(
|
|
(candidate) => candidate.method === method,
|
|
)!;
|
|
const create = (value: string, name: string): string | undefined => {
|
|
const tool = toolOf("POST");
|
|
const [parent] = environmentToolArguments(tool).path.values();
|
|
books.answer(tool.name, { [parent!]: value, body: { name } });
|
|
return books.store
|
|
.snapshot()
|
|
.rea_state.entities.find((entity) => entity.fields["name"] === name)
|
|
?.collection;
|
|
};
|
|
return { books, toolOf, create };
|
|
};
|
|
|
|
it("creates in a collection's recorded spelling after the collection empties", () => {
|
|
const { books, toolOf, create } = twoOrgs("my%20org1", "my org1");
|
|
const remove = toolOf("DELETE");
|
|
const [parent, id] = environmentToolArguments(remove).path.values();
|
|
for (const held of ["1", "3"])
|
|
books.answer(remove.name, { [parent!]: "my%20org1", [id!]: held });
|
|
expect(books.store.collectionsOf("/api/orgs/{org}/books")).not.toContain(
|
|
"/api/orgs/my%20org1/books",
|
|
);
|
|
expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books");
|
|
});
|
|
|
|
it("creates in the Evidence's spelling of a collection a loaded state does not hold", () => {
|
|
const { books, create } = twoOrgs("my%20org1", "my org1");
|
|
const { rea_state: held } = books.store.snapshot();
|
|
expect(
|
|
books.store.replace({
|
|
rea_state: {
|
|
version: held.version,
|
|
entities: held.entities.filter(
|
|
(entity) => !entity.collection.includes("%20"),
|
|
),
|
|
documents: held.documents,
|
|
},
|
|
}).ok,
|
|
).toBe(true);
|
|
expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books");
|
|
});
|
|
|
|
it("creates in the loaded state's collection when the Evidence spells the same one differently", () => {
|
|
const { books, create } = twoOrgs("%61cme1", "acme1");
|
|
const { rea_state: held } = books.store.snapshot();
|
|
expect(
|
|
books.store.replace({
|
|
rea_state: {
|
|
version: held.version,
|
|
entities: held.entities.map((entity) =>
|
|
entity.collection.includes("%61cme1")
|
|
? {
|
|
...entity,
|
|
collection: entity.collection.replace("%61cme1", "a%63me1"),
|
|
record_ordinals: [],
|
|
}
|
|
: entity,
|
|
),
|
|
documents: held.documents,
|
|
},
|
|
}).ok,
|
|
).toBe(true);
|
|
// Both spellings decode to the argument; the one holding books wins over the Evidence's emptied one.
|
|
expect(create("acme1", "Epsilon")).toBe("/api/orgs/a%63me1/books");
|
|
});
|
|
|
|
it("creates in the spelling of a collection the capture listed empty", () => {
|
|
const beta = orgBook(2, "acme2", "Beta");
|
|
const gamma = orgBook(3, "my org1", "Gamma");
|
|
const delta = orgBook(4, "acme2", "Delta");
|
|
// The state starts before the first create, when the encoded organisation's list was empty.
|
|
const books = servedFrom([
|
|
["GET", "/api/orgs/my%20org1/books", []],
|
|
["GET", "/api/orgs/acme2/books", [beta]],
|
|
["GET", "/api/orgs/acme2/books/2", beta],
|
|
["POST", "/api/orgs/my%20org1/books", gamma, 201, { name: "Gamma" }],
|
|
["GET", "/api/orgs/my%20org1/books", [gamma]],
|
|
["POST", "/api/orgs/acme2/books", delta, 201, { name: "Delta" }],
|
|
["GET", "/api/orgs/acme2/books", [beta, delta]],
|
|
]);
|
|
const tool = books.source.manifest.tools.find(
|
|
(candidate) => candidate.method === "POST",
|
|
)!;
|
|
const [parent] = environmentToolArguments(tool).path.values();
|
|
books.answer(tool.name, {
|
|
[parent!]: "my%20org1",
|
|
body: { name: "Epsilon" },
|
|
});
|
|
expect(
|
|
books.store
|
|
.snapshot()
|
|
.rea_state.entities.find(
|
|
(entity) => entity.fields["name"] === "Epsilon",
|
|
)?.collection,
|
|
).toBe("/api/orgs/my%20org1/books");
|
|
});
|
|
|
|
it("forgets the spellings of a loaded state once reset", () => {
|
|
const { books, create } = twoOrgs("%61cme1", "acme1");
|
|
const { rea_state: held } = books.store.snapshot();
|
|
// A loaded state spells the first organisation plainly; the Evidence's state spells it encoded.
|
|
expect(
|
|
books.store.replace({
|
|
rea_state: {
|
|
version: held.version,
|
|
// Moved to another spelling, the entity cites none of the Evidence's records.
|
|
entities: held.entities.map((entity) =>
|
|
entity.collection.includes("%61cme1")
|
|
? {
|
|
...entity,
|
|
collection: entity.collection.replace("%61cme1", "acme1"),
|
|
record_ordinals: [],
|
|
}
|
|
: entity,
|
|
),
|
|
documents: held.documents,
|
|
},
|
|
}).ok,
|
|
).toBe(true);
|
|
books.store.reset();
|
|
expect(create("acme1", "Epsilon")).toBe("/api/orgs/%61cme1/books");
|
|
});
|
|
});
|
|
|
|
describe("paged lines", () => {
|
|
it("answers a requested page the collection has outgrown from its recording, and a call with no query from the state", () => {
|
|
const books = servedFrom([
|
|
["GET", "/api/books?limit=2", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
// The application answers the same page with two books; serving would not answer it from the state.
|
|
["GET", "/api/books?limit=2", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
|
|
]);
|
|
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
|
expect(books.answer("get_books", { limit: "2" })).toMatchObject({
|
|
body: [FIRST_BOOK, SECOND_BOOK],
|
|
state: { source: "recording", reason: "page-exceeded" },
|
|
});
|
|
expect(books.answer("get_books")).toMatchObject({
|
|
body: [FIRST_BOOK, SECOND_BOOK, created],
|
|
state: { source: "state", membership: "all", listed: 3 },
|
|
});
|
|
});
|
|
|
|
it("answers a whole list a zero limit names from the state, as zero lists every match", () => {
|
|
const books = servedFrom([
|
|
["GET", "/api/books?limit=0", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
]);
|
|
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
|
expect(books.answer("get_books", { limit: "0" })).toMatchObject({
|
|
body: [FIRST_BOOK, SECOND_BOOK, created],
|
|
state: { source: "state", membership: "all", listed: 3 },
|
|
});
|
|
});
|
|
|
|
it("does not list an entity a confirmed create adds on an empty ?page=2&per_page=1 page", () => {
|
|
const alpha = { id: 1, name: "Alpha", org: "1" };
|
|
const beta = { id: 2, name: "Beta", org: "2" };
|
|
const books = servedFrom([
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
["GET", "/api/orgs/2/books?page=2&per_page=1", []],
|
|
["POST", "/api/orgs/2/books", beta, 201, { name: "Beta" }],
|
|
["GET", "/api/orgs/2/books/2", beta],
|
|
]);
|
|
const created = books.answer("post_orgs_books_by_id", {
|
|
id: "2",
|
|
body: { name: "Gamma" },
|
|
}).body as Book;
|
|
expect(created).toMatchObject({ name: "Gamma" });
|
|
expect(books.answer("get_orgs_books_by_id", { id: "2" })).toMatchObject({
|
|
body: [],
|
|
state: { source: "recording", reason: "unrecorded-request" },
|
|
});
|
|
expect(
|
|
books.answer("get_orgs_books_by_id", {
|
|
id: "2",
|
|
page: "2",
|
|
per_page: "1",
|
|
}),
|
|
).toMatchObject({
|
|
body: [],
|
|
state: { source: "state", membership: "recorded", listed: 0 },
|
|
});
|
|
});
|
|
|
|
it.each(["offset", "start", "_start"])(
|
|
"does not list an entity a confirmed create adds on an empty ?%s=1 page",
|
|
(name) => {
|
|
const alpha = { id: 1, name: "Alpha", org: "1" };
|
|
const beta = { id: 2, name: "Beta", org: "2" };
|
|
const books = servedFrom([
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
["GET", `/api/orgs/2/books?${name}=1`, []],
|
|
["POST", "/api/orgs/2/books", beta, 201, { name: "Beta" }],
|
|
["GET", "/api/orgs/2/books/2", beta],
|
|
]);
|
|
const created = books.answer("post_orgs_books_by_id", {
|
|
id: "2",
|
|
body: { name: "Gamma" },
|
|
}).body as Book;
|
|
expect(created).toMatchObject({ name: "Gamma" });
|
|
expect(books.answer("get_orgs_books_by_id", { id: "2" })).toMatchObject({
|
|
body: [],
|
|
state: { source: "state", membership: "recorded", listed: 0 },
|
|
});
|
|
expect(
|
|
books.answer("get_orgs_books_by_id", { id: "2", [name]: "1" }),
|
|
).toMatchObject({
|
|
body: [],
|
|
state: { source: "state", membership: "query", listed: 0 },
|
|
});
|
|
},
|
|
);
|
|
|
|
it("answers a zero limit a whole line recorded empty from that recording once a create fills its collection", () => {
|
|
const alpha = { id: 1, name: "Alpha", org: "1" };
|
|
const beta = { id: 2, name: "Beta", org: "2" };
|
|
const books = servedFrom([
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
["GET", "/api/orgs/2/books?limit=0", []],
|
|
["POST", "/api/orgs/2/books", beta, 201, { name: "Beta" }],
|
|
["GET", "/api/orgs/2/books/2", beta],
|
|
]);
|
|
books.answer("post_orgs_books_by_id", { id: "2", body: { name: "Gamma" } });
|
|
// Only a recording that listed items under it shows that zero lists every entity.
|
|
expect(
|
|
books.answer("get_orgs_books_by_id", { id: "2", limit: "0" }),
|
|
).toMatchObject({
|
|
body: [],
|
|
state: { source: "recording", reason: "page-exceeded" },
|
|
});
|
|
});
|
|
|
|
it("keeps a range's bound beside a zero size its recording shows unbounded", () => {
|
|
const books = servedFrom([
|
|
["GET", "/api/books?limit=0&_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
]);
|
|
books.answer("post_books", CREATE_NEW_BOOK);
|
|
expect(
|
|
books.answer("get_books", { limit: "0", _start: "0", _end: "2" }),
|
|
).toMatchObject({
|
|
body: [FIRST_BOOK, SECOND_BOOK],
|
|
state: { source: "recording", reason: "page-exceeded" },
|
|
});
|
|
});
|
|
|
|
it("answers a range the collection has outgrown from its recording", () => {
|
|
const books = servedFrom([
|
|
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
]);
|
|
books.answer("post_books", CREATE_NEW_BOOK);
|
|
expect(books.answer("get_books", { _start: "0", _end: "2" })).toMatchObject(
|
|
{
|
|
body: [FIRST_BOOK, SECOND_BOOK],
|
|
state: { source: "recording", reason: "page-exceeded" },
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("omissions on query-backed read-backs", () => {
|
|
it("confirms a create through a filtered list and serves later omissions in that query", () => {
|
|
const alpha = { id: 1, name: "Alpha", detail: "x" };
|
|
const beta = { id: 2, name: "Beta", detail: "x" };
|
|
const gamma = { id: 3, name: "Gamma" };
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [alpha, beta]],
|
|
["GET", "/api/books?name=Alpha", [alpha]],
|
|
["GET", "/api/books/1", alpha],
|
|
["GET", "/api/books/2", beta],
|
|
["POST", "/api/books", gamma, 201, { name: "Gamma" }],
|
|
["GET", "/api/books?name=Gamma", [gamma]],
|
|
]);
|
|
const created = books.answer("post_books", {
|
|
body: { name: "Delta" },
|
|
}).body;
|
|
expect(created).toMatchObject({ id: 3, name: "Delta" });
|
|
expect(created).not.toHaveProperty("detail");
|
|
expect(books.answer("get_books", { name: "Delta" })).toMatchObject({
|
|
body: [created],
|
|
state: { source: "state", membership: "query", listed: 1 },
|
|
});
|
|
expect(books.answer("get_books", { name: "Alpha" }).body).toEqual([alpha]);
|
|
});
|
|
});
|
|
|
|
describe("own JSON members in update bodies", () => {
|
|
it("accepts constructor added by a later recorded update without inheriting a function kind", () => {
|
|
const alpha = { id: 1, name: "Alpha" };
|
|
const beta = { id: 2, name: "Beta", constructor: "known" };
|
|
const renamed = { ...alpha, name: "Renamed" };
|
|
const extended = { ...renamed, constructor: "stored" };
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [alpha, beta]],
|
|
["GET", "/api/books/1", alpha],
|
|
["GET", "/api/books/2", beta],
|
|
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed" }],
|
|
["GET", "/api/books/1", renamed],
|
|
["PATCH", "/api/books/1", extended, 200, { constructor: "stored" }],
|
|
["GET", "/api/books/1", extended],
|
|
]);
|
|
expect(
|
|
books.answer("patch_books_by_id", {
|
|
id: "1",
|
|
body: { constructor: "value" },
|
|
}).body,
|
|
).toMatchObject({
|
|
id: 1,
|
|
name: "Alpha",
|
|
});
|
|
expect(books.answer("get_books_by_id", { id: "1" }).body).toMatchObject({
|
|
constructor: "value",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("asset sequence inference", () => {
|
|
it("answers the recorded first create consistently when a held asset has outgrown its mask", () => {
|
|
const alpha = { id: 1, name: "Alpha", asset: "99-98" };
|
|
const beta = { id: 2, name: "Beta", asset: "100-00" };
|
|
const gamma = { id: 3, name: "Gamma", asset: "99-99" };
|
|
const books = servedFrom([
|
|
["GET", "/api/books", [alpha, beta]],
|
|
["GET", "/api/books/1", alpha],
|
|
["GET", "/api/books/2", beta],
|
|
["POST", "/api/books", gamma, 201, { name: "Gamma" }],
|
|
["GET", "/api/books/3", gamma],
|
|
]);
|
|
expect(books.state.writes.get("post_books")?.rules).not.toContainEqual({
|
|
field: "asset",
|
|
rule: "sequence",
|
|
scope: "collection",
|
|
});
|
|
expect(
|
|
books.answer("post_books", { body: { name: "Gamma" } }).body,
|
|
).toEqual(gamma);
|
|
});
|
|
});
|
|
|
|
describe("served catalog of calls that only read", () => {
|
|
it("serves a lookup POST the capture shows reading only, by replay", () => {
|
|
const { source, state } = servedFrom([
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books/ids", [FIRST_BOOK], 200, { ids: [1] }],
|
|
]);
|
|
const catalog = servedCatalog(source, state, "books");
|
|
if (!catalog.ok) throw catalog.error;
|
|
expect(catalog.value.tools.map((tool) => tool.name)).toContain(
|
|
"post_books_ids",
|
|
);
|
|
expect(state.writes.has("post_books_ids")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("creates read under another path", () => {
|
|
it("creates into the collection a linked create's returned id was read under", () => {
|
|
const note = (id: number, title: string) => ({ id, title });
|
|
const [alpha, beta, gamma] = [
|
|
note(1001, "Alpha"),
|
|
note(1002, "Beta"),
|
|
note(1003, "Gamma"),
|
|
];
|
|
const notes = servedFrom([
|
|
["GET", "/api/notes", [alpha, beta]],
|
|
["GET", "/api/notes/1001", alpha],
|
|
["GET", "/api/notes/1002", beta],
|
|
["POST", "/api/drafts/publish", { id: 1003 }, 201, { title: "Gamma" }],
|
|
["GET", "/api/notes/1003", gamma],
|
|
["GET", "/api/notes", [alpha, beta, gamma]],
|
|
]);
|
|
expect(
|
|
notes.answer("post_drafts_publish", { body: { title: "Delta" } }),
|
|
).toMatchObject({ status: 201 });
|
|
// The capture's own create is not in the initial state, so the served one takes the next id.
|
|
const delta = note(1003, "Delta");
|
|
expect(notes.answer("get_notes_by_id", { id: "1003" }).body).toEqual(delta);
|
|
expect(notes.answer("get_notes").body).toEqual([alpha, beta, delta]);
|
|
});
|
|
|
|
it("refuses a linked create called with other path arguments than its recording sent", () => {
|
|
const note = (id: number, title: string) => ({ id, title });
|
|
const [alpha, beta, gamma] = [
|
|
note(1001, "Alpha"),
|
|
note(1002, "Beta"),
|
|
note(1003, "Gamma"),
|
|
];
|
|
const notes = servedFrom([
|
|
["GET", "/api/projects/11/notes", [alpha, beta]],
|
|
["GET", "/api/projects/11/notes/1001", alpha],
|
|
["GET", "/api/projects/11/notes/1002", beta],
|
|
[
|
|
"POST",
|
|
"/api/projects/11/publish",
|
|
{ id: 1003 },
|
|
201,
|
|
{ title: "Gamma" },
|
|
],
|
|
["GET", "/api/projects/11/notes/1003", gamma],
|
|
["GET", "/api/projects/11/notes", [alpha, beta, gamma]],
|
|
]);
|
|
const before = notes.snapshot();
|
|
// No read showed where project 12's publish creates, so it is not put into project 11.
|
|
expect(
|
|
notes.refusal("post_projects_publish_by_id", {
|
|
id: "12",
|
|
body: { title: "Delta" },
|
|
}),
|
|
).toMatchObject({
|
|
code: "invalid_request",
|
|
message: expect.stringContaining(
|
|
"creates into /api/projects/11/notes only with the path arguments its recording sent",
|
|
),
|
|
});
|
|
expect(notes.snapshot()).toBe(before);
|
|
expect(
|
|
notes.answer("post_projects_publish_by_id", {
|
|
id: "11",
|
|
body: { title: "Delta" },
|
|
}),
|
|
).toMatchObject({ status: 201 });
|
|
});
|
|
});
|
|
|
|
describe("path values of creates read under another path", () => {
|
|
it("refuses a linked create that changes one of two path values its template names alike", () => {
|
|
const note = (id: number, title: string) => ({ id, title });
|
|
const [alpha, beta, gamma] = [
|
|
note(1001, "Alpha"),
|
|
note(1002, "Beta"),
|
|
note(1003, "Gamma"),
|
|
];
|
|
const group = "/api/projects/11/groups/5";
|
|
const notes = servedFrom([
|
|
["GET", `${group}/notes`, [alpha, beta]],
|
|
["GET", `${group}/notes/1001`, alpha],
|
|
["GET", `${group}/notes/1002`, beta],
|
|
["POST", `${group}/publish`, { id: 1003 }, 201, { title: "Gamma" }],
|
|
["GET", `${group}/notes/1003`, gamma],
|
|
["GET", `${group}/notes`, [alpha, beta, gamma]],
|
|
]);
|
|
const publish = notes.source.manifest.tools.find(
|
|
(tool) => tool.method === "POST",
|
|
)!;
|
|
expect(publish.path_template).toBe(
|
|
"/api/projects/{numeric}/groups/{numeric}/publish",
|
|
);
|
|
const [project, groupArgument] =
|
|
environmentToolArguments(publish).path.values();
|
|
expect(notes.state.writes.get(publish.name)).toMatchObject({
|
|
served: true,
|
|
collection: `${group}/notes`,
|
|
});
|
|
// Project 12's group 5 is another source, though the group value matches.
|
|
expect(
|
|
notes.refusal(publish.name, {
|
|
[project!]: "12",
|
|
[groupArgument!]: "5",
|
|
body: { title: "Delta" },
|
|
}),
|
|
).toMatchObject({ code: "invalid_request" });
|
|
});
|
|
|
|
it("fills a linked create's path field with the decoded value, whichever spelling the call uses", () => {
|
|
const note = (id: number, title: string) => ({
|
|
id,
|
|
title,
|
|
project: "my org1",
|
|
});
|
|
const [alpha, beta, gamma] = [
|
|
note(1001, "Alpha"),
|
|
note(1002, "Beta"),
|
|
note(1003, "Gamma"),
|
|
];
|
|
const other = { id: 2001, title: "Other", project: "my org2" };
|
|
const project = "/api/projects/my%20org1";
|
|
for (const spelling of ["my%20org1", "my org1"]) {
|
|
// A second project makes the project segment a path parameter.
|
|
const notes = servedFrom([
|
|
["GET", "/api/projects/my%20org2/notes", [other]],
|
|
["GET", "/api/projects/my%20org2/notes/2001", other],
|
|
["GET", `${project}/notes`, [alpha, beta]],
|
|
["GET", `${project}/notes/1001`, alpha],
|
|
["GET", `${project}/notes/1002`, beta],
|
|
["POST", `${project}/publish`, { id: 1003 }, 201, { title: "Gamma" }],
|
|
["GET", `${project}/notes/1003`, gamma],
|
|
["GET", `${project}/notes`, [alpha, beta, gamma]],
|
|
["POST", "/api/projects/my%20org2/publish", { ok: true }, 200, {}],
|
|
]);
|
|
notes.answer("post_projects_publish_by_project_id", {
|
|
project_id: spelling,
|
|
body: { title: "Delta" },
|
|
});
|
|
const created = notes.store
|
|
.snapshot()
|
|
.rea_state.entities.filter((entity) => entity.fields.title === "Delta");
|
|
expect(created).toMatchObject([{ fields: { project: "my org1" } }]);
|
|
}
|
|
});
|
|
|
|
it("counts a linked create's call from a source that names the exemplar's only after decoding twice as another source's", () => {
|
|
const note = (id: number, title: string) => ({ id, title });
|
|
const [alpha, beta, gamma, delta] = [
|
|
note(1001, "Alpha"),
|
|
note(1002, "Beta"),
|
|
note(1003, "Gamma"),
|
|
note(1004, "Delta"),
|
|
];
|
|
const other = note(2001, "Other");
|
|
const project = "/api/projects/team1";
|
|
// A second project makes the project segment a path parameter.
|
|
const notes = servedFrom([
|
|
["GET", "/api/projects/team2/notes", [other]],
|
|
["GET", "/api/projects/team2/notes/2001", other],
|
|
["GET", `${project}/notes`, [alpha, beta]],
|
|
["GET", `${project}/notes/1001`, alpha],
|
|
["GET", `${project}/notes/1002`, beta],
|
|
["POST", `${project}/publish`, { id: 1003 }, 201, { title: "Gamma" }],
|
|
["GET", `${project}/notes/1003`, gamma],
|
|
["GET", `${project}/notes`, [alpha, beta, gamma]],
|
|
// `team%2531` decodes once to `team%31`, another project than `team1`.
|
|
[
|
|
"POST",
|
|
"/api/projects/team%2531/publish",
|
|
delta,
|
|
201,
|
|
{ title: "Delta" },
|
|
],
|
|
["GET", `${project}/notes`, [alpha, beta, gamma]],
|
|
]);
|
|
expect(
|
|
notes.state.writes.get("post_projects_publish_by_project_id"),
|
|
).toMatchObject({ served: true, contradicted_by: null });
|
|
});
|
|
|
|
it("takes a linked create's recorded path value in either spelling, as other state-backed calls do", () => {
|
|
const note = (id: number, title: string) => ({ id, title });
|
|
const [alpha, beta, gamma] = [
|
|
note(1001, "Alpha"),
|
|
note(1002, "Beta"),
|
|
note(1003, "Gamma"),
|
|
];
|
|
const project = "/api/projects/my%20org1";
|
|
const other = note(2001, "Other");
|
|
// A second project makes the project segment a path parameter.
|
|
const notes = servedFrom([
|
|
["GET", "/api/projects/my%20org2/notes", [other]],
|
|
["GET", "/api/projects/my%20org2/notes/2001", other],
|
|
["GET", `${project}/notes`, [alpha, beta]],
|
|
["GET", `${project}/notes/1001`, alpha],
|
|
["GET", `${project}/notes/1002`, beta],
|
|
["POST", `${project}/publish`, { id: 1003 }, 201, { title: "Gamma" }],
|
|
["GET", `${project}/notes/1003`, gamma],
|
|
["GET", `${project}/notes`, [alpha, beta, gamma]],
|
|
["POST", "/api/projects/my%20org2/publish", { ok: true }, 200, {}],
|
|
]);
|
|
for (const spelling of ["my%20org1", "my org1"])
|
|
expect(
|
|
notes.answer("post_projects_publish_by_project_id", {
|
|
project_id: spelling,
|
|
body: { title: "Delta" },
|
|
}),
|
|
).toMatchObject({ status: 201 });
|
|
});
|
|
});
|
|
|
|
/** A card whose token is a NanoID and whose edit info a create stamps, created at exchange 3 and read back. */
|
|
const tokenCard = (id: number, token: string, at: string) => ({
|
|
id,
|
|
name: `Card ${String(id)}`,
|
|
token,
|
|
edit: { at, by: "rea" },
|
|
});
|
|
const CARD_ROWS: readonly TimedCaptureRow[] = [
|
|
[
|
|
"GET",
|
|
"/api/cards",
|
|
[
|
|
tokenCard(1, "chYl65ZvCJ9U6yNk-YyqK", "2026-10-01T09:00:00.000Z"),
|
|
tokenCard(2, "Qx81mP0aZkT3vB7n-Lw2e", "2026-10-01T10:00:00.000Z"),
|
|
],
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/cards/1",
|
|
tokenCard(1, "chYl65ZvCJ9U6yNk-YyqK", "2026-10-01T09:00:00.000Z"),
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/cards/2",
|
|
tokenCard(2, "Qx81mP0aZkT3vB7n-Lw2e", "2026-10-01T10:00:00.000Z"),
|
|
],
|
|
[
|
|
"POST",
|
|
"/api/cards",
|
|
tokenCard(3, "cdMLcknG-Lcu6oWL-3ZgA", `${bookClock(3).slice(0, -1)}.030Z`),
|
|
201,
|
|
{ name: "Card 3" },
|
|
],
|
|
[
|
|
"GET",
|
|
"/api/cards/3",
|
|
tokenCard(3, "cdMLcknG-Lcu6oWL-3ZgA", `${bookClock(3).slice(0, -1)}.030Z`),
|
|
],
|
|
];
|
|
|
|
/** Notes whose update sends html the application stores nowhere and derives the excerpt from, and sends back the clock it read. */
|
|
const note = (id: number, title: string, excerpt: string) => ({
|
|
id,
|
|
title,
|
|
excerpt,
|
|
updated_at: bookClock(3),
|
|
});
|
|
const EDITED_NOTE = note(1, "First (edited)", "Edited body text.");
|
|
const NOTE_ROWS: readonly TimedCaptureRow[] = [
|
|
[
|
|
"GET",
|
|
"/api/notes",
|
|
[note(1, "First", "Old body text."), note(2, "Second", "Custom excerpt.")],
|
|
],
|
|
["GET", "/api/notes/1", note(1, "First", "Old body text.")],
|
|
["GET", "/api/notes/2", note(2, "Second", "Custom excerpt.")],
|
|
[
|
|
"PATCH",
|
|
"/api/notes/1?source=html",
|
|
EDITED_NOTE,
|
|
200,
|
|
{
|
|
title: "First (edited)",
|
|
html: "<p>Edited body text.</p>",
|
|
updated_at: bookClock(3),
|
|
},
|
|
],
|
|
["GET", "/api/notes/1", EDITED_NOTE],
|
|
];
|
|
|
|
describe("served values an application stamps", () => {
|
|
it("takes an enveloped body as the capture sent it, and refuses one sent bare", () => {
|
|
const books = servedFrom([
|
|
...BOOK_INIT_ROWS,
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
{ books: [renamedBook(FIRST_BOOK, "Renamed Book", 3)] },
|
|
200,
|
|
{ books: [{ name: "Renamed Book" }] },
|
|
],
|
|
["GET", "/api/books/1", renamedBook(FIRST_BOOK, "Renamed Book", 3)],
|
|
]);
|
|
const before = books.snapshot();
|
|
expect(
|
|
books.refusal("patch_books_by_id", {
|
|
id: "2",
|
|
body: { name: "Second Edition" },
|
|
}).message,
|
|
).toContain('wrapped as {"books":[{...}]}');
|
|
expect(books.snapshot()).toBe(before);
|
|
const answered = books.answer("patch_books_by_id", {
|
|
id: "2",
|
|
body: { books: [{ name: "Second Edition" }] },
|
|
});
|
|
expect(answered).toMatchObject({
|
|
status: 200,
|
|
body: {
|
|
books: [{ id: 2, name: "Second Edition", slug: "second-edition" }],
|
|
},
|
|
});
|
|
expect(books.answer("get_books_by_id", { id: "2" }).body).toMatchObject({
|
|
name: "Second Edition",
|
|
});
|
|
});
|
|
|
|
it("generates a token spelled like the type's and stamps the edit info with the served clock", () => {
|
|
const cards = servedFrom(CARD_ROWS);
|
|
const created = cards.answer("post_cards", { body: { name: "Card 4" } })
|
|
.body as { token: string; edit: { at: string; by: string } };
|
|
expect(created.token).toMatch(
|
|
/^[A-Za-z0-9]{8}-[A-Za-z0-9]{7}-[A-Za-z0-9]{4}$/u,
|
|
);
|
|
expect([
|
|
"chYl65ZvCJ9U6yNk-YyqK",
|
|
"Qx81mP0aZkT3vB7n-Lw2e",
|
|
"cdMLcknG-Lcu6oWL-3ZgA",
|
|
]).not.toContain(created.token);
|
|
expect(created.edit.by).toBe("rea");
|
|
expect(created.edit.at).not.toBe(`${bookClock(3).slice(0, -1)}.030Z`);
|
|
expect(created.edit.at).toMatch(
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u,
|
|
);
|
|
});
|
|
|
|
it("derives a value only on an entity holding what it was derived from, with the recorded query and the held clock", () => {
|
|
const notes = servedFrom(NOTE_ROWS);
|
|
const edit = (
|
|
id: string,
|
|
updated: string,
|
|
query: { source?: string } = { source: "html" },
|
|
) => ({
|
|
id,
|
|
...query,
|
|
body: {
|
|
title: "Renamed",
|
|
html: "<p>Edited body text.</p>",
|
|
updated_at: updated,
|
|
},
|
|
});
|
|
const before = notes.snapshot();
|
|
expect(
|
|
notes.refusal("patch_notes_by_id", edit("2", bookClock(3))).message,
|
|
).toContain("derives excerpt");
|
|
expect(
|
|
notes.refusal(
|
|
"patch_notes_by_id",
|
|
edit("1", bookClock(3), { source: "markdown" }),
|
|
).message,
|
|
).toContain("takes only the query its recording sent (source=html)");
|
|
expect(
|
|
notes.refusal("patch_notes_by_id", edit("1", "2026-01-01T00:00:00Z"))
|
|
.message,
|
|
).toContain("must repeat the value the entity holds");
|
|
expect(notes.snapshot()).toBe(before);
|
|
const answered = notes.answer("patch_notes_by_id", edit("1", bookClock(3)));
|
|
expect(answered).toMatchObject({
|
|
status: 200,
|
|
body: { id: 1, title: "Renamed", excerpt: "Edited body text." },
|
|
});
|
|
expect((answered.body as { updated_at: string }).updated_at).not.toBe(
|
|
bookClock(3),
|
|
);
|
|
// Without the member the value derives from, the update keeps the excerpt the note holds.
|
|
expect(
|
|
notes.answer("patch_notes_by_id", {
|
|
id: "2",
|
|
source: "html",
|
|
body: { title: "Second (renamed)", updated_at: bookClock(3) },
|
|
}).body,
|
|
).toMatchObject({ title: "Second (renamed)", excerpt: "Custom excerpt." });
|
|
});
|
|
});
|
|
|
|
describe("path fields of creates read under another path", () => {
|
|
it("fills a linked create's path field from its exemplar decoded once, whichever accepted spelling the call uses", () => {
|
|
const note = (id: number, title: string) => ({
|
|
id,
|
|
title,
|
|
project: "team%31",
|
|
});
|
|
const [alpha, beta, gamma] = [
|
|
note(1001, "Alpha"),
|
|
note(1002, "Beta"),
|
|
note(1003, "Gamma"),
|
|
];
|
|
const other = { id: 2001, title: "Other", project: "team2" };
|
|
// The recorded segment `team%2531` names the project `team%31`.
|
|
const project = "/api/projects/team%2531";
|
|
for (const spelling of ["team%2531", "team%31"]) {
|
|
const notes = servedFrom([
|
|
["GET", "/api/projects/team2/notes", [other]],
|
|
["GET", "/api/projects/team2/notes/2001", other],
|
|
["GET", `${project}/notes`, [alpha, beta]],
|
|
["GET", `${project}/notes/1001`, alpha],
|
|
["GET", `${project}/notes/1002`, beta],
|
|
["POST", `${project}/publish`, { id: 1003 }, 201, { title: "Gamma" }],
|
|
["GET", `${project}/notes/1003`, gamma],
|
|
["GET", `${project}/notes`, [alpha, beta, gamma]],
|
|
["POST", "/api/projects/team2/publish", { ok: true }, 200, {}],
|
|
]);
|
|
notes.answer("post_projects_publish_by_project_id", {
|
|
project_id: spelling,
|
|
body: { title: "Delta" },
|
|
});
|
|
const created = notes.store
|
|
.snapshot()
|
|
.rea_state.entities.filter((entity) => entity.fields.title === "Delta");
|
|
expect(created).toMatchObject([{ fields: { project: "team%31" } }]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("array fields a view lists sorted", () => {
|
|
it("answers the update with the written order and later reads with it sorted", () => {
|
|
const tagged = (entry: Book, tags: string[]): Book => ({ ...entry, tags });
|
|
const books = servedFrom([
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[tagged(FIRST_BOOK, ["alpha", "beta"]), tagged(SECOND_BOOK, ["gamma"])],
|
|
],
|
|
["GET", "/api/books/1", tagged(FIRST_BOOK, ["alpha", "beta"])],
|
|
["GET", "/api/books/2", tagged(SECOND_BOOK, ["gamma"])],
|
|
[
|
|
"PATCH",
|
|
"/api/books/2",
|
|
tagged(SECOND_BOOK, ["zeta", "delta"]),
|
|
200,
|
|
{ tags: ["zeta", "delta"] },
|
|
],
|
|
["GET", "/api/books/2", tagged(SECOND_BOOK, ["delta", "zeta"])],
|
|
]);
|
|
expect(
|
|
books.answer("patch_books_by_id", {
|
|
id: "1",
|
|
body: { tags: ["omega", "beta"] },
|
|
}).body,
|
|
).toMatchObject({ tags: ["omega", "beta"] });
|
|
expect(books.answer("get_books_by_id", { id: "1" }).body).toMatchObject({
|
|
tags: ["beta", "omega"],
|
|
});
|
|
});
|
|
});
|