mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
Compare retained submillisecond components after milliseconds, so ordered collections recognize and place distinct timestamps correctly. Fixes #6525
1051 lines
31 KiB
TypeScript
1051 lines
31 KiB
TypeScript
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 {
|
|
callRenewal,
|
|
unexplainedRenewals,
|
|
renewedUuids,
|
|
} from "./environmentStateWriteGenerated.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 FRESH = [
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"22222222-2222-4222-8222-222222222222",
|
|
] as const;
|
|
|
|
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,
|
|
others: [],
|
|
...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,
|
|
kinds: [],
|
|
});
|
|
});
|
|
|
|
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("accepts a create member stored under another name, and rebuilds text that embeds the value it wrote", () => {
|
|
const result = explainCreate(
|
|
created(
|
|
{
|
|
id: 7,
|
|
code: "spring-tide",
|
|
link: "https://links.example.test/spring-tide",
|
|
name: "Spring tide",
|
|
label: "Lighthouse rota",
|
|
},
|
|
{
|
|
handle: "spring-tide",
|
|
name: "Spring tide",
|
|
meta: { label: "Lighthouse rota" },
|
|
},
|
|
),
|
|
);
|
|
expect(ruleFor(result.rules, "code")).toEqual({
|
|
field: "code",
|
|
rule: "copy",
|
|
from: "/handle",
|
|
});
|
|
expect(ruleFor(result.rules, "link")).toEqual({
|
|
field: "link",
|
|
rule: "template",
|
|
from: "code",
|
|
});
|
|
// A member copied only in part (one value nested inside it) is still stored differently.
|
|
expect(result.body).toMatchObject({
|
|
accepted: ["handle", "name"],
|
|
fixed: [{ member: "meta", sent: { label: "Lighthouse rota" } }],
|
|
});
|
|
});
|
|
|
|
it("explains an update that puts a fresh UUID in a sent or kept field as unique, accepting the member with any value", () => {
|
|
const before = {
|
|
id: "42",
|
|
name: "Old",
|
|
versionId: UUID,
|
|
revisionId: OTHER_UUID,
|
|
};
|
|
const after = {
|
|
id: "42",
|
|
name: "New",
|
|
versionId: FRESH[0],
|
|
revisionId: FRESH[1],
|
|
};
|
|
const body = { name: "New", versionId: UUID };
|
|
const result = explainUpdate(updated(before, after, body));
|
|
expect(ruleFor(result.rules, "versionId")).toEqual({
|
|
field: "versionId",
|
|
rule: "unique",
|
|
});
|
|
expect(ruleFor(result.rules, "revisionId")).toEqual({
|
|
field: "revisionId",
|
|
rule: "unique",
|
|
});
|
|
expect(result.body).toMatchObject({
|
|
accepted: ["name", "versionId"],
|
|
fixed: [],
|
|
});
|
|
expect(result.unexplained).toEqual([]);
|
|
// A UUID another entity holds is a reference the update moved, not one it generated.
|
|
const held = explainUpdate(
|
|
updated(before, after, body, {
|
|
others: [{ id: "7", versionId: FRESH[0], revisionId: FRESH[1] }],
|
|
}),
|
|
);
|
|
expect(ruleFor(held.rules, "versionId")).toEqual({
|
|
field: "versionId",
|
|
rule: "constant",
|
|
value: FRESH[0],
|
|
});
|
|
expect(held.unexplained).toEqual(["revisionId"]);
|
|
});
|
|
|
|
it("explains child rows an update rewrote with new UUIDs as renewed, and other changes to them as unexplained", () => {
|
|
const rows = (...ids: string[]) =>
|
|
ids.map((id, index) => ({ id, text: `Step ${String(index)}` }));
|
|
const before = { id: "42", notes: "Old", steps: rows(UUID, OTHER_UUID) };
|
|
const renewed = explainUpdate(
|
|
updated(
|
|
before,
|
|
{ ...before, notes: "New", steps: rows(...FRESH) },
|
|
{ notes: "New" },
|
|
),
|
|
);
|
|
expect(ruleFor(renewed.rules, "steps")).toEqual({
|
|
field: "steps",
|
|
rule: "renewed",
|
|
pointers: ["/*/id"],
|
|
});
|
|
const edited = explainUpdate(
|
|
updated(
|
|
before,
|
|
{
|
|
...before,
|
|
notes: "New",
|
|
steps: [{ id: FRESH[0], text: "Step one" }, ...rows(FRESH[1]!)],
|
|
},
|
|
{ notes: "New" },
|
|
),
|
|
);
|
|
expect(edited.unexplained).toEqual(["steps"]);
|
|
});
|
|
|
|
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", value: "closed" },
|
|
],
|
|
body: {
|
|
accepted: ["title"],
|
|
fixed: [
|
|
{ member: "status", sent: "CLOSED" },
|
|
{ member: "comment", sent: "done" },
|
|
],
|
|
required: [],
|
|
identity: "id",
|
|
kinds: [],
|
|
},
|
|
unexplained: [],
|
|
});
|
|
});
|
|
|
|
it("treats omitted read fields as removed only when the selected view covers them", () => {
|
|
const before = {
|
|
id: "42",
|
|
name: "Old",
|
|
description: "Kept",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
const after = { id: "42", name: "New" };
|
|
const result = explainUpdate(
|
|
updated(before, after, { name: "New" }, { readFields: ["id", "name"] }),
|
|
);
|
|
expect(result.unexplained).toEqual([]);
|
|
|
|
const complete = explainUpdate(
|
|
updated(
|
|
before,
|
|
after,
|
|
{ name: "New" },
|
|
{
|
|
readFields: ["id", "name", "description", "updated_at"],
|
|
},
|
|
),
|
|
);
|
|
expect(complete.unexplained).toEqual(["description", "updated_at"]);
|
|
});
|
|
|
|
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", value: "new title" },
|
|
]);
|
|
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],
|
|
["2026-03-04T05:00:00.123000001Z", "2026-03-04T05:00:00.123000002Z", -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);
|
|
});
|
|
|
|
it("uses retained submillisecond precision to recognize and place sorted timestamps", () => {
|
|
const members = [
|
|
{ at: "2026-03-04T05:00:00.123000002Z" },
|
|
{ at: "2026-03-04T05:00:00.123000003Z" },
|
|
];
|
|
const rule: InsertRule = {
|
|
rule: "sorted",
|
|
field: "at",
|
|
direction: "ascending",
|
|
};
|
|
expect(insertCandidates(members, ["at"])).toEqual([
|
|
rule,
|
|
{ rule: "last" },
|
|
{ rule: "first" },
|
|
]);
|
|
expect(
|
|
insertIndex(rule, members, { at: "2026-03-04T05:00:00.123000001Z" }),
|
|
).toBe(0);
|
|
});
|
|
|
|
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],
|
|
["/orgs/%61cme/books", "/orgs/acme/books/1", true],
|
|
["/orgs/%2561cme/books", "/orgs/%61cme/books/1", true],
|
|
["/orgs/%2561cme/books", "/orgs/acme/books/1", false],
|
|
["/orgs/a%2Fb/books", "/orgs/a/b/books/1", false],
|
|
])("relates %s and %s by prefix: %s", (left, right, related) => {
|
|
expect(prefixRelated(left, right)).toBe(related);
|
|
expect(prefixRelated(right, left)).toBe(related);
|
|
});
|
|
|
|
it(
|
|
"explains many copied and slugged fields of a wide body without searching it again for each",
|
|
{ timeout: 5_000 },
|
|
() => {
|
|
const n = 8_000;
|
|
const value = (index: number) => `value-${String(index).padStart(6, "0")}`;
|
|
const nested = Object.fromEntries(
|
|
Array.from({ length: n }, (_, index) => [
|
|
`g${String(index)}`,
|
|
{ s: value(index) },
|
|
]),
|
|
);
|
|
const copies = Object.fromEntries(
|
|
Array.from({ length: n }, (_, index) => [
|
|
`c${String(index)}`,
|
|
value(index),
|
|
]),
|
|
);
|
|
const copied = explainCreate(created({ id: 1, ...copies }, nested));
|
|
expect(ruleFor(copied.rules, `c${String(n - 1)}`)).toEqual({
|
|
field: `c${String(n - 1)}`,
|
|
rule: "copy",
|
|
from: `/g${String(n - 1)}/s`,
|
|
});
|
|
// Strings no member slugs to, in a body of many members, each tried as a slug.
|
|
const flat = Object.fromEntries(
|
|
Array.from({ length: n }, (_, index) => [
|
|
`m${String(index)}`,
|
|
`Member ${String(index)}`,
|
|
]),
|
|
);
|
|
const others = Object.fromEntries(
|
|
Array.from({ length: n }, (_, index) => [
|
|
`o${String(index)}`,
|
|
`x${String(index)}`,
|
|
]),
|
|
);
|
|
const slugged = explainCreate(
|
|
created({ id: 1, ...others, s: "member-7" }, flat),
|
|
);
|
|
expect(ruleFor(slugged.rules, "s")).toEqual({
|
|
field: "s",
|
|
rule: "slug",
|
|
from: "m7",
|
|
});
|
|
},
|
|
);
|
|
|
|
it(
|
|
"searches a wide request body for a copied value in linear time",
|
|
{ timeout: 5_000 },
|
|
() => {
|
|
// Shifting each value off the queue made one fruitless search over 500,000 zeros take about a minute.
|
|
const wide = Array.from({ length: 500_000 }, () => 0);
|
|
const result = explainCreate(
|
|
created({ id: 1, note: "not anywhere in the body" }, { items: wide }),
|
|
);
|
|
expect(ruleFor(result.rules, "note")).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
const ID = (n: number): string =>
|
|
`00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
|
const steps = (...ids: number[]): JsonValue =>
|
|
ids.map((id, index) => ({ id: ID(id), text: `Step ${String(index)}` }));
|
|
|
|
it("finds the UUIDs a value renewed when nothing else in it changed", () => {
|
|
expect(renewedUuids(steps(1, 2), steps(3, 4))).toEqual({
|
|
uuids: new Map([
|
|
[ID(2), ID(4)],
|
|
[ID(1), ID(3)],
|
|
]),
|
|
pointers: ["/*/id"],
|
|
});
|
|
expect(renewedUuids(steps(1, 2), steps(1, 2))).toBeUndefined();
|
|
// Another text, another length, a UUID moved to another row, one UUID renewed twice.
|
|
expect(
|
|
renewedUuids(steps(1), [{ id: ID(3), text: "Step one" }]),
|
|
).toBeUndefined();
|
|
expect(renewedUuids(steps(1, 2), steps(3))).toBeUndefined();
|
|
expect(renewedUuids(steps(1, 2), steps(2, 1))).toBeUndefined();
|
|
expect(renewedUuids([ID(1), ID(1)], [ID(3), ID(4)])).toBeUndefined();
|
|
});
|
|
|
|
it("renews no UUID kept elsewhere, moved from elsewhere or kept at a pointer it renewed", () => {
|
|
expect(
|
|
renewedUuids(
|
|
{ current: ID(1), fallback: ID(2) },
|
|
{ current: ID(2), fallback: ID(2) },
|
|
),
|
|
).toBeUndefined();
|
|
expect(renewedUuids([ID(1), ID(1)], [ID(2), ID(1)])).toBeUndefined();
|
|
expect(renewedUuids([ID(1), ID(2)], [ID(3), ID(2)])).toBeUndefined();
|
|
expect(renewedUuids([ID(1), ID(2)], [ID(3), ID(3)])).toBeUndefined();
|
|
// A reference kept beside renewed ids stays outside the renewal.
|
|
expect(
|
|
renewedUuids(
|
|
[{ id: ID(1), author: ID(9) }],
|
|
[{ id: ID(2), author: ID(9) }],
|
|
),
|
|
).toEqual({ uuids: new Map([[ID(1), ID(2)]]), pointers: ["/*/id"] });
|
|
});
|
|
|
|
it("explains no renewed field whose new UUIDs another entity holds, or that renews a shared UUID otherwise than another field", () => {
|
|
const before = { id: "42", owner: { id: ID(1) }, toc: [{ ref: ID(5) }] };
|
|
const after = { id: "42", owner: { id: ID(2) }, toc: [{ ref: ID(5) }] };
|
|
expect(
|
|
explainUpdate(
|
|
updated(
|
|
before,
|
|
after,
|
|
{},
|
|
{ others: [{ id: "7", owner: { id: ID(2) } }] },
|
|
),
|
|
).unexplained,
|
|
).toEqual(["owner"]);
|
|
expect(
|
|
ruleFor(explainUpdate(updated(before, after, {})).rules, "owner"),
|
|
).toEqual({ field: "owner", rule: "renewed", pointers: ["/id"] });
|
|
const shared = { id: "42", rows: [{ id: ID(1) }], toc: [{ ref: ID(1) }] };
|
|
expect(
|
|
explainUpdate(
|
|
updated(
|
|
shared,
|
|
{ id: "42", rows: [{ id: ID(2) }], toc: [{ ref: ID(3) }] },
|
|
{},
|
|
),
|
|
).unexplained,
|
|
).toEqual(["toc"]);
|
|
expect(
|
|
explainUpdate(
|
|
updated(
|
|
shared,
|
|
{ id: "42", rows: [{ id: ID(2) }], toc: [{ ref: ID(2) }] },
|
|
{},
|
|
),
|
|
).unexplained,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("does not accept a member a create stores otherwise under its own name, though it also copies it", () => {
|
|
const result = explainCreate(
|
|
created(
|
|
{ id: 7, title: "QUARTERLY REPORT", label: "Quarterly Report" },
|
|
{ title: "Quarterly Report" },
|
|
),
|
|
);
|
|
expect(ruleFor(result.rules, "label")).toEqual({
|
|
field: "label",
|
|
rule: "copy",
|
|
from: "/title",
|
|
});
|
|
expect(result.body.accepted).not.toContain("title");
|
|
});
|
|
|
|
it("builds no template from a copy of an echoed value", () => {
|
|
const result = explainCreate(
|
|
created(
|
|
{
|
|
id: 7,
|
|
markdown: "Weekly planning notes",
|
|
text: "Weekly planning notes",
|
|
html: "<p>Weekly planning notes</p>",
|
|
},
|
|
{ markdown: "Weekly planning notes" },
|
|
),
|
|
);
|
|
expect(ruleFor(result.rules, "html")).toBeUndefined();
|
|
});
|
|
|
|
it("explains a sent UUID stored in another letter case as stored otherwise, not as one the application generated", () => {
|
|
const result = explainUpdate(
|
|
updated(
|
|
{ id: "42", assignee: ID(1) },
|
|
{ id: "42", assignee: ID(2).replace("4000", "4abc") },
|
|
{ assignee: ID(2).replace("4000", "4ABC") },
|
|
),
|
|
);
|
|
expect(ruleFor(result.rules, "assignee")).toEqual({
|
|
field: "assignee",
|
|
rule: "constant",
|
|
value: ID(2).replace("4000", "4abc"),
|
|
});
|
|
expect(result.body.accepted).toEqual([]);
|
|
});
|
|
|
|
/** A UUID of one repeated hex letter, so its letter case shows. */
|
|
const LETTERED = (letter: string): string =>
|
|
`${letter.repeat(8)}-${letter.repeat(4)}-4${letter.repeat(3)}-a${letter.repeat(3)}-${letter.repeat(12)}`;
|
|
|
|
it("generates no UUID into a field that holds one the call sent in another member", () => {
|
|
const result = explainUpdate(
|
|
updated(
|
|
{ id: "42", sourceId: ID(1), aliasId: ID(2) },
|
|
{ id: "42", sourceId: ID(3), aliasId: ID(3) },
|
|
{ sourceId: ID(3) },
|
|
),
|
|
);
|
|
// The field follows the sent member, so serving keeps the two linked.
|
|
expect(ruleFor(result.rules, "aliasId")).toEqual({
|
|
field: "aliasId",
|
|
rule: "slug",
|
|
from: "sourceId",
|
|
});
|
|
});
|
|
|
|
it("renews one UUID alike in either letter case, and spells each place in its own case", () => {
|
|
const rules: FieldRule[] = [
|
|
{ field: "rows", rule: "renewed", pointers: ["/*/id"] },
|
|
{ field: "toc", rule: "renewed", pointers: ["/*/ref"] },
|
|
];
|
|
const before = {
|
|
rows: [{ id: LETTERED("a") }],
|
|
toc: [{ ref: LETTERED("a").toUpperCase() }],
|
|
};
|
|
const renewed = (row: string, ref: string) => ({
|
|
rows: [{ id: row }],
|
|
toc: [{ ref }],
|
|
});
|
|
expect(
|
|
unexplainedRenewals(rules, {
|
|
before,
|
|
after: renewed(LETTERED("b"), LETTERED("c").toUpperCase()),
|
|
call: { body: {} },
|
|
others: [],
|
|
}),
|
|
).toEqual(["toc"]);
|
|
expect(
|
|
unexplainedRenewals(rules, {
|
|
before,
|
|
after: renewed(LETTERED("b"), LETTERED("b").toUpperCase()),
|
|
call: { body: {} },
|
|
others: [],
|
|
}),
|
|
).toEqual([]);
|
|
const renew = callRenewal("call");
|
|
const row = renew(LETTERED("a"));
|
|
expect(renew(LETTERED("a").toUpperCase())).toBe(row.toUpperCase());
|
|
expect(row).toBe(row.toLowerCase());
|
|
});
|
|
|
|
it("owns one canonical renewal for mixed-case occurrences of the same UUID", () => {
|
|
expect(
|
|
renewedUuids(
|
|
[LETTERED("a"), LETTERED("a").toUpperCase()],
|
|
[LETTERED("b"), LETTERED("b").toUpperCase()],
|
|
),
|
|
).toEqual({
|
|
uuids: new Map([[LETTERED("a"), LETTERED("b")]]),
|
|
pointers: ["/*"],
|
|
});
|
|
});
|
|
|
|
it.each(["kept-in-renewed-field", "other-entity"] as const)(
|
|
"does not renew a UUID already owned by %s",
|
|
(owner) => {
|
|
const rules: FieldRule[] = [
|
|
{ field: "left", rule: "renewed", pointers: ["/id"] },
|
|
];
|
|
const before: JsonObject = { left: { id: ID(1) } };
|
|
const after: JsonObject = { left: { id: ID(3) } };
|
|
if (owner === "kept-in-renewed-field") {
|
|
rules.push({ field: "right", rule: "renewed", pointers: ["/id"] });
|
|
before.right = { id: ID(2), reference: ID(3) };
|
|
after.right = { id: ID(4), reference: ID(3) };
|
|
}
|
|
expect(
|
|
unexplainedRenewals(rules, {
|
|
before,
|
|
after,
|
|
call: { body: {} },
|
|
others: owner === "other-entity" ? [{ reference: ID(3) }] : [],
|
|
}),
|
|
).toEqual(["left"]);
|
|
},
|
|
);
|
|
|
|
it("does not independently generate a scalar UUID another entity holds under another field", () => {
|
|
const result = explainUpdate({
|
|
...updated({ id: "42", version: ID(1) }, { id: "42", version: ID(3) }, {}),
|
|
others: [{ reference: ID(3) }],
|
|
});
|
|
expect(result.unexplained).toContain("version");
|
|
expect(ruleFor(result.rules, "version")).toBeUndefined();
|
|
});
|
|
|
|
it("refuses renewals that join two old UUIDs onto one new one", () => {
|
|
const rules: FieldRule[] = [
|
|
{ field: "left", rule: "renewed", pointers: ["/*"] },
|
|
{ field: "right", rule: "renewed", pointers: ["/*"] },
|
|
];
|
|
expect(
|
|
unexplainedRenewals(rules, {
|
|
before: { left: [ID(1)], right: [ID(2)] },
|
|
after: { left: [ID(3)], right: [ID(3)] },
|
|
call: { body: {} },
|
|
others: [],
|
|
}),
|
|
).toEqual(["right"]);
|
|
});
|
|
|
|
it("accepts a copied member only with its recorded value when a template keeps it beside another source", () => {
|
|
const result = explainCreate(
|
|
created(
|
|
{
|
|
id: 7,
|
|
name: "Atlas",
|
|
code: "alpha-handle-1",
|
|
groupCode: "group-alpha-2",
|
|
link: "/g/group-alpha-2/u/alpha-handle-1",
|
|
},
|
|
{ name: "Atlas", handle: "alpha-handle-1", group: "group-alpha-2" },
|
|
),
|
|
);
|
|
expect(ruleFor(result.rules, "link")).toEqual({
|
|
field: "link",
|
|
rule: "template",
|
|
from: "code",
|
|
});
|
|
expect(result.body.accepted).toContain("handle");
|
|
expect(result.body.accepted).not.toContain("group");
|
|
expect(result.body.fixed).toContainEqual({
|
|
member: "group",
|
|
sent: "group-alpha-2",
|
|
});
|
|
});
|