From 3eb562f563dbd6ace55d3db231a1e38c3fed4e9b Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Sat, 11 Jul 2026 11:41:10 +0200 Subject: [PATCH] fix(dynamic): key portable ruleset roles by (group-type, role-name), not role-def (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #86 rewrote a captured `dynamic: true` ruleset's ChurchQuery `role.id` operands into `{ __ctRef }` `role-def` markers, resolving them via `/group/roles` by `slug(name)` alone. That is broken: a ruleset's `role.id` is a **groupTypeRoleId** (a role scoped to a group TYPE), and role names are NOT globally unique. Live prod `/api/group/roles` (46 roles, 2026-07-11) has 3 "Leiter", 6 "Organisator", 6 "Mitglied" — each on a different group type — so the resolver throws "ambiguous" and the marker is unresolvable. The (groupTypeId, name) pair IS unique (0 collisions across all 46 roles). This introduces a new compound ref kind `group-type-role` (`{ __ctRef, kind: "group-type-role", groupType, role }`), paralleling `group-role`, and: - refs.ts: new `GroupTypeRoleRef` + `ref.groupTypeRole`; refKey/refLabel and the pending-collection guards handle it. - resolver.ts: resolves the group-type key to this host's id (managed state ∪ `/group/grouptypes`), then picks the one `/group/roles` row matching that groupTypeId AND `slug(name)`; 0 / >1 is a hard error listing candidates. - query-refs.ts: removes the wrong `role.id → role-def` entry from `VAR_REF_KINDS`; portablizes `role.id` via a supplied role catalog (id → {groupTypeId, name}) + group-type id→key map, and also rewrites the out-of-query `process.*.handleMembership.groupTypeRoleId` field. Stays pure. - reverse.ts: adds `roleGroupTypeCatalog()` (id → {groupTypeId, name}). - adopt-group.ts: `--portable-rulesets` fetches the role catalog + group-type id→key map and passes them through. Byte-faithful round-trip covered with the real ct-structure fixture skidscheckinopsmz.json. Live re-localization of the ct-structure rulesets is handled separately by a human. --- docs/dynamic-groups.md | 46 ++-- src/commands/adopt-group.ts | 16 +- src/config/query-refs.ts | 116 ++++++++--- src/resolve/refs.ts | 70 ++++++- src/resolve/resolver.ts | 64 ++++++ src/resolve/reverse.ts | 39 ++++ tests/adopt-group-command.test.ts | 13 +- .../dynamic/portablize-skidscheckinopsmz.json | 167 +++++++++++++++ tests/query-refs.test.ts | 196 ++++++++++++++---- tests/refs.test.ts | 11 + tests/resolver.test.ts | 73 +++++++ 11 files changed, 717 insertions(+), 94 deletions(-) create mode 100644 tests/fixtures/dynamic/portablize-skidscheckinopsmz.json diff --git a/docs/dynamic-groups.md b/docs/dynamic-groups.md index ac5e966..cd50e1c 100644 --- a/docs/dynamic-groups.md +++ b/docs/dynamic-groups.md @@ -105,8 +105,13 @@ no-op — it does not re-`PUT` on every apply). Two equivalent ways to author it { "==": [{ "var": "ctgroup.campusId" }, { "__ctRef": true, "kind": "campus", "key": "mainz" }] } ``` - Marker `kind`s: `campus`, `group`, `group-type`, `role-def` (`key` is the - logical key / slug). See `ref` in `src/resolve/refs.ts`. + Simple marker `kind`s carry a single `key` (the logical key / slug): + `campus`, `group`, `group-type`. A **role** (`role.id`) uses the compound + `group-type-role` marker instead — `{ "__ctRef": true, "kind": + "group-type-role", "groupType": "", "role": "" }` — + because a ruleset's `role.id` is a **groupTypeRoleId** (a role scoped to a + group type), and role names are not globally unique (see the table note + below). See `ref` in `src/resolve/refs.ts`. **Escape hatch — raw numeric ids pass through untouched.** A query that references an _operational_ group outside the managed scaffold (no logical key to @@ -129,17 +134,32 @@ position that maps to a **managed** logical key is rewritten to its `{ __ctRef } marker; every other id is left numeric. The `var → RefKind` catalog it keys off (`VAR_REF_KINDS`) is: -| ChurchQuery `var` | marker `kind` | source catalog / state | -| ---------------------- | ------------- | ------------------------------- | -| `ctgroup.id` | `group` | managed state (no REST catalog) | -| `ctgroup.campusId` | `campus` | `/campuses` | -| `person.campusId` | `campus` | `/campuses` | -| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | -| `role.id` | `role-def` | `/group/roles` | - -`role.id` maps to **`role-def`** (the global role catalog `/group/roles`, a -single numeric id) — not `group-role`, which is a compound (group, role) -permission _domain_ a lone `role.id` number cannot express. +| ChurchQuery `var` | marker `kind` | source catalog / state | +| ---------------------- | ------------------ | ------------------------------- | +| `ctgroup.id` | `group` | managed state (no REST catalog) | +| `ctgroup.campusId` | `campus` | `/campuses` | +| `person.campusId` | `campus` | `/campuses` | +| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | +| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) | + +The same `group-type-role` rewrite also covers the **out-of-query** integer +field `process.*.handleMembership.groupTypeRoleId` (the target role a +query-result membership is granted with) — it is a groupTypeRoleId too, just not +inside the query. + +`role.id` maps to **`group-type-role`**, a compound **(group-type, +role-name)** marker — _not_ `role-def` (a bare `/group/roles` name lookup) and +_not_ `group-role` (a group-instance permission domain). A ruleset's `role.id` +is a **groupTypeRoleId**: a role scoped to a group **type**. Role names are +**not globally unique** across group types — live-verified on prod +(2026-07-11, `/api/group/roles`, 46 roles): **3** roles named "Leiter", **6** +"Organisator", **6** "Mitglied", each on a different group type. So a bare name +(`role-def`) is genuinely ambiguous and the resolver throws. Only the +**(groupTypeId, name) pair is unique** (0 collisions across all 46 roles), so the +marker carries the group-type key + role name, and the resolver picks the one +`/group/roles` row whose `groupTypeId` matches this host's group type and whose +name slugs to the role. This corrects the earlier `role-def` mapping (#86), +which was unresolvable on the real instance. The flag is **default OFF**: auto-rewriting an id you _thought_ was managed would silently change query semantics, so you opt in per invocation. Ids that don't map diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index d00e64d..83ad116 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -18,7 +18,7 @@ import { prepareEnv } from "../env/context.js"; import { normalizeRuleset } from "../engine/dynamic.js"; import type { DynamicStatus } from "../engine/types.js"; import { RESOURCES, configSnippet, fromInformation, slug } from "../resources/registry.js"; -import { ReverseResolver } from "../resolve/reverse.js"; +import { ReverseResolver, type RoleCatalogEntry } from "../resolve/reverse.js"; import type { RefKind } from "../resolve/refs.js"; import { portablizeRuleset } from "../config/query-refs.js"; import { loadState, saveState, upsert, type State } from "../state/state.js"; @@ -249,13 +249,19 @@ export function adoptGroupCommand(): Command { const results: ResolvedAdoption[] = []; const reports: Array<{ action: "created" | "updated"; id: number; key: string }> = []; - // Portable-ruleset catalogs (#76): fetch the catalog-backed id→key maps ONCE (campus/group-type/ - // role-def). The `group` map is state-derived and rebuilt per capture (it grows as this run adopts). + // Portable-ruleset catalogs (#76): fetch the catalog-backed id→key maps ONCE (campus/group-type). + // The `group` map is state-derived and rebuilt per capture (it grows as this run adopts). Roles are + // NOT a simple id→key map — a `role.id`/`groupTypeRoleId` is group-type-scoped and role names are + // not globally unique, so we fetch the (groupTypeId, name) catalog plus the group-type id→key map + // and let portablizeRuleset emit (group-type, role-name) markers (fixes #86's `role-def` mapping). const portableCatalogMaps: Partial>> = {}; + let roleCatalog: Map | undefined; + let groupTypeIdToKey: Map | undefined; if (opts.withDynamic && opts.portableRulesets) { portableCatalogMaps.campus = await reverse.idToKeyByKind("campus"); portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type"); - portableCatalogMaps["role-def"] = await reverse.idToKeyByKind("role-def"); + groupTypeIdToKey = portableCatalogMaps["group-type"]; + roleCatalog = await reverse.roleGroupTypeCatalog(); } for (const id of resolvedIds) { @@ -285,6 +291,8 @@ export function adoptGroupCommand(): Command { } const { ruleset, warnings } = portablizeRuleset(captured.normalizedRuleset, { idToKeyByKind: { ...portableCatalogMaps, group: groupMap }, + roleCatalog, + groupTypeIdToKey, }); rulesetToWrite = ruleset; if (warnings.length > 0) { diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts index 0c8a524..75b09c0 100644 --- a/src/config/query-refs.ts +++ b/src/config/query-refs.ts @@ -8,26 +8,32 @@ * known-entity numeric ids a fresh capture carries into those markers. This module supplies the two * pure, offline pieces for that: * - {@link VAR_REF_KINDS}: which ChurchQuery `var` maps to which {@link RefKind} (Stage 1), and - * - {@link portablizeRuleset}: the reverse-rewrite over a caller-supplied id→key map (Stage 2). + * - {@link portablizeRuleset}: the reverse-rewrite over caller-supplied catalogs (Stage 2). */ -import type { RefKind, SimpleRef } from "../resolve/refs.js"; +import type { GroupTypeRoleRef, RefKind, SimpleRef } from "../resolve/refs.js"; +import type { RoleCatalogEntry } from "../resolve/reverse.js"; + +export type { RoleCatalogEntry }; /** * ChurchQuery `var` name → the {@link RefKind} its id operand denotes. The kinds are the canonical * RefKind strings the resolver already speaks (src/resolve/refs.ts) — do NOT invent new ones. * * Verified against the real captured prod rulesets (ct-structure/rulesets/*.json, 2026-07-11); the - * full set of entity-bearing vars present there is exactly these five: + * entity-bearing vars present there that this simple name-based table covers are exactly these four: * - `ctgroup.id` → `group` (a group's own id) * - `ctgroup.campusId` → `campus` * - `ctgroup.groupTypeId` → `group-type` * - `person.campusId` → `campus` - * - `role.id` → `role-def` * - * `role.id` is `role-def`, NOT `group-role`: the query filters on the GLOBAL role-catalog id - * (`/group/roles`, exactly the catalog the resolver's `role-def` kind reads — see CATALOG_PATH in - * src/resolve/resolver.ts), a single numeric id. `group-role` is a compound (group, role) permission - * DOMAIN, addressed by a pair — a different currency that a lone `role.id` number cannot express. + * `role.id` is DELIBERATELY NOT here (fixed in #76, reverting #86's `role-def` mapping). A ruleset's + * `role.id` is a **groupTypeRoleId** — a role scoped to a group TYPE — not a global role-catalog id. + * Role NAMES are not globally unique across group types (live prod, 2026-07-11: 3 roles named "Leiter", + * 6 "Organisator", 6 "Mitglied", each on a different group type), so mapping it to `role-def` (which + * keys `/group/roles` by `slug(name)` alone) makes the resolver throw "ambiguous". Only the + * (groupTypeId, name) PAIR is unique (0 collisions across all 46 prod roles), so `role.id` needs the + * catalog-driven special case in {@link portablizeRuleset} that emits a `group-type-role` marker — a + * lone name-based table entry cannot express it. * * Deliberately absent (the escape hatch — unknown vars are left untouched by {@link portablizeRuleset}): * - `ctgroup.groupStatusId` — group statuses have NO REST catalog (#67; `/group/memberstatus` is a @@ -39,9 +45,24 @@ export const VAR_REF_KINDS: Readonly> = { "ctgroup.campusId": "campus", "ctgroup.groupTypeId": "group-type", "person.campusId": "campus", - "role.id": "role-def", }; +/** + * The ChurchQuery `var` whose id operands are group-type-scoped role ids (`groupTypeRoleId`), handled + * by the {@link portablizeRuleset} role special case rather than the name-based {@link VAR_REF_KINDS} + * table (see the comment there for why). The same rewrite also covers the OUT-of-query + * `process.*.handleMembership.groupTypeRoleId` integer field (see {@link ROLE_FIELD_NAME}). + */ +const ROLE_VAR = "role.id"; + +/** + * The `process.*.handleMembership.groupTypeRoleId` object field: the target role a query-result-only / + * group-and-query-result membership is granted with. It is a groupTypeRoleId just like a `role.id` + * operand, but sits OUTSIDE the query subtree, so the walk rewrites it by object-key match, not by a + * sibling `{ var }` leaf. Rewritten through the SAME role catalog + group-type map (#76). + */ +const ROLE_FIELD_NAME = "groupTypeRoleId"; + /** An id left numeric because no managed logical key mapped to it — collected, not thrown (escape hatch). */ export interface PortablizeWarning { var: string; @@ -51,6 +72,16 @@ export interface PortablizeWarning { export interface PortablizeOptions { /** Per-kind numeric-id → logical-key maps, supplied by the caller (from state/catalogs). Deterministic. */ idToKeyByKind: Partial>>; + /** + * `/group/roles` catalog: groupTypeRoleId → {groupTypeId, name}, for rewriting `role.id` operands and + * `handleMembership.groupTypeRoleId` fields (#76). Omit to leave every role id numeric (with a warning). + */ + roleCatalog?: Map; + /** + * Managed group-type id → logical key, to reverse-map a role's `groupTypeId` to a portable group-type + * key. A role whose group type is unmanaged (no key) is left numeric with a warning (escape hatch). + */ + groupTypeIdToKey?: Map; } export interface PortablizeResult { @@ -67,20 +98,23 @@ function varNameOf(node: unknown): string | undefined { /** * Rewrite the known-entity numeric ids a captured ruleset carries into logical `{ __ctRef }` markers, - * so the same snapshot resolves per-host (#76 Stage 2). Pure and deterministic: the caller supplies - * `idToKeyByKind` (from managed state / master-data catalogs) — no network, no mutation of the input. + * so the same snapshot resolves per-host (#76 Stage 2). Pure and deterministic: the caller supplies the + * id→key maps and the role catalog (from managed state / master-data catalogs) — no network, no mutation + * of the input. * - * The walk keys off JSONLogic operand arrays: an operator whose operands include a `{ var: }` - * leaf whose `` is in {@link VAR_REF_KINDS} has its OTHER operands treated as id values (a - * scalar for `==`, an array for `oneof`). Each numeric id in that position is replaced with the ref - * marker when it maps to a managed key, else left numeric and reported in `warnings`. Every other - * position — unknown vars (the escape hatch: `groupStatusId`, `isArchived`, …), string labels, - * booleans — passes through structurally unchanged. Expects a normalized ruleset (numeric-string ids - * already coerced to numbers by src/engine/dynamic.ts `normalizeRuleset`, as the adopt path does). + * Simple entity vars ({@link VAR_REF_KINDS}) rewrite off a sibling `{ var: }` leaf: each numeric + * id in the operand position becomes a `{ __ctRef, kind, key }` marker when it maps to a managed key, + * else stays numeric and is reported in `warnings`. The `role.id` var and the out-of-query + * `handleMembership.groupTypeRoleId` field are groupTypeRoleIds: each is looked up in `roleCatalog` to + * recover its (groupTypeId, name), the groupTypeId is reverse-mapped to a managed group-type key, and a + * `{ __ctRef, kind: "group-type-role", groupType, role }` marker is emitted (else numeric + warning). + * Every other position — unknown vars (`groupStatusId`, `isArchived`, …), string labels, booleans — + * passes through structurally unchanged. Expects a normalized ruleset (numeric-string ids already + * coerced to numbers by src/engine/dynamic.ts `normalizeRuleset`, as the adopt path does). */ export function portablizeRuleset( ruleset: Record, - { idToKeyByKind }: PortablizeOptions, + { idToKeyByKind, roleCatalog, groupTypeIdToKey }: PortablizeOptions, ): PortablizeResult { const warnings: PortablizeWarning[] = []; @@ -94,10 +128,27 @@ export function portablizeRuleset( return value; }; - const mapValueOperand = (value: unknown, kind: RefKind, varName: string): unknown => - Array.isArray(value) - ? value.map((el) => mapScalar(el, kind, varName)) // oneof id list - : mapScalar(value, kind, varName); // == scalar + // A groupTypeRoleId → (group-type, role-name) marker, resolvable per host by the `group-type-role` + // resolver. Leaves the id numeric (with a warning) when the role is unknown to the catalog or its + // group type is unmanaged — the escape hatch, identical in spirit to mapScalar's. + const mapRoleScalar = (value: unknown, varName: string): unknown => { + if (typeof value !== "number") return value; + const entry = roleCatalog?.get(value); + const groupTypeKey = entry ? groupTypeIdToKey?.get(entry.groupTypeId) : undefined; + if (entry && groupTypeKey !== undefined) { + return { + __ctRef: true, + kind: "group-type-role", + groupType: groupTypeKey, + role: entry.name, + } as GroupTypeRoleRef; + } + warnings.push({ var: varName, id: value }); + return value; + }; + + const mapOperand = (value: unknown, map: (v: unknown) => unknown): unknown => + Array.isArray(value) ? value.map(map) : map(value); // oneof id list vs `==` scalar const walk = (node: unknown): unknown => { if (Array.isArray(node)) { @@ -106,8 +157,14 @@ export function portablizeRuleset( const varName = varNameOf(node[varIdx])!; const kind = VAR_REF_KINDS[varName]; if (kind !== undefined) { - // Known entity var: keep the `{ var }` leaf, rewrite the sibling id operand(s). - return node.map((el, i) => (i === varIdx ? el : mapValueOperand(el, kind, varName))); + // Known simple entity var: keep the `{ var }` leaf, rewrite the sibling id operand(s). + return node.map((el, i) => + i === varIdx ? el : mapOperand(el, (v) => mapScalar(v, kind, varName)), + ); + } + if (varName === ROLE_VAR) { + // Group-type-scoped role var (#76): rewrite siblings through the role catalog, not VAR_REF_KINDS. + return node.map((el, i) => (i === varIdx ? el : mapOperand(el, (v) => mapRoleScalar(v, ROLE_VAR)))); } // Unknown var (escape hatch) — recurse structurally, leaving its numeric ids untouched. } @@ -115,7 +172,14 @@ export function portablizeRuleset( } if (node !== null && typeof node === "object") { const out: Record = {}; - for (const [k, v] of Object.entries(node as Record)) out[k] = walk(v); + for (const [k, v] of Object.entries(node as Record)) { + // `handleMembership.groupTypeRoleId` sits outside the query, so it has no `{ var }` leaf to key + // off — rewrite it by object-key match, through the same role catalog as the `role.id` operand. + out[k] = + k === ROLE_FIELD_NAME && typeof v === "number" + ? mapRoleScalar(v, ROLE_FIELD_NAME) + : walk(v); + } return out; } return node; diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 2854de5..8ed6288 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -18,7 +18,14 @@ * inside a query `var` value or `ct.groupTypeRole({ groupType: "…" })`. */ -export type RefKind = "campus" | "group-type" | "group-status" | "role-def" | "group" | "group-role"; +export type RefKind = + | "campus" + | "group-type" + | "group-status" + | "role-def" + | "group" + | "group-role" + | "group-type-role"; /** * Shared explanation for why a group-status reference can never be resolved by name (#67): @@ -50,7 +57,32 @@ export interface GroupRoleRef { role: string; } -export type Ref = SimpleRef | GroupRoleRef; +/** + * Compound reference: a group-type-scoped role — CT's `groupTypeRoleId`, addressed by its + * (group-type, role-name) pair (#76). This is what a captured dynamic-group ruleset's ChurchQuery + * `role.id` operand (and a `process.*.handleMembership.groupTypeRoleId` field) actually holds: a role + * that belongs to a specific group TYPE, exposed at `GET /group/roles` with a `groupTypeId` per row. + * + * It is a NEW kind, deliberately NOT reused from either {@link SimpleRef} `role-def` or the compound + * {@link GroupRoleRef} `group-role`, because neither can address this id correctly: + * - `role-def` keys the GLOBAL `/group/roles` catalog by `slug(name)` alone — but role NAMES are not + * globally unique across group types (live prod, 2026-07-11: 3 roles named "Leiter", 6 "Organisator", + * 6 "Mitglied", each on a different group type), so a lone name is genuinely ambiguous and the + * resolver throws. That was #86's wrong decision, which this ref replaces. + * - `group-role` is a (group-INSTANCE, role) permission-pairing domainId resolved via a specific + * group's `/groups/{id}/roles` — a different id in a different currency, keyed by a group not a type. + * The (groupTypeId, name) PAIR IS unique (verified: 0 collisions across all 46 prod roles), so this ref + * carries the group-type key + role name and the resolver picks the one `/group/roles` row matching both. + * Mirrors {@link GroupRoleRef}'s compound shape (two string fields, no single `key`) for consistency. + */ +export interface GroupTypeRoleRef { + __ctRef: true; + kind: "group-type-role"; + groupType: string; + role: string; +} + +export type Ref = SimpleRef | GroupRoleRef | GroupTypeRoleRef; function requireKey(kind: RefKind, value: unknown): string { if (typeof value !== "string" || value.length === 0) { @@ -81,6 +113,18 @@ export const ref = { group: requireKey("group-role", group), role: requireKey("group-role", role), }), + /** + * A group-type-scoped role (`groupTypeRoleId`), by its (group-type, role-name) pair (#76). The + * resolver maps it to this host's numeric groupTypeRoleId by finding the `/group/roles` row whose + * `groupTypeId` is the group-type's id AND whose name slugs to `role`. See {@link GroupTypeRoleRef} + * for why the pair is required (role names are not globally unique across group types). + */ + groupTypeRole: (groupType: string, role: string): GroupTypeRoleRef => ({ + __ctRef: true, + kind: "group-type-role", + groupType: requireKey("group-type-role", groupType), + role: requireKey("group-type-role", role), + }), }; export function isRef(value: unknown): value is Ref { @@ -89,12 +133,26 @@ export function isRef(value: unknown): value is Ref { /** Stable identity string for caching/deduping a Ref (not for display — see {@link refLabel}). */ export function refKey(r: Ref): string { - return r.kind === "group-role" ? `group-role:${r.group} ${r.role}` : `${r.kind}:${r.key}`; + switch (r.kind) { + case "group-role": + return `group-role:${r.group} ${r.role}`; + case "group-type-role": + return `group-type-role:${r.groupType} ${r.role}`; + default: + return `${r.kind}:${r.key}`; + } } /** Human-readable label for error messages and plan rendering. */ export function refLabel(r: Ref): string { - return r.kind === "group-role" ? `group-role(group=${r.group}, role=${r.role})` : `${r.kind}:${r.key}`; + switch (r.kind) { + case "group-role": + return `group-role(group=${r.group}, role=${r.role})`; + case "group-type-role": + return `group-type-role(groupType=${r.groupType}, role=${r.role})`; + default: + return `${r.kind}:${r.key}`; + } } /** @@ -165,7 +223,9 @@ export function collectPendingRefKeys(value: unknown): string[] { const walk = (v: unknown): void => { if (isPendingRef(v)) { const r = v.__pendingRef; - if (r.kind !== "group-role") out.push(r.key); + // Compound refs (group-role, group-type-role) resolve to a concrete id at plan time and never go + // pending, so only the simple, single-`key` kinds contribute an apply-order dependency here. + if (r.kind !== "group-role" && r.kind !== "group-type-role") out.push(r.key); return; } if (Array.isArray(v)) { diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index fdcced3..9b7381d 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -39,6 +39,7 @@ import { refKey, refLabel, type GroupRoleRef, + type GroupTypeRoleRef, type PendingRef, type Ref, type RefKind, @@ -133,6 +134,7 @@ export class Resolver { /** Resolve one Ref to a numeric id, or a {@link PendingRef} for a same-run-created managed target. */ async resolve(r: Ref, site: string): Promise { if (r.kind === "group-role") return this.resolveGroupRole(r, site); + if (r.kind === "group-type-role") return this.resolveGroupTypeRole(r, site); // (1) managed desired ∪ state by logical key const type = REF_KIND_TYPE[r.kind]; if (type !== undefined) { @@ -229,6 +231,63 @@ export class Resolver { return domainId; } + /** + * Resolve a group-type-scoped role (`groupTypeRoleId`) by its (group-type, role) pair to this host's + * numeric id (#76). Role names are NOT globally unique across group types (live prod, 2026-07-11: 3 + * "Leiter", 6 "Organisator", 6 "Mitglied" — each on a different group type), which is exactly why a + * lone `role-def` name is ambiguous; the (groupTypeId, name) PAIR is unique (0 collisions across all + * 46 prod roles). So: resolve the group-type key to this host's group-type id (managed state ∪ the + * `/group/grouptypes` catalog — reusing the normal group-type resolution), then pick the ONE + * `/group/roles` row whose `groupTypeId` matches AND whose name slugs to `role`. Exactly one match by + * construction; 0 or >1 is a hard error listing candidates (mirrors resolveGroupRole's error style). + * Returns a number — never a PendingRef (the catalog id exists independently of any same-run apply). + */ + private async resolveGroupTypeRole(r: GroupTypeRoleRef, site: string): Promise { + const groupTypeId = await this.groupTypeIdForRole(r, site); + const rows = await this.catalog("role-def"); // /group/roles — same cached catalog as role-def + const inType = rows.filter((row) => Number(row.groupTypeId) === groupTypeId); + // slug-primary, exact-name secondary — identical matching to every other catalog lookup. + const bySlug = inType.filter((row) => typeof row.name === "string" && slug(row.name) === slug(r.role)); + const matches = bySlug.length >= 1 ? bySlug : inType.filter((row) => row.name === r.role); + if (matches.length === 0) { + const available = inType + .map((row) => (typeof row.name === "string" ? JSON.stringify(row.name) : `#${row.id}`)) + .join(", "); + throw new Error( + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: group type #${groupTypeId} ` + + `has no role named "${r.role}"${available ? ` (available: ${available})` : ""}. Fix the role ` + + `name, or pass a numeric id.`, + ); + } + if (matches.length > 1) { + const list = matches.map((c) => `${JSON.stringify(c.name)} (#${c.id})`).join(", "); + throw new Error( + `Ambiguous ${refLabel(r)} referenced at ${site} on ${this.host}: ${matches.length} roles on group ` + + `type #${groupTypeId} match — ${list}. Rename to disambiguate, or pass a numeric id.`, + ); + } + return matches[0]!.id; + } + + /** + * Resolve the group-type half of a group-type-role ref to a concrete numeric id, reusing the normal + * group-type resolution (managed state ∪ `/group/grouptypes` catalog). A same-run-declared group type + * would resolve to a PendingRef here — reject it, since the role catalog can't be filtered without a + * concrete id (its message tells the author to apply the group type first or pass a numeric role id). + */ + private async groupTypeIdForRole(r: GroupTypeRoleRef, site: string): Promise { + const gtRef: SimpleRef = { __ctRef: true, kind: "group-type", key: r.groupType }; + const resolved = await this.resolve(gtRef, site); + if (typeof resolved !== "number") { + throw new Error( + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: group type "${r.groupType}" ` + + `is declared in this config but not yet created — a group-type-scoped role id only exists once ` + + `the group type does. Apply the group type first, then re-run, or pass a numeric id.`, + ); + } + return resolved; + } + /** Resolve the group half of a group_role ref to a managed group id (state ∪ declared). */ private groupIdForRole(r: GroupRoleRef, site: string): number { const managed = this.state.resources[r.group]; @@ -311,6 +370,11 @@ function pendingIdFromState(r: Ref, state: State): number { // same-run group is rejected up front), so a pending one should never reach apply. throw new Error(`Pending ${refLabel(r)} reached apply — group_role refs never go pending (#25).`); } + if (r.kind === "group-type-role") { + // A group-type-role ref resolves to a concrete /group/roles id at plan time (the catalog id exists + // independently of any same-run apply), so a pending one should never reach apply either (#76). + throw new Error(`Pending ${refLabel(r)} reached apply — group-type-role refs never go pending (#76).`); + } const managed = state.resources[r.key]; if (!managed) { throw new Error( diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index c3eb5bf..6ea3893 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -47,10 +47,20 @@ interface CatalogRecord { [k: string]: unknown; } +/** A `/group/roles` entry decoded for portablizing a `role.id` / `groupTypeRoleId` (#76). */ +export interface RoleCatalogEntry { + /** The group TYPE this role is scoped to — the disambiguator that makes (groupTypeId, name) unique. */ + groupTypeId: number; + /** The role's display name (used to build the portable (group-type, role-name) marker). */ + name: string; +} + export class ReverseResolver { private readonly client: Pick; /** id → logical key, per catalog path, fetched at most once. A failed fetch caches an empty map. */ private readonly catalogs = new Map>>(); + /** groupTypeRoleId → {groupTypeId, name} from `/group/roles`, fetched at most once (#76). */ + private roleCatalog?: Promise>; constructor(client: Pick) { this.client = client; @@ -95,6 +105,35 @@ export class ReverseResolver { return path ? this.index(path) : new Map(); } + /** + * groupTypeRoleId → {groupTypeId, name} for every `/group/roles` row (#76). Unlike + * {@link idToKeyByKind}('role-def') — which keys by `slug(name)` alone and is WRONG for these ids + * (role names are not globally unique across group types) — this preserves each role's `groupTypeId`, + * so `portablizeRuleset` can reverse-map it to a managed group-type key and emit a (group-type, + * role-name) marker. Fetched at most once; a failed/malformed fetch degrades to an empty map (adopt + * then leaves the id numeric with a warning, the escape hatch). + */ + async roleGroupTypeCatalog(): Promise> { + if (!this.roleCatalog) { + this.roleCatalog = this.client + .get("/group/roles") + .then((rows) => { + const map = new Map(); + if (Array.isArray(rows)) { + for (const row of rows) { + const groupTypeId = Number(row?.groupTypeId); + if (typeof row?.id === "number" && typeof row.name === "string" && Number.isFinite(groupTypeId)) { + map.set(row.id, { groupTypeId, name: row.name }); + } + } + } + return map; + }) + .catch(() => new Map()); + } + return this.roleCatalog; + } + /** * Reverse-sugar a managed-field bag for emission (#52 item A): each numeric id field with a catalog * match becomes its logical `campus`/`groupType` key (dropping the numeric field); an id with NO diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index 8e277a0..bbd269c 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -48,8 +48,10 @@ function makeClient() { // plain group adopt (see the assertion in the --with-dynamic capture test below). const campuses = [{ id: 0, name: "Mainz" }]; const memberStatuses = [{ id: 1, name: "Aktiv" }]; - // Global role catalog (/group/roles) — the `role-def` kind's catalog, used to portablize `role.id`. - const roles = [{ id: 7, name: "Leiter" }]; + // Global role catalog (/group/roles), each row carrying its `groupTypeId` — used to portablize a + // `role.id` groupTypeRoleId into a (group-type, role-name) marker (#76). Role 7 is a "Leiter" on + // group type 5 ("Team"). + const roles = [{ id: 7, name: "Leiter", groupTypeId: 5 }]; const rulesets: Record> = { 30: { description: "x", query: { "==": [{ var: "a" }, "1"] }, process: {} }, // A ruleset carrying every portablizable var shape (#76): a managed group id (10) + an unmanaged @@ -367,8 +369,11 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = expect(and[0]!.oneof![1]).toEqual([{ __ctRef: true, kind: "group", key: "area_a" }, 999]); // campusId 0 → the Mainz campus marker (slug of the catalog name). expect(and[1]!["=="]![1]).toEqual({ __ctRef: true, kind: "campus", key: "mainz" }); - // role.id 7 → role-def marker (global /group/roles catalog), NOT group-role. - expect(and[2]!.oneof![1]).toEqual([{ __ctRef: true, kind: "role-def", key: "leiter" }]); + // role.id 7 → group-type-role marker keyed by (group type "team", role "Leiter"), NOT a bare + // role-def name (which would be ambiguous — role names are not globally unique across group types, #76). + expect(and[2]!.oneof![1]).toEqual([ + { __ctRef: true, kind: "group-type-role", groupType: "team", role: "Leiter" }, + ]); // groupStatusId has no catalog (#67) → left numeric, untouched. expect(and[3]!.oneof![1]).toEqual([1, 2]); }); diff --git a/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json b/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json new file mode 100644 index 0000000..2aed610 --- /dev/null +++ b/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json @@ -0,0 +1,167 @@ +{ + "description": "Automatische Mitgliedschaft für Gruppe #1092", + "shorty": "Autom. Mitgliedschaft Gruppe #1092", + "personIdFieldName": "person.id", + "importance": 0, + "query": { + "method": "ChurchQuery", + "params": { + "groupBy": [ + "person.id" + ], + "filter": { + "and": [ + { + "and": [ + { + "==": [ + { + "var": "person.isArchived" + }, + 0 + ] + } + ] + }, + { + "or": [ + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "112", + "8" + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "84", + "85", + "17", + "16" + ] + ] + } + ] + } + ] + } + ] + }, + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "1246" + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "16", + "17" + ] + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "primaryEntityAlias": "person", + "responseFields": [ + "person.id", + "person.firstName", + "person.lastName" + ] + } + }, + "process": { + "groupOnly": { + "active": { + "handleMembership": { + "groupMemberStatus": "none" + } + } + }, + "queryResultOnly": { + "none": { + "handleMembership": { + "groupMemberStatus": "active", + "groupTypeRoleId": 66 + } + } + }, + "groupAndQueryResult": {} + } +} \ No newline at end of file diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts index 6b2f94b..3c7d4a8 100644 --- a/tests/query-refs.test.ts +++ b/tests/query-refs.test.ts @@ -1,36 +1,39 @@ /** * Portable-ruleset ergonomics (#76, Stages 1–2): the `var → RefKind` catalog and the pure * `portablizeRuleset` reverse-rewrite helper. Everything here is offline and deterministic — the - * caller supplies the per-kind id→key maps; no network, no live writes. + * caller supplies the per-kind id→key maps and the role catalog; no network, no live writes. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; -import { VAR_REF_KINDS, portablizeRuleset } from "../src/config/query-refs.js"; +import { VAR_REF_KINDS, portablizeRuleset, type RoleCatalogEntry } from "../src/config/query-refs.js"; import { q, churchQuery } from "../src/config/query.js"; import { normalizeRuleset } from "../src/engine/dynamic.js"; -import { deepMapRefs, type Ref, type RefKind } from "../src/resolve/refs.js"; +import { deepMapRefs, refKey, type Ref, type RefKind } from "../src/resolve/refs.js"; const here = dirname(fileURLToPath(import.meta.url)); describe("VAR_REF_KINDS catalog (#76 Stage 1)", () => { - it("maps every ChurchQuery var observed in the captured prod rulesets to a real RefKind", () => { - // Exactly the entity-bearing vars present in ct-structure/rulesets/*.json, verified against the - // real files (2026-07-11): the four kinds are the canonical RefKind strings from src/resolve/refs.ts. + it("maps every SIMPLE entity var observed in the captured prod rulesets to a real RefKind", () => { + // Exactly the simple, name-based entity vars present in ct-structure/rulesets/*.json, verified + // against the real files (2026-07-11). `role.id` is NOT here — it is a group-type-scoped role + // (groupTypeRoleId) handled by the role special case in portablizeRuleset (see the test below and + // the module comment), because role names are not globally unique across group types (#76). expect(VAR_REF_KINDS).toEqual({ "ctgroup.id": "group", "ctgroup.campusId": "campus", "ctgroup.groupTypeId": "group-type", "person.campusId": "campus", - "role.id": "role-def", }); }); - it("leaves non-entity and catalog-less vars OUT of the table (escape hatch)", () => { - // groupStatusId has no REST catalog (#67) → never a managed entity; isArchived/dateOfDeath are - // boolean/date literals. All three must be absent so portablize leaves their numbers untouched. + it("leaves role.id, non-entity, and catalog-less vars OUT of the table", () => { + // role.id is deliberately absent (fixed in #76, reverting #86's wrong `role-def` mapping): it needs + // the (group-type, role-name) special case, not a lone name-based kind. groupStatusId has no REST + // catalog (#67); isArchived/dateOfDeath are boolean/date literals. + expect(VAR_REF_KINDS["role.id"]).toBeUndefined(); expect(VAR_REF_KINDS["ctgroup.groupStatusId"]).toBeUndefined(); expect(VAR_REF_KINDS["person.isArchived"]).toBeUndefined(); expect(VAR_REF_KINDS["person.dateOfDeath"]).toBeUndefined(); @@ -89,50 +92,159 @@ describe("portablizeRuleset (#76 Stage 2)", () => { expect(JSON.stringify(ruleset)).toEqual(before); }); - describe("real captured prod ruleset round-trip (sintegrationmeeting.json)", () => { + describe("role.id → (group-type, role-name) marker (#76 — the fix for #86's role-def mapping)", () => { + // A `role.id` is a groupTypeRoleId: two ids can share a role NAME on different group types, so the + // marker must carry the (group-type, role-name) pair, resolved via /group/roles by (groupTypeId, name). + const roleCatalog = new Map([ + [84, { groupTypeId: 12, name: "Leiter" }], + [16, { groupTypeId: 2, name: "Leiter" }], // SAME name as 84, different group type + ]); + const groupTypeIdToKey = new Map([[12, "local_lead"], [2, "team"]]); + + it("disambiguates two same-named roles by their group type", () => { + const ruleset = { query: churchQuery(q.oneof("role.id", [84, 16])) }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: {}, + roleCatalog, + groupTypeIdToKey, + }); + const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; + expect(filter.oneof[1]).toEqual([ + { __ctRef: true, kind: "group-type-role", groupType: "local_lead", role: "Leiter" }, + { __ctRef: true, kind: "group-type-role", groupType: "team", role: "Leiter" }, + ]); + expect(warnings).toEqual([]); + }); + + it("leaves a role id whose group type is unmanaged numeric, with a { var: 'role.id' } warning", () => { + const ruleset = { query: churchQuery(q.oneof("role.id", [84, 999])) }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: {}, + roleCatalog, + groupTypeIdToKey, + }); + const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; + expect(filter.oneof[1]).toEqual([ + { __ctRef: true, kind: "group-type-role", groupType: "local_lead", role: "Leiter" }, + 999, // not in roleCatalog → numeric + ]); + expect(warnings).toEqual([{ var: "role.id", id: 999 }]); + }); + + it("leaves role.id numeric (with a warning) when no roleCatalog is supplied at all", () => { + const ruleset = { query: churchQuery(q.oneof("role.id", [84])) }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: {} }); + const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; + expect(filter.oneof[1]).toEqual([84]); + expect(warnings).toEqual([{ var: "role.id", id: 84 }]); + }); + }); + + describe("process.*.handleMembership.groupTypeRoleId — an out-of-query role field (#76)", () => { + const roleCatalog = new Map([[66, { groupTypeId: 9, name: "Mitglied" }]]); + const groupTypeIdToKey = new Map([[9, "struktur"]]); + + it("rewrites the integer field to a group-type-role marker via the same role catalog", () => { + const ruleset = { + query: churchQuery(q.eq("person.isArchived", 0)), + process: { queryResultOnly: { none: { handleMembership: { groupMemberStatus: "active", groupTypeRoleId: 66 } } } }, + }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: {}, + roleCatalog, + groupTypeIdToKey, + }); + const hm = (out.process as { queryResultOnly: { none: { handleMembership: Record } } }) + .queryResultOnly.none.handleMembership; + expect(hm.groupTypeRoleId).toEqual({ + __ctRef: true, + kind: "group-type-role", + groupType: "struktur", + role: "Mitglied", + }); + expect(hm.groupMemberStatus).toBe("active"); // sibling string untouched + expect(warnings).toEqual([]); + }); + + it("leaves the field numeric with a warning when the role is unknown", () => { + const ruleset = { process: { queryResultOnly: { none: { handleMembership: { groupTypeRoleId: 4242 } } } } }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: {}, + roleCatalog, + groupTypeIdToKey, + }); + const hm = (out.process as { queryResultOnly: { none: { handleMembership: Record } } }) + .queryResultOnly.none.handleMembership; + expect(hm.groupTypeRoleId).toBe(4242); + expect(warnings).toEqual([{ var: "groupTypeRoleId", id: 4242 }]); + }); + }); + + describe("real captured prod ruleset round-trip (skidscheckinopsmz.json)", () => { + // The real ct-structure ruleset the #76 fix targets: ctgroup.id ["112","8","1246"], role.id + // ["84","85","17","16"], and process...handleMembership.groupTypeRoleId 66 (OUT of the query). const raw = JSON.parse( - readFileSync(join(here, "fixtures/dynamic/portablize-sintegrationmeeting.json"), "utf8"), + readFileSync(join(here, "fixtures/dynamic/portablize-skidscheckinopsmz.json"), "utf8"), ); // captureDynamic normalizes first (numeric-string ids → numbers), so Stage 2 runs on numbers. const normalized = normalizeRuleset(raw); - // Every group/role id present in the fixture, mapped to a logical key. - const groupIds = [148, 1228, 32, 1237, 1243, 27, 119, 1974]; - const roleIds = [16, 84, 85, 17, 90, 91, 15]; + // The real decode of the referenced ids (live prod /api/group/roles + /group/grouptypes, 2026-07-11). + const roleCatalog = new Map([ + [84, { groupTypeId: 12, name: "Leiter" }], + [85, { groupTypeId: 12, name: "Organisator" }], + [16, { groupTypeId: 2, name: "Leiter" }], + [17, { groupTypeId: 2, name: "Organisator" }], + [66, { groupTypeId: 9, name: "Mitglied" }], // the process.groupTypeRoleId target + ]); + const groupTypeIdToKey = new Map([[12, "local_lead"], [2, "team"], [9, "struktur"]]); + // Group 1246 is deliberately UNMANAGED (not in the map) — the escape hatch: it stays numeric. const idToKeyByKind: Partial>> = { - group: new Map(groupIds.map((id) => [id, `group-${id}`])), - "role-def": new Map(roleIds.map((id) => [id, `role-${id}`])), + group: new Map([[112, "bereich_kids"], [8, "team_kidsdienst"]]), }; + const opts = { idToKeyByKind, roleCatalog, groupTypeIdToKey }; - it("resolves byte-faithfully back to the original ids (round-trip)", () => { - const { ruleset: portable } = portablizeRuleset(normalized, { idToKeyByKind }); - // Inverse map, applied with the SAME deepMapRefs the resolver uses — markers sit exactly where - // the ids were, so the whole ruleset restores byte-identical to the normalized original. - const keyToId = new Map(); - for (const [id, key] of [...groupIds.map((id) => [id, `group-${id}`] as const)]) keyToId.set(`group:${key}`, id); - for (const [id, key] of [...roleIds.map((id) => [id, `role-${id}`] as const)]) keyToId.set(`role-def:${key}`, id); - const back = deepMapRefs(portable, (r: Ref) => keyToId.get(`${r.kind}:${(r as { key: string }).key}`)); - expect(back).toEqual(normalized); + it("produces (group-type, role-name) markers for the query roles AND the process groupTypeRoleId", () => { + const { ruleset: portable } = portablizeRuleset(normalized, opts); + const json = JSON.stringify(portable); + // A query role marker (84 → local_lead/Leiter) and the process-field marker (66 → struktur/Mitglied). + expect(json).toContain('{"__ctRef":true,"kind":"group-type-role","groupType":"local_lead","role":"Leiter"}'); + const hm = (portable.process as { queryResultOnly: { none: { handleMembership: Record } } }) + .queryResultOnly.none.handleMembership; + expect(hm.groupTypeRoleId).toEqual({ + __ctRef: true, + kind: "group-type-role", + groupType: "struktur", + role: "Mitglied", + }); }); - it("leaves groupStatusId ids numeric with no warnings for them", () => { - const { ruleset: portable, warnings } = portablizeRuleset(normalized, { idToKeyByKind }); - expect(JSON.stringify(portable)).toContain("[1,2,4]"); // the two groupStatusId oneof lists survive - expect(warnings).toEqual([]); // every group/role id was mapped, statuses are never candidates + it("resolves byte-faithfully back to the original ids (round-trip, incl. process.groupTypeRoleId)", () => { + const { ruleset: portable } = portablizeRuleset(normalized, opts); + // Inverse map keyed by refKey — the identity string the resolver caches by — applied with the SAME + // deepMapRefs the resolver uses. Markers sit exactly where the ids were, so the whole ruleset + // (query filter AND the out-of-query process.groupTypeRoleId) restores byte-identical. + const keyToId = new Map(); + for (const [id, key] of [[112, "bereich_kids"], [8, "team_kidsdienst"]] as const) { + keyToId.set(refKey({ __ctRef: true, kind: "group", key }), id); + } + for (const [id, entry] of roleCatalog) { + keyToId.set( + refKey({ __ctRef: true, kind: "group-type-role", groupType: groupTypeIdToKey.get(entry.groupTypeId)!, role: entry.name }), + id, + ); + } + const back = deepMapRefs(portable, (r: Ref) => keyToId.get(refKey(r))); + expect(back).toEqual(normalized); }); - it("warns for each unmanaged group/role id when the maps are partial", () => { - const partial: Partial>> = { - group: new Map([[148, "group-148"]]), - "role-def": new Map(), - }; - const { warnings } = portablizeRuleset(normalized, { idToKeyByKind: partial }); - // Deterministic query-traversal order; groupStatusId ids (1,2,4) never appear. - const ids = warnings.map((w) => w.id); - expect(ids).toContain(1228); - expect(ids).toContain(16); - expect(ids).not.toContain(1); // groupStatusId never warns - expect(warnings.every((w) => w.var === "ctgroup.id" || w.var === "role.id")).toBe(true); + it("leaves the unmanaged group id (1246) and the groupStatusId lists numeric, warning only for 1246", () => { + const { ruleset: portable, warnings } = portablizeRuleset(normalized, opts); + const json = JSON.stringify(portable); + expect(json).toContain("1246"); // unmanaged group id survives numeric + expect(warnings).toEqual([{ var: "ctgroup.id", id: 1246 }]); // exactly the one escape-hatch id + // groupStatusId oneof ([1]) is never a candidate → never rewritten, never warned. + expect(warnings.some((w) => w.id === 1)).toBe(false); }); }); }); diff --git a/tests/refs.test.ts b/tests/refs.test.ts index 8c42d9b..81bc5fc 100644 --- a/tests/refs.test.ts +++ b/tests/refs.test.ts @@ -44,6 +44,17 @@ describe("refKey / refLabel", () => { expect(refKey(ref.groupRole("g", "r"))).toBe("group-role:g r"); expect(refLabel(ref.groupType("mt"))).toBe("group-type:mt"); expect(refLabel(ref.groupRole("g", "r"))).toBe("group-role(group=g, role=r)"); + // group-type-role (#76): a compound (group-type, role) pair, keyed/labelled distinctly. + expect(ref.groupTypeRole("local_lead", "Leiter")).toEqual({ + __ctRef: true, + kind: "group-type-role", + groupType: "local_lead", + role: "Leiter", + }); + expect(refKey(ref.groupTypeRole("local_lead", "Leiter"))).toBe("group-type-role:local_lead Leiter"); + expect(refLabel(ref.groupTypeRole("local_lead", "Leiter"))).toBe( + "group-type-role(groupType=local_lead, role=Leiter)", + ); }); }); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index 2524757..92fd980 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -155,6 +155,79 @@ describe("Resolver.resolve", () => { }); }); +describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { + // The real shape from live prod /group/roles: role NAMES repeat across group types ("Leiter" on both + // group type 12 and 2), which is exactly why #86's `role-def` mapping was ambiguous. Only the + // (groupTypeId, name) PAIR is unique, so this ref carries the group-type + role name. + const rolesCatalog = [ + { id: 84, name: "Leiter", groupTypeId: 12 }, + { id: 85, name: "Organisator", groupTypeId: 12 }, + { id: 16, name: "Leiter", groupTypeId: 2 }, // SAME name as #84, different group type + { id: 17, name: "Organisator", groupTypeId: 2 }, + ]; + const groupTypesCatalog = [{ id: 12, name: "Local Lead" }, { id: 2, name: "Team" }]; + + it("resolves a (group-type, role) pair to its groupTypeRoleId, disambiguating same-named roles", async () => { + const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); + const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + // Same role NAME ("Leiter"), different group type → different id: the pair disambiguates. + expect(await r.resolve(ref.groupTypeRole("local_lead", "Leiter"), "site")).toBe(84); + expect(await r.resolve(ref.groupTypeRole("team", "Leiter"), "site")).toBe(16); + // slug- and exact-name both resolve; /group/roles fetched once, cached across all refs. + expect(await r.resolve(ref.groupTypeRole("team", "Organisator"), "site")).toBe(17); + expect(client.calls["/group/roles"]).toBe(1); + }); + + it("resolves the group-type half from managed state (no group-type catalog fetch)", async () => { + const state = stateWith({ + local_lead: { type: "group-type", id: 12, key: "local_lead", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }); + const client = fakeClient({ "/group/roles": rolesCatalog }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); + expect(await r.resolve(ref.groupTypeRole("local_lead", "Organisator"), "site")).toBe(85); + expect(client.calls["/group/grouptypes"]).toBeUndefined(); // state hit — no catalog fetch + }); + + it("errors clearly when no role of that name exists on the group type (lists candidates)", async () => { + const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); + const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); + await expect(r.resolve(ref.groupTypeRole("team", "Ghost"), "ruleset \"r\"")).rejects.toThrow( + /group-type-role\(groupType=team, role=Ghost\) referenced at ruleset "r" on hostA: group type #2 has no role named "Ghost".*available: "Leiter", "Organisator".*pass a numeric id/is, + ); + }); + + it("errors listing candidates when two roles on the same group type share the name (ambiguous)", async () => { + const client = fakeClient({ + "/group/grouptypes": groupTypesCatalog, + "/group/roles": [ + { id: 84, name: "Leiter", groupTypeId: 12 }, + { id: 800, name: "Leiter", groupTypeId: 12 }, // duplicate on the SAME group type + ], + }); + const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); + await expect(r.resolve(ref.groupTypeRole("local_lead", "Leiter"), "site")).rejects.toThrow( + /Ambiguous group-type-role\(groupType=local_lead, role=Leiter\).*2 roles on group type #12 match — "Leiter" \(#84\), "Leiter" \(#800\)/, + ); + }); + + it("errors when the group-type key itself cannot be resolved", async () => { + const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); + const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostB" }); + await expect(r.resolve(ref.groupTypeRole("ghost_type", "Leiter"), "site")).rejects.toThrow( + /Cannot resolve group-type:ghost_type referenced at site on hostB/, + ); + }); + + it("rejects a same-run-declared (not-yet-created) group type — id only exists once it does", async () => { + const desired: DesiredResource[] = [{ type: "group-type", key: "local_lead", fields: {}, dependsOn: [] }]; + const client = fakeClient({ "/group/roles": rolesCatalog }); + const r = new Resolver({ client, state: emptyState("h"), desired, host: "hostA" }); + await expect(r.resolve(ref.groupTypeRole("local_lead", "Leiter"), "site")).rejects.toThrow( + /declared in this config but not yet created.*Apply the group type first.*pass a numeric id/is, + ); + }); +}); + describe("Resolver.resolveValue", () => { it("deep-rewrites refs to ids and fetches each catalog at most once", async () => { const client = fakeClient({ "/campuses": [{ id: 5, name: "Mainz" }, { id: 6, name: "Berlin" }] });