1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as Location from "@/AWS/Location";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Stream from "effect/Stream";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15export class LocationTestFunction extends Lambda.Function<Lambda.Function>()(
16 "LocationTestFunction",
17) {}
18
19// Downtown Seattle — near the Space Needle. [longitude, latitude]
20const SEATTLE: [number, number] = [-122.3493, 47.6205];
21const PIKE_PLACE: [number, number] = [-122.3421, 47.6091];
22
23// A syntactically valid but nonexistent job id — the /jobs/get-missing and
24// /jobs/cancel-missing probe routes assert the typed ResourceNotFoundException.
25const MISSING_JOB_ID = "00000000-0000-4000-8000-000000000000";
26
27export default LocationTestFunction.make(
28 {
29 main,
30 functionUrl: true,
31 // Place-index and route-calculator calls fan out to upstream data
32 // providers and can exceed Lambda's 3s default.
33 timeout: Duration.seconds(30),
34 },
35 Effect.gen(function* () {
36 const tracker = yield* Location.Tracker("BindingsTracker", {});
37 const collection = yield* Location.GeofenceCollection(
38 "BindingsCollection",
39 {},
40 );
41 // HERE (not Esri): SearchPlaceIndexForText only returns PlaceId — needed
42 // by the /places/get-place route — for HERE and Grab data providers.
43 const index = yield* Location.PlaceIndex("BindingsIndex", {
44 dataSource: "Here",
45 });
46 const calculator = yield* Location.RouteCalculator("BindingsCalculator", {
47 dataSource: "Esri",
48 });
49 const map = yield* Location.Map("BindingsMap", {
50 configuration: { style: "VectorEsriNavigation" },
51 });
52
53 // A role for the StartJob binding — only used to exercise the binding's
54 // deploy-time wiring (geo:StartJob + iam:PassRole grants); no job is ever
55 // started, so the trust policy just needs a principal IAM accepts
56 // ("location.amazonaws.com" is not a recognized service principal).
57 const jobsRole = yield* IAM.Role("LocationJobsRole", {
58 assumeRolePolicyDocument: {
59 Version: "2012-10-17",
60 Statement: [
61 {
62 Effect: "Allow",
63 Principal: { Service: "lambda.amazonaws.com" },
64 Action: ["sts:AssumeRole"],
65 },
66 ],
67 },
68 });
69
70 // Event source: subscribe the host to Location geofence events. The
71 // deploy proves the EventBridge rule + invoke permission wiring.
72 yield* Location.consumeTrackerEvents(
73 { kinds: ["geofence-event", "device-position-event"] },
74 (events) =>
75 Stream.runForEach(events, (event) =>
76 Effect.log(
77 `location event: ${event.detail.DeviceId} ${event.detail.EventType}`,
78 ),
79 ),
80 );
81
82 // Tracker data plane
83 const updatePositions = yield* Location.BatchUpdateDevicePosition(tracker);
84 const getPosition = yield* Location.GetDevicePosition(tracker);
85 const getHistory = yield* Location.GetDevicePositionHistory(tracker);
86 const listPositions = yield* Location.ListDevicePositions(tracker);
87 const batchGetPositions = yield* Location.BatchGetDevicePosition(tracker);
88 const verifyPosition = yield* Location.VerifyDevicePosition(tracker);
89 const deleteHistory =
90 yield* Location.BatchDeleteDevicePositionHistory(tracker);
91
92 // Geofence data plane
93 const putGeofence = yield* Location.PutGeofence(collection);
94 const getGeofence = yield* Location.GetGeofence(collection);
95 const listGeofences = yield* Location.ListGeofences(collection);
96 const batchPutGeofence = yield* Location.BatchPutGeofence(collection);
97 const batchDeleteGeofence = yield* Location.BatchDeleteGeofence(collection);
98 const evaluateGeofences =
99 yield* Location.BatchEvaluateGeofences(collection);
100 const forecastEvents = yield* Location.ForecastGeofenceEvents(collection);
101
102 // Places
103 const searchText = yield* Location.SearchPlaceIndexForText(index);
104 const searchPosition = yield* Location.SearchPlaceIndexForPosition(index);
105 const suggest = yield* Location.SearchPlaceIndexForSuggestions(index);
106 const getPlace = yield* Location.GetPlace(index);
107
108 // Routes
109 const calculateRoute = yield* Location.CalculateRoute(calculator);
110 const calculateMatrix = yield* Location.CalculateRouteMatrix(calculator);
111
112 // Map assets
113 const getStyle = yield* Location.GetMapStyleDescriptor(map);
114 const getGlyphs = yield* Location.GetMapGlyphs(map);
115 const getSprites = yield* Location.GetMapSprites(map);
116 const getTile = yield* Location.GetMapTile(map);
117
118 // Batch metadata jobs
119 const listJobs = yield* Location.ListJobs();
120 const getJob = yield* Location.GetJob();
121 const cancelJob = yield* Location.CancelJob();
122 // Bound for its deploy-time wiring (geo:StartJob + iam:PassRole); starting
123 // a real batch job needs S3 input/output data.
124 yield* Location.StartJob(jobsRole);
125
126 return {
127 fetch: Effect.gen(function* () {
128 const request = yield* HttpServerRequest;
129 const pathname = new URL(request.originalUrl).pathname;
130
131 // Cheap readiness route — no AWS call.
132 if (pathname === "/ping") {
133 return yield* HttpServerResponse.json({ ok: true });
134 }
135
136 if (pathname === "/tracker/update") {
137 const result = yield* updatePositions({
138 Updates: [
139 {
140 DeviceId: "device-1",
141 Position: SEATTLE,
142 SampleTime: new Date(),
143 },
144 ],
145 });
146 return yield* HttpServerResponse.json({
147 errors: result.Errors.length,
148 });
149 }
150
151 if (pathname === "/tracker/latest") {
152 const latest = yield* getPosition({ DeviceId: "device-1" });
153 return yield* HttpServerResponse.json({
154 deviceId: latest.DeviceId,
155 position: latest.Position,
156 });
157 }
158
159 if (pathname === "/tracker/batch-get") {
160 const result = yield* batchGetPositions({
161 DeviceIds: ["device-1"],
162 });
163 return yield* HttpServerResponse.json({
164 found: result.DevicePositions.length,
165 errors: result.Errors.length,
166 });
167 }
168
169 if (pathname === "/tracker/history") {
170 const history = yield* getHistory({ DeviceId: "device-1" });
171 return yield* HttpServerResponse.json({
172 count: history.DevicePositions.length,
173 });
174 }
175
176 if (pathname === "/tracker/list") {
177 const page = yield* listPositions();
178 return yield* HttpServerResponse.json({
179 count: page.Entries.length,
180 deviceIds: page.Entries.map((entry) => entry.DeviceId),
181 });
182 }
183
184 if (pathname === "/tracker/verify") {
185 const result = yield* verifyPosition({
186 DeviceState: {
187 DeviceId: "device-1",
188 SampleTime: new Date(),
189 Position: SEATTLE,
190 WiFiAccessPoints: [{ MacAddress: "A0:EC:F9:1E:32:C1", Rss: -66 }],
191 },
192 }).pipe(
193 Effect.map((verdict) => ({
194 inferredState: verdict.InferredState !== undefined,
195 deviceId: verdict.DeviceId,
196 })),
197 // Position verification needs real cell/Wi-Fi signal data; a
198 // typed ValidationException still proves IAM + wiring.
199 Effect.catchTag("ValidationException", (e) =>
200 Effect.succeed({ validationError: e.message }),
201 ),
202 );
203 return yield* HttpServerResponse.json(result);
204 }
205
206 if (pathname === "/tracker/delete-history") {
207 const result = yield* deleteHistory({ DeviceIds: ["device-1"] });
208 return yield* HttpServerResponse.json({
209 errors: result.Errors.length,
210 });
211 }
212
213 if (pathname === "/geofence/put") {
214 const result = yield* putGeofence({
215 GeofenceId: "fence-1",
216 Geometry: { Circle: { Center: SEATTLE, Radius: 200 } },
217 });
218 return yield* HttpServerResponse.json({
219 geofenceId: result.GeofenceId,
220 });
221 }
222
223 if (pathname === "/geofence/get") {
224 const fence = yield* getGeofence({ GeofenceId: "fence-1" });
225 return yield* HttpServerResponse.json({
226 geofenceId: fence.GeofenceId,
227 status: fence.Status,
228 });
229 }
230
231 if (pathname === "/geofence/list") {
232 // Also serves as the test setup's IAM canary — always responds 200
233 // and surfaces the typed failure tag so setup can wait for geo:*
234 // authorization (or diagnose schema gaps) without blind 500s.
235 const page = yield* Effect.result(listGeofences());
236 return yield* HttpServerResponse.json(
237 Result.isSuccess(page)
238 ? { ok: true, count: (page.success.Entries ?? []).length }
239 : {
240 ok: false,
241 tag: page.failure._tag,
242 message: String(page.failure),
243 },
244 );
245 }
246
247 if (pathname === "/geofence/batch-put") {
248 const result = yield* batchPutGeofence({
249 Entries: [
250 {
251 GeofenceId: "fence-2",
252 Geometry: { Circle: { Center: PIKE_PLACE, Radius: 100 } },
253 },
254 ],
255 });
256 return yield* HttpServerResponse.json({
257 successes: result.Successes.length,
258 errors: result.Errors.length,
259 });
260 }
261
262 if (pathname === "/geofence/evaluate") {
263 const result = yield* Effect.result(
264 evaluateGeofences({
265 DevicePositionUpdates: [
266 {
267 DeviceId: "device-1",
268 Position: SEATTLE,
269 SampleTime: new Date(),
270 },
271 ],
272 }),
273 );
274 return yield* HttpServerResponse.json(
275 Result.isSuccess(result)
276 ? // Errors is omitted from the wire response when every update
277 // evaluated cleanly (patched optional in distilled).
278 { ok: true, errors: (result.success.Errors ?? []).length }
279 : {
280 ok: false,
281 tag: result.failure._tag,
282 message: String(result.failure),
283 },
284 );
285 }
286
287 if (pathname === "/geofence/forecast") {
288 const result = yield* forecastEvents({
289 DeviceState: { Position: SEATTLE, Speed: 15 },
290 TimeHorizonMinutes: 30,
291 });
292 return yield* HttpServerResponse.json({
293 forecasted: result.ForecastedEvents.length,
294 distanceUnit: result.DistanceUnit,
295 });
296 }
297
298 if (pathname === "/geofence/batch-delete") {
299 const result = yield* batchDeleteGeofence({
300 GeofenceIds: ["fence-2"],
301 });
302 return yield* HttpServerResponse.json({
303 errors: result.Errors.length,
304 });
305 }
306
307 if (pathname === "/places/search-text") {
308 const result = yield* searchText({
309 Text: "Space Needle, Seattle, WA",
310 BiasPosition: SEATTLE,
311 MaxResults: 3,
312 });
313 return yield* HttpServerResponse.json({
314 count: (result.Results ?? []).length,
315 firstLabel: result.Results?.[0]?.Place?.Label,
316 });
317 }
318
319 if (pathname === "/places/search-position") {
320 const result = yield* searchPosition({
321 Position: SEATTLE,
322 MaxResults: 1,
323 });
324 return yield* HttpServerResponse.json({
325 count: (result.Results ?? []).length,
326 firstLabel: result.Results?.[0]?.Place?.Label,
327 });
328 }
329
330 if (pathname === "/places/suggestions") {
331 const result = yield* suggest({
332 Text: "coffee",
333 BiasPosition: SEATTLE,
334 MaxResults: 5,
335 });
336 return yield* HttpServerResponse.json({
337 count: (result.Results ?? []).length,
338 firstText: result.Results?.[0]?.Text,
339 });
340 }
341
342 if (pathname === "/places/get-place") {
343 // GetPlace needs a PlaceId — resolve one via SearchText first.
344 const searched = yield* searchText({
345 Text: "Space Needle, Seattle, WA",
346 BiasPosition: SEATTLE,
347 MaxResults: 1,
348 });
349 const placeId = searched.Results?.[0]?.PlaceId;
350 if (placeId === undefined) {
351 return yield* HttpServerResponse.json({
352 ok: false,
353 tag: "NoPlaceId",
354 message: "search returned no PlaceId",
355 });
356 }
357 const place = yield* Effect.result(getPlace({ PlaceId: placeId }));
358 return yield* HttpServerResponse.json(
359 Result.isSuccess(place)
360 ? {
361 ok: true,
362 label: place.success.Place?.Label,
363 point: place.success.Place?.Geometry?.Point,
364 }
365 : {
366 ok: false,
367 tag: place.failure._tag,
368 message: String(place.failure),
369 },
370 );
371 }
372
373 if (pathname === "/routes/calculate") {
374 const route = yield* calculateRoute({
375 DeparturePosition: SEATTLE,
376 DestinationPosition: PIKE_PLACE,
377 });
378 return yield* HttpServerResponse.json({
379 distance: route.Summary?.Distance,
380 duration: route.Summary?.DurationSeconds,
381 });
382 }
383
384 if (pathname === "/routes/matrix") {
385 const matrix = yield* calculateMatrix({
386 DeparturePositions: [SEATTLE],
387 DestinationPositions: [PIKE_PLACE],
388 });
389 return yield* HttpServerResponse.json({
390 rows: (matrix.RouteMatrix ?? []).length,
391 distance: matrix.RouteMatrix?.[0]?.[0]?.Distance,
392 });
393 }
394
395 if (pathname === "/map/style") {
396 const style = yield* getStyle();
397 return yield* HttpServerResponse.json({
398 bytes: style.Blob?.length ?? 0,
399 contentType: style.ContentType,
400 });
401 }
402
403 if (pathname === "/map/glyphs") {
404 const glyphs = yield* getGlyphs({
405 FontStack: "Arial Regular",
406 FontUnicodeRange: "0-255.pbf",
407 });
408 return yield* HttpServerResponse.json({
409 bytes: glyphs.Blob?.length ?? 0,
410 });
411 }
412
413 if (pathname === "/map/sprites") {
414 const sprites = yield* getSprites({ FileName: "sprites.json" });
415 return yield* HttpServerResponse.json({
416 bytes: sprites.Blob?.length ?? 0,
417 });
418 }
419
420 if (pathname === "/map/tile") {
421 const tile = yield* getTile({ Z: "0", X: "0", Y: "0" });
422 return yield* HttpServerResponse.json({
423 bytes: tile.Blob?.length ?? 0,
424 contentType: tile.ContentType,
425 });
426 }
427
428 if (pathname === "/jobs/list") {
429 const page = yield* listJobs();
430 return yield* HttpServerResponse.json({
431 count: page.Entries.length,
432 });
433 }
434
435 if (pathname === "/jobs/get-missing") {
436 const result = yield* Effect.result(
437 getJob({ JobId: MISSING_JOB_ID }),
438 );
439 return yield* HttpServerResponse.json(
440 Result.isSuccess(result)
441 ? { tag: "found" }
442 : {
443 tag: result.failure._tag,
444 message: String(result.failure),
445 },
446 );
447 }
448
449 if (pathname === "/jobs/cancel-missing") {
450 const result = yield* Effect.result(
451 cancelJob({ JobId: MISSING_JOB_ID }),
452 );
453 return yield* HttpServerResponse.json(
454 Result.isSuccess(result)
455 ? { tag: "cancelled" }
456 : {
457 tag: result.failure._tag,
458 message: String(result.failure),
459 },
460 );
461 }
462
463 return yield* HttpServerResponse.json(
464 { error: "Not found", pathname },
465 { status: 404 },
466 );
467 }).pipe(Effect.orDie),
468 };
469 }).pipe(
470 Effect.provide(
471 Layer.mergeAll(
472 Lambda.EventSource,
473 Location.BatchUpdateDevicePositionHttp,
474 Location.GetDevicePositionHttp,
475 Location.GetDevicePositionHistoryHttp,
476 Location.ListDevicePositionsHttp,
477 Location.BatchGetDevicePositionHttp,
478 Location.VerifyDevicePositionHttp,
479 Location.BatchDeleteDevicePositionHistoryHttp,
480 Location.PutGeofenceHttp,
481 Location.GetGeofenceHttp,
482 Location.ListGeofencesHttp,
483 Location.BatchPutGeofenceHttp,
484 Location.BatchDeleteGeofenceHttp,
485 Location.BatchEvaluateGeofencesHttp,
486 Location.ForecastGeofenceEventsHttp,
487 Location.SearchPlaceIndexForTextHttp,
488 Location.SearchPlaceIndexForPositionHttp,
489 Location.SearchPlaceIndexForSuggestionsHttp,
490 Location.GetPlaceHttp,
491 Location.CalculateRouteHttp,
492 Location.CalculateRouteMatrixHttp,
493 Location.GetMapStyleDescriptorHttp,
494 Location.GetMapGlyphsHttp,
495 Location.GetMapSpritesHttp,
496 Location.GetMapTileHttp,
497 Location.ListJobsHttp,
498 Location.GetJobHttp,
499 Location.CancelJobHttp,
500 Location.StartJobHttp,
501 ),
502 ),
503 ),
504);