1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import { Flow } from "@/AWS/MediaConnect";
4import * as Test from "@/Test/Alchemy";
5import * as mediaconnect from "@distilled.cloud/aws/mediaconnect";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Ungated typed-error probe: prove the distilled error union carries the
13// not-found tag this provider's read/delete paths depend on. Runs in every
14// CI pass at near-zero cost, unlike the gated lifecycle below.
15test.provider(
16 "describeFlow on a nonexistent flow ARN fails with NotFoundException",
17 () =>
18 Effect.gen(function* () {
19 const { accountId, region } = yield* AWSEnvironment.current;
20 const error = yield* Effect.flip(
21 mediaconnect.describeFlow({
22 FlowArn: `arn:aws:mediaconnect:${region}:${accountId}:flow:1-00000000000000000000000000000000:alchemy-nonexistent-flow-probe`,
23 }),
24 );
25 expect(error._tag).toBe("NotFoundException");
26 }),
27 { tags: ["provider:aws", "provider:aws:mediaconnect", "live"] },
28);
29
30// Deletion is initiated by the provider and verified as fully gone here;
31// bounded out-of-band re-check in case the final read raced DELETING.
32const assertFlowGone = (flowArn: string) =>
33 Effect.gen(function* () {
34 const status = yield* mediaconnect.describeFlow({ FlowArn: flowArn }).pipe(
35 Effect.map((r) => r.Flow?.Status ?? "gone"),
36 Effect.catchTag("NotFoundException", () =>
37 Effect.succeed("gone" as const),
38 ),
39 );
40 if (status !== "gone") {
41 return yield* Effect.fail(
42 new Error(`flow '${flowArn}' still exists (status: ${status})`),
43 );
44 }
45 }).pipe(
46 Effect.retry({
47 schedule: Schedule.max([
48 Schedule.fixed("10 seconds"),
49 Schedule.recurs(18),
50 ]),
51 }),
52 );
53
54// MediaConnect flows bill hourly while ACTIVE and provisioning is async, so
55// the full lifecycle is gated behind AWS_TEST_MEDIACONNECT=1. Create leaves
56// the flow in STANDBY (the provider never calls StartFlow) and the test
57// always destroys what it created.
58test.provider.skipIf(!process.env.AWS_TEST_MEDIACONNECT)(
59 "create flow (STANDBY), add output, sync source, destroy",
60 (stack) =>
61 Effect.gen(function* () {
62 yield* stack.destroy();
63
64 // Step 1: create with a single RTP source and no outputs.
65 const { flow } = yield* stack.deploy(
66 Effect.gen(function* () {
67 const flow = yield* Flow("Broadcast", {
68 source: {
69 Name: "primary",
70 Protocol: "rtp",
71 WhitelistCidr: "10.24.34.0/23",
72 IngestPort: 5000,
73 },
74 tags: { fixture: "mediaconnect-flow" },
75 });
76 return { flow };
77 }),
78 );
79
80 expect(flow.flowArn).toContain(":flow:");
81 expect(flow.status).toBe("STANDBY");
82 expect(flow.availabilityZone).toBeDefined();
83 expect(flow.sourceArn).toContain(":source:");
84 expect(flow.sourceIngestPort).toBe(5000);
85 expect(flow.outputs).toEqual([]);
86 expect(flow.tags.fixture).toBe("mediaconnect-flow");
87
88 // Out-of-band verification via distilled: created but NOT started.
89 const described = yield* mediaconnect.describeFlow({
90 FlowArn: flow.flowArn,
91 });
92 expect(described.Flow?.Status).toBe("STANDBY");
93 expect(described.Flow?.Source?.WhitelistCidr).toBe("10.24.34.0/23");
94
95 // Step 2: update in place — widen the source whitelist and add an
96 // output. Same logical id, same name → no replacement.
97 const { flow: updated } = yield* stack.deploy(
98 Effect.gen(function* () {
99 const flow = yield* Flow("Broadcast", {
100 source: {
101 Name: "primary",
102 Protocol: "rtp",
103 WhitelistCidr: "10.24.32.0/20",
104 IngestPort: 5000,
105 },
106 outputs: [
107 {
108 Name: "affiliate-east",
109 Protocol: "rtp",
110 Destination: "198.51.100.11",
111 Port: 5010,
112 },
113 ],
114 tags: { fixture: "mediaconnect-flow", stage: "updated" },
115 });
116 return { flow };
117 }),
118 );
119
120 // Update, not replacement: the ARN is stable.
121 expect(updated.flowArn).toBe(flow.flowArn);
122 expect(updated.status).toBe("STANDBY");
123 expect(updated.outputs).toHaveLength(1);
124 expect(updated.outputs[0]?.name).toBe("affiliate-east");
125 expect(updated.outputs[0]?.destination).toBe("198.51.100.11");
126 expect(updated.outputs[0]?.port).toBe(5010);
127 expect(updated.tags.stage).toBe("updated");
128
129 const redescribed = yield* mediaconnect.describeFlow({
130 FlowArn: flow.flowArn,
131 });
132 expect(redescribed.Flow?.Source?.WhitelistCidr).toBe("10.24.32.0/20");
133 expect(redescribed.Flow?.Outputs?.map((o) => o.Name)).toContain(
134 "affiliate-east",
135 );
136
137 // Step 3: drop the output again — the reconciler removes extras.
138 const { flow: pruned } = yield* stack.deploy(
139 Effect.gen(function* () {
140 const flow = yield* Flow("Broadcast", {
141 source: {
142 Name: "primary",
143 Protocol: "rtp",
144 WhitelistCidr: "10.24.32.0/20",
145 IngestPort: 5000,
146 },
147 tags: { fixture: "mediaconnect-flow" },
148 });
149 return { flow };
150 }),
151 );
152 expect(pruned.flowArn).toBe(flow.flowArn);
153 expect(pruned.outputs).toEqual([]);
154 expect(pruned.tags.stage).toBeUndefined();
155
156 // Destroy and verify the flow is fully gone out-of-band.
157 yield* stack.destroy();
158 yield* assertFlowGone(flow.flowArn);
159 }),
160 // create + 2 in-place updates + delete-until-gone.
161 {
162 tags: ["provider:aws", "provider:aws:mediaconnect", "live"],
163 timeout: 900_000,
164 },
165);