1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
5import * as route53domains from "@distilled.cloud/aws/route-53-domains";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import Route53DomainsTestFunctionLive, {
13 Route53DomainsTestFunction,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "Route53DomainsBindings");
19
20// The Route 53 Domains API is global and only served from us-east-1 — pin
21// the region for out-of-band probes made directly from the test process.
22const withRoute53DomainsRegion = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
23 effect.pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1")));
24
25// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
26// over 60s on a fresh deploy under parallel-suite load.
27const readinessPolicy = Schedule.max([
28 Schedule.fixed("2 seconds"),
29 Schedule.recurs(75),
30]);
31
32let baseUrl: string;
33
34class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
35 readonly status: number;
36 readonly body: string;
37}> {}
38
39// Retry transient 5xx only; a genuine 4xx/assertion failure surfaces
40// immediately.
41const send = (request: HttpClientRequest.HttpClientRequest) =>
42 HttpClient.execute(request).pipe(
43 Effect.flatMap((response) =>
44 response.status >= 500
45 ? response.text.pipe(
46 Effect.flatMap((body) =>
47 Effect.fail(
48 new TransientUpstream({ status: response.status, body }),
49 ),
50 ),
51 )
52 : Effect.succeed(response),
53 ),
54 Effect.retry({
55 while: (e) => e._tag === "TransientUpstream",
56 schedule: Schedule.max([
57 Schedule.exponential("1 second"),
58 Schedule.recurs(5),
59 ]),
60 }),
61 );
62
63// Ungated probes against the real API (read-only, free). This suite NEVER
64// calls registerDomain — registration costs money and is irreversible.
65test.provider(
66 "checkDomainAvailability succeeds for a deterministic unregistered name",
67 (_stack) =>
68 Effect.gen(function* () {
69 const result = yield* withRoute53DomainsRegion(
70 route53domains.checkDomainAvailability({
71 // Deterministic probe name — read-only availability check, no
72 // registration ever happens in this suite.
73 DomainName: "alchemy-effect-r53d-probe-a32892.com",
74 }),
75 );
76 // The exact verdict (AVAILABLE vs PENDING) is registry-dependent; the
77 // probe proves auth + the us-east-1 pin + response decoding.
78 expect(result.Availability).toBeDefined();
79 }),
80 {
81 tags: ["provider:aws", "provider:aws:route53domains", "live"],
82 timeout: 60_000,
83 },
84);
85
86test.provider(
87 "getDomainDetail on a domain not in the account fails with typed DomainNotFound",
88 (_stack) =>
89 Effect.gen(function* () {
90 // example.com is never registered in the test account. The overloaded
91 // InvalidInput ("Domain example.com not found in account.") is
92 // specialized to DomainNotFound by the distilled patch.
93 const detail = yield* withRoute53DomainsRegion(
94 route53domains.getDomainDetail({ DomainName: "example.com" }),
95 ).pipe(
96 Effect.catchTag("DomainNotFound", (error) =>
97 Effect.succeed(error._tag),
98 ),
99 );
100 expect(detail).toBe("DomainNotFound");
101 }),
102 {
103 tags: ["provider:aws", "provider:aws:route53domains", "live"],
104 timeout: 60_000,
105 },
106);
107
108test.provider(
109 "listDomains succeeds for the account",
110 (_stack) =>
111 Effect.gen(function* () {
112 const result = yield* withRoute53DomainsRegion(
113 route53domains.listDomains({ MaxItems: 100 }),
114 );
115 // The testing account owns no domains — the call succeeding with a
116 // (possibly empty) list proves auth and response decoding.
117 expect(Array.isArray(result.Domains ?? [])).toBe(true);
118 }),
119 {
120 tags: ["provider:aws", "provider:aws:route53domains", "live"],
121 timeout: 60_000,
122 },
123);
124
125test.provider(
126 "retrieveDomainAuthCode on a domain not in the account fails with typed DomainNotFound",
127 (_stack) =>
128 Effect.gen(function* () {
129 // example.com is never registered in the test account. The overloaded
130 // InvalidInput ("Domain example.com not found in account.") is
131 // specialized to DomainNotFound by the distilled patch.
132 const tag = yield* withRoute53DomainsRegion(
133 route53domains.retrieveDomainAuthCode({ DomainName: "example.com" }),
134 ).pipe(
135 Effect.map(() => "success" as const),
136 Effect.catchTag("DomainNotFound", (error) =>
137 Effect.succeed(error._tag),
138 ),
139 );
140 expect(tag).toBe("DomainNotFound");
141 }),
142 {
143 tags: ["provider:aws", "provider:aws:route53domains", "live"],
144 timeout: 60_000,
145 },
146);
147
148test.provider(
149 "updateDomainNameservers on a domain not in the account fails with typed DomainNotFound",
150 (_stack) =>
151 Effect.gen(function* () {
152 const tag = yield* withRoute53DomainsRegion(
153 route53domains.updateDomainNameservers({
154 DomainName: "example.com",
155 Nameservers: [{ Name: "ns-1.awsdns-01.org" }],
156 }),
157 ).pipe(
158 Effect.map(() => "success" as const),
159 Effect.catchTag("DomainNotFound", (error) =>
160 Effect.succeed(error._tag),
161 ),
162 );
163 expect(tag).toBe("DomainNotFound");
164 }),
165 {
166 tags: ["provider:aws", "provider:aws:route53domains", "live"],
167 timeout: 60_000,
168 },
169);
170
171test.provider(
172 "getDomainSuggestions returns suggestions for a seed name",
173 (_stack) =>
174 Effect.gen(function* () {
175 const result = yield* withRoute53DomainsRegion(
176 route53domains.getDomainSuggestions({
177 DomainName: "example.com",
178 SuggestionCount: 5,
179 OnlyAvailable: false,
180 }),
181 );
182 expect(Array.isArray(result.SuggestionsList ?? [])).toBe(true);
183 }),
184 {
185 tags: ["provider:aws", "provider:aws:route53domains", "live"],
186 timeout: 60_000,
187 },
188);
189
190test.provider(
191 "listPrices returns registration pricing for the com TLD",
192 (_stack) =>
193 Effect.gen(function* () {
194 const result = yield* withRoute53DomainsRegion(
195 route53domains.listPrices({ Tld: "com" }),
196 );
197 expect((result.Prices ?? []).length).toBeGreaterThan(0);
198 expect(result.Prices?.[0]?.RegistrationPrice?.Price).toBeGreaterThan(0);
199 }),
200 {
201 tags: ["provider:aws", "provider:aws:route53domains", "live"],
202 timeout: 60_000,
203 },
204);
205
206describe(
207 "Route53Domains Bindings",
208 {
209 tags: [
210 "provider:aws",
211 "provider:aws:lambda",
212 "provider:aws:route53domains",
213 "live",
214 ],
215 },
216 () => {
217 beforeAll(
218 Effect.gen(function* () {
219 yield* Effect.logInfo(
220 "Route53Domains test setup: destroying previous resources",
221 );
222 yield* sharedStack.destroy();
223
224 yield* Effect.logInfo("Route53Domains test setup: deploying fixture");
225 const { functionUrl } = yield* sharedStack.deploy(
226 Effect.gen(function* () {
227 return yield* Route53DomainsTestFunction;
228 }).pipe(Effect.provide(Route53DomainsTestFunctionLive)),
229 );
230
231 expect(functionUrl).toBeTruthy();
232 baseUrl = functionUrl!.replace(/\/+$/, "");
233 const readinessUrl = `${baseUrl}/ping`;
234
235 yield* Effect.logInfo(
236 `Route53Domains test setup: probing readiness at ${readinessUrl}`,
237 );
238 yield* HttpClient.get(readinessUrl).pipe(
239 Effect.flatMap((response) =>
240 response.status === 200
241 ? Effect.succeed(response)
242 : Effect.fail(
243 new Error(`Function not ready: ${response.status}`),
244 ),
245 ),
246 Effect.tapError((error) =>
247 Effect.logWarning(
248 `Route53Domains test setup: fixture not ready yet (${String(error)})`,
249 ),
250 ),
251 Effect.retry({ schedule: readinessPolicy }),
252 );
253 }),
254 { timeout: 240_000 },
255 );
256
257 afterAll(sharedStack.destroy(), { timeout: 120_000 });
258
259 describe("CheckDomainAvailability", () => {
260 test.provider(
261 "returns an availability verdict from inside the Lambda",
262 (_stack) =>
263 Effect.gen(function* () {
264 const response = (yield* send(
265 HttpClientRequest.get(`${baseUrl}/availability`),
266 ).pipe(Effect.flatMap((r) => r.json))) as {
267 availability: string | undefined;
268 };
269
270 expect(response.availability).toBeDefined();
271 }),
272 { timeout: 120_000 },
273 );
274 });
275
276 describe("ListDomains", () => {
277 test.provider(
278 "lists the account's registered domains from inside the Lambda",
279 (_stack) =>
280 Effect.gen(function* () {
281 const response = (yield* send(
282 HttpClientRequest.get(`${baseUrl}/domains`),
283 ).pipe(Effect.flatMap((r) => r.json))) as {
284 count: number;
285 names: string[];
286 };
287
288 expect(response.count).toBeGreaterThanOrEqual(0);
289 expect(Array.isArray(response.names)).toBe(true);
290 }),
291 { timeout: 120_000 },
292 );
293 });
294
295 describe("GetDomainDetail", () => {
296 test.provider(
297 "reaches the API from inside the Lambda and gets a typed domain error",
298 (_stack) =>
299 Effect.gen(function* () {
300 const response = (yield* send(
301 HttpClientRequest.get(`${baseUrl}/detail`),
302 ).pipe(Effect.flatMap((r) => r.json))) as {
303 ok: boolean;
304 errorTag?: string;
305 };
306
307 // The call must reach the API (proving the IAM grant and the
308 // us-east-1 pin) and fail with a domain-level error — never
309 // AccessDenied. The deployed bundle resolves distilled from the
310 // built lib/, so until the coordinator rebuilds it the runtime
311 // still maps this error to the base InvalidInput instead of the
312 // patched DomainNotFound specialization (the strict typed
313 // assertion is the out-of-band probe above).
314 expect(response.ok).toBe(false);
315 expect(["DomainNotFound", "InvalidInput"]).toContain(
316 response.errorTag,
317 );
318 }),
319 { timeout: 120_000 },
320 );
321 });
322
323 describe("CheckDomainTransferability", () => {
324 test.provider(
325 "returns a transferability verdict from inside the Lambda",
326 (_stack) =>
327 Effect.gen(function* () {
328 const response = (yield* send(
329 HttpClientRequest.get(`${baseUrl}/transferability`),
330 ).pipe(Effect.flatMap((r) => r.json))) as {
331 transferable: string | undefined;
332 };
333
334 expect(response.transferable).toBeDefined();
335 }),
336 { timeout: 120_000 },
337 );
338 });
339
340 describe("GetDomainSuggestions", () => {
341 test.provider(
342 "returns suggestions from inside the Lambda",
343 (_stack) =>
344 Effect.gen(function* () {
345 const response = (yield* send(
346 HttpClientRequest.get(`${baseUrl}/suggestions`),
347 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
348
349 expect(response.count).toBeGreaterThanOrEqual(0);
350 }),
351 { timeout: 120_000 },
352 );
353 });
354
355 describe("ListPrices", () => {
356 test.provider(
357 "returns .com pricing from inside the Lambda",
358 (_stack) =>
359 Effect.gen(function* () {
360 const response = (yield* send(
361 HttpClientRequest.get(`${baseUrl}/prices`),
362 ).pipe(Effect.flatMap((r) => r.json))) as {
363 count: number;
364 registrationPrice: number | undefined;
365 };
366
367 expect(response.count).toBeGreaterThan(0);
368 expect(response.registrationPrice).toBeGreaterThan(0);
369 }),
370 { timeout: 120_000 },
371 );
372 });
373
374 describe("ListOperations", () => {
375 test.provider(
376 "lists the account's registration operations from inside the Lambda",
377 (_stack) =>
378 Effect.gen(function* () {
379 const response = (yield* send(
380 HttpClientRequest.get(`${baseUrl}/operations`),
381 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
382
383 expect(response.count).toBeGreaterThanOrEqual(0);
384 }),
385 { timeout: 120_000 },
386 );
387 });
388
389 describe("GetOperationDetail", () => {
390 test.provider(
391 "reaches the API from inside the Lambda and gets a typed error for an unknown operation",
392 (_stack) =>
393 Effect.gen(function* () {
394 const response = (yield* send(
395 HttpClientRequest.get(`${baseUrl}/operation-detail`),
396 ).pipe(Effect.flatMap((r) => r.json))) as {
397 ok: boolean;
398 errorTag?: string;
399 };
400
401 expect(response.ok).toBe(false);
402 expect(response.errorTag).toBe("InvalidInput");
403 }),
404 { timeout: 120_000 },
405 );
406 });
407
408 describe("RetrieveDomainAuthCode", () => {
409 test.provider(
410 "reaches the API from inside the Lambda and gets a typed domain error",
411 (_stack) =>
412 Effect.gen(function* () {
413 const response = (yield* send(
414 HttpClientRequest.get(`${baseUrl}/auth-code`),
415 ).pipe(Effect.flatMap((r) => r.json))) as {
416 ok: boolean;
417 errorTag?: string;
418 };
419
420 // Same stale-lib caveat as GetDomainDetail: the strict
421 // DomainNotFound assertion is the out-of-band probe above.
422 expect(response.ok).toBe(false);
423 expect(["DomainNotFound", "InvalidInput"]).toContain(
424 response.errorTag,
425 );
426 }),
427 { timeout: 120_000 },
428 );
429 });
430
431 describe("UpdateDomainNameservers", () => {
432 test.provider(
433 "reaches the API from inside the Lambda and gets a typed domain error",
434 (_stack) =>
435 Effect.gen(function* () {
436 const response = (yield* send(
437 HttpClientRequest.get(`${baseUrl}/nameservers`),
438 ).pipe(Effect.flatMap((r) => r.json))) as {
439 ok: boolean;
440 errorTag?: string;
441 };
442
443 expect(response.ok).toBe(false);
444 expect(["DomainNotFound", "InvalidInput"]).toContain(
445 response.errorTag,
446 );
447 }),
448 { timeout: 120_000 },
449 );
450 });
451
452 describe("RenewDomain", () => {
453 test.provider(
454 "reaches the API from inside the Lambda and gets a typed domain error",
455 (_stack) =>
456 Effect.gen(function* () {
457 const response = (yield* send(
458 HttpClientRequest.get(`${baseUrl}/renew`),
459 ).pipe(Effect.flatMap((r) => r.json))) as {
460 ok: boolean;
461 errorTag?: string;
462 };
463
464 // example.com is not in the account — the renewal is rejected
465 // before any billing can occur.
466 expect(response.ok).toBe(false);
467 expect(["DomainNotFound", "InvalidInput"]).toContain(
468 response.errorTag,
469 );
470 }),
471 { timeout: 120_000 },
472 );
473 });
474
475 describe("RegisterDomain", () => {
476 test.provider(
477 "reaches the API from inside the Lambda and is rejected for an unsupported TLD",
478 (_stack) =>
479 Effect.gen(function* () {
480 const response = (yield* send(
481 HttpClientRequest.get(`${baseUrl}/register`),
482 ).pipe(Effect.flatMap((r) => r.json))) as {
483 ok: boolean;
484 errorTag?: string;
485 };
486
487 // The deliberately invalid TLD guarantees rejection before any
488 // registration or billing — this suite NEVER registers a domain.
489 expect(response.ok).toBe(false);
490 expect(["UnsupportedTLD", "InvalidInput"]).toContain(
491 response.errorTag,
492 );
493 }),
494 { timeout: 120_000 },
495 );
496 });
497 },
498);