1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import LocationTestFunctionLive, { LocationTestFunction } from "./handler.ts";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "LocationBindings");
15
16const readinessPolicy = Schedule.max([
17 Schedule.fixed("2 seconds"),
18 Schedule.recurs(75),
19]);
20
21let baseUrl: string;
22
23class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
24 readonly status: number;
25 readonly body: string;
26}> {}
27
28/**
29 * GET a fixture route, retrying transient 5xx (cold starts, Location
30 * eventual-consistency windows surfaced as handler failures).
31 */
32const send = (route: string) =>
33 HttpClient.execute(HttpClientRequest.get(`${baseUrl}${route}`)).pipe(
34 Effect.flatMap((response) =>
35 response.status >= 500
36 ? response.text.pipe(
37 Effect.flatMap((body) =>
38 Effect.fail(
39 new TransientUpstream({ status: response.status, body }),
40 ),
41 ),
42 )
43 : Effect.succeed(response),
44 ),
45 Effect.retry({
46 while: (e) => e._tag === "TransientUpstream",
47 schedule: Schedule.max([
48 Schedule.exponential("1 second"),
49 Schedule.recurs(6),
50 ]),
51 }),
52 Effect.flatMap((response) => response.json),
53 );
54
55/**
56 * GET a fixture route repeatedly until `until` holds — bounded. Location's
57 * list APIs (geofences, device positions, history) are eventually consistent
58 * with respect to writes made moments earlier.
59 */
60const sendUntil = <T>(route: string, until: (response: T) => boolean) =>
61 send(route).pipe(
62 Effect.map((response) => response as T),
63 Effect.repeat({
64 schedule: Schedule.spaced("3 seconds"),
65 until,
66 times: 10,
67 }),
68 );
69
70// Sequential: the tracker tests are write-then-read (BatchUpdateDevicePosition
71// must land before GetDevicePosition/List/BatchGet observe it), and the global
72// vitest config runs tests concurrently by default.
73describe.skipIf(!!process.env.FAST).sequential(
74 "AWS.Location Bindings",
75 {
76 tags: [
77 "provider:aws",
78 "provider:aws:batch",
79 "provider:aws:forecast",
80 "provider:aws:iam",
81 "provider:aws:lambda",
82 "provider:aws:location",
83 "live",
84 ],
85 },
86 () => {
87 beforeAll(
88 Effect.gen(function* () {
89 yield* Effect.logInfo(
90 "Location test setup: destroying previous resources",
91 );
92 yield* sharedStack.destroy();
93
94 yield* Effect.logInfo("Location test setup: deploying fixture");
95 const attrs = yield* sharedStack.deploy(
96 Effect.gen(function* () {
97 return yield* LocationTestFunction;
98 }).pipe(Effect.provide(LocationTestFunctionLive)),
99 );
100
101 expect(attrs.functionUrl).toBeTruthy();
102 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
103 const readinessUrl = `${baseUrl}/ping`;
104
105 yield* Effect.logInfo(
106 `Location test setup: probing readiness at ${readinessUrl}`,
107 );
108 yield* HttpClient.get(readinessUrl).pipe(
109 Effect.flatMap((response) =>
110 response.status === 200
111 ? Effect.succeed(response)
112 : Effect.fail(
113 new Error(`Function not ready: ${response.status}`),
114 ),
115 ),
116 Effect.tapError((error) =>
117 Effect.logWarning(
118 `Location test setup: fixture not ready yet (${String(error)})`,
119 ),
120 ),
121 Effect.retry({ schedule: readinessPolicy }),
122 );
123
124 // AWS canary: the fresh role's inline policy can take minutes to
125 // propagate — every geo:* call 500s until it does. Poll a cheap
126 // Location-backed route until it succeeds so tests start only once
127 // IAM is live.
128 yield* Effect.logInfo(
129 "Location test setup: waiting for IAM propagation",
130 );
131 yield* HttpClient.get(`${baseUrl}/geofence/list`).pipe(
132 Effect.flatMap((response) =>
133 response.status === 200
134 ? response.json
135 : Effect.fail(
136 new Error(`IAM not propagated: ${response.status}`),
137 ),
138 ),
139 Effect.flatMap((body) =>
140 (body as { ok?: boolean }).ok === true
141 ? Effect.void
142 : Effect.fail(
143 new Error(`geo:* not ready: ${JSON.stringify(body)}`),
144 ),
145 ),
146 Effect.tapError((error) =>
147 Effect.logWarning(
148 `Location test setup: geo:* not authorized yet (${String(error)})`,
149 ),
150 ),
151 // IAM propagation of the fresh execution-role policy to geo:* has
152 // been observed to take >150s — give it up to ~5 minutes.
153 Effect.retry({
154 schedule: Schedule.max([
155 Schedule.fixed("2 seconds"),
156 Schedule.recurs(150),
157 ]),
158 }),
159 );
160 }),
161 { timeout: 600_000 },
162 );
163
164 afterAll(sharedStack.destroy(), { timeout: 300_000 });
165
166 describe("Location.BatchUpdateDevicePosition", () => {
167 test.provider(
168 "uploads a device position to the tracker",
169 (_stack) =>
170 Effect.gen(function* () {
171 const response = (yield* send("/tracker/update")) as {
172 errors: number;
173 };
174 expect(response.errors).toBe(0);
175 }),
176 { timeout: 120_000 },
177 );
178 });
179
180 describe("Location.GetDevicePosition", () => {
181 test.provider(
182 "reads the device's latest position",
183 (_stack) =>
184 Effect.gen(function* () {
185 const response = (yield* send("/tracker/latest")) as {
186 deviceId?: string;
187 position?: number[];
188 };
189 expect(response.deviceId).toBe("device-1");
190 expect(response.position).toHaveLength(2);
191 }),
192 { timeout: 120_000 },
193 );
194 });
195
196 describe("Location.BatchGetDevicePosition", () => {
197 test.provider(
198 "reads several devices in one call",
199 (_stack) =>
200 Effect.gen(function* () {
201 const response = yield* sendUntil<{
202 found: number;
203 errors: number;
204 }>("/tracker/batch-get", (r) => r.found > 0);
205 expect(response.found).toBe(1);
206 expect(response.errors).toBe(0);
207 }),
208 { timeout: 120_000 },
209 );
210 });
211
212 describe("Location.GetDevicePositionHistory", () => {
213 test.provider(
214 "reads the device's position history",
215 (_stack) =>
216 Effect.gen(function* () {
217 const response = yield* sendUntil<{ count: number }>(
218 "/tracker/history",
219 (r) => r.count > 0,
220 );
221 expect(response.count).toBeGreaterThan(0);
222 }),
223 { timeout: 120_000 },
224 );
225 });
226
227 describe("Location.ListDevicePositions", () => {
228 test.provider(
229 "lists the tracker's device positions",
230 (_stack) =>
231 Effect.gen(function* () {
232 const response = yield* sendUntil<{
233 count: number;
234 deviceIds: string[];
235 }>("/tracker/list", (r) => r.count > 0);
236 expect(response.count).toBeGreaterThan(0);
237 expect(response.deviceIds).toContain("device-1");
238 }),
239 { timeout: 120_000 },
240 );
241 });
242
243 describe("Location.VerifyDevicePosition", () => {
244 test.provider(
245 "verifies a position (or returns the typed validation error)",
246 (_stack) =>
247 Effect.gen(function* () {
248 const response = (yield* send("/tracker/verify")) as {
249 inferredState?: boolean;
250 deviceId?: string;
251 validationError?: string;
252 };
253 // Fabricated Wi-Fi signals either verify (InferredState) or are
254 // rejected with the typed ValidationException — both prove the
255 // binding, IAM grant, and typed error union.
256 expect(
257 response.inferredState === true ||
258 typeof response.validationError === "string",
259 ).toBe(true);
260 }),
261 { timeout: 120_000 },
262 );
263 });
264
265 describe("Location.PutGeofence", () => {
266 test.provider(
267 "stores a circular geofence",
268 (_stack) =>
269 Effect.gen(function* () {
270 const response = (yield* send("/geofence/put")) as {
271 geofenceId: string;
272 };
273 expect(response.geofenceId).toBe("fence-1");
274 }),
275 { timeout: 120_000 },
276 );
277 });
278
279 describe("Location.GetGeofence", () => {
280 test.provider(
281 "reads the geofence back",
282 (_stack) =>
283 Effect.gen(function* () {
284 const response = (yield* send("/geofence/get")) as {
285 geofenceId: string;
286 status: string;
287 };
288 expect(response.geofenceId).toBe("fence-1");
289 expect(typeof response.status).toBe("string");
290 }),
291 { timeout: 120_000 },
292 );
293 });
294
295 describe("Location.ListGeofences", () => {
296 test.provider(
297 "lists the collection's geofences",
298 (_stack) =>
299 Effect.gen(function* () {
300 const response = yield* sendUntil<{
301 ok: boolean;
302 count?: number;
303 }>("/geofence/list", (r) => r.ok && (r.count ?? 0) > 0);
304 expect(response).toMatchObject({ ok: true });
305 expect(response.count).toBeGreaterThan(0);
306 }),
307 { timeout: 120_000 },
308 );
309 });
310
311 describe("Location.BatchPutGeofence", () => {
312 test.provider(
313 "stores several geofences in one call",
314 (_stack) =>
315 Effect.gen(function* () {
316 const response = (yield* send("/geofence/batch-put")) as {
317 successes: number;
318 errors: number;
319 };
320 expect(response.successes).toBe(1);
321 expect(response.errors).toBe(0);
322 }),
323 { timeout: 120_000 },
324 );
325 });
326
327 describe("Location.BatchEvaluateGeofences", () => {
328 test.provider(
329 "evaluates a device position against the collection",
330 (_stack) =>
331 Effect.gen(function* () {
332 const response = (yield* send("/geofence/evaluate")) as {
333 ok: boolean;
334 errors?: number;
335 };
336 expect(response).toMatchObject({ ok: true, errors: 0 });
337 }),
338 { timeout: 120_000 },
339 );
340 });
341
342 describe("Location.ForecastGeofenceEvents", () => {
343 test.provider(
344 "forecasts upcoming geofence events",
345 (_stack) =>
346 Effect.gen(function* () {
347 const response = (yield* send("/geofence/forecast")) as {
348 forecasted: number;
349 distanceUnit: string;
350 };
351 expect(response.forecasted).toBeGreaterThanOrEqual(0);
352 expect(typeof response.distanceUnit).toBe("string");
353 }),
354 { timeout: 120_000 },
355 );
356 });
357
358 describe("Location.BatchDeleteGeofence", () => {
359 test.provider(
360 "deletes geofences in one call",
361 (_stack) =>
362 Effect.gen(function* () {
363 const response = (yield* send("/geofence/batch-delete")) as {
364 errors: number;
365 };
366 expect(response.errors).toBe(0);
367 }),
368 { timeout: 120_000 },
369 );
370 });
371
372 describe("Location.BatchDeleteDevicePositionHistory", () => {
373 test.provider(
374 "purges the device's position history",
375 (_stack) =>
376 Effect.gen(function* () {
377 const response = (yield* send("/tracker/delete-history")) as {
378 errors: number;
379 };
380 expect(response.errors).toBe(0);
381 }),
382 { timeout: 120_000 },
383 );
384 });
385
386 describe("Location.SearchPlaceIndexForText", () => {
387 test.provider(
388 "geocodes a text query",
389 (_stack) =>
390 Effect.gen(function* () {
391 const response = (yield* send("/places/search-text")) as {
392 count: number;
393 firstLabel?: string;
394 };
395 expect(response.count).toBeGreaterThan(0);
396 expect(typeof response.firstLabel).toBe("string");
397 }),
398 { timeout: 120_000 },
399 );
400 });
401
402 describe("Location.SearchPlaceIndexForPosition", () => {
403 test.provider(
404 "reverse-geocodes a coordinate",
405 (_stack) =>
406 Effect.gen(function* () {
407 const response = (yield* send("/places/search-position")) as {
408 count: number;
409 firstLabel?: string;
410 };
411 expect(response.count).toBeGreaterThan(0);
412 expect(typeof response.firstLabel).toBe("string");
413 }),
414 { timeout: 120_000 },
415 );
416 });
417
418 describe("Location.SearchPlaceIndexForSuggestions", () => {
419 test.provider(
420 "returns typeahead suggestions",
421 (_stack) =>
422 Effect.gen(function* () {
423 const response = (yield* send("/places/suggestions")) as {
424 count: number;
425 firstText?: string;
426 };
427 expect(response.count).toBeGreaterThan(0);
428 expect(typeof response.firstText).toBe("string");
429 }),
430 { timeout: 120_000 },
431 );
432 });
433
434 describe("Location.GetPlace", () => {
435 test.provider(
436 "fetches place details by PlaceId",
437 (_stack) =>
438 Effect.gen(function* () {
439 const response = (yield* send("/places/get-place")) as {
440 ok: boolean;
441 label?: string;
442 point?: number[];
443 };
444 expect(response).toMatchObject({ ok: true });
445 expect(typeof response.label).toBe("string");
446 expect(response.point).toHaveLength(2);
447 }),
448 { timeout: 120_000 },
449 );
450 });
451
452 describe("Location.CalculateRoute", () => {
453 test.provider(
454 "calculates a route between two positions",
455 (_stack) =>
456 Effect.gen(function* () {
457 const response = (yield* send("/routes/calculate")) as {
458 distance?: number;
459 duration?: number;
460 };
461 expect(response.distance).toBeGreaterThan(0);
462 expect(response.duration).toBeGreaterThan(0);
463 }),
464 { timeout: 120_000 },
465 );
466 });
467
468 describe("Location.CalculateRouteMatrix", () => {
469 test.provider(
470 "calculates a distance matrix",
471 (_stack) =>
472 Effect.gen(function* () {
473 const response = (yield* send("/routes/matrix")) as {
474 rows: number;
475 distance?: number;
476 };
477 expect(response.rows).toBe(1);
478 expect(response.distance).toBeGreaterThan(0);
479 }),
480 { timeout: 120_000 },
481 );
482 });
483
484 describe("Location.GetMapStyleDescriptor", () => {
485 test.provider(
486 "serves the style descriptor",
487 (_stack) =>
488 Effect.gen(function* () {
489 const response = (yield* send("/map/style")) as {
490 bytes: number;
491 contentType?: string;
492 };
493 expect(response.bytes).toBeGreaterThan(0);
494 }),
495 { timeout: 120_000 },
496 );
497 });
498
499 describe("Location.GetMapGlyphs", () => {
500 test.provider(
501 "serves a glyph range",
502 (_stack) =>
503 Effect.gen(function* () {
504 const response = (yield* send("/map/glyphs")) as {
505 bytes: number;
506 };
507 expect(response.bytes).toBeGreaterThan(0);
508 }),
509 { timeout: 120_000 },
510 );
511 });
512
513 describe("Location.GetMapSprites", () => {
514 test.provider(
515 "serves the sprite index",
516 (_stack) =>
517 Effect.gen(function* () {
518 const response = (yield* send("/map/sprites")) as {
519 bytes: number;
520 };
521 expect(response.bytes).toBeGreaterThan(0);
522 }),
523 { timeout: 120_000 },
524 );
525 });
526
527 describe("Location.GetMapTile", () => {
528 test.provider(
529 "serves a map tile",
530 (_stack) =>
531 Effect.gen(function* () {
532 const response = (yield* send("/map/tile")) as { bytes: number };
533 expect(response.bytes).toBeGreaterThan(0);
534 }),
535 { timeout: 120_000 },
536 );
537 });
538
539 describe("Location.ListJobs", () => {
540 test.provider(
541 "lists batch metadata jobs",
542 (_stack) =>
543 Effect.gen(function* () {
544 const response = (yield* send("/jobs/list")) as { count: number };
545 expect(response.count).toBeGreaterThanOrEqual(0);
546 }),
547 { timeout: 120_000 },
548 );
549 });
550
551 describe("Location.GetJob", () => {
552 test.provider(
553 "returns the typed ResourceNotFoundException for a missing job",
554 (_stack) =>
555 Effect.gen(function* () {
556 const response = (yield* send("/jobs/get-missing")) as {
557 tag: string;
558 message?: string;
559 };
560 expect(response).toMatchObject({
561 tag: "ResourceNotFoundException",
562 });
563 }),
564 { timeout: 120_000 },
565 );
566 });
567
568 describe("Location.CancelJob", () => {
569 test.provider(
570 "returns the typed ResourceNotFoundException for a missing job",
571 (_stack) =>
572 Effect.gen(function* () {
573 const response = (yield* send("/jobs/cancel-missing")) as {
574 tag: string;
575 message?: string;
576 };
577 expect(response).toMatchObject({
578 tag: "ResourceNotFoundException",
579 });
580 }),
581 { timeout: 120_000 },
582 );
583 });
584 },
585);