1import * as AWS from "@/AWS";
2import { Asset, AssetModel, Gateway } from "@/AWS/IoTSiteWise";
3import * as Test from "@/Test/Alchemy";
4import * as sitewise from "@distilled.cloud/aws/iotsitewise";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Ungated typed-error probe: prove the distilled error union carries the
12// not-found tag this provider's read/delete/wait paths depend on. Runs in
13// every CI pass at near-zero cost.
14test.provider(
15 "describeAssetModel on a nonexistent id fails with ResourceNotFoundException",
16 () =>
17 Effect.gen(function* () {
18 const error = yield* Effect.flip(
19 sitewise.describeAssetModel({
20 // syntactically valid UUID that does not exist
21 assetModelId: "12345678-1234-4123-8123-123456789012",
22 }),
23 );
24 expect(error._tag).toBe("ResourceNotFoundException");
25 }),
26 { tags: ["provider:aws", "provider:aws:iotsitewise", "live"] },
27);
28
29// Bounded wait until a describe reports the resource gone.
30const untilGone = <R>(
31 label: string,
32 probe: Effect.Effect<"gone" | string, never, R>,
33) =>
34 Effect.gen(function* () {
35 const state = yield* probe;
36 if (state !== "gone") {
37 return yield* Effect.fail(
38 new Error(`${label} still exists (state: ${state})`),
39 );
40 }
41 }).pipe(
42 Effect.retry({
43 schedule: Schedule.max([
44 Schedule.spaced("3 seconds"),
45 Schedule.recurs(10),
46 ]),
47 }),
48 );
49
50test.provider(
51 "asset model + asset lifecycle (create, update, destroy)",
52 (stack) =>
53 Effect.gen(function* () {
54 yield* stack.destroy();
55
56 const deploy = (description: string) =>
57 stack.deploy(
58 Effect.gen(function* () {
59 const model = yield* AssetModel("PumpModel", {
60 assetModelDescription: description,
61 assetModelProperties: [
62 {
63 name: "SerialNumber",
64 dataType: "STRING",
65 type: { attribute: { defaultValue: "unknown" } },
66 },
67 {
68 name: "Temperature",
69 dataType: "DOUBLE",
70 unit: "Celsius",
71 type: { measurement: {} },
72 },
73 ],
74 tags: { fixture: "iotsitewise" },
75 });
76 const asset = yield* Asset("Pump1", {
77 assetModelId: model.assetModelId,
78 assetDescription: description,
79 tags: { fixture: "iotsitewise" },
80 });
81 return { model, asset };
82 }),
83 );
84
85 // Create.
86 const first = yield* deploy("pump model v1");
87 expect(first.model.assetModelId).toBeDefined();
88 expect(first.model.assetModelArn).toContain(":asset-model/");
89 expect(first.model.state).toBe("ACTIVE");
90 expect(first.asset.assetArn).toContain(":asset/");
91 expect(first.asset.state).toBe("ACTIVE");
92 expect(first.asset.assetModelId).toBe(first.model.assetModelId);
93
94 // Out-of-band verification via distilled.
95 const describedModel = yield* sitewise.describeAssetModel({
96 assetModelId: first.model.assetModelId,
97 });
98 expect(describedModel.assetModelDescription).toBe("pump model v1");
99 expect(
100 describedModel.assetModelProperties.map((p) => p.name).sort(),
101 ).toEqual(["SerialNumber", "Temperature"]);
102 const describedAsset = yield* sitewise.describeAsset({
103 assetId: first.asset.assetId,
104 excludeProperties: true,
105 });
106 expect(describedAsset.assetDescription).toBe("pump model v1");
107 const modelTags = yield* sitewise.listTagsForResource({
108 resourceArn: first.model.assetModelArn,
109 });
110 expect(modelTags.tags?.fixture).toBe("iotsitewise");
111 expect(modelTags.tags?.["alchemy::id"]).toBe("PumpModel");
112
113 // Update in place — descriptions change, ids are stable.
114 const second = yield* deploy("pump model v2");
115 expect(second.model.assetModelId).toBe(first.model.assetModelId);
116 expect(second.asset.assetId).toBe(first.asset.assetId);
117 const updatedModel = yield* sitewise.describeAssetModel({
118 assetModelId: first.model.assetModelId,
119 excludeProperties: true,
120 });
121 expect(updatedModel.assetModelDescription).toBe("pump model v2");
122 const updatedAsset = yield* sitewise.describeAsset({
123 assetId: first.asset.assetId,
124 excludeProperties: true,
125 });
126 expect(updatedAsset.assetDescription).toBe("pump model v2");
127
128 // Destroy — asset must go before the model; verify both are gone.
129 yield* stack.destroy();
130 yield* untilGone(
131 "asset",
132 sitewise
133 .describeAsset({
134 assetId: first.asset.assetId,
135 excludeProperties: true,
136 })
137 .pipe(
138 Effect.map((r) => r.assetStatus.state as string),
139 Effect.catchTag("ResourceNotFoundException", () =>
140 Effect.succeed("gone" as const),
141 ),
142 Effect.orDie,
143 ),
144 );
145 yield* untilGone(
146 "asset model",
147 sitewise
148 .describeAssetModel({
149 assetModelId: first.model.assetModelId,
150 excludeProperties: true,
151 })
152 .pipe(
153 Effect.map((r) => r.assetModelStatus.state as string),
154 Effect.catchTag("ResourceNotFoundException", () =>
155 Effect.succeed("gone" as const),
156 ),
157 Effect.orDie,
158 ),
159 );
160 }),
161 {
162 tags: ["provider:aws", "provider:aws:iotsitewise", "live"],
163 timeout: 240_000,
164 },
165);
166
167test.provider(
168 "gateway lifecycle with platform replacement",
169 (stack) =>
170 Effect.gen(function* () {
171 yield* stack.destroy();
172
173 const deploy = (coreDeviceThingName: string) =>
174 stack.deploy(
175 Effect.gen(function* () {
176 const gateway = yield* Gateway("EdgeGateway", {
177 gatewayPlatform: { greengrassV2: { coreDeviceThingName } },
178 tags: { fixture: "iotsitewise-gateway" },
179 });
180 return { gateway };
181 }),
182 );
183
184 // Create — the referenced core device does not need to exist.
185 const first = yield* deploy("AlchemyIoTSiteWiseCoreA");
186 expect(first.gateway.gatewayId).toBeDefined();
187 expect(first.gateway.gatewayArn).toContain(":gateway/");
188
189 // Out-of-band verification via distilled.
190 const described = yield* sitewise.describeGateway({
191 gatewayId: first.gateway.gatewayId,
192 });
193 expect(described.gatewayPlatform?.greengrassV2?.coreDeviceThingName).toBe(
194 "AlchemyIoTSiteWiseCoreA",
195 );
196 const tags = yield* sitewise.listTagsForResource({
197 resourceArn: first.gateway.gatewayArn,
198 });
199 expect(tags.tags?.fixture).toBe("iotsitewise-gateway");
200
201 // Changing the platform replaces the gateway.
202 const second = yield* deploy("AlchemyIoTSiteWiseCoreB");
203 expect(second.gateway.gatewayId).not.toBe(first.gateway.gatewayId);
204 expect(
205 (yield* sitewise.describeGateway({
206 gatewayId: second.gateway.gatewayId,
207 })).gatewayPlatform?.greengrassV2?.coreDeviceThingName,
208 ).toBe("AlchemyIoTSiteWiseCoreB");
209 yield* untilGone(
210 "replaced gateway",
211 sitewise.describeGateway({ gatewayId: first.gateway.gatewayId }).pipe(
212 Effect.map(() => "exists"),
213 Effect.catchTag("ResourceNotFoundException", () =>
214 Effect.succeed("gone" as const),
215 ),
216 Effect.orDie,
217 ),
218 );
219
220 // Destroy and verify gone.
221 yield* stack.destroy();
222 yield* untilGone(
223 "gateway",
224 sitewise.describeGateway({ gatewayId: second.gateway.gatewayId }).pipe(
225 Effect.map(() => "exists"),
226 Effect.catchTag("ResourceNotFoundException", () =>
227 Effect.succeed("gone" as const),
228 ),
229 Effect.orDie,
230 ),
231 );
232 }),
233 {
234 tags: ["provider:aws", "provider:aws:iotsitewise", "live"],
235 timeout: 180_000,
236 },
237);