1import * as AWS from "@/AWS";
2import { PlaybackConfiguration } from "@/AWS/MediaTailor";
3import * as Test from "@/Test/Alchemy";
4import * as mediatailor from "@distilled.cloud/aws/mediatailor";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const findConfiguration = (name: string) =>
13 mediatailor.getPlaybackConfiguration({ Name: name }).pipe(
14 Effect.map(
15 (config): mediatailor.GetPlaybackConfigurationResponse | undefined =>
16 config,
17 ),
18 Effect.catchTag("PlaybackConfigurationNotFound", () =>
19 Effect.succeed(undefined),
20 ),
21 );
22
23class ConfigurationStillExists extends Data.TaggedError(
24 "ConfigurationStillExists",
25)<{ readonly name: string }> {}
26
27const assertConfigurationDeleted = (name: string) =>
28 findConfiguration(name).pipe(
29 Effect.flatMap((config) =>
30 config === undefined
31 ? Effect.void
32 : Effect.fail(new ConfigurationStillExists({ name })),
33 ),
34 Effect.retry({
35 while: (e) => e._tag === "ConfigurationStillExists",
36 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
37 }),
38 );
39
40// Ungated typed-error probe: prove the distilled synthetic tag
41// (patches/mediatailor.json) carves PlaybackConfigurationNotFound out of the
42// unmodeled wire NotFoundException. Runs in every CI pass at near-zero cost.
43test.provider(
44 "getPlaybackConfiguration on a nonexistent name fails with PlaybackConfigurationNotFound",
45 () =>
46 Effect.gen(function* () {
47 const error = yield* Effect.flip(
48 mediatailor.getPlaybackConfiguration({
49 Name: "alchemy-nonexistent-playback-config-probe",
50 }),
51 );
52 expect(error._tag).toBe("PlaybackConfigurationNotFound");
53 }),
54 { tags: ["provider:aws", "provider:aws:mediatailor", "live"] },
55);
56
57test.provider(
58 "create, update, and delete a playback configuration",
59 (stack) =>
60 Effect.gen(function* () {
61 yield* stack.destroy();
62
63 const config = yield* stack.deploy(
64 Effect.gen(function* () {
65 return yield* PlaybackConfiguration("TestConfig", {
66 adDecisionServerUrl: "https://ads.example.com/vast?ip=[client_ip]",
67 videoContentSourceUrl: "https://origin.example.com/live",
68 slateAdUrl: "https://origin.example.com/slate.mp4",
69 tags: { Environment: "test" },
70 });
71 }),
72 );
73
74 expect(config.name).toBeDefined();
75 expect(config.playbackConfigurationArn).toContain(
76 ":playbackConfiguration/",
77 );
78 expect(config.playbackEndpointPrefix).toContain("mediatailor");
79 expect(config.sessionInitializationEndpointPrefix).toContain(
80 "mediatailor",
81 );
82
83 // out-of-band verification via distilled
84 const created = yield* findConfiguration(config.name);
85 expect(created?.AdDecisionServerUrl).toBe(
86 "https://ads.example.com/vast?ip=[client_ip]",
87 );
88 expect(created?.VideoContentSourceUrl).toBe(
89 "https://origin.example.com/live",
90 );
91 expect(created?.SlateAdUrl).toBe("https://origin.example.com/slate.mp4");
92 expect(created?.Tags?.Environment).toBe("test");
93 expect(created?.Tags?.["alchemy::id"]).toBe("TestConfig");
94
95 // update: change the ADS URL, drop the slate, add manifest rules and
96 // avail suppression, swap the user tag
97 const updated = yield* stack.deploy(
98 Effect.gen(function* () {
99 return yield* PlaybackConfiguration("TestConfig", {
100 adDecisionServerUrl: "https://ads.example.com/vast/v2",
101 videoContentSourceUrl: "https://origin.example.com/live",
102 personalizationThreshold: "2 seconds",
103 manifestProcessingRules: { adMarkerPassthroughEnabled: true },
104 availSuppression: { mode: "BEHIND_LIVE_EDGE", value: "00:00:30" },
105 logConfiguration: { percentEnabled: 10 },
106 tags: { Purpose: "alchemy-live-test" },
107 });
108 }),
109 );
110 expect(updated.name).toBe(config.name);
111 expect(updated.playbackConfigurationArn).toBe(
112 config.playbackConfigurationArn,
113 );
114
115 const afterUpdate = yield* findConfiguration(config.name);
116 expect(afterUpdate?.AdDecisionServerUrl).toBe(
117 "https://ads.example.com/vast/v2",
118 );
119 // the put is a full replace — the removed slate URL must be cleared
120 expect(afterUpdate?.SlateAdUrl ?? undefined).toBeUndefined();
121 expect(afterUpdate?.PersonalizationThresholdSeconds).toBe(2);
122 expect(
123 afterUpdate?.ManifestProcessingRules?.AdMarkerPassthrough?.Enabled,
124 ).toBe(true);
125 expect(afterUpdate?.AvailSuppression?.Mode).toBe("BEHIND_LIVE_EDGE");
126 expect(afterUpdate?.AvailSuppression?.Value).toBe("00:00:30");
127 // log sync: ConfigureLogsForPlaybackConfiguration applied the percent
128 expect(afterUpdate?.LogConfiguration?.PercentEnabled).toBe(10);
129 // tag sync: new tag present, old user tag removed, branding intact
130 expect(afterUpdate?.Tags?.Purpose).toBe("alchemy-live-test");
131 expect(afterUpdate?.Tags?.Environment ?? undefined).toBeUndefined();
132 expect(afterUpdate?.Tags?.["alchemy::id"]).toBe("TestConfig");
133
134 yield* stack.destroy();
135 yield* assertConfigurationDeleted(config.name);
136 }),
137 {
138 tags: ["provider:aws", "provider:aws:mediatailor", "live"],
139 timeout: 120_000,
140 },
141);
142
143test.provider(
144 "custom name and replacement on rename",
145 (stack) =>
146 Effect.gen(function* () {
147 yield* stack.destroy();
148
149 const first = yield* stack.deploy(
150 Effect.gen(function* () {
151 return yield* PlaybackConfiguration("NamedConfig", {
152 name: "alchemy-test-playback-config-a",
153 adDecisionServerUrl: "https://ads.example.com/vast",
154 videoContentSourceUrl: "https://origin.example.com/vod",
155 });
156 }),
157 );
158 expect(first.name).toBe("alchemy-test-playback-config-a");
159
160 // renaming replaces the configuration
161 const second = yield* stack.deploy(
162 Effect.gen(function* () {
163 return yield* PlaybackConfiguration("NamedConfig", {
164 name: "alchemy-test-playback-config-b",
165 adDecisionServerUrl: "https://ads.example.com/vast",
166 videoContentSourceUrl: "https://origin.example.com/vod",
167 });
168 }),
169 );
170 expect(second.name).toBe("alchemy-test-playback-config-b");
171 expect(second.playbackConfigurationArn).not.toBe(
172 first.playbackConfigurationArn,
173 );
174
175 // the replaced configuration is deleted after the new one is live
176 yield* assertConfigurationDeleted("alchemy-test-playback-config-a");
177 const replacement = yield* findConfiguration(
178 "alchemy-test-playback-config-b",
179 );
180 expect(replacement?.PlaybackConfigurationArn).toBe(
181 second.playbackConfigurationArn,
182 );
183
184 yield* stack.destroy();
185 yield* assertConfigurationDeleted("alchemy-test-playback-config-b");
186 }),
187 {
188 tags: ["provider:aws", "provider:aws:mediatailor", "live"],
189 timeout: 120_000,
190 },
191);