1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import MediaLiveChannelTestFunctionLive, {
12 MediaLiveChannelTestFunction,
13} from "./fixtures/channel-handler";
14import MediaLiveTestFunctionLive, {
15 MediaLiveTestFunction,
16} from "./fixtures/handler";
17
18const testOptions = { providers: AWS.providers() };
19const { test, beforeAll, afterAll } = Test.make(testOptions);
20const sharedStack = Core.scratchStack(testOptions, "MediaLiveBindings");
21const channelStack = Core.scratchStack(testOptions, "MediaLiveChannelBindings");
22
23// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
24// over 60s on a fresh deploy.
25const readinessPolicy = Schedule.max([
26 Schedule.fixed("2 seconds"),
27 Schedule.recurs(75),
28]);
29
30class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
31 readonly status: number;
32 readonly body: string;
33}> {}
34
35// The shared Lambda fixture occasionally answers a transient 5xx under load
36// (cold re-init, IAM propagation on the freshly attached policy that the
37// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
38// 4xx/assertion failure surfaces immediately.
39const send = (request: HttpClientRequest.HttpClientRequest) =>
40 HttpClient.execute(request).pipe(
41 Effect.flatMap((response) =>
42 response.status >= 500
43 ? response.text.pipe(
44 Effect.flatMap((body) =>
45 Effect.fail(
46 new TransientUpstream({ status: response.status, body }),
47 ),
48 ),
49 )
50 : Effect.succeed(response),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "TransientUpstream",
54 schedule: Schedule.max([
55 Schedule.exponential("500 millis"),
56 Schedule.recurs(6),
57 ]),
58 }),
59 );
60
61const getJson = (base: string, path: string) =>
62 send(HttpClientRequest.get(`${base}${path}`)).pipe(
63 Effect.flatMap((r) => r.json),
64 );
65
66const postJson = (base: string, path: string) =>
67 send(HttpClientRequest.post(`${base}${path}`)).pipe(
68 Effect.flatMap((r) => r.json),
69 );
70
71const awaitReady = (readinessUrl: string) =>
72 HttpClient.get(readinessUrl).pipe(
73 Effect.flatMap((response) =>
74 response.status === 200
75 ? Effect.succeed(response)
76 : Effect.fail(new Error(`Function not ready: ${response.status}`)),
77 ),
78 Effect.tapError((error) =>
79 Effect.logWarning(
80 `MediaLive test setup: fixture not ready yet (${String(error)})`,
81 ),
82 ),
83 Effect.retry({ schedule: readinessPolicy }),
84 );
85
86let baseUrl: string;
87let functionArn: string;
88
89describe.sequential(
90 "MediaLive Bindings",
91 {
92 tags: [
93 "provider:aws",
94 "provider:aws:lambda",
95 "provider:aws:medialive",
96 "live",
97 ],
98 },
99 () => {
100 beforeAll(
101 Effect.gen(function* () {
102 yield* Effect.logInfo(
103 "MediaLive test setup: destroying previous resources",
104 );
105 yield* sharedStack.destroy();
106
107 yield* Effect.logInfo("MediaLive test setup: deploying fixture");
108 const attrs = yield* sharedStack.deploy(
109 Effect.gen(function* () {
110 return yield* MediaLiveTestFunction;
111 }).pipe(Effect.provide(MediaLiveTestFunctionLive)),
112 );
113
114 expect(attrs.functionUrl).toBeTruthy();
115 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
116 functionArn = attrs.functionArn;
117
118 yield* awaitReady(`${baseUrl}/bindings`);
119 }),
120 { timeout: 300_000 },
121 );
122
123 afterAll(sharedStack.destroy(), { timeout: 240_000 });
124
125 describe("binding registration", () => {
126 test.provider("all three capabilities initialize in the runtime", () =>
127 Effect.gen(function* () {
128 const response = (yield* getJson(baseUrl, "/bindings")) as {
129 bound: string[];
130 };
131 expect(response.bound).toHaveLength(3);
132 }),
133 );
134 });
135
136 describe("DescribeInput", () => {
137 test.provider("the fixture input idles DETACHED with its pull URL", () =>
138 Effect.gen(function* () {
139 const response = (yield* getJson(baseUrl, "/input")) as {
140 state?: string;
141 type?: string;
142 sourceUrls?: string[];
143 };
144 expect(["DETACHED", "ATTACHED"]).toContain(response.state);
145 expect(response.type).toBe("URL_PULL");
146 expect(response.sourceUrls).toEqual([
147 "https://example.com/stream/index.m3u8",
148 ]);
149 }),
150 );
151 });
152
153 describe("ListChannels", () => {
154 test.provider("account-level channel enumeration succeeds", () =>
155 Effect.gen(function* () {
156 const response = (yield* getJson(baseUrl, "/channels")) as {
157 count: number;
158 };
159 expect(response.count).toBeGreaterThanOrEqual(0);
160 }),
161 );
162 });
163
164 describe("ListInputs", () => {
165 test.provider(
166 "account-level input enumeration sees the fixture input",
167 () =>
168 Effect.gen(function* () {
169 const response = (yield* getJson(baseUrl, "/inputs")) as {
170 count: number;
171 };
172 expect(response.count).toBeGreaterThanOrEqual(1);
173 }),
174 );
175 });
176
177 describe(
178 "consumeChannelEvents",
179 { tags: ["provider:aws:eventbridge"] },
180 () => {
181 test.provider(
182 "the deploy created an EventBridge rule targeting the function",
183 () =>
184 Effect.gen(function* () {
185 // Out-of-band via distilled: the fixture's consumeChannelEvents
186 // must have materialized as a rule on the default bus with the
187 // Lambda as target.
188 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
189 TargetArn: functionArn,
190 });
191 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
192 }),
193 );
194 },
195 );
196 },
197);
198
199// ---------------------------------------------------------------------------
200// Channel-scoped bindings — the fixture provisions a real (IDLE, unbilled)
201// channel, which takes minutes to create/delete, so the block is gated the
202// same way as the Channel lifecycle test.
203// ---------------------------------------------------------------------------
204
205let channelBaseUrl: string;
206
207describe.skipIf(!process.env.AWS_TEST_MEDIALIVE)(
208 "MediaLive Channel Bindings (gated AWS_TEST_MEDIALIVE=1)",
209 {
210 tags: [
211 "provider:aws",
212 "provider:aws:batch",
213 "provider:aws:iam",
214 "provider:aws:lambda",
215 "provider:aws:medialive",
216 "live",
217 ],
218 },
219 () => {
220 beforeAll(
221 Effect.gen(function* () {
222 yield* Effect.logInfo(
223 "MediaLive channel test setup: destroying previous resources",
224 );
225 yield* channelStack.destroy();
226
227 yield* Effect.logInfo(
228 "MediaLive channel test setup: deploying channel fixture",
229 );
230 const attrs = yield* channelStack.deploy(
231 Effect.gen(function* () {
232 return yield* MediaLiveChannelTestFunction;
233 }).pipe(Effect.provide(MediaLiveChannelTestFunctionLive)),
234 );
235
236 expect(attrs.functionUrl).toBeTruthy();
237 channelBaseUrl = attrs.functionUrl!.replace(/\/+$/, "");
238
239 yield* awaitReady(`${channelBaseUrl}/bindings`);
240 }),
241 { timeout: 600_000 },
242 );
243
244 afterAll(channelStack.destroy(), { timeout: 600_000 });
245
246 describe("binding registration", () => {
247 test.provider("all nine capabilities initialize in the runtime", () =>
248 Effect.gen(function* () {
249 const response = (yield* getJson(channelBaseUrl, "/bindings")) as {
250 bound: string[];
251 };
252 expect(response.bound).toHaveLength(9);
253 }),
254 );
255 });
256
257 describe("DescribeChannel", () => {
258 test.provider("the fixture channel idles IDLE and never runs", () =>
259 Effect.gen(function* () {
260 const response = (yield* getJson(channelBaseUrl, "/channel")) as {
261 state?: string;
262 channelClass?: string;
263 pipelinesRunning?: number;
264 };
265 expect(response.state).toBe("IDLE");
266 expect(response.channelClass).toBe("SINGLE_PIPELINE");
267 }),
268 );
269 });
270
271 describe("DescribeSchedule", () => {
272 test.provider("a fresh channel has an empty schedule", () =>
273 Effect.gen(function* () {
274 const response = (yield* getJson(channelBaseUrl, "/schedule")) as {
275 actions: number;
276 };
277 expect(response.actions).toBe(0);
278 }),
279 );
280 });
281
282 describe("BatchUpdateSchedule + DeleteSchedule", () => {
283 test.provider(
284 "programming and clearing a schedule action roundtrips",
285 () =>
286 Effect.gen(function* () {
287 const response = (yield* postJson(
288 channelBaseUrl,
289 "/schedule-cycle",
290 )) as { created: number; cleared: boolean; tag?: string };
291 // An IDLE channel accepts fixed-time actions on most accounts;
292 // where MediaLive rejects them, the typed tag is the outcome.
293 if (response.tag === undefined) {
294 expect(response.created).toBe(1);
295 expect(response.cleared).toBe(true);
296 } else {
297 expect([
298 "BadRequestException",
299 "UnprocessableEntityException",
300 ]).toContain(response.tag);
301 }
302 }),
303 );
304 });
305
306 describe("ListAlerts", () => {
307 test.provider("a never-started channel lists its (empty) alerts", () =>
308 Effect.gen(function* () {
309 const response = (yield* getJson(channelBaseUrl, "/alerts")) as {
310 alerts: number;
311 };
312 expect(response.alerts).toBeGreaterThanOrEqual(0);
313 }),
314 );
315 });
316
317 describe("DescribeThumbnails", () => {
318 test.provider(
319 "an IDLE channel has no thumbnails — empty details or the typed BadRequestException",
320 () =>
321 Effect.gen(function* () {
322 const response = (yield* getJson(
323 channelBaseUrl,
324 "/thumbnails",
325 )) as { details: number; tag?: string };
326 expect(response.details).toBe(0);
327 if (response.tag !== undefined) {
328 expect(response.tag).toBe("BadRequestException");
329 }
330 }),
331 );
332 });
333 },
334);