diff --git a/docs/dynamic-groups.md b/docs/dynamic-groups.md index 6c31800..6742dec 100644 --- a/docs/dynamic-groups.md +++ b/docs/dynamic-groups.md @@ -159,9 +159,10 @@ up as permanent phantom diffs on every `ct plan`: and `"1"` across (and even within) a single ruleset. Numeric-looking strings inside the `query` subtree are coerced to numbers. - **read-only fields** (`dynamicGroupUpdateStarted`, - `dynamicGroupUpdateFinished`) and the transport envelope — both `GET` and - `PUT` use the SAME single-element `[RuleSet]` array (see `putRulesetBody` - in `src/engine/dynamic.ts`) — are unwrapped/dropped before comparison. + `dynamicGroupUpdateFinished`) and the transport envelopes — `GET` returns a + single-element `[RuleSet]` array; `PUT` takes `{ dynamicGroupRuleSet: [RuleSet] }` + (live-decoded, #77; see `putRulesetBody` in `src/engine/dynamic.ts`) — are + unwrapped/dropped before comparison. Both the desired side (your config) and the actual side (fetched from ChurchTools) are run through the same `normalizeRuleset` diff --git a/src/api/ctClient.ts b/src/api/ctClient.ts index b09de65..3cce5e6 100644 --- a/src/api/ctClient.ts +++ b/src/api/ctClient.ts @@ -35,9 +35,8 @@ export class CtApiError extends Error { } } -// Most write bodies are objects, but a few (dynamic-group ruleset PUT, #77) are a single-element -// array — CT's array-envelope endpoints expect the request body itself to be an array, not an -// object wrapper. +// Write bodies are objects today; the union keeps array bodies representable for any future +// CT endpoint that wants one (the body is only ever JSON.stringify'd, never property-accessed). type Json = Record | unknown[]; /** diff --git a/src/engine/dynamic.ts b/src/engine/dynamic.ts index 5ee7fb4..fe17a4d 100644 --- a/src/engine/dynamic.ts +++ b/src/engine/dynamic.ts @@ -64,10 +64,12 @@ export function normalizeRuleset(rule: unknown): Record { if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] let obj = (r ?? {}) as Record; if (obj.dynamicGroupRuleSet && typeof obj.dynamicGroupRuleSet === "object") { - // Defensive: tolerate a legacy/foreign `{ dynamicGroupRuleSet }` object envelope if one is ever - // fed through here. CT's actual envelope (both GET and PUT) is the `[RuleSet]` array handled - // above, not this wrapper — see `putRulesetBody`. - obj = obj.dynamicGroupRuleSet as Record; + // Defensive: tolerate the `{ dynamicGroupRuleSet: ... }` wrapper if one is ever fed through + // here — including the real PUT envelope, whose property is a single-element ARRAY + // (see `putRulesetBody`); unwrap that inner array too, or `dropReadOnly` would mangle it. + let inner: unknown = obj.dynamicGroupRuleSet; + if (Array.isArray(inner)) inner = inner[0] ?? {}; + obj = (inner ?? {}) as Record; } const base = dropReadOnly(obj); // Cosmetic dterm labels and int/string id inconsistencies live inside `query`. Normalize only @@ -80,14 +82,19 @@ export function normalizeRuleset(rule: unknown): Record { } /** - * The body `PUT /dynamicgroups/{id}/ruleset` expects: a single-element array, matching the shape - * `GET` returns exactly ([RuleSet], not `{ dynamicGroupRuleSet: RuleSet }`). CT 3.134.1 500s - * (`TypeException: Array expected ... at #->properties:dynamicGroupRuleSet`) on a bare object (#77). + * The body `PUT /dynamicgroups/{id}/ruleset` expects: an OBJECT wrapper whose + * `dynamicGroupRuleSet` property is a single-element array. Decoded from CT 3.134.1's own + * validator, live (#77): `{ dynamicGroupRuleSet: RuleSet }` → "Array expected ... at + * #->properties:dynamicGroupRuleSet"; a bare `[RuleSet]` → "Object expected, [...] received" + * at the root. Together: root = object, property = array → `{ dynamicGroupRuleSet: [RuleSet] }`. + * (GET, by contrast, returns the bare `[RuleSet]` array.) * The single source of truth for the PUT envelope — every writer (apply path, live-gated tests) * must go through this so the envelope can't drift out of sync again. */ -export function putRulesetBody(ruleset: Record): [Record] { - return [ruleset]; +export function putRulesetBody( + ruleset: Record, +): { dynamicGroupRuleSet: [Record] } { + return { dynamicGroupRuleSet: [ruleset] }; } export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } diff --git a/tests/dynamic.test.ts b/tests/dynamic.test.ts index 27af149..3a0acc0 100644 --- a/tests/dynamic.test.ts +++ b/tests/dynamic.test.ts @@ -83,16 +83,17 @@ describe("normalizeRuleset", () => { }); describe("putRulesetBody (#77)", () => { - it("wraps the ruleset in a single-element array, matching GET's [RuleSet] shape exactly", () => { + it("wraps the ruleset as { dynamicGroupRuleSet: [ruleset] } — object root, single-element array property", () => { const ruleset = { description: "x", query: {}, process: {} }; - expect(putRulesetBody(ruleset)).toEqual([ruleset]); + expect(putRulesetBody(ruleset)).toEqual({ dynamicGroupRuleSet: [ruleset] }); }); - it("is NOT the { dynamicGroupRuleSet } object envelope — CT 3.134.1 500s on that shape", () => { + it("is NEITHER the bare object NOR the bare array — CT 3.134.1 rejects both (live-decoded, #77)", () => { const ruleset = { description: "x", query: {}, process: {} }; const body = putRulesetBody(ruleset); - expect(Array.isArray(body)).toBe(true); - expect(body).not.toHaveProperty("dynamicGroupRuleSet"); + expect(Array.isArray(body)).toBe(false); + expect(Array.isArray(body.dynamicGroupRuleSet)).toBe(true); + expect(body.dynamicGroupRuleSet).toHaveLength(1); }); }); diff --git a/tests/portable-refs.test.ts b/tests/portable-refs.test.ts index d1b7aa8..b2459b9 100644 --- a/tests/portable-refs.test.ts +++ b/tests/portable-refs.test.ts @@ -112,10 +112,10 @@ describe("apply-time pending re-resolution (same-run campus + group)", () => { await executePlan(plan, { client: host, state, statePath: "s.json", save: noSave, now: () => "t" }); const rulesetPut = host.calls.find((c) => c.path === "/dynamicgroups/100/ruleset" && c.method === "PUT")!; - // PUT body is the [RuleSet] array envelope (#77), matching GET's shape — not a bare/wrapped object. - const body = rulesetPut.body as [{ query: { params: { filter: { "==": unknown[] } } } }]; + // PUT envelope: { dynamicGroupRuleSet: [ruleset] } (live-decoded, #77). + const body = rulesetPut.body as { dynamicGroupRuleSet: [{ query: { params: { filter: { "==": unknown[] } } } }] }; // The campus ref, pending at plan time, is the freshly-created id (42) in the PUT — not a sentinel. - expect(body[0].query.params.filter["=="][1]).toBe(42); + expect(body.dynamicGroupRuleSet[0].query.params.filter["=="][1]).toBe(42); }); it("orders a same-tier pending ref target before its referencer (group → ref.group)", async () => { @@ -146,8 +146,8 @@ describe("apply-time pending re-resolution (same-run campus + group)", () => { const putIdx = host.calls.findIndex((c) => c.method === "PUT" && c.path === "/dynamicgroups/100/ruleset"); expect(createIdx).toBeGreaterThanOrEqual(0); expect(putIdx).toBeGreaterThan(createIdx); // target created before the referencing ruleset writes - const body = host.calls[putIdx]!.body as [{ query: { params: { filter: { "==": unknown[] } } } }]; - expect(body[0].query.params.filter["=="][1]).toBe(55); // the fresh id, not a sentinel + const body = host.calls[putIdx]!.body as { dynamicGroupRuleSet: [{ query: { params: { filter: { "==": unknown[] } } } }] }; + expect(body.dynamicGroupRuleSet[0].query.params.filter["=="][1]).toBe(55); // the fresh id, not a sentinel }); }); diff --git a/tests/synthetic-dynamic.test.ts b/tests/synthetic-dynamic.test.ts index 3b7c0e5..d0d908b 100644 --- a/tests/synthetic-dynamic.test.ts +++ b/tests/synthetic-dynamic.test.ts @@ -142,10 +142,9 @@ describe("dynamic synthetic field — apply", () => { await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, change: { field: "dynamic", from: undefined, to: { status: "active", ruleset: { description: "x", query: {}, process: {} } } } }); - // CT 3.134.1 500s on a bare object — the PUT body must match GET's [RuleSet] array shape exactly, - // NOT a `{ dynamicGroupRuleSet }` wrapper. + // PUT envelope: `{ dynamicGroupRuleSet: [ruleset] }` — object root, array property (live-decoded, #77). expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - [{ description: "x", query: {}, process: {} }]); + { dynamicGroupRuleSet: [{ description: "x", query: {}, process: {} }] }); expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); }); @@ -170,7 +169,7 @@ describe("dynamic synthetic field — apply", () => { from: { status: "active", ruleset: { description: "old", query: {}, process: {} } }, to: { status: "active", ruleset: { description: "new", query: {}, process: {} } } } }); expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - [{ description: "new", query: {}, process: {} }]); + { dynamicGroupRuleSet: [{ description: "new", query: {}, process: {} }] }); expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); });