1import * as AWS from "@/AWS";
2import { DataSet, EventAction, Revision } from "@/AWS/DataExchange";
3import { toTagRecord } from "@/AWS/DataExchange/internal.ts";
4import * as Test from "@/Test/Alchemy";
5import * as dataexchange from "@distilled.cloud/aws/dataexchange";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Typed-error probe: proves the SDK decodes DataExchange errors as typed
12// tags. A missing data set surfaces as ResourceNotFoundException.
13test.provider(
14 "getDataSet on a nonexistent id fails with a typed error",
15 () =>
16 Effect.gen(function* () {
17 const error = yield* Effect.flip(
18 dataexchange.getDataSet({
19 DataSetId: "ffffffffffffffffffffffffffffffff",
20 }),
21 );
22 expect(error._tag).toBe("ResourceNotFoundException");
23 }),
24 { tags: ["provider:aws", "provider:aws:dataexchange", "live"] },
25);
26
27// Data sets and revisions are cheap, instant metadata objects — the live
28// lifecycle runs ungated with out-of-band verification. Asset import jobs
29// (the paid/slow part) are out of scope for the definition lifecycle.
30test.provider(
31 "create, update, destroy a data set with a revision",
32 (stack) =>
33 Effect.gen(function* () {
34 yield* stack.destroy();
35
36 const created = yield* stack.deploy(
37 Effect.gen(function* () {
38 const dataSet = yield* DataSet("Prices", {
39 description: "Daily commodity price snapshots",
40 tags: { Environment: "test" },
41 });
42 const revision = yield* Revision("PricesV1", {
43 dataSetId: dataSet.dataSetId,
44 comment: "Initial snapshot",
45 tags: { Environment: "test" },
46 });
47 return { dataSet, revision };
48 }),
49 );
50
51 expect(created.dataSet.dataSetArn).toContain(":data-sets/");
52 expect(created.dataSet.assetType).toBe("S3_SNAPSHOT");
53 expect(created.dataSet.origin).toBe("OWNED");
54 expect(created.revision.revisionArn).toContain("/revisions/");
55 expect(created.revision.dataSetId).toBe(created.dataSet.dataSetId);
56 expect(created.revision.finalized).toBe(false);
57
58 // Out-of-band verification via distilled.
59 const observed = yield* dataexchange.getDataSet({
60 DataSetId: created.dataSet.dataSetId,
61 });
62 expect(observed.Name).toBe(created.dataSet.name);
63 expect(observed.Description).toBe("Daily commodity price snapshots");
64 const dataSetTags = yield* dataexchange.listTagsForResource({
65 ResourceArn: created.dataSet.dataSetArn,
66 });
67 expect(toTagRecord(dataSetTags.Tags)["alchemy::id"]).toBe("Prices");
68 expect(toTagRecord(dataSetTags.Tags).Environment).toBe("test");
69
70 const observedRevision = yield* dataexchange.getRevision({
71 DataSetId: created.dataSet.dataSetId,
72 RevisionId: created.revision.revisionId,
73 });
74 expect(observedRevision.Comment).toBe("Initial snapshot");
75 const revisionTags = yield* dataexchange.listTagsForResource({
76 ResourceArn: created.revision.revisionArn,
77 });
78 expect(toTagRecord(revisionTags.Tags)["alchemy::id"]).toBe("PricesV1");
79
80 // Update in place: description, revision comment, and tags.
81 const updated = yield* stack.deploy(
82 Effect.gen(function* () {
83 const dataSet = yield* DataSet("Prices", {
84 description: "Hourly commodity price snapshots",
85 tags: { Environment: "test", Extra: "yes" },
86 });
87 const revision = yield* Revision("PricesV1", {
88 dataSetId: dataSet.dataSetId,
89 comment: "Amended snapshot",
90 });
91 return { dataSet, revision };
92 }),
93 );
94
95 // Stable identifiers are preserved across the update.
96 expect(updated.dataSet.dataSetId).toBe(created.dataSet.dataSetId);
97 expect(updated.revision.revisionId).toBe(created.revision.revisionId);
98
99 const reobserved = yield* dataexchange.getDataSet({
100 DataSetId: created.dataSet.dataSetId,
101 });
102 expect(reobserved.Description).toBe("Hourly commodity price snapshots");
103 const updatedTags = yield* dataexchange.listTagsForResource({
104 ResourceArn: created.dataSet.dataSetArn,
105 });
106 expect(toTagRecord(updatedTags.Tags).Extra).toBe("yes");
107
108 const reobservedRevision = yield* dataexchange.getRevision({
109 DataSetId: created.dataSet.dataSetId,
110 RevisionId: created.revision.revisionId,
111 });
112 expect(reobservedRevision.Comment).toBe("Amended snapshot");
113 // User tag was removed from the revision; internal tags survive.
114 const updatedRevisionTags = yield* dataexchange.listTagsForResource({
115 ResourceArn: created.revision.revisionArn,
116 });
117 expect(toTagRecord(updatedRevisionTags.Tags).Environment).toBeUndefined();
118 expect(toTagRecord(updatedRevisionTags.Tags)["alchemy::id"]).toBe(
119 "PricesV1",
120 );
121
122 // Destroy and verify deletion out-of-band.
123 yield* stack.destroy();
124 const dataSetError = yield* Effect.flip(
125 dataexchange.getDataSet({ DataSetId: created.dataSet.dataSetId }),
126 );
127 expect(dataSetError._tag).toBe("ResourceNotFoundException");
128 }),
129 {
130 tags: ["provider:aws", "provider:aws:dataexchange", "live"],
131 timeout: 300_000,
132 },
133);
134
135// Replacement: assetType is immutable, so changing it replaces the data set.
136test.provider(
137 "changing assetType replaces the data set",
138 (stack) =>
139 Effect.gen(function* () {
140 yield* stack.destroy();
141
142 const created = yield* stack.deploy(
143 Effect.gen(function* () {
144 return yield* DataSet("Replaceable", {
145 assetType: "S3_SNAPSHOT",
146 description: "before replacement",
147 });
148 }),
149 );
150 expect(created.assetType).toBe("S3_SNAPSHOT");
151
152 const replaced = yield* stack.deploy(
153 Effect.gen(function* () {
154 return yield* DataSet("Replaceable", {
155 assetType: "API_GATEWAY_API",
156 description: "after replacement",
157 });
158 }),
159 );
160 expect(replaced.assetType).toBe("API_GATEWAY_API");
161 expect(replaced.dataSetId).not.toBe(created.dataSetId);
162
163 // The old data set is gone.
164 const oldError = yield* Effect.flip(
165 dataexchange.getDataSet({ DataSetId: created.dataSetId }),
166 );
167 expect(oldError._tag).toBe("ResourceNotFoundException");
168
169 yield* stack.destroy();
170 const newError = yield* Effect.flip(
171 dataexchange.getDataSet({ DataSetId: replaced.dataSetId }),
172 );
173 expect(newError._tag).toBe("ResourceNotFoundException");
174 }),
175 {
176 tags: ["provider:aws", "provider:aws:dataexchange", "live"],
177 timeout: 300_000,
178 },
179);
180
181// Event actions only attach to ENTITLED data sets (subscriptions / accepted
182// data grants), which cannot be provisioned self-contained. This ungated
183// probe proves the rejection against an OWNED data set is a typed tag.
184test.provider(
185 "createEventAction against an owned data set fails with a typed error",
186 () =>
187 Effect.gen(function* () {
188 const dataSet = yield* dataexchange.createDataSet({
189 AssetType: "S3_SNAPSHOT",
190 Name: "alchemy-test-dataexchange-eventaction-probe",
191 Description: "event action entitlement probe",
192 });
193 const error = yield* Effect.flip(
194 dataexchange.createEventAction({
195 Action: {
196 ExportRevisionToS3: {
197 RevisionDestination: { Bucket: "alchemy-nonexistent-bucket" },
198 },
199 },
200 Event: { RevisionPublished: { DataSetId: dataSet.Id! } },
201 }),
202 ).pipe(
203 Effect.ensuring(
204 // Cleanup tolerates ONLY the typed not-found; any other delete
205 // failure must surface rather than silently orphan the data set.
206 dataexchange.deleteDataSet({ DataSetId: dataSet.Id! }).pipe(
207 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
208 Effect.orDie,
209 ),
210 ),
211 );
212 expect(error._tag).toBe("ValidationException");
213 }),
214 {
215 tags: ["provider:aws", "provider:aws:dataexchange", "live"],
216 timeout: 60_000,
217 },
218);
219
220// Full event-action lifecycle needs an entitled data set — run it from an
221// account with an active subscription/data grant by setting
222// AWS_TEST_DATAEXCHANGE_ENTITLED_DATASET_ID (and an export bucket whose
223// policy admits dataexchange.amazonaws.com via
224// AWS_TEST_DATAEXCHANGE_EXPORT_BUCKET).
225test.provider.skipIf(!process.env.AWS_TEST_DATAEXCHANGE_ENTITLED_DATASET_ID)(
226 "create, update, destroy an event action on an entitled data set",
227 (stack) =>
228 Effect.gen(function* () {
229 const entitledDataSetId =
230 process.env.AWS_TEST_DATAEXCHANGE_ENTITLED_DATASET_ID!;
231 const exportBucket = process.env.AWS_TEST_DATAEXCHANGE_EXPORT_BUCKET!;
232 yield* stack.destroy();
233
234 const created = yield* stack.deploy(
235 Effect.gen(function* () {
236 return yield* EventAction("AutoExport", {
237 dataSetId: entitledDataSetId,
238 exportRevisionToS3: { bucket: exportBucket },
239 });
240 }),
241 );
242 expect(created.eventActionArn).toContain(":event-actions/");
243
244 // Update the key pattern in place.
245 const updated = yield* stack.deploy(
246 Effect.gen(function* () {
247 return yield* EventAction("AutoExport", {
248 dataSetId: entitledDataSetId,
249 exportRevisionToS3: {
250 bucket: exportBucket,
251 keyPattern: "exports/${Revision.CreatedAt}/${Asset.Name}",
252 },
253 });
254 }),
255 );
256 expect(updated.eventActionId).toBe(created.eventActionId);
257
258 const observed = yield* dataexchange.getEventAction({
259 EventActionId: created.eventActionId,
260 });
261 expect(
262 observed.Action?.ExportRevisionToS3?.RevisionDestination.KeyPattern,
263 ).toBe("exports/${Revision.CreatedAt}/${Asset.Name}");
264
265 yield* stack.destroy();
266 const gone = yield* Effect.flip(
267 dataexchange.getEventAction({
268 EventActionId: created.eventActionId,
269 }),
270 );
271 expect(gone._tag).toBe("ResourceNotFoundException");
272 }),
273 {
274 tags: ["provider:aws", "provider:aws:dataexchange", "live"],
275 timeout: 300_000,
276 },
277);