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 MediaPackageV2TestFunctionLive, {
12 MediaPackageV2TestFunction,
13} from "./fixtures/handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "MediaPackageV2Bindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26let baseUrl: string;
27let functionArn: string;
28
29class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
30 readonly status: number;
31 readonly body: string;
32}> {}
33
34// The shared Lambda fixture occasionally answers a transient 5xx under load
35// (cold re-init, IAM propagation on the freshly attached policy that the
36// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
37// 4xx/assertion failure surfaces immediately.
38const send = (request: HttpClientRequest.HttpClientRequest) =>
39 HttpClient.execute(request).pipe(
40 Effect.flatMap((response) =>
41 response.status >= 500
42 ? response.text.pipe(
43 Effect.flatMap((body) =>
44 Effect.fail(
45 new TransientUpstream({ status: response.status, body }),
46 ),
47 ),
48 )
49 : Effect.succeed(response),
50 ),
51 Effect.retry({
52 while: (e) => e._tag === "TransientUpstream",
53 schedule: Schedule.max([
54 Schedule.exponential("500 millis"),
55 Schedule.recurs(6),
56 ]),
57 }),
58 );
59
60const getJson = (path: string) =>
61 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
62 Effect.flatMap((r) => r.json),
63 );
64
65const postJson = (path: string) =>
66 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
67 Effect.flatMap((r) => r.json),
68 );
69
70describe.sequential(
71 "MediaPackageV2 Bindings",
72 {
73 tags: [
74 "provider:aws",
75 "provider:aws:lambda",
76 "provider:aws:mediapackagev2",
77 "provider:aws:s3",
78 "live",
79 ],
80 },
81 () => {
82 beforeAll(
83 Effect.gen(function* () {
84 yield* Effect.logInfo(
85 "MediaPackageV2 test setup: destroying previous resources",
86 );
87 yield* sharedStack.destroy();
88
89 yield* Effect.logInfo("MediaPackageV2 test setup: deploying fixture");
90 const attrs = yield* sharedStack.deploy(
91 Effect.gen(function* () {
92 return yield* MediaPackageV2TestFunction;
93 }).pipe(Effect.provide(MediaPackageV2TestFunctionLive)),
94 );
95
96 expect(attrs.functionUrl).toBeTruthy();
97 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
98 functionArn = attrs.functionArn;
99
100 const readinessUrl = `${baseUrl}/bindings`;
101 yield* Effect.logInfo(
102 `MediaPackageV2 test setup: probing readiness at ${readinessUrl}`,
103 );
104 yield* HttpClient.get(readinessUrl).pipe(
105 Effect.flatMap((response) =>
106 response.status === 200
107 ? Effect.succeed(response)
108 : Effect.fail(
109 new Error(`Function not ready: ${response.status}`),
110 ),
111 ),
112 Effect.tapError((error) =>
113 Effect.logWarning(
114 `MediaPackageV2 test setup: fixture not ready yet (${String(error)})`,
115 ),
116 ),
117 Effect.retry({ schedule: readinessPolicy }),
118 );
119 }),
120 { timeout: 300_000 },
121 );
122
123 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
124 timeout: 240_000,
125 });
126
127 describe("binding registration", () => {
128 test.provider("all six capabilities initialize in the runtime", () =>
129 Effect.gen(function* () {
130 const response = (yield* getJson("/bindings")) as { bound: string[] };
131 expect(response.bound).toHaveLength(6);
132 }),
133 );
134 });
135
136 describe("ResetChannelState", () => {
137 test.provider("resetting the idle channel's ingest state succeeds", () =>
138 Effect.gen(function* () {
139 const response = (yield* postJson("/reset-channel")) as {
140 arn?: string;
141 tag?: string;
142 };
143 // A retained stack (NO_DESTROY) can hit MediaPackage's reset rate
144 // limit; the typed tag is then the observable outcome.
145 if (response.tag === undefined) {
146 expect(response.arn).toContain("/channel/");
147 } else {
148 expect(["ConflictException", "ThrottlingException"]).toContain(
149 response.tag,
150 );
151 }
152 }),
153 );
154 });
155
156 describe("ResetOriginEndpointState", () => {
157 test.provider("resetting the idle endpoint's state succeeds", () =>
158 Effect.gen(function* () {
159 const response = (yield* postJson("/reset-endpoint")) as {
160 arn?: string;
161 tag?: string;
162 };
163 if (response.tag === undefined) {
164 expect(response.arn).toContain("/originEndpoint/");
165 } else {
166 expect(["ConflictException", "ThrottlingException"]).toContain(
167 response.tag,
168 );
169 }
170 }),
171 );
172 });
173
174 describe("ListHarvestJobs", () => {
175 test.provider("enumerating the group's harvest jobs succeeds", () =>
176 Effect.gen(function* () {
177 const response = (yield* getJson("/harvest-jobs")) as {
178 count: number;
179 };
180 expect(response.count).toBeGreaterThanOrEqual(0);
181 }),
182 );
183 });
184
185 describe("CreateHarvestJob + GetHarvestJob + CancelHarvestJob", () => {
186 test.provider(
187 "create → get → cancel roundtrips (or typed rejection)",
188 () =>
189 Effect.gen(function* () {
190 const response = (yield* postJson("/harvest-cycle")) as {
191 created: boolean;
192 status?: string;
193 cancelled: boolean;
194 tag?: string;
195 };
196 if (response.created) {
197 expect([
198 "QUEUED",
199 "IN_PROGRESS",
200 "CANCELLED",
201 "COMPLETED",
202 "FAILED",
203 ]).toContain(response.status);
204 } else {
205 // The idle channel has no harvestable content; MediaPackage may
206 // reject the job upfront with the typed ValidationException.
207 expect(["ValidationException", "ConflictException"]).toContain(
208 response.tag,
209 );
210 }
211 }),
212 );
213 });
214
215 describe(
216 "consumeHarvestJobEvents",
217 { tags: ["provider:aws:eventbridge"] },
218 () => {
219 test.provider(
220 "the deploy created an EventBridge rule targeting the function",
221 () =>
222 Effect.gen(function* () {
223 // Out-of-band via distilled: the fixture's consumeHarvestJobEvents
224 // must have materialized as a rule on the default bus with the
225 // Lambda as target.
226 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
227 TargetArn: functionArn,
228 });
229 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
230 }),
231 );
232 },
233 );
234 },
235);