1import * as AWS from "@/AWS";
2import { Job, JobTemplate, Preset, Queue } from "@/AWS/MediaConvert";
3import * as Test from "@/Test/Alchemy";
4import * as mediaconvert from "@distilled.cloud/aws/mediaconvert";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Deterministic, run-stable physical names (never Date.now()).
13const QUEUE_NAME = "alchemy-test-mc-queue";
14const PRESET_NAME = "alchemy-test-mc-preset";
15const TEMPLATE_NAME = "alchemy-test-mc-jobtemplate";
16
17// A minimal, valid H.264 / AAC MP4 output preset.
18const PRESET_SETTINGS: mediaconvert.PresetSettings = {
19 ContainerSettings: { Container: "MP4", Mp4Settings: {} },
20 VideoDescription: {
21 Width: 1280,
22 Height: 720,
23 CodecSettings: {
24 Codec: "H_264",
25 H264Settings: {
26 RateControlMode: "QVBR",
27 MaxBitrate: 3000000,
28 SceneChangeDetect: "TRANSITION_DETECTION",
29 },
30 },
31 },
32 AudioDescriptions: [
33 {
34 CodecSettings: {
35 Codec: "AAC",
36 AacSettings: {
37 Bitrate: 96000,
38 CodingMode: "CODING_MODE_2_0",
39 SampleRate: 48000,
40 },
41 },
42 },
43 ],
44};
45
46// A minimal, valid single-output file-group job template.
47const TEMPLATE_SETTINGS: mediaconvert.JobTemplateSettings = {
48 Inputs: [
49 { TimecodeSource: "ZEROBASED", VideoSelector: {}, AudioSelectors: {} },
50 ],
51 OutputGroups: [
52 {
53 Name: "File Group",
54 OutputGroupSettings: {
55 Type: "FILE_GROUP_SETTINGS",
56 FileGroupSettings: {},
57 },
58 Outputs: [
59 {
60 ContainerSettings: { Container: "MP4", Mp4Settings: {} },
61 VideoDescription: {
62 CodecSettings: {
63 Codec: "H_264",
64 H264Settings: {
65 RateControlMode: "QVBR",
66 MaxBitrate: 3000000,
67 SceneChangeDetect: "TRANSITION_DETECTION",
68 },
69 },
70 },
71 },
72 ],
73 },
74 ],
75};
76
77class StillExists extends Data.TaggedError("StillExists")<{
78 readonly name: string;
79}> {}
80
81// ---------------------------------------------------------------------------
82// Ungated typed-error probes — prove the distilled error union carries the
83// not-found tag the read/delete paths depend on, at near-zero cost.
84// ---------------------------------------------------------------------------
85
86test.provider(
87 "getQueue/getPreset/getJobTemplate on a missing name fail with NotFoundException",
88 () =>
89 Effect.gen(function* () {
90 const q = yield* Effect.flip(
91 mediaconvert.getQueue({ Name: "alchemy-does-not-exist-000" }),
92 );
93 expect(q._tag).toBe("NotFoundException");
94 const p = yield* Effect.flip(
95 mediaconvert.getPreset({ Name: "alchemy-does-not-exist-000" }),
96 );
97 expect(p._tag).toBe("NotFoundException");
98 const t = yield* Effect.flip(
99 mediaconvert.getJobTemplate({ Name: "alchemy-does-not-exist-000" }),
100 );
101 expect(t._tag).toBe("NotFoundException");
102 }),
103 { tags: ["provider:aws", "provider:aws:mediaconvert", "live"] },
104);
105
106test.provider(
107 "createJob with an invalid role is rejected with a typed BadRequestException",
108 () =>
109 Effect.gen(function* () {
110 // Proves Job's create path has a typed error union (no untyped catch-all)
111 // without submitting a billable transcode.
112 const error = yield* Effect.flip(
113 mediaconvert.createJob({
114 Role: "arn:aws:iam::000000000000:role/does-not-exist",
115 Settings: {
116 Inputs: [{ FileInput: "s3://alchemy-nonexistent/in.mp4" }],
117 OutputGroups: TEMPLATE_SETTINGS.OutputGroups,
118 },
119 }),
120 );
121 // A bad role surfaces as a typed tag (AccessDenied/BadRequest/Forbidden),
122 // never the untyped catch-all — that is the point of the probe.
123 expect(error._tag).not.toBe("UnknownAwsError");
124 expect([
125 "AccessDeniedException",
126 "BadRequestException",
127 "ForbiddenException",
128 ]).toContain(error._tag);
129 }),
130 { tags: ["provider:aws", "provider:aws:mediaconvert", "live"] },
131);
132
133// ---------------------------------------------------------------------------
134// Queue lifecycle — on-demand queues are free and provision instantly.
135// ---------------------------------------------------------------------------
136
137test.provider(
138 "Queue: create, update, and destroy an on-demand queue",
139 (stack) =>
140 Effect.gen(function* () {
141 yield* stack.destroy();
142
143 const created = yield* stack.deploy(
144 Effect.gen(function* () {
145 return yield* Queue("Q", {
146 queueName: QUEUE_NAME,
147 description: "alchemy test queue",
148 tags: { Environment: "test" },
149 });
150 }),
151 );
152 expect(created.queueName).toBe(QUEUE_NAME);
153 expect(created.queueArn).toContain(":queues/");
154 expect(created.pricingPlan).toBe("ON_DEMAND");
155 expect(created.status).toBe("ACTIVE");
156
157 // Out-of-band verification. MediaConvert returns tags only via
158 // listTagsForResource, not in the Get response body.
159 const observed = yield* mediaconvert.getQueue({ Name: QUEUE_NAME });
160 expect(observed.Queue?.Description).toBe("alchemy test queue");
161 const queueTags = yield* mediaconvert.listTagsForResource({
162 Arn: created.queueArn,
163 });
164 expect(queueTags.ResourceTags?.Tags?.["alchemy::id"]).toBe("Q");
165
166 // Update: change description + pause the queue.
167 const updated = yield* stack.deploy(
168 Effect.gen(function* () {
169 return yield* Queue("Q", {
170 queueName: QUEUE_NAME,
171 description: "alchemy test queue v2",
172 status: "PAUSED",
173 tags: { Environment: "test", Extra: "yes" },
174 });
175 }),
176 );
177 expect(updated.status).toBe("PAUSED");
178
179 const observed2 = yield* mediaconvert.getQueue({ Name: QUEUE_NAME });
180 expect(observed2.Queue?.Description).toBe("alchemy test queue v2");
181 expect(observed2.Queue?.Status).toBe("PAUSED");
182 const queueTags2 = yield* mediaconvert.listTagsForResource({
183 Arn: created.queueArn,
184 });
185 expect(queueTags2.ResourceTags?.Tags?.["Extra"]).toBe("yes");
186
187 yield* stack.destroy();
188 yield* assertQueueDeleted(QUEUE_NAME);
189 }),
190 {
191 tags: ["provider:aws", "provider:aws:mediaconvert", "live"],
192 timeout: 120_000,
193 },
194);
195
196// ---------------------------------------------------------------------------
197// Preset lifecycle.
198// ---------------------------------------------------------------------------
199
200test.provider(
201 "Preset: create, update, and destroy an output preset",
202 (stack) =>
203 Effect.gen(function* () {
204 yield* stack.destroy();
205
206 const created = yield* stack.deploy(
207 Effect.gen(function* () {
208 return yield* Preset("P", {
209 presetName: PRESET_NAME,
210 description: "alchemy test preset",
211 settings: PRESET_SETTINGS,
212 tags: { Environment: "test" },
213 });
214 }),
215 );
216 expect(created.presetName).toBe(PRESET_NAME);
217 expect(created.presetArn).toContain(":presets/");
218 expect(created.type).toBe("CUSTOM");
219
220 const observed = yield* mediaconvert.getPreset({ Name: PRESET_NAME });
221 expect(observed.Preset?.Description).toBe("alchemy test preset");
222 const presetTags = yield* mediaconvert.listTagsForResource({
223 Arn: created.presetArn,
224 });
225 expect(presetTags.ResourceTags?.Tags?.["alchemy::id"]).toBe("P");
226
227 const updated = yield* stack.deploy(
228 Effect.gen(function* () {
229 return yield* Preset("P", {
230 presetName: PRESET_NAME,
231 description: "alchemy test preset v2",
232 category: "alchemy",
233 settings: PRESET_SETTINGS,
234 tags: { Environment: "test" },
235 });
236 }),
237 );
238 expect(updated.category).toBe("alchemy");
239
240 const observed2 = yield* mediaconvert.getPreset({ Name: PRESET_NAME });
241 expect(observed2.Preset?.Description).toBe("alchemy test preset v2");
242
243 yield* stack.destroy();
244 yield* assertPresetDeleted(PRESET_NAME);
245 }),
246 {
247 tags: ["provider:aws", "provider:aws:mediaconvert", "live"],
248 timeout: 120_000,
249 },
250);
251
252// ---------------------------------------------------------------------------
253// JobTemplate lifecycle.
254// ---------------------------------------------------------------------------
255
256test.provider(
257 "JobTemplate: create, update, and destroy a job template",
258 (stack) =>
259 Effect.gen(function* () {
260 yield* stack.destroy();
261
262 const created = yield* stack.deploy(
263 Effect.gen(function* () {
264 return yield* JobTemplate("T", {
265 jobTemplateName: TEMPLATE_NAME,
266 description: "alchemy test template",
267 settings: TEMPLATE_SETTINGS,
268 tags: { Environment: "test" },
269 });
270 }),
271 );
272 expect(created.jobTemplateName).toBe(TEMPLATE_NAME);
273 expect(created.jobTemplateArn).toContain(":jobTemplates/");
274 expect(created.type).toBe("CUSTOM");
275
276 const observed = yield* mediaconvert.getJobTemplate({
277 Name: TEMPLATE_NAME,
278 });
279 expect(observed.JobTemplate?.Description).toBe("alchemy test template");
280 const templateTags = yield* mediaconvert.listTagsForResource({
281 Arn: created.jobTemplateArn,
282 });
283 expect(templateTags.ResourceTags?.Tags?.["alchemy::id"]).toBe("T");
284
285 const updated = yield* stack.deploy(
286 Effect.gen(function* () {
287 return yield* JobTemplate("T", {
288 jobTemplateName: TEMPLATE_NAME,
289 description: "alchemy test template v2",
290 priority: 10,
291 settings: TEMPLATE_SETTINGS,
292 tags: { Environment: "test" },
293 });
294 }),
295 );
296 expect(updated.jobTemplateName).toBe(TEMPLATE_NAME);
297
298 const observed2 = yield* mediaconvert.getJobTemplate({
299 Name: TEMPLATE_NAME,
300 });
301 expect(observed2.JobTemplate?.Description).toBe(
302 "alchemy test template v2",
303 );
304 expect(observed2.JobTemplate?.Priority).toBe(10);
305
306 yield* stack.destroy();
307 yield* assertJobTemplateDeleted(TEMPLATE_NAME);
308 }),
309 {
310 tags: ["provider:aws", "provider:aws:mediaconvert", "live"],
311 timeout: 120_000,
312 },
313);
314
315// ---------------------------------------------------------------------------
316// Job live lifecycle — slow/billable; needs an IAM role MediaConvert can
317// assume plus S3 access. Gated behind AWS_TEST_SLOW. Requires:
318// AWS_TEST_SLOW=1
319// MEDIACONVERT_ROLE_ARN=<role arn>
320// MEDIACONVERT_TEST_INPUT=s3://bucket/input.mp4
321// MEDIACONVERT_TEST_OUTPUT=s3://bucket/out/
322// The job intentionally runs to completion or errors on its own; the test
323// only asserts submission + a terminal-or-progressing status, then cancels.
324// ---------------------------------------------------------------------------
325
326const runJob =
327 process.env.AWS_TEST_SLOW === "1" && !!process.env.MEDIACONVERT_ROLE_ARN;
328
329test.provider.skipIf(!runJob)(
330 "Job: submit a transcode and cancel it (gated AWS_TEST_SLOW=1)",
331 (stack) =>
332 Effect.gen(function* () {
333 yield* stack.destroy();
334
335 const roleArn = process.env.MEDIACONVERT_ROLE_ARN!;
336 const input =
337 process.env.MEDIACONVERT_TEST_INPUT ??
338 "s3://alchemy-nonexistent/in.mp4";
339 const output =
340 process.env.MEDIACONVERT_TEST_OUTPUT ?? "s3://alchemy-nonexistent/out/";
341
342 const created = yield* stack.deploy(
343 Effect.gen(function* () {
344 return yield* Job("J", {
345 role: roleArn,
346 settings: {
347 Inputs: [
348 {
349 FileInput: input,
350 TimecodeSource: "ZEROBASED",
351 VideoSelector: {},
352 AudioSelectors: {},
353 },
354 ],
355 OutputGroups: [
356 {
357 Name: "File Group",
358 OutputGroupSettings: {
359 Type: "FILE_GROUP_SETTINGS",
360 FileGroupSettings: { Destination: output },
361 },
362 Outputs: TEMPLATE_SETTINGS.OutputGroups![0].Outputs,
363 },
364 ],
365 },
366 });
367 }),
368 );
369 expect(created.jobId).toBeTruthy();
370 expect(created.jobArn).toContain(":jobs/");
371
372 const observed = yield* mediaconvert.getJob({ Id: created.jobId });
373 expect(observed.Job).toBeDefined();
374 expect(
375 ["SUBMITTED", "PROGRESSING", "COMPLETE", "ERROR"].includes(
376 observed.Job!.Status!,
377 ),
378 ).toBe(true);
379
380 // destroy() cancels the job if still in flight.
381 yield* stack.destroy();
382 }),
383 {
384 tags: ["provider:aws", "provider:aws:mediaconvert", "live"],
385 timeout: 180_000,
386 },
387);
388
389// ---------------------------------------------------------------------------
390// Deletion assertions — typed wait-until-gone.
391// ---------------------------------------------------------------------------
392
393const assertQueueDeleted = (name: string) =>
394 mediaconvert.getQueue({ Name: name }).pipe(
395 Effect.flatMap(() => Effect.fail(new StillExists({ name }))),
396 Effect.catchTag("NotFoundException", () => Effect.void),
397 Effect.retry({
398 while: (e) => e._tag === "StillExists",
399 schedule: Schedule.max([
400 Schedule.spaced("2 seconds"),
401 Schedule.recurs(10),
402 ]),
403 }),
404 );
405
406const assertPresetDeleted = (name: string) =>
407 mediaconvert.getPreset({ Name: name }).pipe(
408 Effect.flatMap(() => Effect.fail(new StillExists({ name }))),
409 Effect.catchTag("NotFoundException", () => Effect.void),
410 Effect.retry({
411 while: (e) => e._tag === "StillExists",
412 schedule: Schedule.max([
413 Schedule.spaced("2 seconds"),
414 Schedule.recurs(10),
415 ]),
416 }),
417 );
418
419const assertJobTemplateDeleted = (name: string) =>
420 mediaconvert.getJobTemplate({ Name: name }).pipe(
421 Effect.flatMap(() => Effect.fail(new StillExists({ name }))),
422 Effect.catchTag("NotFoundException", () => Effect.void),
423 Effect.retry({
424 while: (e) => e._tag === "StillExists",
425 schedule: Schedule.max([
426 Schedule.spaced("2 seconds"),
427 Schedule.recurs(10),
428 ]),
429 }),
430 );