1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as SQS from "@distilled.cloud/aws/sqs";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import IoTWirelessTestFunctionLive, {
12 IoTWirelessTestFunction,
13} from "./fixtures/handler.ts";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "IoTWirelessBindings");
18
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(60),
22]);
23
24let baseUrl: string;
25let resultQueueUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// POST a fixture route; retry 5xx (cold-start IAM propagation surfaces as
33// a typed-error 500 from the fixture until the fresh role policy is
34// visible to IoT Wireless).
35const post = (path: string) =>
36 HttpClient.execute(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
37 Effect.flatMap((response) =>
38 response.status >= 500
39 ? response.text.pipe(
40 Effect.flatMap((body) =>
41 Effect.fail(
42 new TransientUpstream({ status: response.status, body }),
43 ),
44 ),
45 )
46 : Effect.succeed(response),
47 ),
48 Effect.retry({
49 while: (e) => e._tag === "TransientUpstream",
50 schedule: Schedule.max([
51 Schedule.exponential("1 second"),
52 Schedule.recurs(6),
53 ]),
54 }),
55 );
56
57const postJson = (path: string) =>
58 post(path).pipe(Effect.flatMap((response) => response.json));
59
60describe(
61 "IoTWireless Bindings",
62 {
63 tags: [
64 "provider:aws",
65 "provider:aws:iam",
66 "provider:aws:iotwireless",
67 "provider:aws:lambda",
68 "provider:aws:sqs",
69 "live",
70 ],
71 },
72 () => {
73 beforeAll(
74 Effect.gen(function* () {
75 yield* Effect.logInfo(
76 "IoTWireless test setup: destroying previous resources",
77 );
78 yield* sharedStack.destroy();
79
80 yield* Effect.logInfo("IoTWireless test setup: deploying fixture");
81 const { functionUrl } = yield* sharedStack.deploy(
82 Effect.gen(function* () {
83 return yield* IoTWirelessTestFunction;
84 }).pipe(Effect.provide(IoTWirelessTestFunctionLive)),
85 );
86
87 expect(functionUrl).toBeTruthy();
88 baseUrl = functionUrl!.replace(/\/+$/, "");
89 const readinessUrl = `${baseUrl}/ping`;
90
91 yield* Effect.logInfo(
92 `IoTWireless test setup: probing readiness at ${readinessUrl}`,
93 );
94 // Ride out cold-start / URL propagation until /ping reports the
95 // result queue (early invocations can briefly resolve outputs late).
96 const ready = yield* HttpClient.get(readinessUrl).pipe(
97 Effect.flatMap((response) =>
98 response.status === 200
99 ? (response.json as Effect.Effect<{
100 ok: boolean;
101 resultQueueUrl?: string;
102 }>)
103 : Effect.fail(
104 new Error(`Function not ready: ${response.status}`),
105 ),
106 ),
107 Effect.flatMap((body) =>
108 body.resultQueueUrl
109 ? Effect.succeed(body as { resultQueueUrl: string })
110 : Effect.fail(new Error("no result queue url yet")),
111 ),
112 Effect.tapError((error) =>
113 Effect.logWarning(
114 `IoTWireless test setup: fixture not ready yet (${String(error)})`,
115 ),
116 ),
117 Effect.retry({ schedule: readinessPolicy }),
118 );
119 resultQueueUrl = ready.resultQueueUrl;
120 }),
121 { timeout: 300_000 },
122 );
123
124 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
125 timeout: 180_000,
126 });
127
128 describe("GetServiceEndpoint", () => {
129 test.provider(
130 "reads the account's LNS endpoint",
131 (_stack) =>
132 Effect.gen(function* () {
133 const response = (yield* postJson("/service-endpoint")) as {
134 serviceType: string | null;
135 serviceEndpoint: string | null;
136 };
137 expect(response.serviceType).toBe("LNS");
138 expect(response.serviceEndpoint).toBeTruthy();
139 }),
140 { timeout: 120_000 },
141 );
142 });
143
144 describe("SendDataToWirelessDevice", () => {
145 test.provider(
146 "queues a downlink to the bound ABP device",
147 (_stack) =>
148 Effect.gen(function* () {
149 const response = (yield* postJson("/send")) as {
150 messageId: string | null;
151 };
152 expect(response.messageId).toBeTruthy();
153 }),
154 { timeout: 120_000 },
155 );
156 });
157
158 describe("ListQueuedMessages", () => {
159 test.provider(
160 "sees the queued downlink",
161 (_stack) =>
162 Effect.gen(function* () {
163 const response = (yield* postJson("/queued").pipe(
164 Effect.repeat({
165 schedule: Schedule.spaced("2 seconds"),
166 until: (r): boolean => (r as { count: number }).count >= 1,
167 times: 15,
168 }),
169 )) as { count: number; messageIds: (string | null)[] };
170 expect(response.count).toBeGreaterThanOrEqual(1);
171 }),
172 { timeout: 120_000 },
173 );
174 });
175
176 describe("DeleteQueuedMessages", () => {
177 test.provider(
178 "purges the downlink queue",
179 (_stack) =>
180 Effect.gen(function* () {
181 const purge = (yield* postJson("/purge")) as { ok: boolean };
182 expect(purge.ok).toBe(true);
183
184 const drained = (yield* postJson("/queued").pipe(
185 Effect.repeat({
186 schedule: Schedule.spaced("2 seconds"),
187 until: (r): boolean => (r as { count: number }).count === 0,
188 times: 15,
189 }),
190 )) as { count: number };
191 expect(drained.count).toBe(0);
192 }),
193 { timeout: 120_000 },
194 );
195 });
196
197 describe("GetWirelessDeviceStatistics", () => {
198 test.provider(
199 "reads the bound device's operating information",
200 (_stack) =>
201 Effect.gen(function* () {
202 const response = (yield* postJson("/device-stats")) as {
203 wirelessDeviceId: string | null;
204 };
205 expect(response.wirelessDeviceId).toBeTruthy();
206 }),
207 { timeout: 120_000 },
208 );
209 });
210
211 describe("GetWirelessGatewayStatistics", () => {
212 test.provider(
213 "reads the bound gateway's connection status",
214 (_stack) =>
215 Effect.gen(function* () {
216 const response = (yield* postJson("/gateway-stats")) as {
217 wirelessGatewayId: string | null;
218 connectionStatus: string | null;
219 };
220 expect(response.wirelessGatewayId).toBeTruthy();
221 }),
222 { timeout: 120_000 },
223 );
224 });
225
226 describe("UpdateResourcePosition + GetResourcePosition", () => {
227 test.provider(
228 "writes a static position and reads it back",
229 (_stack) =>
230 Effect.gen(function* () {
231 const updated = (yield* postJson("/update-position")) as {
232 ok: boolean;
233 };
234 expect(updated.ok).toBe(true);
235
236 const read = (yield* postJson("/position").pipe(
237 Effect.repeat({
238 schedule: Schedule.spaced("2 seconds"),
239 until: (r): boolean =>
240 typeof (r as { geoJson: string | null }).geoJson ===
241 "string" &&
242 ((r as { geoJson: string }).geoJson.includes("-122.33") ||
243 (r as { geoJson: string }).geoJson.includes("coordinates")),
244 times: 10,
245 }),
246 )) as { geoJson: string | null };
247 expect(read.geoJson).toBeTruthy();
248 const parsed = JSON.parse(read.geoJson!) as {
249 coordinates?: number[];
250 };
251 expect(parsed.coordinates?.[0]).toBeCloseTo(-122.33, 2);
252 expect(parsed.coordinates?.[1]).toBeCloseTo(47.61, 2);
253 }),
254 { timeout: 120_000 },
255 );
256 });
257
258 describe("GetPositionEstimate", () => {
259 test.provider(
260 "invokes the position solver with fabricated WiFi scans",
261 (_stack) =>
262 Effect.gen(function* () {
263 const response = (yield* postJson("/position-estimate")) as {
264 ok: boolean;
265 tag?: string;
266 geoJson?: string | null;
267 };
268 // Fabricated MACs may not resolve to a position — the solver
269 // then returns a typed not-found/validation error. Either way the
270 // IAM grant and the call path are proven (an access failure
271 // would surface as a retried-then-failing 500 instead).
272 if (response.ok) {
273 expect(response.geoJson).toBeTruthy();
274 } else {
275 expect([
276 "ResourceNotFoundException",
277 "ValidationException",
278 ]).toContain(response.tag);
279 }
280 }),
281 { timeout: 120_000 },
282 );
283 });
284
285 describe("TestWirelessDevice", () => {
286 test.provider(
287 "simulates an uplink from the bound device",
288 (_stack) =>
289 Effect.gen(function* () {
290 const response = (yield* postJson("/test-device")) as {
291 result: string | null;
292 };
293 expect(response.result).toBeTruthy();
294 }),
295 { timeout: 120_000 },
296 );
297 });
298
299 describe("DestinationEventSource (consumeUplinks)", () => {
300 test.provider(
301 "routes a simulated uplink through the destination's rule into the handler",
302 (_stack) =>
303 Effect.gen(function* () {
304 const { wirelessDeviceId } = (yield* postJson("/device-stats")) as {
305 wirelessDeviceId: string;
306 };
307 expect(wirelessDeviceId).toBeTruthy();
308
309 // Fire a simulated "Hello" uplink; IoT Wireless routes it through
310 // the destination's rule, which invokes the fixture Lambda, whose
311 // consumeUplinks handler forwards the envelope into the result
312 // queue. Re-fire on each poll retry to ride out rule/permission
313 // propagation on a fresh deploy.
314 yield* postJson("/test-device");
315
316 const body = yield* Effect.gen(function* () {
317 const received = yield* SQS.receiveMessage({
318 QueueUrl: resultQueueUrl,
319 MaxNumberOfMessages: 10,
320 WaitTimeSeconds: 2,
321 });
322 const match = (received.Messages ?? []).find((message) =>
323 message.Body?.includes(wirelessDeviceId),
324 );
325 if (!match?.ReceiptHandle) {
326 yield* postJson("/test-device").pipe(Effect.ignore);
327 return yield* Effect.fail(new UplinkNotDelivered());
328 }
329 yield* SQS.deleteMessage({
330 QueueUrl: resultQueueUrl,
331 ReceiptHandle: match.ReceiptHandle,
332 });
333 return match.Body!;
334 }).pipe(
335 Effect.retry({
336 while: (error): boolean => error._tag === "UplinkNotDelivered",
337 schedule: Schedule.max([
338 Schedule.fixed("2 seconds"),
339 Schedule.recurs(40),
340 ]),
341 }),
342 );
343
344 const uplink = JSON.parse(body) as {
345 WirelessDeviceId: string;
346 PayloadData: string;
347 };
348 expect(uplink.WirelessDeviceId).toBe(wirelessDeviceId);
349 expect(uplink.PayloadData).toBeTruthy();
350 }),
351 { timeout: 240_000 },
352 );
353 });
354 },
355);
356
357class UplinkNotDelivered extends Data.TaggedError("UplinkNotDelivered") {}