1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as mediatailor from "@distilled.cloud/aws/mediatailor";
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 Stream from "effect/Stream";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import MediaTailorTestFunctionLive, {
13 MediaTailorTestFunction,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "MediaTailorBindings");
19
20const PREFETCH_NAME = "alchemy-test-prefetch-schedule";
21
22let baseUrl: string;
23let configName: string | undefined;
24let configArn: string | undefined;
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). Retry only 5xx.
33const send = (request: HttpClientRequest.HttpClientRequest) =>
34 HttpClient.execute(request).pipe(
35 Effect.flatMap((response) =>
36 response.status >= 500
37 ? response.text.pipe(
38 Effect.flatMap((body) =>
39 Effect.fail(
40 new TransientUpstream({ status: response.status, body }),
41 ),
42 ),
43 )
44 : Effect.succeed(response),
45 ),
46 Effect.retry({
47 while: (e) => e._tag === "TransientUpstream",
48 schedule: Schedule.max([
49 Schedule.exponential("1 second"),
50 Schedule.recurs(8),
51 ]),
52 }),
53 );
54
55// The fixture's IAM policies are attached moments before the first request;
56// IAM propagation surfaces as a typed AccessDeniedException in the JSON body.
57// Repeat (bounded) until the grant has propagated.
58const untilAuthorized = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
59 effect.pipe(
60 Effect.repeat({
61 schedule: Schedule.spaced("3 seconds"),
62 until: (body): boolean =>
63 (body as { error?: string }).error !== "AccessDeniedException",
64 times: 20,
65 }),
66 );
67
68const getJson = (path: string) =>
69 untilAuthorized(
70 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
71 Effect.flatMap((r) => r.json),
72 ),
73 );
74
75const postJson = (path: string, body: object) =>
76 untilAuthorized(
77 send(
78 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
79 HttpClientRequest.bodyJsonUnsafe(body),
80 ),
81 ).pipe(Effect.flatMap((r) => r.json)),
82 );
83
84// Out-of-band: find the playback configuration the fixture deployed (its
85// physical name is generated) by its alchemy ownership tags. Runs inside
86// `test.provider` so distilled has credentials; memoized across tests.
87const resolveConfig = Effect.gen(function* () {
88 if (configName !== undefined) return;
89 const configs = yield* mediatailor.listPlaybackConfigurations
90 .items({})
91 .pipe(Stream.runCollect);
92 const config = Array.from(configs).find(
93 (candidate) =>
94 candidate.Tags?.["alchemy::id"] === "BindingsConfig" &&
95 candidate.Tags?.["alchemy::stack"] === "MediaTailorBindings",
96 );
97 expect(config).toBeDefined();
98 configName = config!.Name!;
99 configArn = config!.PlaybackConfigurationArn!;
100});
101
102describe.sequential(
103 "MediaTailor Bindings",
104 {
105 tags: [
106 "provider:aws",
107 "provider:aws:lambda",
108 "provider:aws:mediatailor",
109 "live",
110 ],
111 },
112 () => {
113 beforeAll(
114 Effect.gen(function* () {
115 yield* Effect.logInfo(
116 "MediaTailor test setup: destroying previous resources",
117 );
118 yield* sharedStack.destroy();
119
120 yield* Effect.logInfo("MediaTailor test setup: deploying fixture");
121 const { functionUrl } = yield* sharedStack.deploy(
122 Effect.gen(function* () {
123 return yield* MediaTailorTestFunction;
124 }).pipe(Effect.provide(MediaTailorTestFunctionLive)),
125 );
126
127 expect(functionUrl).toBeTruthy();
128 baseUrl = functionUrl!.replace(/\/+$/, "");
129
130 // Readiness probe — fresh function URLs take seconds (sometimes over a
131 // minute) to serve 200s.
132 yield* HttpClient.get(`${baseUrl}/health`).pipe(
133 Effect.flatMap((response) =>
134 response.status === 200
135 ? Effect.succeed(response)
136 : Effect.fail(
137 new Error(`Function not ready: ${response.status}`),
138 ),
139 ),
140 Effect.retry({
141 schedule: Schedule.max([
142 Schedule.fixed("2 seconds"),
143 Schedule.recurs(75),
144 ]),
145 }),
146 );
147 }),
148 { timeout: 300_000 },
149 );
150 afterAll(sharedStack.destroy(), { timeout: 300_000 });
151
152 describe("CreatePrefetchSchedule + GetPrefetchSchedule + ListPrefetchSchedules + DeletePrefetchSchedule", () => {
153 test.provider(
154 "full prefetch-schedule lifecycle against the deployed configuration",
155 () =>
156 Effect.gen(function* () {
157 yield* resolveConfig;
158
159 // pre-clean: a previous partial run may have left the schedule
160 yield* postJson("/prefetch/delete", { name: PREFETCH_NAME });
161
162 // create
163 const created = (yield* postJson("/prefetch/create", {
164 name: PREFETCH_NAME,
165 })) as { arn?: string; error?: string; detail?: string };
166 expect(created.error, created.detail).toBeUndefined();
167 expect(created.arn).toContain(":prefetchSchedule/");
168
169 // out-of-band verification via distilled
170 const fetched = yield* mediatailor.getPrefetchSchedule({
171 Name: PREFETCH_NAME,
172 PlaybackConfigurationName: configName!,
173 });
174 expect(fetched.Name).toBe(PREFETCH_NAME);
175
176 // get through the binding
177 const got = (yield* getJson(
178 `/prefetch/get?name=${PREFETCH_NAME}`,
179 )) as { name?: string; error?: string; detail?: string };
180 expect(got.error, got.detail).toBeUndefined();
181 expect(got.name).toBe(PREFETCH_NAME);
182
183 // list through the binding
184 const listed = (yield* getJson("/prefetch/list")) as {
185 names: string[];
186 error?: string;
187 detail?: string;
188 };
189 expect(listed.error, listed.detail).toBeUndefined();
190 expect(listed.names).toContain(PREFETCH_NAME);
191
192 // delete through the binding
193 const deleted = (yield* postJson("/prefetch/delete", {
194 name: PREFETCH_NAME,
195 })) as { deleted: boolean; error?: string; detail?: string };
196 expect(deleted.error, deleted.detail).toBeUndefined();
197 expect(deleted.deleted).toBe(true);
198
199 // get after delete surfaces the typed synthetic tag
200 const gone = (yield* getJson(
201 `/prefetch/get?name=${PREFETCH_NAME}`,
202 )) as { name?: string; error?: string };
203 expect(gone.error).toBe("PrefetchScheduleNotFound");
204 }),
205 { timeout: 120_000 },
206 );
207 });
208
209 describe("ListAlerts", () => {
210 test.provider(
211 "rejects a non-channel-assembly ARN with the typed BadRequestException",
212 () =>
213 Effect.gen(function* () {
214 yield* resolveConfig;
215
216 // ListAlerts only accepts channel-assembly resource ARNs; a
217 // playback-configuration ARN is rejected with the typed
218 // BadRequestException (never AccessDenied — proving the
219 // mediatailor:ListAlerts grant reached the API).
220 const body = (yield* getJson(
221 `/alerts?arn=${encodeURIComponent(configArn!)}`,
222 )) as { count: number; error?: string; detail?: string };
223 expect(body.error, body.detail).toBe("BadRequestException");
224 }),
225 { timeout: 120_000 },
226 );
227 });
228
229 describe("GetChannelSchedule", () => {
230 test.provider(
231 "returns the typed ChannelNotFound for a missing channel",
232 () =>
233 Effect.gen(function* () {
234 const body = (yield* getJson(
235 "/channel/schedule?name=alchemy-nonexistent-mediatailor-channel",
236 )) as { count: number; error?: string; detail?: string };
237 // A typed not-found (never AccessDenied) proves the
238 // mediatailor:GetChannelSchedule grant reached the API.
239 expect(body.error, body.detail).toBe("ChannelNotFound");
240 }),
241 { timeout: 120_000 },
242 );
243 });
244
245 describe("StartChannel + StopChannel", () => {
246 test.provider(
247 "start/stop of a missing channel fail with typed tags (never AccessDenied)",
248 () =>
249 Effect.gen(function* () {
250 const started = (yield* postJson("/channel/start", {
251 name: "alchemy-nonexistent-mediatailor-channel",
252 })) as { started: boolean; error?: string; detail?: string };
253 expect(started.started).toBe(false);
254 expect(
255 ["ChannelNotFound", "BadRequestException"],
256 started.detail,
257 ).toContain(started.error);
258
259 const stopped = (yield* postJson("/channel/stop", {
260 name: "alchemy-nonexistent-mediatailor-channel",
261 })) as { stopped: boolean; error?: string; detail?: string };
262 expect(stopped.stopped).toBe(false);
263 expect(
264 ["ChannelNotFound", "BadRequestException"],
265 stopped.detail,
266 ).toContain(stopped.error);
267 }),
268 { timeout: 120_000 },
269 );
270 });
271
272 describe("CreateProgram + DescribeProgram + UpdateProgram + DeleteProgram", () => {
273 test.provider(
274 "program operations on a missing channel fail with typed tags (never AccessDenied)",
275 () =>
276 Effect.gen(function* () {
277 const created = (yield* postJson("/program/create", {})) as {
278 created: boolean;
279 error?: string;
280 detail?: string;
281 };
282 expect(created.created).toBe(false);
283 expect(
284 ["ChannelNotFound", "BadRequestException"],
285 created.detail,
286 ).toContain(created.error);
287
288 const described = (yield* getJson("/program")) as {
289 name?: string;
290 error?: string;
291 detail?: string;
292 };
293 expect(described.name).toBeUndefined();
294 expect(
295 ["ProgramNotFound", "BadRequestException"],
296 described.detail,
297 ).toContain(described.error);
298
299 const updated = (yield* postJson("/program/update", {})) as {
300 updated: boolean;
301 error?: string;
302 detail?: string;
303 };
304 expect(updated.updated).toBe(false);
305 expect(
306 ["ProgramNotFound", "BadRequestException"],
307 updated.detail,
308 ).toContain(updated.error);
309
310 const deleted = (yield* postJson("/program/delete", {})) as {
311 deleted: boolean;
312 error?: string;
313 detail?: string;
314 };
315 expect(deleted.deleted).toBe(false);
316 expect(
317 ["ProgramNotFound", "BadRequestException"],
318 deleted.detail,
319 ).toContain(deleted.error);
320 }),
321 { timeout: 120_000 },
322 );
323 });
324 },
325);