1import * as AWS from "@/AWS";
2import { HealthCheck, HostedZone, Record } from "@/AWS/Route53";
3import * as Test from "@/Test/Alchemy";
4import * as route53 from "@distilled.cloud/aws/route-53";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7
8const { test } = Test.make({ providers: AWS.providers() });
9
10const normalizeId = (id: string) => id.replace(/^\/hostedzone\//, "");
11
12const findSet = (
13 sets: route53.ResourceRecordSet[],
14 name: string,
15 setId: string,
16) =>
17 sets.find(
18 (s) => s.Name === name && s.SetIdentifier === setId && s.Type === "A",
19 );
20
21const zoneName = "alchemy-route53-routing.alchemy.";
22
23describe.skipIf(process.env.FAST)(
24 "RoutingPolicy",
25 { tags: ["provider:aws", "provider:aws:route53", "live"] },
26 () => {
27 test.provider(
28 "weighted, failover, and alias routing records",
29 (stack) =>
30 Effect.gen(function* () {
31 yield* stack.destroy();
32
33 const result = yield* stack.deploy(
34 Effect.gen(function* () {
35 const zone = yield* HostedZone("RoutingZone", { name: zoneName });
36
37 // AWS requires a non-alias PRIMARY failover record to have a health
38 // check.
39 const check = yield* HealthCheck("PrimaryCheck", {
40 type: "HTTP",
41 ipAddress: "1.1.1.1",
42 port: 80,
43 resourcePath: "/",
44 requestInterval: "30 seconds",
45 });
46
47 // Weighted pair — same name/type, distinct setIdentifier + weight.
48 yield* Record("Blue", {
49 hostedZoneId: zone.id,
50 name: `api.${zoneName}`,
51 type: "A",
52 ttl: "60 seconds",
53 records: ["1.2.3.4"],
54 setIdentifier: "blue",
55 weight: 90,
56 });
57 yield* Record("Green", {
58 hostedZoneId: zone.id,
59 name: `api.${zoneName}`,
60 type: "A",
61 ttl: "60 seconds",
62 records: ["5.6.7.8"],
63 setIdentifier: "green",
64 weight: 10,
65 });
66
67 // Failover pair — PRIMARY gated on the health check.
68 yield* Record("Primary", {
69 hostedZoneId: zone.id,
70 name: `app.${zoneName}`,
71 type: "A",
72 ttl: "60 seconds",
73 records: ["1.1.1.1"],
74 setIdentifier: "primary",
75 failover: "PRIMARY",
76 healthCheckId: check.id,
77 });
78 yield* Record("Secondary", {
79 hostedZoneId: zone.id,
80 name: `app.${zoneName}`,
81 type: "A",
82 ttl: "60 seconds",
83 records: ["2.2.2.2"],
84 setIdentifier: "secondary",
85 failover: "SECONDARY",
86 });
87
88 return { zoneId: zone.id };
89 }),
90 );
91
92 const zoneId = normalizeId(result.zoneId);
93
94 // Verify out of band.
95 const listed = yield* route53.listResourceRecordSets({
96 HostedZoneId: zoneId,
97 MaxItems: 100,
98 });
99 const sets = listed.ResourceRecordSets ?? [];
100
101 const blue = findSet(sets, `api.${zoneName}`, "blue");
102 const green = findSet(sets, `api.${zoneName}`, "green");
103 expect(blue?.Weight).toBe(90);
104 expect(green?.Weight).toBe(10);
105 // Duration.Input ttl ("60 seconds") lands on the wire as TTL: 60.
106 expect(blue?.TTL).toBe(60);
107
108 const primary = findSet(sets, `app.${zoneName}`, "primary");
109 const secondary = findSet(sets, `app.${zoneName}`, "secondary");
110 expect(primary?.Failover).toBe("PRIMARY");
111 expect(primary?.HealthCheckId).toBeDefined();
112 expect(secondary?.Failover).toBe("SECONDARY");
113
114 // Destroy — this exercises the DELETE builder serializing policy fields
115 // (Weight, Failover, HealthCheckId). A mismatch would fail with
116 // InvalidChangeBatch.
117 yield* stack.destroy();
118
119 // The zone must be fully cleaned up (records deleted, then zone deleted).
120 const gone = yield* route53.getHostedZone({ Id: zoneId }).pipe(
121 Effect.map(() => false),
122 Effect.catchTag("NoSuchHostedZone", () => Effect.succeed(true)),
123 );
124 expect(gone).toBe(true);
125 }),
126 { timeout: 300_000 },
127 );
128 },
129);