1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as backupsearch from "@distilled.cloud/aws/backupsearch";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import BackupSearchTestFunctionLive, {
12 BackupSearchTestFunction,
13 FIXTURE_SEARCH_JOB_NAME,
14} from "./handler.ts";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "BackupSearchBindings");
19
20// Every deploy starts a real search job, and search jobs CANNOT be deleted —
21// terminal records are retained server-side for ~7 days. The suite is gated
22// behind AWS_TEST_BACKUP_SEARCH=1 alongside the SearchJob lifecycle test so
23// routine CI runs don't accumulate un-deletable job records.
24const gated = !process.env.AWS_TEST_BACKUP_SEARCH;
25
26// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
27// over 60s on a fresh deploy under parallel-suite load.
28const readinessPolicy = Schedule.max([
29 Schedule.fixed("2 seconds"),
30 Schedule.recurs(75),
31]);
32
33let baseUrl: string;
34
35class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
36 readonly status: number;
37 readonly body: string;
38}> {}
39
40// Retry transient 5xx from cold re-inits; genuine 4xx fails 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("500 millis"),
58 Schedule.recurs(6),
59 ]),
60 }),
61 );
62
63// Search jobs cannot be deleted; "orphan-free" means never leaving one
64// RUNNING. Stop any RUNNING job with the fixture's deterministic name
65// (crash-safe — covers a run that died before the afterAll destroy).
66const stopLeakedSearchJobs = Effect.gen(function* () {
67 const page = yield* backupsearch.listSearchJobs({
68 ByStatus: "RUNNING",
69 MaxResults: 25,
70 });
71 yield* Effect.forEach(
72 (page.SearchJobs ?? []).filter(
73 (job) => job.Name === FIXTURE_SEARCH_JOB_NAME,
74 ),
75 (job) =>
76 backupsearch
77 .stopSearchJob({ SearchJobIdentifier: job.SearchJobIdentifier! })
78 .pipe(
79 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
80 Effect.catchTag("ConflictException", () => Effect.void),
81 ),
82 );
83}).pipe(Effect.orDie);
84
85describe.skipIf(gated)(
86 "BackupSearch Bindings",
87 {
88 tags: [
89 "provider:aws",
90 "provider:aws:backupsearch",
91 "provider:aws:lambda",
92 "live",
93 ],
94 },
95 () => {
96 beforeAll(
97 Effect.gen(function* () {
98 yield* sharedStack.destroy();
99 // Pre-clean: the scratch state is in-memory, so the destroy above
100 // cannot see a job leaked by a crashed prior run.
101 yield* Core.withProviders(
102 stopLeakedSearchJobs,
103 testOptions,
104 sharedStack.name,
105 );
106
107 const { functionUrl } = yield* sharedStack.deploy(
108 Effect.gen(function* () {
109 return yield* BackupSearchTestFunction;
110 }).pipe(Effect.provide(BackupSearchTestFunctionLive)),
111 );
112
113 expect(functionUrl).toBeTruthy();
114 baseUrl = functionUrl!.replace(/\/+$/, "");
115
116 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
117 Effect.flatMap((response) =>
118 response.status === 200
119 ? Effect.succeed(response)
120 : Effect.fail(
121 new Error(`Function not ready: ${response.status}`),
122 ),
123 ),
124 Effect.retry({ schedule: readinessPolicy }),
125 );
126 }),
127 { timeout: 240_000 },
128 );
129
130 afterAll(
131 sharedStack
132 .destroy()
133 .pipe(
134 Effect.ensuring(
135 Core.withProviders(
136 stopLeakedSearchJobs,
137 testOptions,
138 sharedStack.name,
139 ),
140 ),
141 ),
142 { timeout: 120_000 },
143 );
144
145 describe("binding registration", () => {
146 test.provider("all capabilities initialize in the runtime", (_stack) =>
147 Effect.gen(function* () {
148 const response = yield* send(
149 HttpClientRequest.get(`${baseUrl}/bindings`),
150 ).pipe(Effect.flatMap((r) => r.json));
151 expect((response as any).bound).toEqual([
152 "listSearchJobResults",
153 "listSearchJobBackups",
154 "getSearchJob",
155 ]);
156 }),
157 );
158 });
159
160 describe("GetSearchJob", () => {
161 test.provider("reads the fixture search job's status", (_stack) =>
162 Effect.gen(function* () {
163 const response = yield* send(
164 HttpClientRequest.get(`${baseUrl}/job`),
165 ).pipe(Effect.flatMap((r) => r.json));
166 expect((response as any).name).toBe(FIXTURE_SEARCH_JOB_NAME);
167 expect([
168 "RUNNING",
169 "COMPLETED",
170 "STOPPING",
171 "STOPPED",
172 "FAILED",
173 ]).toContain((response as any).status);
174 }),
175 );
176 });
177
178 describe("ListSearchJobResults", () => {
179 test.provider("lists the fixture search job's results", (_stack) =>
180 Effect.gen(function* () {
181 // The fixture job matches nothing on purpose — assert the call
182 // round-trips (IAM grant + identifier injection) with a well-formed
183 // page rather than any particular hit count.
184 const response = yield* send(
185 HttpClientRequest.get(`${baseUrl}/results`),
186 ).pipe(Effect.flatMap((r) => r.json));
187 expect(typeof (response as any).count).toBe("number");
188 }),
189 );
190 });
191
192 describe("ListSearchJobBackups", () => {
193 test.provider(
194 "lists the recovery points the search job covered",
195 (_stack) =>
196 Effect.gen(function* () {
197 const response = yield* send(
198 HttpClientRequest.get(`${baseUrl}/backups`),
199 ).pipe(Effect.flatMap((r) => r.json));
200 expect(typeof (response as any).count).toBe("number");
201 expect(Array.isArray((response as any).statuses)).toBe(true);
202 }),
203 );
204 });
205 },
206);