1import * as Amplify from "@/AWS/Amplify";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13export class AmplifyTestFunction extends Lambda.Function<Lambda.Function>()(
14 "AmplifyTestFunction",
15) {}
16
17export default AmplifyTestFunction.make(
18 {
19 main,
20 functionUrl: true,
21 // GenerateAccessLogs can take longer than Lambda's 3s default while
22 // Amplify prepares the pre-signed archive URL. Keep the invocation alive
23 // long enough for the handler to return either the typed service result or
24 // its structured diagnostic response instead of a raw Function URL 502.
25 timeout: Duration.seconds(30),
26 },
27 Effect.gen(function* () {
28 const app = yield* Amplify.App("BindingsTestApp", {
29 description: "Amplify bindings fixture (manual deploys, no repo)",
30 platform: "WEB",
31 });
32
33 const AppId = yield* app.appId;
34 const DefaultDomain = yield* app.defaultDomain;
35
36 const createDeployment = yield* Amplify.CreateDeployment(app);
37 const startDeployment = yield* Amplify.StartDeployment(app);
38 const startJob = yield* Amplify.StartJob(app);
39 const stopJob = yield* Amplify.StopJob(app);
40 const getJob = yield* Amplify.GetJob(app);
41 const listJobs = yield* Amplify.ListJobs(app);
42 const deleteJob = yield* Amplify.DeleteJob(app);
43 const listArtifacts = yield* Amplify.ListArtifacts(app);
44 const getArtifactUrl = yield* Amplify.GetArtifactUrl(app);
45 const generateAccessLogs = yield* Amplify.GenerateAccessLogs(app);
46
47 // Event source: creates the EventBridge rule + Lambda permission at
48 // deploy; at runtime, delivered events are recorded in instance memory
49 // (best-effort — a different instance may serve /events) and exposed on
50 // the /events route.
51 const deploymentEvents: Amplify.DeploymentStatusChangeDetail[] = [];
52 yield* Amplify.consumeDeploymentStatusChanges(
53 { id: "BindingsTestApp" },
54 (events) =>
55 Stream.runForEach(events, (event) =>
56 Effect.sync(() => {
57 deploymentEvents.push(event.detail);
58 }),
59 ),
60 );
61
62 return {
63 fetch: Effect.gen(function* () {
64 const request = yield* HttpServerRequest;
65 const url = new URL(request.originalUrl);
66 const pathname = url.pathname;
67
68 if (request.method === "GET" && pathname === "/meta") {
69 return yield* HttpServerResponse.json({
70 appId: yield* AppId,
71 defaultDomain: yield* DefaultDomain,
72 });
73 }
74
75 if (request.method === "POST" && pathname === "/deployments") {
76 const body = (yield* request.json) as { branchName: string };
77 const result = yield* createDeployment({
78 branchName: body.branchName,
79 });
80 return yield* HttpServerResponse.json({
81 jobId: result.jobId,
82 zipUploadUrl: result.zipUploadUrl,
83 });
84 }
85
86 if (request.method === "POST" && pathname === "/deployments/start") {
87 const body = (yield* request.json) as {
88 branchName: string;
89 jobId?: string;
90 sourceUrl?: string;
91 };
92 const result = yield* startDeployment({
93 branchName: body.branchName,
94 jobId: body.jobId,
95 sourceUrl: body.sourceUrl,
96 });
97 return yield* HttpServerResponse.json({
98 jobSummary: result.jobSummary,
99 });
100 }
101
102 if (request.method === "GET" && pathname === "/job") {
103 const branchName = url.searchParams.get("branchName")!;
104 const jobId = url.searchParams.get("jobId")!;
105 const result = yield* getJob({ branchName, jobId });
106 return yield* HttpServerResponse.json({
107 status: result.job.summary.status,
108 stepCount: result.job.steps.length,
109 });
110 }
111
112 if (request.method === "GET" && pathname === "/jobs") {
113 const branchName = url.searchParams.get("branchName")!;
114 const result = yield* listJobs({ branchName });
115 return yield* HttpServerResponse.json({
116 jobSummaries: result.jobSummaries,
117 });
118 }
119
120 if (request.method === "POST" && pathname === "/jobs/start") {
121 const body = (yield* request.json) as {
122 branchName: string;
123 jobType: string;
124 };
125 // A manual-deploy branch has no connected repo, so RELEASE jobs are
126 // rejected by the service — surface the typed tag instead of dying
127 // so the test can assert the binding + IAM wiring end-to-end.
128 const result = yield* startJob({
129 branchName: body.branchName,
130 jobType: body.jobType as "RELEASE",
131 }).pipe(
132 Effect.map((r) => ({
133 started: true as const,
134 jobId: r.jobSummary.jobId,
135 })),
136 Effect.catchTag(["BadRequestException", "NotFoundException"], (e) =>
137 Effect.succeed({ started: false as const, errorTag: e._tag }),
138 ),
139 );
140 return yield* HttpServerResponse.json(result);
141 }
142
143 if (request.method === "POST" && pathname === "/jobs/stop") {
144 const body = (yield* request.json) as {
145 branchName: string;
146 jobId: string;
147 };
148 // Stopping a job that already settled is a BadRequest/NotFound —
149 // report the tag rather than 500 so the race stays assertable.
150 const result = yield* stopJob({
151 branchName: body.branchName,
152 jobId: body.jobId,
153 }).pipe(
154 Effect.map((r) => ({
155 stopped: true as const,
156 status: r.jobSummary.status,
157 })),
158 Effect.catchTag(
159 [
160 "BadRequestException",
161 "NotFoundException",
162 "LimitExceededException",
163 ],
164 (e) =>
165 Effect.succeed({ stopped: false as const, errorTag: e._tag }),
166 ),
167 );
168 return yield* HttpServerResponse.json(result);
169 }
170
171 if (request.method === "POST" && pathname === "/jobs/delete") {
172 const body = (yield* request.json) as {
173 branchName: string;
174 jobId: string;
175 };
176 const result = yield* deleteJob({
177 branchName: body.branchName,
178 jobId: body.jobId,
179 }).pipe(
180 Effect.map((r) => ({
181 deleted: true as const,
182 status: r.jobSummary.status,
183 })),
184 Effect.catchTag(["BadRequestException", "NotFoundException"], (e) =>
185 Effect.succeed({
186 deleted: false as const,
187 errorTag: e._tag,
188 message: e.message,
189 }),
190 ),
191 );
192 return yield* HttpServerResponse.json(result);
193 }
194
195 if (request.method === "GET" && pathname === "/artifacts") {
196 const branchName = url.searchParams.get("branchName")!;
197 const jobId = url.searchParams.get("jobId")!;
198 const result = yield* listArtifacts({ branchName, jobId });
199 return yield* HttpServerResponse.json({
200 artifacts: result.artifacts,
201 });
202 }
203
204 if (request.method === "GET" && pathname === "/artifact-url") {
205 const artifactId = url.searchParams.get("artifactId")!;
206 const result = yield* getArtifactUrl({ artifactId });
207 return yield* HttpServerResponse.json({
208 artifactUrl: result.artifactUrl,
209 });
210 }
211
212 if (request.method === "POST" && pathname === "/access-logs") {
213 const body = (yield* request.json) as { domainName: string };
214 const result = yield* generateAccessLogs({
215 domainName: body.domainName,
216 }).pipe(
217 Effect.map((r) => ({ ok: true as const, logUrl: r.logUrl })),
218 Effect.catchTag(["BadRequestException", "NotFoundException"], (e) =>
219 Effect.succeed({
220 ok: false as const,
221 errorTag: e._tag,
222 message: e.message,
223 }),
224 ),
225 Effect.catchCause((cause) =>
226 Effect.succeed({
227 ok: false as const,
228 errorTag: "UnexpectedCause",
229 cause: String(cause),
230 }),
231 ),
232 );
233 return yield* HttpServerResponse.json(result);
234 }
235
236 if (request.method === "GET" && pathname === "/events") {
237 return yield* HttpServerResponse.json({ events: deploymentEvents });
238 }
239
240 if (request.method === "GET" && pathname === "/ping") {
241 return yield* HttpServerResponse.json({ ok: true });
242 }
243
244 return yield* HttpServerResponse.json(
245 { error: "Not found", method: request.method, pathname },
246 { status: 404 },
247 );
248 }).pipe(
249 // Surface unexpected failures in the response body — the fixture
250 // Lambda has no CloudWatch logs permission, so the HTTP body is the
251 // only diagnostic channel the test can observe.
252 Effect.catchCause((cause) =>
253 HttpServerResponse.json(
254 { error: "unhandled", cause: String(cause) },
255 { status: 500 },
256 ),
257 ),
258 ),
259 };
260 }).pipe(
261 Effect.provide(
262 Layer.mergeAll(
263 Lambda.EventSource,
264 Amplify.CreateDeploymentHttp,
265 Amplify.StartDeploymentHttp,
266 Amplify.StartJobHttp,
267 Amplify.StopJobHttp,
268 Amplify.GetJobHttp,
269 Amplify.ListJobsHttp,
270 Amplify.DeleteJobHttp,
271 Amplify.ListArtifactsHttp,
272 Amplify.GetArtifactUrlHttp,
273 Amplify.GenerateAccessLogsHttp,
274 ),
275 ),
276 ),
277);