dopespec 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -0
- package/bin/dopespec.mjs +26 -0
- package/dist/__tests__/codegen.test.d.ts +2 -0
- package/dist/__tests__/codegen.test.d.ts.map +1 -0
- package/dist/__tests__/codegen.test.js +713 -0
- package/dist/__tests__/codegen.test.js.map +1 -0
- package/dist/__tests__/e2e-proof.test.d.ts +2 -0
- package/dist/__tests__/e2e-proof.test.d.ts.map +1 -0
- package/dist/__tests__/e2e-proof.test.js +153 -0
- package/dist/__tests__/e2e-proof.test.js.map +1 -0
- package/dist/__tests__/schema.test.d.ts +2 -0
- package/dist/__tests__/schema.test.d.ts.map +1 -0
- package/dist/__tests__/schema.test.js +822 -0
- package/dist/__tests__/schema.test.js.map +1 -0
- package/dist/__tests__/type-errors.d.ts +2 -0
- package/dist/__tests__/type-errors.d.ts.map +1 -0
- package/dist/__tests__/type-errors.js +128 -0
- package/dist/__tests__/type-errors.js.map +1 -0
- package/dist/cli/generate.d.ts +2 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +250 -0
- package/dist/cli/generate.js.map +1 -0
- package/dist/codegen/commands.d.ts +4 -0
- package/dist/codegen/commands.d.ts.map +1 -0
- package/dist/codegen/commands.js +25 -0
- package/dist/codegen/commands.js.map +1 -0
- package/dist/codegen/decisions-evaluate.d.ts +3 -0
- package/dist/codegen/decisions-evaluate.d.ts.map +1 -0
- package/dist/codegen/decisions-evaluate.js +50 -0
- package/dist/codegen/decisions-evaluate.js.map +1 -0
- package/dist/codegen/decisions-table.d.ts +3 -0
- package/dist/codegen/decisions-table.d.ts.map +1 -0
- package/dist/codegen/decisions-table.js +27 -0
- package/dist/codegen/decisions-table.js.map +1 -0
- package/dist/codegen/decisions-tests.d.ts +3 -0
- package/dist/codegen/decisions-tests.d.ts.map +1 -0
- package/dist/codegen/decisions-tests.js +43 -0
- package/dist/codegen/decisions-tests.js.map +1 -0
- package/dist/codegen/e2e-stubs.d.ts +4 -0
- package/dist/codegen/e2e-stubs.d.ts.map +1 -0
- package/dist/codegen/e2e-stubs.js +21 -0
- package/dist/codegen/e2e-stubs.js.map +1 -0
- package/dist/codegen/events.d.ts +8 -0
- package/dist/codegen/events.d.ts.map +1 -0
- package/dist/codegen/events.js +36 -0
- package/dist/codegen/events.js.map +1 -0
- package/dist/codegen/index.d.ts +18 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +18 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/codegen/invariants.d.ts +4 -0
- package/dist/codegen/invariants.d.ts.map +1 -0
- package/dist/codegen/invariants.js +53 -0
- package/dist/codegen/invariants.js.map +1 -0
- package/dist/codegen/mermaid.d.ts +4 -0
- package/dist/codegen/mermaid.d.ts.map +1 -0
- package/dist/codegen/mermaid.js +21 -0
- package/dist/codegen/mermaid.js.map +1 -0
- package/dist/codegen/orchestrators.d.ts +7 -0
- package/dist/codegen/orchestrators.d.ts.map +1 -0
- package/dist/codegen/orchestrators.js +32 -0
- package/dist/codegen/orchestrators.js.map +1 -0
- package/dist/codegen/policy-index.d.ts +7 -0
- package/dist/codegen/policy-index.d.ts.map +1 -0
- package/dist/codegen/policy-index.js +40 -0
- package/dist/codegen/policy-index.js.map +1 -0
- package/dist/codegen/policy-mermaid.d.ts +7 -0
- package/dist/codegen/policy-mermaid.d.ts.map +1 -0
- package/dist/codegen/policy-mermaid.js +30 -0
- package/dist/codegen/policy-mermaid.js.map +1 -0
- package/dist/codegen/policy-tests.d.ts +8 -0
- package/dist/codegen/policy-tests.d.ts.map +1 -0
- package/dist/codegen/policy-tests.js +167 -0
- package/dist/codegen/policy-tests.js.map +1 -0
- package/dist/codegen/policy-validator.d.ts +8 -0
- package/dist/codegen/policy-validator.d.ts.map +1 -0
- package/dist/codegen/policy-validator.js +69 -0
- package/dist/codegen/policy-validator.js.map +1 -0
- package/dist/codegen/tests.d.ts +4 -0
- package/dist/codegen/tests.d.ts.map +1 -0
- package/dist/codegen/tests.js +125 -0
- package/dist/codegen/tests.js.map +1 -0
- package/dist/codegen/transitions.d.ts +4 -0
- package/dist/codegen/transitions.d.ts.map +1 -0
- package/dist/codegen/transitions.js +41 -0
- package/dist/codegen/transitions.js.map +1 -0
- package/dist/codegen/types.d.ts +4 -0
- package/dist/codegen/types.d.ts.map +1 -0
- package/dist/codegen/types.js +48 -0
- package/dist/codegen/types.js.map +1 -0
- package/dist/codegen/utils.d.ts +85 -0
- package/dist/codegen/utils.d.ts.map +1 -0
- package/dist/codegen/utils.js +357 -0
- package/dist/codegen/utils.js.map +1 -0
- package/dist/codegen/zod.d.ts +4 -0
- package/dist/codegen/zod.d.ts.map +1 -0
- package/dist/codegen/zod.js +32 -0
- package/dist/codegen/zod.js.map +1 -0
- package/dist/examples/generated/customer.types.d.ts +7 -0
- package/dist/examples/generated/customer.types.d.ts.map +1 -0
- package/dist/examples/generated/customer.types.js +2 -0
- package/dist/examples/generated/customer.types.js.map +1 -0
- package/dist/examples/generated/order.types.d.ts +9 -0
- package/dist/examples/generated/order.types.d.ts.map +1 -0
- package/dist/examples/generated/order.types.js +2 -0
- package/dist/examples/generated/order.types.js.map +1 -0
- package/dist/examples/generated/pet.types.d.ts +11 -0
- package/dist/examples/generated/pet.types.d.ts.map +1 -0
- package/dist/examples/generated/pet.types.js +2 -0
- package/dist/examples/generated/pet.types.js.map +1 -0
- package/dist/examples/pet-store.d.ts +137 -0
- package/dist/examples/pet-store.d.ts.map +1 -0
- package/dist/examples/pet-store.js +139 -0
- package/dist/examples/pet-store.js.map +1 -0
- package/dist/examples/src/customer.e2e.d.ts +2 -0
- package/dist/examples/src/customer.e2e.d.ts.map +1 -0
- package/dist/examples/src/customer.e2e.js +17 -0
- package/dist/examples/src/customer.e2e.js.map +1 -0
- package/dist/examples/src/customer.orchestrators.d.ts +5 -0
- package/dist/examples/src/customer.orchestrators.d.ts.map +1 -0
- package/dist/examples/src/customer.orchestrators.js +5 -0
- package/dist/examples/src/customer.orchestrators.js.map +1 -0
- package/dist/examples/src/order.e2e.d.ts +2 -0
- package/dist/examples/src/order.e2e.d.ts.map +1 -0
- package/dist/examples/src/order.e2e.js +22 -0
- package/dist/examples/src/order.e2e.js.map +1 -0
- package/dist/examples/src/order.orchestrators.d.ts +9 -0
- package/dist/examples/src/order.orchestrators.d.ts.map +1 -0
- package/dist/examples/src/order.orchestrators.js +10 -0
- package/dist/examples/src/order.orchestrators.js.map +1 -0
- package/dist/examples/src/pet.e2e.d.ts +2 -0
- package/dist/examples/src/pet.e2e.d.ts.map +1 -0
- package/dist/examples/src/pet.e2e.js +17 -0
- package/dist/examples/src/pet.e2e.js.map +1 -0
- package/dist/examples/src/pet.orchestrators.d.ts +8 -0
- package/dist/examples/src/pet.orchestrators.d.ts.map +1 -0
- package/dist/examples/src/pet.orchestrators.js +9 -0
- package/dist/examples/src/pet.orchestrators.js.map +1 -0
- package/dist/schema/actions.d.ts +37 -0
- package/dist/schema/actions.d.ts.map +1 -0
- package/dist/schema/actions.js +35 -0
- package/dist/schema/actions.js.map +1 -0
- package/dist/schema/constraints.d.ts +20 -0
- package/dist/schema/constraints.d.ts.map +1 -0
- package/dist/schema/constraints.js +15 -0
- package/dist/schema/constraints.js.map +1 -0
- package/dist/schema/decisions.d.ts +29 -0
- package/dist/schema/decisions.d.ts.map +1 -0
- package/dist/schema/decisions.js +56 -0
- package/dist/schema/decisions.js.map +1 -0
- package/dist/schema/index.d.ts +17 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/model.d.ts +65 -0
- package/dist/schema/model.d.ts.map +1 -0
- package/dist/schema/model.js +30 -0
- package/dist/schema/model.js.map +1 -0
- package/dist/schema/policy.d.ts +25 -0
- package/dist/schema/policy.d.ts.map +1 -0
- package/dist/schema/policy.js +53 -0
- package/dist/schema/policy.js.map +1 -0
- package/dist/schema/props.d.ts +57 -0
- package/dist/schema/props.d.ts.map +1 -0
- package/dist/schema/props.js +53 -0
- package/dist/schema/props.js.map +1 -0
- package/dist/schema/relations.d.ts +9 -0
- package/dist/schema/relations.d.ts.map +1 -0
- package/dist/schema/relations.js +9 -0
- package/dist/schema/relations.js.map +1 -0
- package/dist/schema/transitions.d.ts +34 -0
- package/dist/schema/transitions.d.ts.map +1 -0
- package/dist/schema/transitions.js +25 -0
- package/dist/schema/transitions.js.map +1 -0
- package/dist/schema/types.d.ts +18 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/types.js +11 -0
- package/dist/schema/types.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { action } from "../schema/actions.js";
|
|
3
|
+
import { rule } from "../schema/constraints.js";
|
|
4
|
+
import { decisions } from "../schema/decisions.js";
|
|
5
|
+
import { model } from "../schema/model.js";
|
|
6
|
+
import { policy } from "../schema/policy.js";
|
|
7
|
+
import { boolean, date, lifecycle, number, oneOf, optional, OPTIONAL_BRAND, string, } from "../schema/props.js";
|
|
8
|
+
import { belongsTo, hasMany } from "../schema/relations.js";
|
|
9
|
+
import { from } from "../schema/transitions.js";
|
|
10
|
+
import { ref } from "../schema/types.js";
|
|
11
|
+
describe("props", () => {
|
|
12
|
+
it("string() returns string prop", () => {
|
|
13
|
+
expect(string()).toEqual({ kind: "string", values: null });
|
|
14
|
+
});
|
|
15
|
+
it("number() returns number prop", () => {
|
|
16
|
+
expect(number()).toEqual({ kind: "number", values: null });
|
|
17
|
+
});
|
|
18
|
+
it("boolean() returns boolean prop", () => {
|
|
19
|
+
expect(boolean()).toEqual({ kind: "boolean", values: null });
|
|
20
|
+
});
|
|
21
|
+
it("date() returns date prop", () => {
|
|
22
|
+
expect(date()).toEqual({ kind: "date", values: null });
|
|
23
|
+
});
|
|
24
|
+
it("oneOf() captures values", () => {
|
|
25
|
+
const states = ["a", "b", "c"];
|
|
26
|
+
const prop = oneOf(states);
|
|
27
|
+
expect(prop.kind).toBe("oneOf");
|
|
28
|
+
expect(prop.values).toEqual(["a", "b", "c"]);
|
|
29
|
+
});
|
|
30
|
+
// as const arrays tested for backward compat; lifecycle.states() is the preferred API
|
|
31
|
+
it("lifecycle() captures values", () => {
|
|
32
|
+
const states = ["pending", "done"];
|
|
33
|
+
const prop = lifecycle(states);
|
|
34
|
+
expect(prop.kind).toBe("lifecycle");
|
|
35
|
+
expect(prop.values).toEqual(["pending", "done"]);
|
|
36
|
+
});
|
|
37
|
+
it("lifecycle.states() creates named state object", () => {
|
|
38
|
+
const states = lifecycle.states("pending", "paid", "shipped");
|
|
39
|
+
expect(states.pending).toBe("pending");
|
|
40
|
+
expect(states.paid).toBe("paid");
|
|
41
|
+
expect(states.shipped).toBe("shipped");
|
|
42
|
+
});
|
|
43
|
+
it("lifecycle.states() throws on duplicate names", () => {
|
|
44
|
+
expect(() => lifecycle.states("a", "b", "a")).toThrow("lifecycle.states() received duplicate name 'a'");
|
|
45
|
+
});
|
|
46
|
+
it("lifecycle.states() result is frozen", () => {
|
|
47
|
+
const states = lifecycle.states("a", "b");
|
|
48
|
+
expect(Object.isFrozen(states)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("lifecycle() accepts lifecycle.states() result", () => {
|
|
51
|
+
const states = lifecycle.states("pending", "done");
|
|
52
|
+
const prop = lifecycle(states);
|
|
53
|
+
expect(prop.kind).toBe("lifecycle");
|
|
54
|
+
expect(prop.values).toEqual(["pending", "done"]);
|
|
55
|
+
});
|
|
56
|
+
it("optional() wraps a prop with optional flag", () => {
|
|
57
|
+
const prop = optional(string());
|
|
58
|
+
expect(prop.kind).toBe("string");
|
|
59
|
+
expect(prop.values).toBe(null);
|
|
60
|
+
expect(OPTIONAL_BRAND in prop).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it("optional() preserves oneOf values", () => {
|
|
63
|
+
const prop = optional(oneOf(["a", "b"]));
|
|
64
|
+
expect(prop.kind).toBe("oneOf");
|
|
65
|
+
expect(prop.values).toEqual(["a", "b"]);
|
|
66
|
+
expect(OPTIONAL_BRAND in prop).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it("optional() preserves number prop", () => {
|
|
69
|
+
const prop = optional(number());
|
|
70
|
+
expect(prop.kind).toBe("number");
|
|
71
|
+
expect(OPTIONAL_BRAND in prop).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it("optional() preserves boolean prop", () => {
|
|
74
|
+
const prop = optional(boolean());
|
|
75
|
+
expect(prop.kind).toBe("boolean");
|
|
76
|
+
expect(prop.values).toBe(null);
|
|
77
|
+
expect(OPTIONAL_BRAND in prop).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it("optional() preserves date prop", () => {
|
|
80
|
+
const prop = optional(date());
|
|
81
|
+
expect(prop.kind).toBe("date");
|
|
82
|
+
expect(prop.values).toBe(null);
|
|
83
|
+
expect(OPTIONAL_BRAND in prop).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it("optional(lifecycle()) is a compile-time error", () => {
|
|
86
|
+
const states = lifecycle.states("a", "b");
|
|
87
|
+
// @ts-expect-error — lifecycle props cannot be optional
|
|
88
|
+
expect(() => optional(lifecycle(states))).toThrow("lifecycle props cannot be optional");
|
|
89
|
+
});
|
|
90
|
+
it("optional(lifecycle() as any) throws at runtime", () => {
|
|
91
|
+
const states = lifecycle.states("a", "b");
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- testing runtime guard
|
|
93
|
+
expect(() => optional(lifecycle(states))).toThrow("lifecycle props cannot be optional");
|
|
94
|
+
});
|
|
95
|
+
it("InferContext makes optional props optional and required props required", () => {
|
|
96
|
+
// Required keys are present
|
|
97
|
+
const _checkRequired = { age: 1, name: "a" };
|
|
98
|
+
// Optional key is accepted
|
|
99
|
+
const _checkOptional = { age: 1, name: "a", phone: "555" };
|
|
100
|
+
// Suppress unused warnings
|
|
101
|
+
expect(_checkRequired).toBeDefined();
|
|
102
|
+
expect(_checkOptional).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe("transitions (standalone)", () => {
|
|
106
|
+
it("from().to() creates transition with correct states", () => {
|
|
107
|
+
const t = from("pending").to("paid");
|
|
108
|
+
expect(t.kind).toBe("transition");
|
|
109
|
+
expect(t.from).toBe("pending");
|
|
110
|
+
expect(t.to).toBe("paid");
|
|
111
|
+
expect(t.guard).toBeNull();
|
|
112
|
+
expect(t.scenarios).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
it(".when() stores guard function", () => {
|
|
115
|
+
const guard = () => true;
|
|
116
|
+
const t = from("pending").to("paid").when(guard);
|
|
117
|
+
expect(t.guard).toBe(guard);
|
|
118
|
+
});
|
|
119
|
+
it(".scenario() appends to scenarios", () => {
|
|
120
|
+
const t = from("pending")
|
|
121
|
+
.to("paid")
|
|
122
|
+
.scenario({ total: 100 }, "paid")
|
|
123
|
+
.scenario({ total: 0 }, "pending");
|
|
124
|
+
expect(t.scenarios).toHaveLength(2);
|
|
125
|
+
expect(t.scenarios[0]).toEqual({ expected: "paid", given: { total: 100 } });
|
|
126
|
+
expect(t.scenarios[1]).toEqual({
|
|
127
|
+
expected: "pending",
|
|
128
|
+
given: { total: 0 },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
it("chaining is immutable", () => {
|
|
132
|
+
const t1 = from("a").to("b");
|
|
133
|
+
const t2 = t1.when(() => true);
|
|
134
|
+
const t3 = t1.scenario({ x: 1 }, "b");
|
|
135
|
+
expect(t1.guard).toBeNull();
|
|
136
|
+
expect(t1.scenarios).toHaveLength(0);
|
|
137
|
+
expect(t2.guard).not.toBeNull();
|
|
138
|
+
expect(t3.scenarios).toHaveLength(1);
|
|
139
|
+
});
|
|
140
|
+
it(".when().scenario() together", () => {
|
|
141
|
+
const guard = () => true;
|
|
142
|
+
const t = from("a").to("b").when(guard).scenario({ x: 1 }, "b");
|
|
143
|
+
expect(t.guard).toBe(guard);
|
|
144
|
+
expect(t.scenarios).toHaveLength(1);
|
|
145
|
+
});
|
|
146
|
+
it(".scenario().when() — reverse order", () => {
|
|
147
|
+
const guard = () => false;
|
|
148
|
+
const t = from("a").to("b").scenario({ x: 1 }, "b").when(guard);
|
|
149
|
+
expect(t.guard).toBe(guard);
|
|
150
|
+
expect(t.scenarios).toHaveLength(1);
|
|
151
|
+
});
|
|
152
|
+
it("multiple .when() — last wins", () => {
|
|
153
|
+
const first = () => true;
|
|
154
|
+
const second = () => false;
|
|
155
|
+
const t = from("a").to("b").when(first).when(second);
|
|
156
|
+
expect(t.guard).toBe(second);
|
|
157
|
+
});
|
|
158
|
+
it("multiple .scenario() — accumulates", () => {
|
|
159
|
+
const t = from("a")
|
|
160
|
+
.to("b")
|
|
161
|
+
.scenario({ x: 1 }, "a")
|
|
162
|
+
.scenario({ x: 2 }, "b")
|
|
163
|
+
.scenario({ x: 3 }, "a");
|
|
164
|
+
expect(t.scenarios).toHaveLength(3);
|
|
165
|
+
expect(t.scenarios[0]?.expected).toBe("a");
|
|
166
|
+
expect(t.scenarios[1]?.expected).toBe("b");
|
|
167
|
+
expect(t.scenarios[2]?.expected).toBe("a");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe("constraints (standalone)", () => {
|
|
171
|
+
it("rule() creates empty constraint", () => {
|
|
172
|
+
const c = rule();
|
|
173
|
+
expect(c.kind).toBe("constraint");
|
|
174
|
+
expect(c.guard).toBeNull();
|
|
175
|
+
expect(c.preventedAction).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
it(".when().prevent() stores guard and action", () => {
|
|
178
|
+
const guard = () => true;
|
|
179
|
+
const c = rule().when(guard).prevent("pay");
|
|
180
|
+
expect(c.guard).toBe(guard);
|
|
181
|
+
expect(c.preventedAction).toBe("pay");
|
|
182
|
+
});
|
|
183
|
+
it("chaining is immutable", () => {
|
|
184
|
+
const c1 = rule();
|
|
185
|
+
const c2 = c1.when(() => true);
|
|
186
|
+
const c3 = c1.prevent("x");
|
|
187
|
+
expect(c1.guard).toBeNull();
|
|
188
|
+
expect(c1.preventedAction).toBeNull();
|
|
189
|
+
expect(c2.guard).not.toBeNull();
|
|
190
|
+
expect(c3.preventedAction).toBe("x");
|
|
191
|
+
});
|
|
192
|
+
it(".prevent().when() — reverse order", () => {
|
|
193
|
+
const guard = () => true;
|
|
194
|
+
const c = rule().prevent("x").when(guard);
|
|
195
|
+
expect(c.preventedAction).toBe("x");
|
|
196
|
+
expect(c.guard).toBe(guard);
|
|
197
|
+
});
|
|
198
|
+
it("multiple .prevent() — last wins", () => {
|
|
199
|
+
const c = rule().prevent("first").prevent("second");
|
|
200
|
+
expect(c.preventedAction).toBe("second");
|
|
201
|
+
});
|
|
202
|
+
it("multiple .when() — last wins", () => {
|
|
203
|
+
const first = () => true;
|
|
204
|
+
const second = () => false;
|
|
205
|
+
const c = rule().when(first).when(second);
|
|
206
|
+
expect(c.guard).toBe(second);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe("guard execution", () => {
|
|
210
|
+
it("transition guard can be called and returns expected value", () => {
|
|
211
|
+
const states = ["off", "on"];
|
|
212
|
+
const m = model("Light", {
|
|
213
|
+
props: { brightness: number(), status: lifecycle(states) },
|
|
214
|
+
transitions: ({ from }) => ({
|
|
215
|
+
turnOn: from(states[0])
|
|
216
|
+
.to(states[1])
|
|
217
|
+
.when((ctx) => ctx.brightness > 0),
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
const guard = m.transitions?.turnOn.guard;
|
|
221
|
+
expect(guard).not.toBeNull();
|
|
222
|
+
expect(guard?.({ brightness: 100, status: "off" })).toBe(true);
|
|
223
|
+
expect(guard?.({ brightness: 0, status: "off" })).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
it("constraint guard can be called and returns expected value", () => {
|
|
226
|
+
const states = ["active", "inactive"];
|
|
227
|
+
const m = model("Switch", {
|
|
228
|
+
actions: { toggle: action() },
|
|
229
|
+
constraints: ({ rule }) => ({
|
|
230
|
+
noToggleWhenLocked: rule()
|
|
231
|
+
.when((ctx) => ctx.locked === true)
|
|
232
|
+
.prevent("toggle"),
|
|
233
|
+
}),
|
|
234
|
+
props: { locked: boolean(), status: lifecycle(states) },
|
|
235
|
+
});
|
|
236
|
+
const guard = m.constraints?.noToggleWhenLocked.guard;
|
|
237
|
+
expect(guard).not.toBeNull();
|
|
238
|
+
expect(guard?.({ locked: true, status: "active" })).toBe(true);
|
|
239
|
+
expect(guard?.({ locked: false, status: "active" })).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
it("guard on optional prop handles undefined and present values", () => {
|
|
242
|
+
const states = ["draft", "published"];
|
|
243
|
+
const m = model("Article", {
|
|
244
|
+
actions: { publish: action() },
|
|
245
|
+
constraints: ({ rule }) => ({
|
|
246
|
+
subtitleTooLong: rule()
|
|
247
|
+
.when((ctx) => ctx.subtitle !== undefined && ctx.subtitle.length > 50)
|
|
248
|
+
.prevent("publish"),
|
|
249
|
+
}),
|
|
250
|
+
props: {
|
|
251
|
+
status: lifecycle(states),
|
|
252
|
+
subtitle: optional(string()),
|
|
253
|
+
title: string(),
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const guard = m.constraints?.subtitleTooLong.guard;
|
|
257
|
+
expect(guard).not.toBeNull();
|
|
258
|
+
// undefined — guard does not fire
|
|
259
|
+
expect(guard?.({ status: "draft", title: "hi" })).toBe(false);
|
|
260
|
+
// short subtitle — guard does not fire
|
|
261
|
+
expect(guard?.({ status: "draft", subtitle: "short", title: "hi" })).toBe(false);
|
|
262
|
+
// long subtitle — guard fires
|
|
263
|
+
expect(guard?.({
|
|
264
|
+
status: "draft",
|
|
265
|
+
subtitle: "a".repeat(51),
|
|
266
|
+
title: "hi",
|
|
267
|
+
})).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
describe("actions", () => {
|
|
271
|
+
it("action() returns action def", () => {
|
|
272
|
+
const a = action();
|
|
273
|
+
expect(a.kind).toBe("action");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
describe("relations", () => {
|
|
277
|
+
it("hasMany() returns hasMany relation with model ref", () => {
|
|
278
|
+
const target = model("Other", {});
|
|
279
|
+
const r = hasMany(target);
|
|
280
|
+
expect(r.kind).toBe("hasMany");
|
|
281
|
+
expect(r.target.name).toBe("Other");
|
|
282
|
+
});
|
|
283
|
+
it("belongsTo() returns belongsTo relation with model ref", () => {
|
|
284
|
+
const target = model("Other", {});
|
|
285
|
+
const r = belongsTo(target);
|
|
286
|
+
expect(r.kind).toBe("belongsTo");
|
|
287
|
+
expect(r.target.name).toBe("Other");
|
|
288
|
+
});
|
|
289
|
+
it("ref() creates a forward reference", () => {
|
|
290
|
+
const r = hasMany(ref("FutureModel"));
|
|
291
|
+
expect(r.kind).toBe("hasMany");
|
|
292
|
+
expect(r.target.name).toBe("FutureModel");
|
|
293
|
+
});
|
|
294
|
+
it("ref() trims whitespace", () => {
|
|
295
|
+
const r = ref(" Padded ");
|
|
296
|
+
expect(r.name).toBe("Padded");
|
|
297
|
+
});
|
|
298
|
+
it('ref("") throws', () => {
|
|
299
|
+
expect(() => ref("")).toThrow("ref() requires a non-empty model name");
|
|
300
|
+
});
|
|
301
|
+
it('ref(" ") throws for whitespace-only', () => {
|
|
302
|
+
expect(() => ref(" ")).toThrow("ref() requires a non-empty model name");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
describe("model", () => {
|
|
306
|
+
it("assembles all fields with typed callbacks", () => {
|
|
307
|
+
const states = ["pending", "paid"];
|
|
308
|
+
const m = model("Order", {
|
|
309
|
+
actions: {
|
|
310
|
+
addItem: action(),
|
|
311
|
+
},
|
|
312
|
+
constraints: ({ rule }) => ({
|
|
313
|
+
noop: rule()
|
|
314
|
+
.when((ctx) => ctx.total === 0)
|
|
315
|
+
.prevent("addItem"),
|
|
316
|
+
}),
|
|
317
|
+
props: {
|
|
318
|
+
status: lifecycle(states),
|
|
319
|
+
total: number(),
|
|
320
|
+
},
|
|
321
|
+
transitions: ({ from }) => ({
|
|
322
|
+
pay: from(states[0])
|
|
323
|
+
.to(states[1])
|
|
324
|
+
.when((ctx) => ctx.total > 0)
|
|
325
|
+
.scenario({ total: 100 }, states[1])
|
|
326
|
+
.scenario({ total: 0 }, states[0]),
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
expect(m.kind).toBe("model");
|
|
330
|
+
expect(m.name).toBe("Order");
|
|
331
|
+
expect(m.props?.total.kind).toBe("number");
|
|
332
|
+
expect(m.props?.status.kind).toBe("lifecycle");
|
|
333
|
+
expect(m.transitions?.pay.from).toBe("pending");
|
|
334
|
+
expect(m.transitions?.pay.to).toBe("paid");
|
|
335
|
+
expect(m.transitions?.pay.scenarios).toHaveLength(2);
|
|
336
|
+
expect(m.constraints?.noop.preventedAction).toBe("addItem");
|
|
337
|
+
expect(m.actions?.addItem.kind).toBe("action");
|
|
338
|
+
});
|
|
339
|
+
it("works with relations", () => {
|
|
340
|
+
const target = model("Item", {});
|
|
341
|
+
const m = model("Order", {
|
|
342
|
+
relations: {
|
|
343
|
+
items: hasMany(target),
|
|
344
|
+
parent: belongsTo(target),
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
expect(m.relations?.items.kind).toBe("hasMany");
|
|
348
|
+
expect(m.relations?.items.target.name).toBe("Item");
|
|
349
|
+
expect(m.relations?.parent.kind).toBe("belongsTo");
|
|
350
|
+
});
|
|
351
|
+
it("works with minimal config", () => {
|
|
352
|
+
const m = model("Empty", {});
|
|
353
|
+
expect(m.kind).toBe("model");
|
|
354
|
+
expect(m.name).toBe("Empty");
|
|
355
|
+
});
|
|
356
|
+
it("trims model name", () => {
|
|
357
|
+
const m = model(" Padded ", {});
|
|
358
|
+
expect(m.name).toBe("Padded");
|
|
359
|
+
});
|
|
360
|
+
it('model("", {}) throws', () => {
|
|
361
|
+
expect(() => model("", {})).toThrow("model() requires a non-empty name");
|
|
362
|
+
});
|
|
363
|
+
it('model(" ", {}) throws for whitespace-only', () => {
|
|
364
|
+
expect(() => model(" ", {})).toThrow("model() requires a non-empty name");
|
|
365
|
+
});
|
|
366
|
+
it("typed from() constrains states from lifecycle()", () => {
|
|
367
|
+
const states = ["a", "b"];
|
|
368
|
+
const m = model("Test", {
|
|
369
|
+
props: { status: lifecycle(states) },
|
|
370
|
+
transitions: ({ from }) => ({
|
|
371
|
+
move: from(states[0]).to(states[1]),
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
expect(m.transitions?.move.from).toBe("a");
|
|
375
|
+
expect(m.transitions?.move.to).toBe("b");
|
|
376
|
+
});
|
|
377
|
+
it("typed from() works with lifecycle.states() named references", () => {
|
|
378
|
+
const states = lifecycle.states("pending", "paid", "shipped");
|
|
379
|
+
const m = model("Order", {
|
|
380
|
+
props: { status: lifecycle(states), total: number() },
|
|
381
|
+
transitions: ({ from }) => ({
|
|
382
|
+
pay: from(states.pending)
|
|
383
|
+
.to(states.paid)
|
|
384
|
+
.when((ctx) => ctx.total > 0)
|
|
385
|
+
.scenario({ total: 100 }, states.paid)
|
|
386
|
+
.scenario({ total: 0 }, states.pending),
|
|
387
|
+
ship: from(states.paid).to(states.shipped),
|
|
388
|
+
}),
|
|
389
|
+
});
|
|
390
|
+
expect(m.transitions?.pay.from).toBe("pending");
|
|
391
|
+
expect(m.transitions?.pay.to).toBe("paid");
|
|
392
|
+
expect(m.transitions?.ship.from).toBe("paid");
|
|
393
|
+
expect(m.transitions?.ship.to).toBe("shipped");
|
|
394
|
+
expect(m.transitions?.pay.scenarios).toHaveLength(2);
|
|
395
|
+
});
|
|
396
|
+
it("typed prevent constrains to action keys", () => {
|
|
397
|
+
const states = ["open", "closed"];
|
|
398
|
+
const m = model("Gate", {
|
|
399
|
+
actions: {
|
|
400
|
+
lock: action(),
|
|
401
|
+
unlock: action(),
|
|
402
|
+
},
|
|
403
|
+
constraints: ({ rule }) => ({
|
|
404
|
+
noLockWhenOpen: rule()
|
|
405
|
+
.when((ctx) => ctx.status === states[0])
|
|
406
|
+
.prevent("lock"),
|
|
407
|
+
}),
|
|
408
|
+
props: { status: lifecycle(states) },
|
|
409
|
+
});
|
|
410
|
+
expect(m.constraints?.noLockWhenOpen.preventedAction).toBe("lock");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
describe("scenario given: partial vs full", () => {
|
|
414
|
+
it("accepts partial given (subset of props)", () => {
|
|
415
|
+
const states = ["off", "on"];
|
|
416
|
+
const m = model("Device", {
|
|
417
|
+
props: {
|
|
418
|
+
label: string(),
|
|
419
|
+
status: lifecycle(states),
|
|
420
|
+
voltage: number(),
|
|
421
|
+
},
|
|
422
|
+
transitions: ({ from }) => ({
|
|
423
|
+
turnOn: from(states[0])
|
|
424
|
+
.to(states[1])
|
|
425
|
+
.scenario({ voltage: 120 }, states[1]),
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
expect(m.transitions?.turnOn.scenarios[0]).toEqual({
|
|
429
|
+
expected: "on",
|
|
430
|
+
given: { voltage: 120 },
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
it("accepts full given (all props specified)", () => {
|
|
434
|
+
const states = ["off", "on"];
|
|
435
|
+
const m = model("Device", {
|
|
436
|
+
props: {
|
|
437
|
+
label: string(),
|
|
438
|
+
status: lifecycle(states),
|
|
439
|
+
voltage: number(),
|
|
440
|
+
},
|
|
441
|
+
transitions: ({ from }) => ({
|
|
442
|
+
turnOn: from(states[0])
|
|
443
|
+
.to(states[1])
|
|
444
|
+
.scenario({ label: "main", status: states[1], voltage: 120 }, states[1]),
|
|
445
|
+
}),
|
|
446
|
+
});
|
|
447
|
+
expect(m.transitions?.turnOn.scenarios[0]?.given).toEqual({
|
|
448
|
+
label: "main",
|
|
449
|
+
status: "on",
|
|
450
|
+
voltage: 120,
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
it("accepts empty given {}", () => {
|
|
454
|
+
const states = ["a", "b"];
|
|
455
|
+
const m = model("Minimal", {
|
|
456
|
+
props: { status: lifecycle(states) },
|
|
457
|
+
transitions: ({ from }) => ({
|
|
458
|
+
go: from(states[0]).to(states[1]).scenario({}, states[1]),
|
|
459
|
+
}),
|
|
460
|
+
});
|
|
461
|
+
expect(m.transitions?.go.scenarios[0]?.given).toEqual({});
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
describe("edge cases", () => {
|
|
465
|
+
it("oneOf with single value", () => {
|
|
466
|
+
const prop = oneOf(["only"]);
|
|
467
|
+
expect(prop.values).toEqual(["only"]);
|
|
468
|
+
});
|
|
469
|
+
it("lifecycle with single value", () => {
|
|
470
|
+
const states = ["sole"];
|
|
471
|
+
const m = model("Single", {
|
|
472
|
+
props: { status: lifecycle(states) },
|
|
473
|
+
transitions: ({ from }) => ({
|
|
474
|
+
loop: from(states[0]).to(states[0]),
|
|
475
|
+
}),
|
|
476
|
+
});
|
|
477
|
+
expect(m.transitions?.loop.from).toBe("sole");
|
|
478
|
+
expect(m.transitions?.loop.to).toBe("sole");
|
|
479
|
+
});
|
|
480
|
+
it("model with multiple oneOf props — they do not leak into lifecycle states", () => {
|
|
481
|
+
const states = ["active", "inactive"];
|
|
482
|
+
const priorities = ["low", "medium", "high"];
|
|
483
|
+
const m = model("Task", {
|
|
484
|
+
props: {
|
|
485
|
+
priority: oneOf(priorities),
|
|
486
|
+
status: lifecycle(states),
|
|
487
|
+
},
|
|
488
|
+
transitions: ({ from }) => ({
|
|
489
|
+
deactivate: from(states[0]).to(states[1]),
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
// Only lifecycle values are valid — oneOf 'low'/'medium'/'high' cannot be used in from/to
|
|
493
|
+
expect(m.transitions?.deactivate.from).toBe("active");
|
|
494
|
+
expect(m.transitions?.deactivate.to).toBe("inactive");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
// --- decisions() ---
|
|
498
|
+
describe("decisions", () => {
|
|
499
|
+
it("creates a valid DecisionDef", () => {
|
|
500
|
+
const d = decisions("Tier", {
|
|
501
|
+
inputs: { score: number() },
|
|
502
|
+
outputs: { level: string() },
|
|
503
|
+
rules: [{ then: { level: "gold" }, when: { score: 100 } }],
|
|
504
|
+
});
|
|
505
|
+
expect(d.kind).toBe("decision");
|
|
506
|
+
expect(d.name).toBe("Tier");
|
|
507
|
+
expect(d.inputs.score.kind).toBe("number");
|
|
508
|
+
expect(d.outputs.level.kind).toBe("string");
|
|
509
|
+
expect(d.rules).toHaveLength(1);
|
|
510
|
+
expect(d.rules[0]?.when).toEqual({ score: 100 });
|
|
511
|
+
expect(d.rules[0]?.then).toEqual({ level: "gold" });
|
|
512
|
+
});
|
|
513
|
+
it("supports multiple rules", () => {
|
|
514
|
+
const d = decisions("Discount", {
|
|
515
|
+
inputs: { tier: string() },
|
|
516
|
+
outputs: { percent: number() },
|
|
517
|
+
rules: [
|
|
518
|
+
{ then: { percent: 10 }, when: { tier: "silver" } },
|
|
519
|
+
{ then: { percent: 20 }, when: { tier: "gold" } },
|
|
520
|
+
{ then: { percent: 30 }, when: { tier: "platinum" } },
|
|
521
|
+
],
|
|
522
|
+
});
|
|
523
|
+
expect(d.rules).toHaveLength(3);
|
|
524
|
+
});
|
|
525
|
+
it("supports partial when (not all inputs matched)", () => {
|
|
526
|
+
const d = decisions("Route", {
|
|
527
|
+
inputs: { method: string(), path: string() },
|
|
528
|
+
outputs: { handler: string() },
|
|
529
|
+
rules: [{ then: { handler: "healthCheck" }, when: { path: "/health" } }],
|
|
530
|
+
});
|
|
531
|
+
expect(d.rules[0]?.when).toEqual({ path: "/health" });
|
|
532
|
+
});
|
|
533
|
+
it("supports empty when (catch-all)", () => {
|
|
534
|
+
const d = decisions("Default", {
|
|
535
|
+
inputs: { x: number() },
|
|
536
|
+
outputs: { y: number() },
|
|
537
|
+
rules: [{ then: { y: 0 }, when: {} }],
|
|
538
|
+
});
|
|
539
|
+
expect(d.rules[0]?.when).toEqual({});
|
|
540
|
+
});
|
|
541
|
+
it("supports oneOf inputs", () => {
|
|
542
|
+
const d = decisions("Access", {
|
|
543
|
+
inputs: { role: oneOf(["admin", "user"]) },
|
|
544
|
+
outputs: { canDelete: boolean() },
|
|
545
|
+
rules: [
|
|
546
|
+
{ then: { canDelete: true }, when: { role: "admin" } },
|
|
547
|
+
{ then: { canDelete: false }, when: { role: "user" } },
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
expect(d.rules).toHaveLength(2);
|
|
551
|
+
});
|
|
552
|
+
it("supports multiple outputs", () => {
|
|
553
|
+
const d = decisions("Pricing", {
|
|
554
|
+
inputs: { plan: string() },
|
|
555
|
+
outputs: { maxUsers: number(), price: number() },
|
|
556
|
+
rules: [
|
|
557
|
+
{ then: { maxUsers: 5, price: 0 }, when: { plan: "free" } },
|
|
558
|
+
{ then: { maxUsers: 100, price: 49 }, when: { plan: "pro" } },
|
|
559
|
+
],
|
|
560
|
+
});
|
|
561
|
+
expect(d.rules[0]?.then).toEqual({ maxUsers: 5, price: 0 });
|
|
562
|
+
});
|
|
563
|
+
it("trims whitespace from name", () => {
|
|
564
|
+
const d = decisions(" Spaced ", {
|
|
565
|
+
inputs: { x: number() },
|
|
566
|
+
outputs: { y: number() },
|
|
567
|
+
rules: [{ then: { y: 2 }, when: { x: 1 } }],
|
|
568
|
+
});
|
|
569
|
+
expect(d.name).toBe("Spaced");
|
|
570
|
+
});
|
|
571
|
+
it("throws on empty name", () => {
|
|
572
|
+
expect(() => decisions("", {
|
|
573
|
+
inputs: { x: number() },
|
|
574
|
+
outputs: { y: number() },
|
|
575
|
+
rules: [{ then: { y: 2 }, when: { x: 1 } }],
|
|
576
|
+
})).toThrow("non-empty name");
|
|
577
|
+
});
|
|
578
|
+
it("throws on no inputs", () => {
|
|
579
|
+
expect(() => decisions("Bad", {
|
|
580
|
+
inputs: {},
|
|
581
|
+
outputs: { y: number() },
|
|
582
|
+
rules: [{ then: { y: 0 }, when: {} }],
|
|
583
|
+
})).toThrow("at least one input");
|
|
584
|
+
});
|
|
585
|
+
it("throws on no outputs", () => {
|
|
586
|
+
expect(() => decisions("Bad", {
|
|
587
|
+
inputs: { x: number() },
|
|
588
|
+
outputs: {},
|
|
589
|
+
rules: [{ then: {}, when: { x: 1 } }],
|
|
590
|
+
})).toThrow("at least one output");
|
|
591
|
+
});
|
|
592
|
+
it("throws on no rules", () => {
|
|
593
|
+
expect(() => decisions("Bad", {
|
|
594
|
+
inputs: { x: number() },
|
|
595
|
+
outputs: { y: number() },
|
|
596
|
+
rules: [],
|
|
597
|
+
})).toThrow("at least one rule");
|
|
598
|
+
});
|
|
599
|
+
it("throws on lifecycle input", () => {
|
|
600
|
+
expect(() => decisions("Bad", {
|
|
601
|
+
inputs: { status: lifecycle(["a", "b"]) },
|
|
602
|
+
outputs: { y: number() },
|
|
603
|
+
rules: [{ then: { y: 1 }, when: { status: "a" } }],
|
|
604
|
+
})).toThrow("lifecycle");
|
|
605
|
+
});
|
|
606
|
+
it("throws on lifecycle output", () => {
|
|
607
|
+
expect(() => decisions("Bad", {
|
|
608
|
+
inputs: { x: number() },
|
|
609
|
+
outputs: { status: lifecycle(["a", "b"]) },
|
|
610
|
+
rules: [{ then: { status: "a" }, when: { x: 1 } }],
|
|
611
|
+
})).toThrow("lifecycle");
|
|
612
|
+
});
|
|
613
|
+
it("throws on optional input", () => {
|
|
614
|
+
expect(() => decisions("Bad", {
|
|
615
|
+
inputs: { x: optional(number()) },
|
|
616
|
+
outputs: { y: number() },
|
|
617
|
+
rules: [{ then: { y: 2 }, when: { x: 1 } }],
|
|
618
|
+
})).toThrow("optional");
|
|
619
|
+
});
|
|
620
|
+
it("throws on optional output", () => {
|
|
621
|
+
expect(() => decisions("Bad", {
|
|
622
|
+
inputs: { x: number() },
|
|
623
|
+
outputs: { y: optional(number()) },
|
|
624
|
+
rules: [{ then: { y: 2 }, when: { x: 1 } }],
|
|
625
|
+
})).toThrow("optional");
|
|
626
|
+
});
|
|
627
|
+
it("throws on unknown when key", () => {
|
|
628
|
+
expect(() => decisions("Bad", {
|
|
629
|
+
inputs: { x: number() },
|
|
630
|
+
outputs: { y: number() },
|
|
631
|
+
// @ts-expect-error -- intentionally passing invalid when key
|
|
632
|
+
rules: [{ then: { y: 2 }, when: { z: 1 } }],
|
|
633
|
+
})).toThrow('when key "z" is not a defined input');
|
|
634
|
+
});
|
|
635
|
+
it("throws on unknown then key", () => {
|
|
636
|
+
expect(() => decisions("Bad", {
|
|
637
|
+
inputs: { x: number() },
|
|
638
|
+
outputs: { y: number() },
|
|
639
|
+
// @ts-expect-error -- intentionally passing invalid then key
|
|
640
|
+
rules: [{ then: { z: 2 }, when: { x: 1 } }],
|
|
641
|
+
})).toThrow('then key "z" is not a defined output');
|
|
642
|
+
});
|
|
643
|
+
it("throws on missing then key", () => {
|
|
644
|
+
expect(() => decisions("Bad", {
|
|
645
|
+
inputs: { x: number() },
|
|
646
|
+
outputs: { a: number(), b: number() },
|
|
647
|
+
// @ts-expect-error -- intentionally missing output key
|
|
648
|
+
rules: [{ then: { a: 1 }, when: { x: 1 } }],
|
|
649
|
+
})).toThrow('missing output key "b"');
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
// --- policy() ---
|
|
653
|
+
describe("policy", () => {
|
|
654
|
+
const targetModel = model("Order", {
|
|
655
|
+
actions: { addItem: action() },
|
|
656
|
+
props: { status: lifecycle(["pending", "paid"]), total: number() },
|
|
657
|
+
});
|
|
658
|
+
const relatedModel = model("Customer", {
|
|
659
|
+
props: {
|
|
660
|
+
name: string(),
|
|
661
|
+
status: lifecycle(["active", "suspended"]),
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
it("creates a valid PolicyDef", () => {
|
|
665
|
+
const p = policy("NoBadCustomers", {
|
|
666
|
+
on: { action: "addItem", model: targetModel },
|
|
667
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
668
|
+
rules: [
|
|
669
|
+
{
|
|
670
|
+
effect: "prevent",
|
|
671
|
+
when: (ctx) => ctx.customer.status === "suspended",
|
|
672
|
+
},
|
|
673
|
+
],
|
|
674
|
+
});
|
|
675
|
+
expect(p.kind).toBe("policy");
|
|
676
|
+
expect(p.name).toBe("NoBadCustomers");
|
|
677
|
+
expect(p.on.model.name).toBe("Order");
|
|
678
|
+
expect(p.on.action).toBe("addItem");
|
|
679
|
+
expect(Object.keys(p.requires)).toEqual(["customer"]);
|
|
680
|
+
expect(p.rules).toHaveLength(1);
|
|
681
|
+
expect(p.rules[0]?.effect).toBe("prevent");
|
|
682
|
+
});
|
|
683
|
+
it("supports warn effect", () => {
|
|
684
|
+
const p = policy("WarnOnSuspended", {
|
|
685
|
+
on: { action: "addItem", model: targetModel },
|
|
686
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
687
|
+
rules: [
|
|
688
|
+
{ effect: "warn", when: (ctx) => ctx.customer.status === "suspended" },
|
|
689
|
+
],
|
|
690
|
+
});
|
|
691
|
+
expect(p.rules[0]?.effect).toBe("warn");
|
|
692
|
+
});
|
|
693
|
+
it("supports hasMany in requires (collections)", () => {
|
|
694
|
+
const p = policy("LimitItems", {
|
|
695
|
+
on: { action: "addItem", model: targetModel },
|
|
696
|
+
requires: { items: hasMany(relatedModel) },
|
|
697
|
+
rules: [{ effect: "prevent", when: (ctx) => ctx.items.length > 10 }],
|
|
698
|
+
});
|
|
699
|
+
expect(p.requires["items"]?.kind).toBe("hasMany");
|
|
700
|
+
});
|
|
701
|
+
it("supports multiple rules", () => {
|
|
702
|
+
const p = policy("MultiRule", {
|
|
703
|
+
on: { action: "addItem", model: targetModel },
|
|
704
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
705
|
+
rules: [
|
|
706
|
+
{
|
|
707
|
+
effect: "prevent",
|
|
708
|
+
when: (ctx) => ctx.customer.status === "suspended",
|
|
709
|
+
},
|
|
710
|
+
{ effect: "warn", when: (ctx) => ctx.customer.name === "" },
|
|
711
|
+
],
|
|
712
|
+
});
|
|
713
|
+
expect(p.rules).toHaveLength(2);
|
|
714
|
+
expect(p.rules[0]?.effect).toBe("prevent");
|
|
715
|
+
expect(p.rules[1]?.effect).toBe("warn");
|
|
716
|
+
});
|
|
717
|
+
it("trims whitespace from name", () => {
|
|
718
|
+
const p = policy(" Spaced ", {
|
|
719
|
+
on: { action: "addItem", model: targetModel },
|
|
720
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
721
|
+
rules: [
|
|
722
|
+
{ effect: "prevent", when: (ctx) => ctx.customer.status === "x" },
|
|
723
|
+
],
|
|
724
|
+
});
|
|
725
|
+
expect(p.name).toBe("Spaced");
|
|
726
|
+
});
|
|
727
|
+
it("throws on empty name", () => {
|
|
728
|
+
expect(() => policy("", {
|
|
729
|
+
on: { action: "addItem", model: targetModel },
|
|
730
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
731
|
+
rules: [{ effect: "prevent", when: () => true }],
|
|
732
|
+
})).toThrow("non-empty name");
|
|
733
|
+
});
|
|
734
|
+
it("throws on empty action", () => {
|
|
735
|
+
expect(() => policy("Bad", {
|
|
736
|
+
on: { action: "", model: targetModel },
|
|
737
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
738
|
+
rules: [{ effect: "prevent", when: () => true }],
|
|
739
|
+
})).toThrow("non-empty string");
|
|
740
|
+
});
|
|
741
|
+
it("throws on action not defined on model", () => {
|
|
742
|
+
expect(() => policy("Bad", {
|
|
743
|
+
on: { action: "nonExistent", model: targetModel },
|
|
744
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
745
|
+
rules: [{ effect: "prevent", when: () => true }],
|
|
746
|
+
})).toThrow('"nonExistent" is not a defined action');
|
|
747
|
+
});
|
|
748
|
+
it("skips action validation for ref() models", () => {
|
|
749
|
+
// ref() models don't have actions at definition time — no error
|
|
750
|
+
const p = policy("WithRef", {
|
|
751
|
+
on: { action: "anything", model: ref("FutureModel") },
|
|
752
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
753
|
+
rules: [
|
|
754
|
+
{
|
|
755
|
+
effect: "prevent",
|
|
756
|
+
when: (ctx) => ctx.customer.status === "suspended",
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
});
|
|
760
|
+
expect(p.on.action).toBe("anything");
|
|
761
|
+
});
|
|
762
|
+
it("throws on invalid model ref", () => {
|
|
763
|
+
expect(() => policy("Bad", {
|
|
764
|
+
// @ts-expect-error -- intentionally passing invalid model
|
|
765
|
+
on: { action: "x", model: { name: "Fake" } },
|
|
766
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
767
|
+
rules: [{ effect: "prevent", when: () => true }],
|
|
768
|
+
})).toThrow("model() or ref()");
|
|
769
|
+
});
|
|
770
|
+
it("throws on empty requires", () => {
|
|
771
|
+
expect(() => policy("Bad", {
|
|
772
|
+
on: { action: "addItem", model: targetModel },
|
|
773
|
+
requires: {},
|
|
774
|
+
rules: [{ effect: "prevent", when: () => true }],
|
|
775
|
+
})).toThrow("at least one entry");
|
|
776
|
+
});
|
|
777
|
+
it("throws on no rules", () => {
|
|
778
|
+
expect(() => policy("Bad", {
|
|
779
|
+
on: { action: "addItem", model: targetModel },
|
|
780
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
781
|
+
rules: [],
|
|
782
|
+
})).toThrow("at least one rule");
|
|
783
|
+
});
|
|
784
|
+
it("throws on invalid effect", () => {
|
|
785
|
+
expect(() => policy("Bad", {
|
|
786
|
+
on: { action: "addItem", model: targetModel },
|
|
787
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
788
|
+
// @ts-expect-error -- intentionally passing invalid effect
|
|
789
|
+
rules: [{ effect: "block", when: () => true }],
|
|
790
|
+
})).toThrow('"prevent" or "warn"');
|
|
791
|
+
});
|
|
792
|
+
it("throws on non-function when", () => {
|
|
793
|
+
expect(() => policy("Bad", {
|
|
794
|
+
on: { action: "addItem", model: targetModel },
|
|
795
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
796
|
+
// @ts-expect-error -- intentionally passing invalid when
|
|
797
|
+
rules: [{ effect: "prevent", when: "not a fn" }],
|
|
798
|
+
})).toThrow("must be a function");
|
|
799
|
+
});
|
|
800
|
+
it("throws on invalid requires entry", () => {
|
|
801
|
+
expect(() => policy("Bad", {
|
|
802
|
+
on: { action: "addItem", model: targetModel },
|
|
803
|
+
// @ts-expect-error -- intentionally passing invalid requires
|
|
804
|
+
requires: { customer: { kind: "invalid" } },
|
|
805
|
+
rules: [{ effect: "prevent", when: () => true }],
|
|
806
|
+
})).toThrow("belongsTo() or hasMany()");
|
|
807
|
+
});
|
|
808
|
+
it("accepts ref() in on.model", () => {
|
|
809
|
+
const p = policy("WithRef", {
|
|
810
|
+
on: { action: "create", model: ref("FutureModel") },
|
|
811
|
+
requires: { customer: belongsTo(relatedModel) },
|
|
812
|
+
rules: [
|
|
813
|
+
{
|
|
814
|
+
effect: "prevent",
|
|
815
|
+
when: (ctx) => ctx.customer.status === "suspended",
|
|
816
|
+
},
|
|
817
|
+
],
|
|
818
|
+
});
|
|
819
|
+
expect(p.on.model.name).toBe("FutureModel");
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
//# sourceMappingURL=schema.test.js.map
|