Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 2 additions & 3 deletions src/api/ctClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | unknown[];

/**
Expand Down
25 changes: 16 additions & 9 deletions src/engine/dynamic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ export function normalizeRuleset(rule: unknown): Record<string, unknown> {
if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet]
let obj = (r ?? {}) as Record<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
}
const base = dropReadOnly(obj);
// Cosmetic dterm labels and int/string id inconsistencies live inside `query`. Normalize only
Expand All @@ -80,14 +82,19 @@ export function normalizeRuleset(rule: unknown): Record<string, unknown> {
}

/**
* 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<string, unknown>): [Record<string, unknown>] {
return [ruleset];
export function putRulesetBody(
ruleset: Record<string, unknown>,
): { dynamicGroupRuleSet: [Record<string, unknown>] } {
return { dynamicGroupRuleSet: [ruleset] };
}

export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record<string, unknown> }
Expand Down
11 changes: 6 additions & 5 deletions tests/dynamic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
10 changes: 5 additions & 5 deletions tests/portable-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
});
});

Expand Down
7 changes: 3 additions & 4 deletions tests/synthetic-dynamic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,9 @@ describe("dynamic synthetic field — apply", () => {
await dynamicField().apply({ client: { request } as unknown as Pick<CtClient, "request">, 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" });
});

Expand All @@ -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" });
});

Expand Down
Loading