1import * as AWS from "@/AWS";
2import {
3 Campaign,
4 DecoderManifest,
5 Fleet,
6 ModelManifest,
7 SignalCatalog,
8 StateTemplate,
9 Vehicle,
10} from "@/AWS/IoTFleetWise";
11import { Bucket } from "@/AWS/S3";
12import * as Test from "@/Test/Alchemy";
13import { Region } from "@distilled.cloud/aws/Region";
14import * as iotfleetwise from "@distilled.cloud/aws/iotfleetwise";
15import { expect } from "alchemy-test";
16import * as Effect from "effect/Effect";
17import * as Schedule from "effect/Schedule";
18
19const { test } = Test.make({ providers: AWS.providers() });
20
21// Deterministic bucket name (constant per test suite — never Date.now())
22// so the FleetWise bucket policy can reference the ARN as a literal.
23const TELEMETRY_BUCKET = "alchemy-test-iotfleetwise-telemetry";
24
25// AWS IoT FleetWise is offered in us-east-1/eu-central-1 only — pin every
26// out-of-band distilled call to the service's home region (the providers
27// pin themselves the same way).
28const inHomeRegion = <A, E, R>(
29 effect: Effect.Effect<A, E, R>,
30): Effect.Effect<A, E, R> =>
31 effect.pipe(Effect.provideService(Region, Effect.succeed("us-east-1")));
32
33// Ungated typed-error probe: prove the distilled error union carries the
34// typed tags the providers' read/delete paths depend on. AWS IoT FleetWise
35// is allowlist-gated — accounts without service access observe
36// AccessDeniedException (with an empty message) on every operation, while
37// allowlisted accounts observe ResourceNotFoundException for a bogus name.
38// Both are typed tags in the operation's error union; the probe proves the
39// decode path for whichever gate this account is behind.
40test.provider(
41 "getSignalCatalog on a nonexistent catalog fails with a typed tag",
42 () =>
43 Effect.gen(function* () {
44 const error = yield* Effect.flip(
45 iotfleetwise
46 .getSignalCatalog({ name: "alchemy-nonexistent-catalog-probe" })
47 .pipe(inHomeRegion),
48 );
49 expect(["ResourceNotFoundException", "AccessDeniedException"]).toContain(
50 error._tag,
51 );
52 }),
53 { tags: ["provider:aws", "provider:aws:iotfleetwise", "live"] },
54);
55
56// Same ungated probe for the state-template API — proves the typed tags the
57// StateTemplate provider's read path depends on decode on every account.
58test.provider(
59 "getStateTemplate on a nonexistent template fails with a typed tag",
60 () =>
61 Effect.gen(function* () {
62 const error = yield* Effect.flip(
63 iotfleetwise
64 .getStateTemplate({
65 identifier: "alchemy-nonexistent-state-template-probe",
66 })
67 .pipe(inHomeRegion),
68 );
69 expect(["ResourceNotFoundException", "AccessDeniedException"]).toContain(
70 error._tag,
71 );
72 }),
73 { tags: ["provider:aws", "provider:aws:iotfleetwise", "live"] },
74);
75
76// Typed wait-until-gone for the gated lifecycle teardown.
77const assertCatalogGone = (name: string) =>
78 Effect.gen(function* () {
79 const found = yield* iotfleetwise.getSignalCatalog({ name }).pipe(
80 inHomeRegion,
81 Effect.catchTag("ResourceNotFoundException", () =>
82 Effect.succeed(undefined),
83 ),
84 );
85 if (found !== undefined) {
86 return yield* Effect.fail(
87 new Error(`signal catalog '${name}' still exists`),
88 );
89 }
90 }).pipe(
91 Effect.retry({
92 schedule: Schedule.max([
93 Schedule.fixed("5 seconds"),
94 Schedule.recurs(12),
95 ]),
96 }),
97 );
98
99// Full lifecycle across all six resources. AWS IoT FleetWise requires the
100// account to be allowlisted for the service (and registered via
101// RegisterAccount for legacy Timestream destinations) — the standing test
102// account is not, so the live run is gated behind AWS_TEST_IOTFLEETWISE=1.
103// All six resources are free and provision in seconds once entitled.
104test.provider.skipIf(!process.env.AWS_TEST_IOTFLEETWISE)(
105 "signal catalog -> model -> decoder -> fleet -> vehicle -> campaign lifecycle",
106 (stack) =>
107 Effect.gen(function* () {
108 yield* stack.destroy();
109
110 const deployed = yield* stack.deploy(
111 Effect.gen(function* () {
112 const catalog = yield* SignalCatalog("Signals", {
113 description: "alchemy fleetwise test signals",
114 nodes: [
115 { branch: { fullyQualifiedName: "Vehicle" } },
116 {
117 sensor: {
118 fullyQualifiedName: "Vehicle.Speed",
119 dataType: "DOUBLE",
120 unit: "km/h",
121 },
122 },
123 {
124 attribute: {
125 fullyQualifiedName: "Vehicle.VIN",
126 dataType: "STRING",
127 },
128 },
129 ],
130 tags: { fixture: "iotfleetwise" },
131 });
132
133 const model = yield* ModelManifest("SedanModel", {
134 signalCatalogArn: catalog.signalCatalogArn,
135 nodes: ["Vehicle.Speed", "Vehicle.VIN"],
136 description: "alchemy fleetwise test model",
137 status: "ACTIVE",
138 });
139
140 const decoder = yield* DecoderManifest("SedanDecoder", {
141 modelManifestArn: model.modelManifestArn,
142 description: "alchemy fleetwise test decoder",
143 networkInterfaces: [
144 {
145 interfaceId: "obd0",
146 type: "OBD_INTERFACE",
147 obdInterface: { name: "obd", requestMessageId: 2015 },
148 },
149 ],
150 signalDecoders: [
151 {
152 fullyQualifiedName: "Vehicle.Speed",
153 type: "OBD_SIGNAL",
154 interfaceId: "obd0",
155 obdSignal: {
156 pidResponseLength: 1,
157 serviceMode: 1,
158 pid: 13,
159 scaling: 1,
160 offset: 0,
161 startByte: 0,
162 byteLength: 1,
163 },
164 },
165 ],
166 status: "ACTIVE",
167 });
168
169 const fleet = yield* Fleet("TestFleet", {
170 signalCatalogArn: catalog.signalCatalogArn,
171 description: "alchemy fleetwise test fleet",
172 });
173
174 const vehicle = yield* Vehicle("TestVehicle", {
175 modelManifestArn: model.modelManifestArn,
176 decoderManifestArn: decoder.decoderManifestArn,
177 attributes: { "Vehicle.VIN": "1HGBH41JXMN109186" },
178 });
179
180 const stateTemplate = yield* StateTemplate("SpeedState", {
181 signalCatalogArn: catalog.signalCatalogArn,
182 stateTemplateProperties: ["Vehicle.Speed"],
183 description: "alchemy fleetwise test state template",
184 });
185
186 // Campaign data lands in S3; FleetWise writes require a bucket
187 // policy trusting the service principal. Deterministic name so
188 // the policy can reference the bucket ARN as a literal.
189 const bucket = yield* Bucket("TelemetryBucket", {
190 bucketName: TELEMETRY_BUCKET,
191 forceDestroy: true,
192 policy: [
193 {
194 Effect: "Allow",
195 Principal: { Service: "iotfleetwise.amazonaws.com" },
196 Action: ["s3:GetBucketLocation", "s3:PutObject"],
197 Resource: [
198 `arn:aws:s3:::${TELEMETRY_BUCKET}`,
199 `arn:aws:s3:::${TELEMETRY_BUCKET}/*`,
200 ],
201 },
202 ],
203 });
204
205 const campaign = yield* Campaign("SpeedTelemetry", {
206 signalCatalogArn: catalog.signalCatalogArn,
207 targetArn: fleet.fleetArn,
208 collectionScheme: {
209 timeBasedCollectionScheme: { period: "10 seconds" },
210 },
211 signalsToCollect: [{ name: "Vehicle.Speed" }],
212 dataDestinationConfigs: [
213 { s3Config: { bucketArn: bucket.bucketArn } },
214 ],
215 });
216
217 return {
218 catalog,
219 model,
220 decoder,
221 fleet,
222 vehicle,
223 stateTemplate,
224 campaign,
225 };
226 }),
227 );
228
229 expect(deployed.catalog.signalCatalogArn).toContain(":signal-catalog/");
230 expect(deployed.stateTemplate.stateTemplateArn).toContain(
231 ":state-template/",
232 );
233 expect(deployed.stateTemplate.stateTemplateProperties).toEqual([
234 "Vehicle.Speed",
235 ]);
236 expect(deployed.model.status).toBe("ACTIVE");
237 expect(deployed.decoder.status).toBe("ACTIVE");
238 expect(deployed.fleet.fleetArn).toContain(":fleet/");
239 expect(deployed.vehicle.vehicleArn).toContain(":vehicle/");
240 expect(deployed.campaign.status).toBe("WAITING_FOR_APPROVAL");
241
242 // Out-of-band verification via distilled.
243 const observedModel = yield* iotfleetwise
244 .getModelManifest({ name: deployed.model.modelManifestName })
245 .pipe(inHomeRegion);
246 expect(observedModel.status).toBe("ACTIVE");
247 const observedVehicle = yield* iotfleetwise
248 .getVehicle({ vehicleName: deployed.vehicle.vehicleName })
249 .pipe(inHomeRegion);
250 expect(observedVehicle.attributes?.["Vehicle.VIN"]).toBe(
251 "1HGBH41JXMN109186",
252 );
253
254 yield* stack.destroy();
255 yield* assertCatalogGone(deployed.catalog.signalCatalogName);
256 }),
257 {
258 tags: [
259 "provider:aws",
260 "provider:aws:iotfleetwise",
261 "provider:aws:s3",
262 "live",
263 ],
264 timeout: 600_000,
265 },
266);