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 * as sts from "@distilled.cloud/aws/sts";
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 IVSRealtimeTestFunctionLive, {
13 IVSRealtimeTestFunction,
14} from "./fixtures/handler.ts";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "IVSRealtimeBindings");
19
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(60),
23]);
24
25let baseUrl: string;
26let functionArn: string;
27
28class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
29 readonly status: number;
30 readonly body: string;
31}> {}
32
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(5),
51 ]),
52 }),
53 );
54
55const getJson = (path: string) =>
56 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
57 Effect.flatMap((r) => r.json),
58 );
59
60const postJson = (path: string) =>
61 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
62 Effect.flatMap((r) => r.json),
63 );
64
65/** Result of a probe route: success marker or the typed error tag. */
66interface Probe {
67 ok?: boolean;
68 found?: boolean;
69 count?: number;
70 tag?: string;
71}
72
73describe(
74 "IVSRealtime Bindings",
75 {
76 tags: [
77 "provider:aws",
78 "provider:aws:ivsrealtime",
79 "provider:aws:lambda",
80 "live",
81 ],
82 },
83 () => {
84 beforeAll(
85 Effect.gen(function* () {
86 yield* Effect.logInfo(
87 "IVSRealtime test setup: destroying previous resources",
88 );
89 yield* sharedStack.destroy();
90
91 yield* Effect.logInfo("IVSRealtime test setup: deploying fixture");
92 const attrs = yield* sharedStack.deploy(
93 Effect.gen(function* () {
94 return yield* IVSRealtimeTestFunction;
95 }).pipe(Effect.provide(IVSRealtimeTestFunctionLive)),
96 );
97
98 expect(attrs.functionUrl).toBeTruthy();
99 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
100 functionArn = attrs.functionArn;
101 const readinessUrl = `${baseUrl}/ping`;
102
103 yield* Effect.logInfo(
104 `IVSRealtime test setup: probing readiness at ${readinessUrl}`,
105 );
106 yield* HttpClient.get(readinessUrl).pipe(
107 Effect.flatMap((response) =>
108 response.status === 200
109 ? Effect.succeed(response)
110 : Effect.fail(
111 new Error(`Function not ready: ${response.status}`),
112 ),
113 ),
114 Effect.tapError((error) =>
115 Effect.logWarning(
116 `IVSRealtime test setup: fixture not ready yet (${String(error)})`,
117 ),
118 ),
119 Effect.retry({ schedule: readinessPolicy }),
120 );
121 }),
122 { timeout: 240_000 },
123 );
124
125 afterAll(sharedStack.destroy(), { timeout: 120_000 });
126
127 describe("binding registration", () => {
128 test.provider("all fourteen capabilities initialize in the runtime", () =>
129 Effect.gen(function* () {
130 const response = (yield* getJson("/bindings")) as { bound: string[] };
131 expect(response.bound).toHaveLength(14);
132 }),
133 );
134 });
135
136 describe("IVSRealtime.CreateParticipantToken", () => {
137 test.provider(
138 "mints a redacted participant token honoring duration",
139 () =>
140 Effect.gen(function* () {
141 const response = (yield* postJson("/token")) as {
142 tokenLength: number;
143 tokenIsRedacted: boolean;
144 participantId?: string;
145 duration?: number;
146 };
147
148 expect(response.tokenLength).toBeGreaterThan(0);
149 // SensitiveString in distilled decodes to a Redacted value.
150 expect(response.tokenIsRedacted).toBe(true);
151 expect(response.participantId).toBeTruthy();
152 // duration: "30 minutes" must reach the wire as duration: 30.
153 expect(response.duration).toBe(30);
154 }),
155 { timeout: 120_000 },
156 );
157 });
158
159 describe("IVSRealtime.ListStageSessions", () => {
160 test.provider(
161 "lists the fresh stage's sessions (none yet)",
162 () =>
163 Effect.gen(function* () {
164 const response = (yield* getJson("/sessions")) as { count: number };
165 expect(response.count).toBe(0);
166 }),
167 { timeout: 120_000 },
168 );
169 });
170
171 describe("IVSRealtime.GetStageSession", () => {
172 test.provider(
173 "returns the typed not-found tag for a missing session",
174 () =>
175 Effect.gen(function* () {
176 const response = (yield* getJson("/session-missing")) as Probe;
177 expect([
178 "ResourceNotFoundException",
179 "ValidationException",
180 ]).toContain(response.tag);
181 }),
182 { timeout: 120_000 },
183 );
184 });
185
186 describe("IVSRealtime.ListParticipants", () => {
187 test.provider(
188 "round-trips a session-scoped list",
189 () =>
190 Effect.gen(function* () {
191 const response = (yield* getJson("/participants")) as Probe;
192 if (response.tag !== undefined) {
193 expect(response.tag).toBe("ValidationException");
194 } else {
195 expect(response.count).toBe(0);
196 }
197 }),
198 { timeout: 120_000 },
199 );
200 });
201
202 describe("IVSRealtime.GetParticipant", () => {
203 test.provider(
204 "returns the typed not-found tag for a missing participant",
205 () =>
206 Effect.gen(function* () {
207 const response = (yield* getJson("/participant-missing")) as Probe;
208 expect([
209 "ResourceNotFoundException",
210 "ValidationException",
211 ]).toContain(response.tag);
212 }),
213 { timeout: 120_000 },
214 );
215 });
216
217 describe("IVSRealtime.ListParticipantEvents", () => {
218 test.provider(
219 "round-trips a participant-scoped event list",
220 () =>
221 Effect.gen(function* () {
222 const response = (yield* getJson("/participant-events")) as Probe;
223 if (response.tag !== undefined) {
224 expect(response.tag).toBe("ValidationException");
225 } else {
226 expect(response.count).toBe(0);
227 }
228 }),
229 { timeout: 120_000 },
230 );
231 });
232
233 describe("IVSRealtime.ListParticipantReplicas", () => {
234 test.provider(
235 "round-trips a replica list for a missing participant",
236 () =>
237 Effect.gen(function* () {
238 const response = (yield* getJson("/replicas")) as Probe;
239 if (response.tag !== undefined) {
240 expect(response.tag).toBe("ValidationException");
241 } else {
242 expect(response.count).toBe(0);
243 }
244 }),
245 { timeout: 120_000 },
246 );
247 });
248
249 describe("IVSRealtime.DisconnectParticipant", () => {
250 test.provider(
251 "round-trips a disconnect for a missing participant",
252 () =>
253 Effect.gen(function* () {
254 const response = (yield* postJson("/disconnect")) as Probe;
255 if (response.tag !== undefined) {
256 expect([
257 "ResourceNotFoundException",
258 "ValidationException",
259 ]).toContain(response.tag);
260 } else {
261 expect(response.ok).toBe(true);
262 }
263 }),
264 { timeout: 120_000 },
265 );
266 });
267
268 describe("IVSRealtime.StartParticipantReplication", () => {
269 test.provider(
270 "returns a typed rejection for a missing participant",
271 () =>
272 Effect.gen(function* () {
273 const response = (yield* postJson("/replication-start")) as Probe;
274 expect([
275 "ResourceNotFoundException",
276 "ValidationException",
277 "ConflictException",
278 "PendingVerification",
279 ]).toContain(response.tag);
280 }),
281 { timeout: 120_000 },
282 );
283 });
284
285 describe("IVSRealtime.StopParticipantReplication", () => {
286 test.provider(
287 "round-trips a stop for a missing participant",
288 () =>
289 Effect.gen(function* () {
290 const response = (yield* postJson("/replication-stop")) as Probe;
291 if (response.tag !== undefined) {
292 expect([
293 "ResourceNotFoundException",
294 "ValidationException",
295 ]).toContain(response.tag);
296 } else {
297 expect(response.ok).toBe(true);
298 }
299 }),
300 { timeout: 120_000 },
301 );
302 });
303
304 describe("IVSRealtime.ListCompositions", () => {
305 test.provider(
306 "lists the account's compositions",
307 () =>
308 Effect.gen(function* () {
309 const response = (yield* getJson("/compositions")) as {
310 count: number;
311 };
312 expect(response.count).toBeGreaterThanOrEqual(0);
313 }),
314 { timeout: 120_000 },
315 );
316 });
317
318 describe("IVSRealtime.GetComposition", () => {
319 test.provider(
320 "returns the typed not-found tag for a missing composition",
321 () =>
322 Effect.gen(function* () {
323 const region = yield* yield* AWS.Region;
324 const { Account } = yield* sts.getCallerIdentity({});
325 const arn = `arn:aws:ivs:${region}:${Account}:composition/AbCdEfGh1234`;
326 const response = (yield* getJson(
327 `/composition-missing?arn=${encodeURIComponent(arn)}`,
328 )) as Probe;
329 expect([
330 "ResourceNotFoundException",
331 "ValidationException",
332 ]).toContain(response.tag);
333 }),
334 { timeout: 120_000 },
335 );
336 });
337
338 describe("IVSRealtime.StopComposition", () => {
339 test.provider(
340 "returns the typed not-found tag for a missing composition",
341 () =>
342 Effect.gen(function* () {
343 const region = yield* yield* AWS.Region;
344 const { Account } = yield* sts.getCallerIdentity({});
345 const arn = `arn:aws:ivs:${region}:${Account}:composition/AbCdEfGh1234`;
346 const response = (yield* postJson(
347 `/composition-stop?arn=${encodeURIComponent(arn)}`,
348 )) as Probe;
349 expect([
350 "ResourceNotFoundException",
351 "ValidationException",
352 ]).toContain(response.tag);
353 }),
354 { timeout: 120_000 },
355 );
356 });
357
358 describe("IVSRealtime.StartComposition", () => {
359 test.provider(
360 "returns a typed rejection for a missing storage configuration",
361 () =>
362 Effect.gen(function* () {
363 const region = yield* yield* AWS.Region;
364 const { Account } = yield* sts.getCallerIdentity({});
365 const arn = `arn:aws:ivs:${region}:${Account}:storage-configuration/AbCdEfGh1234`;
366 const response = (yield* postJson(
367 `/composition-start?arn=${encodeURIComponent(arn)}`,
368 )) as Probe;
369 expect([
370 "ResourceNotFoundException",
371 "ValidationException",
372 "PendingVerification",
373 ]).toContain(response.tag);
374 }),
375 { timeout: 120_000 },
376 );
377 });
378
379 describe(
380 "consumeStageEvents",
381 { tags: ["provider:aws:eventbridge"] },
382 () => {
383 test.provider(
384 "the deploy created an EventBridge rule targeting the function",
385 () =>
386 Effect.gen(function* () {
387 // Out-of-band via distilled: the fixture's consumeStageEvents must
388 // have materialized as a rule on the default bus with the Lambda
389 // as target.
390 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
391 TargetArn: functionArn,
392 });
393 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
394 }),
395 { timeout: 120_000 },
396 );
397 },
398 );
399 },
400);