1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { Region } from "@distilled.cloud/aws/Region";
5import * as iotfleetwise from "@distilled.cloud/aws/iotfleetwise";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import FleetWiseBindingsFunctionLive, {
11 FleetWiseBindingsFunction,
12} from "./bindings-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16
17// AWS IoT FleetWise is allowlist-gated — accounts without service access
18// observe AccessDeniedException on every operation. The live Lambda E2E
19// needs real FleetWise resources, so it is gated behind
20// AWS_TEST_IOTFLEETWISE=1 for allowlisted accounts.
21const RUN_LIVE = !!process.env.AWS_TEST_IOTFLEETWISE;
22
23// FleetWise is offered in us-east-1/eu-central-1 only — pin every
24// out-of-band distilled call to the service's home region.
25const inHomeRegion = <A, E, R>(
26 effect: Effect.Effect<A, E, R>,
27): Effect.Effect<A, E, R> =>
28 effect.pipe(Effect.provideService(Region, Effect.succeed("us-east-1")));
29
30// Ungated typed-error probes: prove the distilled error unions the bindings
31// depend on decode on every account, allowlisted or not, at near-zero cost.
32test.provider(
33 "getVehicleStatus on a nonexistent vehicle fails with a typed tag",
34 () =>
35 Effect.gen(function* () {
36 const error = yield* Effect.flip(
37 iotfleetwise
38 .getVehicleStatus({
39 vehicleName: "alchemy-nonexistent-vehicle-probe",
40 })
41 .pipe(inHomeRegion),
42 );
43 expect(["ResourceNotFoundException", "AccessDeniedException"]).toContain(
44 error._tag,
45 );
46 }),
47 { tags: ["provider:aws", "provider:aws:iotfleetwise", "live"] },
48);
49
50test.provider(
51 "listVehiclesInFleet on a nonexistent fleet fails with a typed tag",
52 () =>
53 Effect.gen(function* () {
54 const error = yield* Effect.flip(
55 iotfleetwise
56 .listVehiclesInFleet({ fleetId: "alchemy-nonexistent-fleet-probe" })
57 .pipe(inHomeRegion),
58 );
59 expect(["ResourceNotFoundException", "AccessDeniedException"]).toContain(
60 error._tag,
61 );
62 }),
63 { tags: ["provider:aws", "provider:aws:iotfleetwise", "live"] },
64);
65
66const sharedStack = Core.scratchStack(testOptions, "FleetWiseBindings");
67
68let baseUrl: string;
69
70const get = (path: string) =>
71 HttpClient.get(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
72const post = (path: string) =>
73 HttpClient.post(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
74
75describe(
76 "IoTFleetWise Bindings (E2E)",
77 {
78 tags: [
79 "provider:aws",
80 "provider:aws:batch",
81 "provider:aws:iotfleetwise",
82 "provider:aws:lambda",
83 "provider:aws:s3",
84 "live",
85 ],
86 },
87 () => {
88 beforeAll(
89 Effect.gen(function* () {
90 if (!RUN_LIVE) return;
91 yield* Effect.logInfo("FleetWise E2E setup: destroying previous run");
92 yield* sharedStack.destroy();
93
94 yield* Effect.logInfo(
95 "FleetWise E2E setup: deploying resources + Lambda",
96 );
97 const { functionUrl } = yield* sharedStack.deploy(
98 Effect.gen(function* () {
99 return yield* FleetWiseBindingsFunction;
100 }).pipe(Effect.provide(FleetWiseBindingsFunctionLive)),
101 );
102
103 expect(functionUrl).toBeTruthy();
104 baseUrl = functionUrl!.replace(/\/+$/, "");
105
106 // Readiness probe — fresh function URLs take seconds to serve 200s.
107 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
108 Effect.flatMap((response) =>
109 response.status === 200
110 ? Effect.succeed(response)
111 : Effect.fail(
112 new Error(`Function not ready: ${response.status}`),
113 ),
114 ),
115 Effect.retry({
116 schedule: Schedule.max([
117 Schedule.fixed("2 seconds"),
118 Schedule.recurs(60),
119 ]),
120 }),
121 );
122 }),
123 { timeout: 600_000 },
124 );
125 afterAll(
126 Effect.gen(function* () {
127 if (!RUN_LIVE) return;
128 yield* sharedStack.destroy();
129 }),
130 { timeout: 600_000 },
131 );
132
133 test.provider.skipIf(!RUN_LIVE)(
134 "all 13 capabilities initialize in the runtime",
135 () =>
136 Effect.gen(function* () {
137 const response = (yield* get("/bindings")) as any;
138 expect(response.bound).toHaveLength(13);
139 }),
140 );
141
142 test.provider.skipIf(!RUN_LIVE)(
143 "GetVehicleStatus reads the bound vehicle's campaign deployments",
144 () =>
145 Effect.gen(function* () {
146 const response = (yield* get("/vehicle-status")) as any;
147 expect(typeof response.campaigns).toBe("number");
148 }),
149 );
150
151 test.provider.skipIf(!RUN_LIVE)(
152 "Associate/Disassociate + ListVehiclesInFleet + ListFleetsForVehicle round-trip",
153 () =>
154 Effect.gen(function* () {
155 const associated = (yield* post("/fleet/associate")) as any;
156 expect(associated.count).toBeGreaterThanOrEqual(1);
157 const fleets = (yield* get("/vehicle-fleets")) as any;
158 expect(fleets.count).toBeGreaterThanOrEqual(1);
159 const disassociated = (yield* post("/fleet/disassociate")) as any;
160 expect(disassociated.ok).toBe(true);
161 }),
162 );
163
164 test.provider.skipIf(!RUN_LIVE)(
165 "signal-definition reads cover catalog, model, and decoder",
166 () =>
167 Effect.gen(function* () {
168 const catalogNodes = (yield* get("/catalog-nodes")) as any;
169 expect(catalogNodes.count).toBe(3);
170 const modelNodes = (yield* get("/model-nodes")) as any;
171 expect(modelNodes.count).toBeGreaterThanOrEqual(2);
172 const interfaces = (yield* get("/decoder-interfaces")) as any;
173 expect(interfaces.count).toBe(1);
174 const signals = (yield* get("/decoder-signals")) as any;
175 expect(signals.count).toBe(1);
176 }),
177 );
178
179 test.provider.skipIf(!RUN_LIVE)(
180 "ListVehicles filters by the bound model manifest",
181 () =>
182 Effect.gen(function* () {
183 const response = (yield* get("/vehicles")) as any;
184 expect(response.count).toBeGreaterThanOrEqual(1);
185 }),
186 );
187
188 test.provider.skipIf(!RUN_LIVE)(
189 "UpdateCampaign suspends and resumes the running campaign",
190 () =>
191 Effect.gen(function* () {
192 const response = (yield* post("/campaign/suspend-resume")) as any;
193 expect(response.ok).toBe(true);
194 }),
195 );
196
197 test.provider.skipIf(!RUN_LIVE)(
198 "BatchUpdateVehicle succeeds and BatchCreateVehicle surfaces per-item errors",
199 () =>
200 Effect.gen(function* () {
201 const response = (yield* post("/vehicles/batch")) as any;
202 expect(response.updated).toBe(1);
203 expect(response.updateErrors).toBe(0);
204 expect(response.createErrors).toBe(1);
205 }),
206 );
207 },
208);