| 1 | import * as AWS from "@/AWS"; |
| 2 | import { DataSet, EventAction, Revision } from "@/AWS/DataExchange"; |
| 3 | import { toTagRecord } from "@/AWS/DataExchange/internal.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as dataexchange from "@distilled.cloud/aws/dataexchange"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | |
| 9 | const { 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. |
| 13 | test.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. |
| 30 | test.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. |
| 136 | test.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. |
| 184 | test.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). |
| 225 | test.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 | ); |