1import * as AWS from "@/AWS";
2import { Channel, ChannelGroup, OriginEndpoint } from "@/AWS/MediaPackageV2";
3import * as Test from "@/Test/Alchemy";
4import * as mediapackagev2 from "@distilled.cloud/aws/mediapackagev2";
5import { expect } from "alchemy-test";
6import type * as Duration from "effect/Duration";
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/reconcile paths depend on.
14test.provider(
15 "getChannelGroup on a bogus name fails with ResourceNotFoundException",
16 () =>
17 Effect.gen(function* () {
18 const error = yield* Effect.flip(
19 mediapackagev2.getChannelGroup({
20 ChannelGroupName: "alchemy-nonexistent-channel-group-probe",
21 }),
22 );
23 expect(error._tag).toBe("ResourceNotFoundException");
24 }),
25 { tags: ["provider:aws", "provider:aws:mediapackagev2", "live"] },
26);
27
28const assertGroupGone = (channelGroupName: string) =>
29 Effect.gen(function* () {
30 const status = yield* mediapackagev2
31 .getChannelGroup({ ChannelGroupName: channelGroupName })
32 .pipe(
33 Effect.map(() => "exists" as const),
34 Effect.catchTag("ResourceNotFoundException", () =>
35 Effect.succeed("gone" as const),
36 ),
37 );
38 if (status !== "gone") {
39 return yield* Effect.fail(
40 new Error(`channel group '${channelGroupName}' still exists`),
41 );
42 }
43 }).pipe(
44 Effect.retry({
45 schedule: Schedule.max([
46 Schedule.fixed("3 seconds"),
47 Schedule.recurs(10),
48 ]),
49 }),
50 );
51
52test.provider(
53 "channel group → channel → origin endpoint lifecycle (create, update, replace, destroy)",
54 (stack) =>
55 Effect.gen(function* () {
56 yield* stack.destroy();
57
58 const deployStack = (props: {
59 endpointDescription?: string;
60 startoverWindow?: Duration.Input;
61 containerType: mediapackagev2.ContainerType;
62 channelPolicy?: string;
63 endpointPolicy?: string;
64 }) =>
65 stack.deploy(
66 Effect.gen(function* () {
67 const group = yield* ChannelGroup("Group", {
68 description: "alchemy mediapackagev2 test group",
69 tags: { fixture: "mediapackagev2" },
70 });
71 const channel = yield* Channel("Feed", {
72 channelGroupName: group.channelGroupName,
73 inputType: "HLS",
74 description: "alchemy mediapackagev2 test channel",
75 policy: props.channelPolicy,
76 tags: { fixture: "mediapackagev2" },
77 });
78 const endpoint = yield* OriginEndpoint("Playback", {
79 channelGroupName: group.channelGroupName,
80 channelName: channel.channelName,
81 containerType: props.containerType,
82 segment: { SegmentDurationSeconds: 6 },
83 description: props.endpointDescription,
84 startoverWindow: props.startoverWindow,
85 hlsManifests: [{ ManifestName: "index" }],
86 policy: props.endpointPolicy,
87 tags: { fixture: "mediapackagev2" },
88 });
89 return { group, channel, endpoint };
90 }),
91 );
92
93 // 1. Greenfield create.
94 const first = yield* deployStack({
95 endpointDescription: "v1",
96 containerType: "TS",
97 });
98
99 expect(first.group.channelGroupArn).toContain(":channelGroup/");
100 expect(first.group.egressDomain).toContain(".mediapackagev2.");
101 expect(first.channel.channelArn).toContain("/channel/");
102 expect(first.channel.ingestEndpoints.length).toBeGreaterThan(0);
103 expect(first.channel.inputType).toBe("HLS");
104 expect(first.endpoint.originEndpointArn).toContain("/originEndpoint/");
105 expect(first.endpoint.containerType).toBe("TS");
106 expect(first.endpoint.hlsManifests).toHaveLength(1);
107 expect(first.endpoint.hlsManifests[0]?.manifestName).toBe("index");
108 expect(first.endpoint.hlsManifests[0]?.url).toContain("index.m3u8");
109
110 // Out-of-band verification via distilled.
111 const observedGroup = yield* mediapackagev2.getChannelGroup({
112 ChannelGroupName: first.group.channelGroupName,
113 });
114 expect(observedGroup.EgressDomain).toBe(first.group.egressDomain);
115 expect(observedGroup.Description).toBe(
116 "alchemy mediapackagev2 test group",
117 );
118 const observedChannel = yield* mediapackagev2.getChannel({
119 ChannelGroupName: first.group.channelGroupName,
120 ChannelName: first.channel.channelName,
121 });
122 expect(observedChannel.Arn).toBe(first.channel.channelArn);
123 const observedEndpoint = yield* mediapackagev2.getOriginEndpoint({
124 ChannelGroupName: first.group.channelGroupName,
125 ChannelName: first.channel.channelName,
126 OriginEndpointName: first.endpoint.originEndpointName,
127 });
128 expect(observedEndpoint.Description).toBe("v1");
129 expect(observedEndpoint.Tags?.fixture).toBe("mediapackagev2");
130
131 // 2. In-place update: endpoint description + startover window change,
132 // and resource policies are attached to the channel and endpoint;
133 // every ARN must survive.
134 const accountId = first.channel.channelArn.split(":")[4]!;
135 const channelPolicy = JSON.stringify({
136 Version: "2012-10-17",
137 Statement: [
138 {
139 Sid: "AllowIngest",
140 Effect: "Allow",
141 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
142 Action: "mediapackagev2:PutObject",
143 Resource: first.channel.channelArn,
144 },
145 ],
146 });
147 const endpointPolicy = JSON.stringify({
148 Version: "2012-10-17",
149 Statement: [
150 {
151 Sid: "AllowPlayback",
152 Effect: "Allow",
153 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
154 Action: [
155 "mediapackagev2:GetObject",
156 "mediapackagev2:GetHeadObject",
157 ],
158 Resource: first.endpoint.originEndpointArn,
159 },
160 ],
161 });
162 const second = yield* deployStack({
163 endpointDescription: "v2",
164 startoverWindow: "5 minutes",
165 containerType: "TS",
166 channelPolicy,
167 endpointPolicy,
168 });
169 expect(second.group.channelGroupArn).toBe(first.group.channelGroupArn);
170 expect(second.channel.channelArn).toBe(first.channel.channelArn);
171 expect(second.endpoint.originEndpointArn).toBe(
172 first.endpoint.originEndpointArn,
173 );
174 const updatedEndpoint = yield* mediapackagev2.getOriginEndpoint({
175 ChannelGroupName: second.group.channelGroupName,
176 ChannelName: second.channel.channelName,
177 OriginEndpointName: second.endpoint.originEndpointName,
178 });
179 expect(updatedEndpoint.Description).toBe("v2");
180 expect(updatedEndpoint.StartoverWindowSeconds).toBe(300);
181 // Policies landed — verify out-of-band.
182 const observedChannelPolicy = yield* mediapackagev2.getChannelPolicy({
183 ChannelGroupName: second.group.channelGroupName,
184 ChannelName: second.channel.channelName,
185 });
186 expect(JSON.parse(observedChannelPolicy.Policy).Statement[0].Sid).toBe(
187 "AllowIngest",
188 );
189 const observedEndpointPolicy =
190 yield* mediapackagev2.getOriginEndpointPolicy({
191 ChannelGroupName: second.group.channelGroupName,
192 ChannelName: second.channel.channelName,
193 OriginEndpointName: second.endpoint.originEndpointName,
194 });
195 expect(JSON.parse(observedEndpointPolicy.Policy).Statement[0].Sid).toBe(
196 "AllowPlayback",
197 );
198
199 // 3. Replacement: the container type is immutable, so TS → CMAF must
200 // replace the endpoint (new physical name) while the channel and
201 // group stay in place. Policies are dropped, exercising the
202 // delete-policy sync path on the surviving channel.
203 const third = yield* deployStack({
204 endpointDescription: "v2",
205 startoverWindow: "5 minutes",
206 containerType: "CMAF",
207 });
208 expect(third.group.channelGroupArn).toBe(first.group.channelGroupArn);
209 expect(third.channel.channelArn).toBe(first.channel.channelArn);
210 expect(third.endpoint.originEndpointArn).not.toBe(
211 first.endpoint.originEndpointArn,
212 );
213 expect(third.endpoint.originEndpointName).not.toBe(
214 first.endpoint.originEndpointName,
215 );
216 expect(third.endpoint.containerType).toBe("CMAF");
217 // The replaced endpoint is deleted.
218 const oldEndpointError = yield* Effect.flip(
219 mediapackagev2.getOriginEndpoint({
220 ChannelGroupName: first.group.channelGroupName,
221 ChannelName: first.channel.channelName,
222 OriginEndpointName: first.endpoint.originEndpointName,
223 }),
224 );
225 expect(oldEndpointError._tag).toBe("ResourceNotFoundException");
226 // The channel policy was removed (policy prop dropped).
227 const removedPolicyError = yield* Effect.flip(
228 mediapackagev2.getChannelPolicy({
229 ChannelGroupName: third.group.channelGroupName,
230 ChannelName: third.channel.channelName,
231 }),
232 );
233 expect(removedPolicyError._tag).toBe("ResourceNotFoundException");
234
235 // 4. Destroy everything (endpoint → channel → group) and verify the
236 // group is gone out-of-band.
237 yield* stack.destroy();
238 yield* assertGroupGone(first.group.channelGroupName);
239 }),
240 {
241 tags: ["provider:aws", "provider:aws:mediapackagev2", "live"],
242 timeout: 300_000,
243 },
244);