1/**
2 * In-process unit tests for the Step Functions program DSL: `Sfn.simulate`
3 * interprets the SAME program the live `FromProgram.test.ts` deploys, plus
4 * the ASL error semantics (`catchTag`, `retry`, `match`) and compile
5 * determinism (the `normalizeDefinition` drift obligation).
6 */
7import { Sfn } from "@/AWS/StepFunctions";
8import { describe, expect, it } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Result from "effect/Result";
12import {
13 makeOrderProgram,
14 OrderRejected,
15 type OrderInput,
16} from "./fixtures/order-program.ts";
17
18const doubler: Sfn.InvokableFunction = {
19 LogicalId: "SfnDoubler",
20 functionArn: "arn:aws:lambda:us-east-1:000000000000:function:doubler",
21};
22
23const doublerHandlers: Sfn.SimulateHandlers = {
24 SfnDoubler: (payload) =>
25 Effect.succeed({
26 doubled: ((payload as { value: number }).value ?? 0) * 2,
27 }),
28};
29
30describe(
31 "Sfn.simulate",
32 { tags: ["unit", "provider:aws", "provider:aws:stepfunctions", "local"] },
33 () => {
34 it.effect("runs the order program in-process", () =>
35 Effect.gen(function* () {
36 const result = yield* Sfn.simulate(
37 makeOrderProgram(doubler),
38 { value: 6, items: [1, 2, 3] } satisfies OrderInput,
39 { handlers: doublerHandlers },
40 );
41 expect(result).toEqual({
42 doubled: 12,
43 items: [2, 4, 6],
44 size: "big",
45 recovered: "no stock",
46 });
47 }),
48 );
49
50 it.effect("takes the false Choice branch for small inputs", () =>
51 Effect.gen(function* () {
52 const result = yield* Sfn.simulate(
53 makeOrderProgram(doubler),
54 { value: 2, items: [] } satisfies OrderInput,
55 { handlers: doublerHandlers },
56 );
57 expect(result.size).toBe("small");
58 expect(result.items).toEqual([]);
59 }),
60 );
61
62 it.effect("uncaught Sfn.fail surfaces as the typed failure", () =>
63 Effect.gen(function* () {
64 const result = yield* Effect.result(
65 Sfn.simulate(Sfn.fail(new OrderRejected({ reason: "nope" })), {}),
66 );
67 expect(Result.isFailure(result)).toBe(true);
68 if (
69 Result.isFailure(result) &&
70 !(result.failure instanceof Sfn.SimulateError)
71 ) {
72 expect(result.failure._tag).toBe("OrderRejected");
73 expect(result.failure.reason).toBe("nope");
74 }
75 }),
76 );
77
78 it.effect("catchTag ignores non-matching tags", () =>
79 Effect.gen(function* () {
80 const program = Sfn.fail(new OrderRejected({ reason: "nope" })).pipe(
81 Sfn.catchTag("SomethingElse", () => Sfn.succeed("recovered")),
82 );
83 const result = yield* Effect.result(Sfn.simulate(program, {}));
84 expect(Result.isFailure(result)).toBe(true);
85 }),
86 );
87
88 it.effect("retry re-runs matching tags up to maxAttempts", () =>
89 Effect.gen(function* () {
90 class Transient extends Data.TaggedError("Transient")<{}> {}
91 let calls = 0;
92 const program = Sfn.invoke<{ ok: boolean }, Transient>(doubler).pipe(
93 Sfn.retry({ while: "Transient", maxAttempts: 5 }),
94 );
95 const result = yield* Sfn.simulate(program, null, {
96 handlers: {
97 SfnDoubler: () =>
98 Effect.suspend(() => {
99 calls++;
100 return calls < 3
101 ? Effect.fail(new Transient())
102 : Effect.succeed({ ok: true });
103 }),
104 },
105 });
106 expect(result).toEqual({ ok: true });
107 expect(calls).toBe(3);
108 }),
109 );
110
111 it.effect("retry does not retry non-matching tags", () =>
112 Effect.gen(function* () {
113 let calls = 0;
114 const program = Sfn.invoke(doubler).pipe(
115 Sfn.retry({ while: "Transient", maxAttempts: 5 }),
116 );
117 const result = yield* Effect.result(
118 Sfn.simulate(program, null, {
119 handlers: {
120 SfnDoubler: () =>
121 Effect.suspend(() => {
122 calls++;
123 return Effect.fail(
124 new OrderRejected({ reason: "permanent" }),
125 );
126 }),
127 },
128 }),
129 );
130 expect(Result.isFailure(result)).toBe(true);
131 expect(calls).toBe(1);
132 }),
133 );
134
135 it.effect("match dispatches on literal cases with a fallback", () =>
136 Effect.gen(function* () {
137 const program = Sfn.gen(function* (input: Sfn.Expr<{ kind: string }>) {
138 return yield* Sfn.match(
139 input.kind,
140 { a: Sfn.succeed(1), b: Sfn.succeed(2) },
141 Sfn.succeed(0),
142 );
143 });
144 expect(yield* Sfn.simulate(program, { kind: "b" })).toBe(2);
145 expect(yield* Sfn.simulate(program, { kind: "z" })).toBe(0);
146 }),
147 );
148
149 it.effect("all runs branches and returns the tuple", () =>
150 Effect.gen(function* () {
151 const program = Sfn.all([
152 Sfn.succeed("left"),
153 Sfn.invoke<{ doubled: number }>(doubler, { value: 4 }),
154 ]);
155 const result = yield* Sfn.simulate(program, null, {
156 handlers: doublerHandlers,
157 });
158 expect(result).toEqual(["left", { doubled: 8 }]);
159 }),
160 );
161
162 it.effect("waitForTaskToken dispatches to the integration handler", () =>
163 Effect.gen(function* () {
164 const program = Sfn.waitForTaskToken<{ approved: boolean }>({
165 resource: "arn:aws:states:::sqs:sendMessage",
166 arguments: { MessageBody: { token: Sfn.taskToken } },
167 });
168 const result = yield* Sfn.simulate(program, null, {
169 taskToken: "tok-123",
170 handlers: {
171 "arn:aws:states:::sqs:sendMessage.waitForTaskToken": (payload) =>
172 Effect.succeed({
173 approved:
174 (payload as { MessageBody: { token: string } }).MessageBody
175 .token === "tok-123",
176 }),
177 },
178 });
179 expect(result).toEqual({ approved: true });
180 }),
181 );
182
183 it.effect("missing handler fails with SimulateError", () =>
184 Effect.gen(function* () {
185 const result = yield* Effect.result(
186 Sfn.simulate(Sfn.invoke(doubler), null),
187 );
188 expect(Result.isFailure(result)).toBe(true);
189 if (Result.isFailure(result)) {
190 expect(result.failure._tag).toBe("SimulateError");
191 }
192 }),
193 );
194 },
195);
196
197describe(
198 "Sfn.compileProgram",
199 { tags: ["unit", "provider:aws", "provider:aws:stepfunctions", "local"] },
200 () => {
201 it("compiles deterministically (drift-stable definitions)", () => {
202 const first = Sfn.compileProgram(makeOrderProgram(doubler));
203 const second = Sfn.compileProgram(makeOrderProgram(doubler));
204 expect(JSON.stringify(first.definition)).toBe(
205 JSON.stringify(second.definition),
206 );
207 expect(first.definition.QueryLanguage).toBe("JSONata");
208 expect(typeof first.definition.StartAt).toBe("string");
209 // one lambda:InvokeFunction statement per invoked function (deduped)
210 expect(first.policyStatements).toHaveLength(1);
211 });
212
213 it("rejects unreachable steps after an unconditional Sfn.fail", () => {
214 const program = Sfn.gen(function* () {
215 yield* Sfn.fail(new OrderRejected({ reason: "always" }));
216 yield* Sfn.succeed("unreachable");
217 return null;
218 });
219 expect(() => Sfn.compileProgram(program)).toThrow(/unreachable steps/);
220 });
221
222 it("rejects yielding a non-Sfn value at compile time", () => {
223 const program = Sfn.gen(function* () {
224 // a real Effect is a compile-time type error; simulate the runtime
225 // guard with a plain value smuggled past the types
226 yield* Effect.succeed(1) as unknown as Sfn.SfnEffect<number>;
227 return null;
228 });
229 expect(() => Sfn.compileProgram(program)).toThrow(
230 /may only yield Sfn effects/,
231 );
232 });
233 },
234);