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 AppRegistryTestFunctionLive, {
11 AppRegistryTestFunction,
12} from "./handler";
13import { makeAppRegistryTestLease } from "./TestLease.ts";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "AppRegistryBindings");
18const serviceLease = makeAppRegistryTestLease();
19
20beforeAll(serviceLease.acquire, { timeout: 3_600_000 });
21// Release at the same (top) level as the acquire so the lease can never be
22// left held when the gated describe below is skipped.
23afterAll(serviceLease.release);
24
25// AppRegistry is in maintenance mode (see Application.test.ts) — the fixture
26// deploy creates real AppRegistry resources, so the whole suite is gated
27// behind AWS_TEST_APPREGISTRY=1 for accounts that retain access.
28const gated = !process.env.AWS_TEST_APPREGISTRY;
29
30// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
31// over 60s on a fresh deploy.
32const readinessPolicy = Schedule.max([
33 Schedule.fixed("2 seconds"),
34 Schedule.recurs(75),
35]);
36
37let baseUrl: string;
38
39class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
40 readonly status: number;
41 readonly body: string;
42}> {}
43
44// The shared Lambda fixture occasionally answers a transient 5xx under load
45// (cold re-init, IAM propagation on the freshly attached policy that the
46// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
47// 4xx/assertion failure surfaces immediately.
48const send = (request: HttpClientRequest.HttpClientRequest) =>
49 HttpClient.execute(request).pipe(
50 Effect.flatMap((response) =>
51 response.status >= 500
52 ? response.text.pipe(
53 Effect.flatMap((body) =>
54 Effect.fail(
55 new TransientUpstream({ status: response.status, body }),
56 ),
57 ),
58 )
59 : Effect.succeed(response),
60 ),
61 Effect.retry({
62 while: (e) => e._tag === "TransientUpstream",
63 schedule: Schedule.max([
64 Schedule.exponential("500 millis"),
65 Schedule.recurs(6),
66 ]),
67 }),
68 );
69
70describe.skipIf(gated).sequential(
71 "AppRegistry Bindings",
72 {
73 tags: [
74 "provider:aws",
75 "provider:aws:appregistry",
76 "provider:aws:lambda",
77 "live",
78 ],
79 },
80 () => {
81 beforeAll(
82 Effect.gen(function* () {
83 yield* Effect.logInfo(
84 "AppRegistry test setup: destroying previous resources",
85 );
86 yield* sharedStack.destroy();
87
88 yield* Effect.logInfo("AppRegistry test setup: deploying fixture");
89 const { functionUrl } = yield* sharedStack.deploy(
90 Effect.gen(function* () {
91 return yield* AppRegistryTestFunction;
92 }).pipe(Effect.provide(AppRegistryTestFunctionLive)),
93 );
94
95 expect(functionUrl).toBeTruthy();
96 baseUrl = functionUrl!.replace(/\/+$/, "");
97
98 const readinessUrl = `${baseUrl}/bindings`;
99 yield* Effect.logInfo(
100 `AppRegistry test setup: probing readiness at ${readinessUrl}`,
101 );
102 yield* HttpClient.get(readinessUrl).pipe(
103 Effect.flatMap((response) =>
104 response.status === 200
105 ? Effect.succeed(response)
106 : Effect.fail(
107 new Error(`Function not ready: ${response.status}`),
108 ),
109 ),
110 Effect.tapError((error) =>
111 Effect.logWarning(
112 `AppRegistry test setup: fixture not ready yet (${String(error)})`,
113 ),
114 ),
115 Effect.retry({ schedule: readinessPolicy }),
116 );
117 }),
118 { timeout: 240_000 },
119 );
120
121 afterAll(sharedStack.destroy(), { timeout: 120_000 });
122
123 describe("binding registration", () => {
124 test.provider("all 9 capabilities initialize in the runtime", (_stack) =>
125 Effect.gen(function* () {
126 const response = yield* send(
127 HttpClientRequest.get(`${baseUrl}/bindings`),
128 ).pipe(Effect.flatMap((r) => r.json));
129 expect((response as any).bound).toHaveLength(9);
130 }),
131 );
132 });
133
134 describe("GetApplication", () => {
135 test.provider("reads the fixture application's metadata", (_stack) =>
136 Effect.gen(function* () {
137 const response = yield* send(
138 HttpClientRequest.get(`${baseUrl}/application`),
139 ).pipe(Effect.flatMap((r) => r.json));
140 expect(typeof (response as any).name).toBe("string");
141 expect((response as any).name.length).toBeGreaterThan(0);
142 expect(typeof (response as any).associatedResourceCount).toBe(
143 "number",
144 );
145 }),
146 );
147 });
148
149 describe("GetAttributeGroup", () => {
150 test.provider("reads the fixture group's JSON attributes", (_stack) =>
151 Effect.gen(function* () {
152 const response = yield* send(
153 HttpClientRequest.get(`${baseUrl}/attribute-group`),
154 ).pipe(Effect.flatMap((r) => r.json));
155 expect((response as any).attributes.owner).toBe("alchemy-test");
156 expect((response as any).attributes.tier).toBe("bindings");
157 }),
158 );
159 });
160
161 describe("ListAssociatedAttributeGroups", () => {
162 test.provider("lists the associated attribute group", (_stack) =>
163 Effect.gen(function* () {
164 const response = yield* send(
165 HttpClientRequest.get(`${baseUrl}/associated-attribute-groups`),
166 ).pipe(Effect.flatMap((r) => r.json));
167 expect((response as any).count).toBeGreaterThanOrEqual(1);
168 }),
169 );
170 });
171
172 describe("ListAttributeGroupsForApplication", () => {
173 test.provider("lists the associated attribute group details", (_stack) =>
174 Effect.gen(function* () {
175 const response = yield* send(
176 HttpClientRequest.get(`${baseUrl}/attribute-groups-details`),
177 ).pipe(Effect.flatMap((r) => r.json));
178 expect((response as any).names.length).toBeGreaterThanOrEqual(1);
179 }),
180 );
181 });
182
183 describe("ListAssociatedResources", () => {
184 test.provider("lists the application's associated resources", (_stack) =>
185 Effect.gen(function* () {
186 const response = yield* send(
187 HttpClientRequest.get(`${baseUrl}/associated-resources`),
188 ).pipe(Effect.flatMap((r) => r.json));
189 expect(typeof (response as any).count).toBe("number");
190 }),
191 );
192 });
193
194 describe("GetAssociatedResource", () => {
195 test.provider(
196 "returns the typed not-found error for an unknown resource",
197 (_stack) =>
198 Effect.gen(function* () {
199 const response = yield* send(
200 HttpClientRequest.get(`${baseUrl}/associated-resource-not-found`),
201 ).pipe(Effect.flatMap((r) => r.json));
202 expect((response as any).found).toBe(false);
203 }),
204 );
205 });
206
207 describe("ListApplications", () => {
208 test.provider(
209 "account listing includes the fixture application",
210 (_stack) =>
211 Effect.gen(function* () {
212 const app = yield* send(
213 HttpClientRequest.get(`${baseUrl}/application`),
214 ).pipe(Effect.flatMap((r) => r.json));
215 const response = yield* send(
216 HttpClientRequest.get(`${baseUrl}/applications`),
217 ).pipe(Effect.flatMap((r) => r.json));
218 expect((response as any).names).toContain((app as any).name);
219 }),
220 );
221 });
222
223 describe("ListAttributeGroups", () => {
224 test.provider(
225 "account listing includes the fixture attribute group",
226 (_stack) =>
227 Effect.gen(function* () {
228 const group = yield* send(
229 HttpClientRequest.get(`${baseUrl}/attribute-group`),
230 ).pipe(Effect.flatMap((r) => r.json));
231 const response = yield* send(
232 HttpClientRequest.get(`${baseUrl}/attribute-groups`),
233 ).pipe(Effect.flatMap((r) => r.json));
234 expect((response as any).names).toContain((group as any).name);
235 }),
236 );
237 });
238
239 describe("SyncResource", () => {
240 test.provider(
241 "surfaces a typed error for a nonexistent stack (proving the grant)",
242 (_stack) =>
243 Effect.gen(function* () {
244 const response = yield* send(
245 HttpClientRequest.post(`${baseUrl}/sync-resource`),
246 ).pipe(Effect.flatMap((r) => r.json));
247 expect([
248 "ResourceNotFoundException",
249 "ValidationException",
250 ]).toContain((response as any).tag);
251 }),
252 );
253 });
254 },
255);