1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import AppSyncBindingsFunctionLive, {
11 AppSyncBindingsFunction,
12} from "./fixtures/bindings-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "AppSyncBindings");
17
18let baseUrl: string;
19
20class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
21 readonly status: number;
22 readonly body: string;
23}> {}
24
25// Fresh Lambda role + appsync:GraphQL permissions propagate eventually — the
26// first calls can 500 with an auth error under the handler's `Effect.orDie`.
27// Retry 5xx only; a genuine 4xx fails immediately.
28const send = (request: HttpClientRequest.HttpClientRequest) =>
29 HttpClient.execute(request).pipe(
30 Effect.flatMap((response) =>
31 response.status >= 500
32 ? response.text.pipe(
33 Effect.flatMap((body) =>
34 Effect.fail(
35 new TransientUpstream({ status: response.status, body }),
36 ),
37 ),
38 )
39 : Effect.succeed(response),
40 ),
41 Effect.retry({
42 while: (e) => e._tag === "TransientUpstream",
43 schedule: Schedule.max([
44 Schedule.exponential("1 second"),
45 Schedule.recurs(8),
46 ]),
47 }),
48 );
49
50describe(
51 "AppSync Bindings",
52 {
53 tags: [
54 "provider:aws",
55 "provider:aws:appsync",
56 "provider:aws:lambda",
57 "live",
58 ],
59 },
60 () => {
61 beforeAll(
62 Effect.gen(function* () {
63 yield* Effect.logInfo(
64 "AppSync bindings setup: destroying previous resources",
65 );
66 yield* sharedStack.destroy();
67
68 yield* Effect.logInfo(
69 "AppSync bindings setup: deploying api -> resolvers -> Lambda",
70 );
71 const { functionUrl } = yield* sharedStack.deploy(
72 Effect.gen(function* () {
73 return yield* AppSyncBindingsFunction;
74 }).pipe(Effect.provide(AppSyncBindingsFunctionLive)),
75 );
76
77 expect(functionUrl).toBeTruthy();
78 baseUrl = functionUrl!.replace(/\/+$/, "");
79
80 // Readiness probe — fresh function URLs take seconds to serve 200s.
81 yield* HttpClient.get(`${baseUrl}/ping`).pipe(
82 Effect.flatMap((response) =>
83 response.status === 200
84 ? Effect.succeed(response)
85 : Effect.fail(
86 new Error(`Function not ready: ${response.status}`),
87 ),
88 ),
89 Effect.retry({
90 schedule: Schedule.max([
91 Schedule.fixed("2 seconds"),
92 Schedule.recurs(75),
93 ]),
94 }),
95 );
96 }),
97 { timeout: 300_000 },
98 );
99 afterAll(sharedStack.destroy(), { timeout: 300_000 });
100
101 describe("GraphQL", () => {
102 test.provider(
103 "the Lambda executes a SigV4-signed query through the binding",
104 () =>
105 Effect.gen(function* () {
106 const result = yield* send(
107 HttpClientRequest.post(`${baseUrl}/graphql`).pipe(
108 HttpClientRequest.bodyJsonUnsafe({
109 query: "query($a: Int!, $b: Int!) { add(a: $a, b: $b) }",
110 variables: { a: 2, b: 3 },
111 }),
112 ),
113 ).pipe(
114 Effect.flatMap((response) => response.json),
115 Effect.map(
116 (json) =>
117 json as {
118 data?: { add?: number };
119 errors?: Array<{ message: string }>;
120 },
121 ),
122 Effect.filterOrFail(
123 (json) => json.data?.add !== undefined,
124 (json) => new Error(`no data.add: ${JSON.stringify(json)}`),
125 ),
126 Effect.retry({
127 schedule: Schedule.max([
128 Schedule.spaced("3 seconds"),
129 Schedule.recurs(20),
130 ]),
131 }),
132 );
133 expect(result.data?.add).toBe(5);
134 }),
135 { timeout: 180_000 },
136 );
137
138 test.provider(
139 "GraphQL field errors surface on result.errors (not as failures)",
140 () =>
141 Effect.gen(function* () {
142 const result = yield* send(
143 HttpClientRequest.post(`${baseUrl}/graphql`).pipe(
144 HttpClientRequest.bodyJsonUnsafe({
145 query: "query { nonexistentField }",
146 }),
147 ),
148 ).pipe(
149 Effect.flatMap((response) => response.json),
150 Effect.map(
151 (json) => json as { errors?: Array<{ message: string }> },
152 ),
153 );
154 expect(result.errors?.length).toBeGreaterThan(0);
155 }),
156 { timeout: 120_000 },
157 );
158
159 test.provider(
160 "resolvers read environmentVariables via ctx.env",
161 () =>
162 Effect.gen(function* () {
163 const result = yield* send(
164 HttpClientRequest.post(`${baseUrl}/graphql`).pipe(
165 HttpClientRequest.bodyJsonUnsafe({
166 query: "query { greeting }",
167 }),
168 ),
169 ).pipe(
170 Effect.flatMap((response) => response.json),
171 Effect.map((json) => json as { data?: { greeting?: string } }),
172 Effect.filterOrFail(
173 (json) => json.data?.greeting != null,
174 (json) =>
175 new Error(`no data.greeting: ${JSON.stringify(json)}`),
176 ),
177 Effect.retry({
178 schedule: Schedule.max([
179 Schedule.spaced("3 seconds"),
180 Schedule.recurs(10),
181 ]),
182 }),
183 );
184 expect(result.data?.greeting).toBe("hello from ctx.env");
185 }),
186 { timeout: 120_000 },
187 );
188 });
189
190 describe("GetIntrospectionSchema", () => {
191 test.provider(
192 "the Lambda reads the API's live SDL through the binding",
193 () =>
194 Effect.gen(function* () {
195 const result = yield* send(
196 HttpClientRequest.get(`${baseUrl}/schema`),
197 ).pipe(
198 Effect.flatMap((response) => response.json),
199 Effect.map((json) => json as { sdl?: string }),
200 Effect.filterOrFail(
201 (json) => json.sdl != null,
202 (json) => new Error(`no sdl: ${JSON.stringify(json)}`),
203 ),
204 Effect.retry({
205 schedule: Schedule.max([
206 Schedule.spaced("3 seconds"),
207 Schedule.recurs(10),
208 ]),
209 }),
210 );
211 expect(result.sdl).toContain("add(a: Int!, b: Int!): Int!");
212 expect(result.sdl).toContain("type Query");
213 }),
214 { timeout: 120_000 },
215 );
216 });
217
218 describe("EvaluateCode", () => {
219 test.provider(
220 "the Lambda evaluates APPSYNC_JS resolver code through the binding",
221 () =>
222 Effect.gen(function* () {
223 const result = yield* send(
224 HttpClientRequest.post(`${baseUrl}/evaluate`),
225 ).pipe(
226 Effect.flatMap((response) => response.json),
227 Effect.map(
228 (json) =>
229 json as {
230 evaluationResult?: string;
231 error?: { message?: string };
232 },
233 ),
234 Effect.filterOrFail(
235 (json) => json.evaluationResult != null,
236 (json) =>
237 new Error(`no evaluationResult: ${JSON.stringify(json)}`),
238 ),
239 Effect.retry({
240 schedule: Schedule.max([
241 Schedule.spaced("3 seconds"),
242 Schedule.recurs(10),
243 ]),
244 }),
245 );
246 expect(JSON.parse(result.evaluationResult!)).toMatchObject({
247 payload: 5,
248 });
249 }),
250 { timeout: 120_000 },
251 );
252 });
253
254 describe("EvaluateMappingTemplate", () => {
255 test.provider(
256 "the Lambda renders a VTL mapping template through the binding",
257 () =>
258 Effect.gen(function* () {
259 const result = yield* send(
260 HttpClientRequest.post(`${baseUrl}/evaluate-template`),
261 ).pipe(
262 Effect.flatMap((response) => response.json),
263 Effect.map(
264 (json) =>
265 json as {
266 evaluationResult?: string;
267 error?: { message?: string };
268 },
269 ),
270 Effect.filterOrFail(
271 (json) => json.evaluationResult != null,
272 (json) =>
273 new Error(`no evaluationResult: ${JSON.stringify(json)}`),
274 ),
275 Effect.retry({
276 schedule: Schedule.max([
277 Schedule.spaced("3 seconds"),
278 Schedule.recurs(10),
279 ]),
280 }),
281 );
282 expect(JSON.parse(result.evaluationResult!)).toMatchObject({
283 sum: 5,
284 });
285 }),
286 { timeout: 120_000 },
287 );
288 });
289
290 describe("FlushApiCache", () => {
291 test.provider(
292 "the flush call is authorized and returns the typed NotFoundException without a cache",
293 () =>
294 Effect.gen(function* () {
295 // The fixture API deliberately has NO cache (cache instances bill
296 // hourly): a typed NotFoundException proves the binding executed
297 // with the granted appsync:FlushApiCache; an IAM denial surfaces
298 // a different tag. Repeat briefly through IAM propagation.
299 const outcome = yield* send(
300 HttpClientRequest.post(`${baseUrl}/flush`),
301 ).pipe(
302 Effect.flatMap((response) => response.json),
303 Effect.map(
304 (json) =>
305 json as {
306 flushed: boolean;
307 reason?: string;
308 message?: string;
309 },
310 ),
311 Effect.repeat({
312 schedule: Schedule.spaced("2 seconds"),
313 until: (json): boolean => json.reason === "NotFoundException",
314 times: 10,
315 }),
316 );
317 expect(outcome).toMatchObject({
318 flushed: false,
319 reason: "NotFoundException",
320 });
321 }),
322 { timeout: 90_000 },
323 );
324 });
325 },
326);