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 GeoTestFunctionLive, { GeoTestFunction } from "./handler.ts";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "GeoBindings");
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
28const send = (request: HttpClientRequest.HttpClientRequest) =>
29 HttpClient.execute(request).pipe(
30 Effect.flatMap((response) =>
31 response.status >= 500
32 ? response.text.pipe(
33 Effect.flatMap((body) =>
34 Effect.fail(
35 new TransientUpstream({ status: response.status, body }),
36 ),
37 ),
38 )
39 : Effect.succeed(response),
40 ),
41 Effect.retry({
42 while: (e) => e._tag === "TransientUpstream",
43 schedule: Schedule.max([
44 Schedule.exponential("1 second"),
45 Schedule.recurs(5),
46 ]),
47 }),
48 );
49
50describe(
51 "Geo Bindings",
52 {
53 tags: [
54 "provider:aws",
55 "provider:aws:geomaps",
56 "provider:aws:geoplaces",
57 "provider:aws:georoutes",
58 "provider:aws:lambda",
59 "live",
60 ],
61 },
62 () => {
63 beforeAll(
64 Effect.gen(function* () {
65 yield* Effect.logInfo("Geo test setup: destroying previous resources");
66 yield* sharedStack.destroy();
67
68 yield* Effect.logInfo("Geo test setup: deploying fixture");
69 const { functionUrl } = yield* sharedStack.deploy(
70 Effect.gen(function* () {
71 return yield* GeoTestFunction;
72 }).pipe(Effect.provide(GeoTestFunctionLive)),
73 );
74
75 expect(functionUrl).toBeTruthy();
76 baseUrl = functionUrl!.replace(/\/+$/, "");
77 const readinessUrl = `${baseUrl}/ping`;
78
79 yield* Effect.logInfo(
80 `Geo test setup: probing readiness at ${readinessUrl}`,
81 );
82 yield* HttpClient.get(readinessUrl).pipe(
83 Effect.flatMap((response) =>
84 response.status === 200
85 ? Effect.succeed(response)
86 : Effect.fail(
87 new Error(`Function not ready: ${response.status}`),
88 ),
89 ),
90 Effect.tapError((error) =>
91 Effect.logWarning(
92 `Geo test setup: fixture not ready yet (${String(error)})`,
93 ),
94 ),
95 Effect.retry({ schedule: readinessPolicy }),
96 );
97 }),
98 { timeout: 240_000 },
99 );
100
101 afterAll(sharedStack.destroy(), { timeout: 120_000 });
102
103 describe("GeoPlaces.SearchText", () => {
104 test.provider(
105 "returns ranked results for a text query",
106 (_stack) =>
107 Effect.gen(function* () {
108 const response = (yield* send(
109 HttpClientRequest.get(`${baseUrl}/search-text`),
110 ).pipe(Effect.flatMap((r) => r.json))) as {
111 count: number;
112 firstPosition?: number[];
113 };
114
115 expect(response.count).toBeGreaterThan(0);
116 expect(Array.isArray(response.firstPosition)).toBe(true);
117 expect(response.firstPosition).toHaveLength(2);
118 }),
119 { timeout: 120_000 },
120 );
121 });
122
123 describe("GeoPlaces.Geocode", () => {
124 test.provider(
125 "geocodes an address into coordinates",
126 (_stack) =>
127 Effect.gen(function* () {
128 const response = (yield* send(
129 HttpClientRequest.get(`${baseUrl}/geocode`),
130 ).pipe(Effect.flatMap((r) => r.json))) as {
131 count: number;
132 position?: number[];
133 };
134
135 expect(response.count).toBeGreaterThan(0);
136 expect(Array.isArray(response.position)).toBe(true);
137 expect(response.position).toHaveLength(2);
138 }),
139 { timeout: 120_000 },
140 );
141 });
142
143 describe("GeoPlaces.ReverseGeocode", () => {
144 test.provider(
145 "reverse geocodes coordinates into an address",
146 (_stack) =>
147 Effect.gen(function* () {
148 const response = (yield* send(
149 HttpClientRequest.get(`${baseUrl}/reverse-geocode`),
150 ).pipe(Effect.flatMap((r) => r.json))) as {
151 count: number;
152 label?: string;
153 };
154
155 expect(response.count).toBeGreaterThan(0);
156 expect(typeof response.label).toBe("string");
157 expect(response.label!.length).toBeGreaterThan(0);
158 }),
159 { timeout: 120_000 },
160 );
161 });
162
163 describe("GeoRoutes.CalculateRoutes", () => {
164 test.provider(
165 "computes a route between two points",
166 (_stack) =>
167 Effect.gen(function* () {
168 const response = (yield* send(
169 HttpClientRequest.get(`${baseUrl}/calculate-routes`),
170 ).pipe(Effect.flatMap((r) => r.json))) as {
171 count: number;
172 distance?: number;
173 duration?: number;
174 };
175
176 expect(response.count).toBeGreaterThan(0);
177 expect(response.distance).toBeGreaterThan(0);
178 expect(response.duration).toBeGreaterThan(0);
179 }),
180 { timeout: 120_000 },
181 );
182 });
183
184 describe("GeoPlaces.Autocomplete", () => {
185 test.provider(
186 "completes a partial address query",
187 (_stack) =>
188 Effect.gen(function* () {
189 const response = (yield* send(
190 HttpClientRequest.get(`${baseUrl}/autocomplete`),
191 ).pipe(Effect.flatMap((r) => r.json))) as {
192 count: number;
193 firstTitle?: string;
194 };
195
196 expect(response.count).toBeGreaterThan(0);
197 expect(typeof response.firstTitle).toBe("string");
198 expect(response.firstTitle!.length).toBeGreaterThan(0);
199 }),
200 { timeout: 120_000 },
201 );
202 });
203
204 describe("GeoRoutes.CalculateIsolines", () => {
205 test.provider(
206 "computes a drive-time isoline around a point",
207 (_stack) =>
208 Effect.gen(function* () {
209 const response = (yield* send(
210 HttpClientRequest.get(`${baseUrl}/calculate-isolines`),
211 ).pipe(Effect.flatMap((r) => r.json))) as {
212 count: number;
213 geometryCount: number;
214 pricingBucket?: string;
215 };
216
217 expect(response.count).toBeGreaterThan(0);
218 expect(response.geometryCount).toBeGreaterThan(0);
219 expect(typeof response.pricingBucket).toBe("string");
220 }),
221 { timeout: 120_000 },
222 );
223 });
224
225 describe("GeoMaps.GetTile", () => {
226 test.provider(
227 "fetches a vector tile at z/x/y",
228 (_stack) =>
229 Effect.gen(function* () {
230 const response = (yield* send(
231 HttpClientRequest.get(`${baseUrl}/get-tile`),
232 ).pipe(Effect.flatMap((r) => r.json))) as {
233 byteLength: number;
234 contentType?: string;
235 pricingBucket?: string;
236 };
237
238 expect(response.byteLength).toBeGreaterThan(0);
239 expect(typeof response.pricingBucket).toBe("string");
240 }),
241 { timeout: 120_000 },
242 );
243 });
244
245 describe("GeoMaps.GetStaticMap", () => {
246 test.provider(
247 "renders a static map image",
248 (_stack) =>
249 Effect.gen(function* () {
250 const response = (yield* send(
251 HttpClientRequest.get(`${baseUrl}/get-static-map`),
252 ).pipe(Effect.flatMap((r) => r.json))) as {
253 byteLength: number;
254 contentType?: string;
255 pricingBucket?: string;
256 };
257
258 expect(response.byteLength).toBeGreaterThan(0);
259 expect(response.contentType).toContain("image");
260 expect(typeof response.pricingBucket).toBe("string");
261 }),
262 { timeout: 120_000 },
263 );
264 });
265 },
266);