1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Output from "@/Output";
4import * as Test from "@/Test/Alchemy";
5import * as eventbridge from "@distilled.cloud/aws/eventbridge";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import MediaConvertTestFunctionLive, {
13 MediaConvertTestFunction,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "MediaConvertBindings");
19
20// A well-formed-but-nonexistent job id (MediaConvert ids look like
21// `1582077664817-n4h2ck`) — drives the typed NotFoundException path.
22const NONEXISTENT_JOB_ID = "0000000000000-aaaaaa";
23
24let baseUrl: string;
25
26class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
27 readonly status: number;
28 readonly body: string;
29}> {}
30
31// The shared Lambda fixture occasionally answers a transient 5xx (cold
32// re-init, IAM propagation on the freshly attached policy that the handler's
33// `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine 4xx surfaces
34// immediately.
35const send = (request: HttpClientRequest.HttpClientRequest) =>
36 HttpClient.execute(request).pipe(
37 Effect.flatMap((response) =>
38 response.status >= 500
39 ? response.text.pipe(
40 Effect.flatMap((body) =>
41 Effect.fail(
42 new TransientUpstream({ status: response.status, body }),
43 ),
44 ),
45 )
46 : Effect.succeed(response),
47 ),
48 Effect.retry({
49 while: (e) => e._tag === "TransientUpstream",
50 schedule: Schedule.max([
51 Schedule.exponential("1 second"),
52 Schedule.recurs(8),
53 ]),
54 }),
55 );
56
57const getJson = (path: string) =>
58 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
59 Effect.flatMap((r) => r.json),
60 );
61
62const postJson = (path: string, body: object) =>
63 send(
64 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
65 HttpClientRequest.bodyJsonUnsafe(body),
66 ),
67 ).pipe(Effect.flatMap((r) => r.json));
68
69describe.sequential(
70 "MediaConvert Bindings",
71 {
72 tags: [
73 "provider:aws",
74 "provider:aws:lambda",
75 "provider:aws:mediaconvert",
76 "live",
77 ],
78 },
79 () => {
80 beforeAll(
81 Effect.gen(function* () {
82 yield* Effect.logInfo(
83 "MediaConvert test setup: destroying previous resources",
84 );
85 yield* sharedStack.destroy();
86
87 yield* Effect.logInfo("MediaConvert test setup: deploying fixture");
88 const { functionUrl } = yield* sharedStack.deploy(
89 Effect.gen(function* () {
90 return yield* MediaConvertTestFunction;
91 }).pipe(Effect.provide(MediaConvertTestFunctionLive)),
92 );
93
94 expect(functionUrl).toBeTruthy();
95 baseUrl = functionUrl!.replace(/\/+$/, "");
96
97 // Readiness probe — fresh function URLs take seconds (sometimes over a
98 // minute) to serve 200s.
99 yield* HttpClient.get(`${baseUrl}/jobs`).pipe(
100 Effect.flatMap((response) =>
101 response.status === 200
102 ? Effect.succeed(response)
103 : Effect.fail(
104 new Error(`Function not ready: ${response.status}`),
105 ),
106 ),
107 Effect.retry({
108 schedule: Schedule.max([
109 Schedule.fixed("2 seconds"),
110 Schedule.recurs(75),
111 ]),
112 }),
113 );
114 }),
115 { timeout: 300_000 },
116 );
117 afterAll(sharedStack.destroy(), { timeout: 300_000 });
118
119 describe("ListJobs", () => {
120 test.provider(
121 "lists recent jobs from the runtime",
122 () =>
123 Effect.gen(function* () {
124 const body = (yield* getJson("/jobs")) as { count: number };
125 expect(typeof body.count).toBe("number");
126 }),
127 { timeout: 60_000 },
128 );
129 });
130
131 describe("SearchJobs", () => {
132 test.provider(
133 "searches completed jobs from the runtime",
134 () =>
135 Effect.gen(function* () {
136 const body = (yield* getJson("/search")) as { count: number };
137 expect(typeof body.count).toBe("number");
138 }),
139 { timeout: 60_000 },
140 );
141 });
142
143 describe("GetJob", () => {
144 test.provider(
145 "returns the typed NotFoundException for a missing job",
146 () =>
147 Effect.gen(function* () {
148 const body = (yield* getJson(`/job?id=${NONEXISTENT_JOB_ID}`)) as {
149 status?: string;
150 error?: string;
151 };
152 // NotFound (never Forbidden) proves the mediaconvert:GetJob grant
153 // reached the API and the typed tag surfaced in the runtime.
154 expect(body.error).toBe("NotFoundException");
155 }),
156 { timeout: 60_000 },
157 );
158 });
159
160 describe("CancelJob", () => {
161 test.provider(
162 "returns the typed NotFoundException for a missing job",
163 () =>
164 Effect.gen(function* () {
165 const body = (yield* postJson("/cancel", {
166 id: NONEXISTENT_JOB_ID,
167 })) as { cancelled: boolean; error?: string };
168 expect(body.cancelled).toBe(false);
169 expect(body.error).toBe("NotFoundException");
170 }),
171 { timeout: 60_000 },
172 );
173 });
174
175 describe("Probe", () => {
176 test.provider(
177 "probe of a missing input fails with a typed NotFoundException",
178 () =>
179 Effect.gen(function* () {
180 const body = (yield* postJson("/probe", {
181 fileUrl: "s3://alchemy-nonexistent/in.mp4",
182 })) as { probed: boolean; error?: string };
183 expect(body.probed).toBe(false);
184 // NotFound (never Forbidden/AccessDenied) proves the
185 // mediaconvert:Probe grant reached the API; the input simply does
186 // not exist.
187 expect(body.error).toBe("NotFoundException");
188 }),
189 { timeout: 60_000 },
190 );
191 });
192
193 describe("CreateJob", () => {
194 test.provider(
195 "submit with an unassumable role fails with a typed tag (never Forbidden)",
196 () =>
197 Effect.gen(function* () {
198 // An in-account-format role that does not exist: the request passes
199 // IAM authorization (mediaconvert:CreateJob granted + iam:PassRole
200 // conditioned to the service) and fails service-side validation with
201 // a typed BadRequestException — no billable transcode is started.
202 const body = (yield* postJson("/submit", {
203 role: "arn:aws:iam::000000000000:role/alchemy-does-not-exist",
204 })) as { jobId?: string; error?: string };
205 expect(body.jobId).toBeUndefined();
206 expect(["BadRequestException", "AccessDeniedException"]).toContain(
207 body.error,
208 );
209 }),
210 { timeout: 60_000 },
211 );
212 });
213
214 describe("StartJobsQuery + GetJobsQueryResults", () => {
215 test.provider(
216 "starts an async jobs query and fetches its results",
217 () =>
218 Effect.gen(function* () {
219 const started = (yield* postJson("/jobsQuery", {})) as {
220 queryId?: string;
221 error?: string;
222 };
223 expect(started.error).toBeUndefined();
224 expect(started.queryId).toBeTruthy();
225
226 const result = yield* getJson(
227 `/jobsQueryResults?id=${started.queryId}`,
228 ).pipe(
229 Effect.map(
230 (r) => r as { status?: string; count: number; error?: string },
231 ),
232 Effect.repeat({
233 schedule: Schedule.spaced("3 seconds"),
234 until: (r): boolean =>
235 r.status === "COMPLETE" ||
236 r.status === "ERROR" ||
237 r.error !== undefined,
238 times: 20,
239 }),
240 );
241 expect(result.error).toBeUndefined();
242 expect(result.status).toBe("COMPLETE");
243 expect(typeof result.count).toBe("number");
244 }),
245 { timeout: 120_000 },
246 );
247 });
248
249 describe("consumeJobEvents", { tags: ["provider:aws:eventbridge"] }, () => {
250 test.provider(
251 "created the EventBridge rule for MediaConvert job state changes",
252 () =>
253 Effect.gen(function* () {
254 const ref = yield* AWS.EventBridge.Rule.ref(
255 "MediaConvertJobEvents",
256 {
257 stack: sharedStack.name,
258 stage: sharedStack.stage,
259 },
260 );
261 const { Name, EventBusName } = yield* Effect.all({
262 Name: Output.evaluate(ref.ruleName, {}),
263 EventBusName: Output.evaluate(ref.eventBusName, {}),
264 }).pipe(Effect.provide(sharedStack.state));
265 const rule = yield* eventbridge.describeRule({
266 Name,
267 EventBusName,
268 });
269 expect(rule?.EventPattern).toContain("aws.mediaconvert");
270 expect(rule?.EventPattern).toContain(
271 "MediaConvert Job State Change",
272 );
273 }),
274 { timeout: 60_000 },
275 );
276 });
277 },
278);