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 MailManagerTestFunctionLive, {
11 MailManagerTestFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "MailManagerBindings");
17
18// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
19// over 60s on a fresh deploy.
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// The shared Lambda fixture occasionally answers a transient 5xx under load
33// (cold re-init, IAM propagation on the freshly attached policy that the
34// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
35// 4xx/assertion failure surfaces immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58const getJson = (path: string) =>
59 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
60 Effect.flatMap((r) => r.json),
61 );
62
63const postJson = (path: string) =>
64 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
65 Effect.flatMap((r) => r.json),
66 );
67
68describe.sequential(
69 "MailManager Bindings",
70 {
71 tags: [
72 "provider:aws",
73 "provider:aws:lambda",
74 "provider:aws:mailmanager",
75 "live",
76 ],
77 },
78 () => {
79 beforeAll(
80 Effect.gen(function* () {
81 yield* Effect.logInfo(
82 "MailManager test setup: destroying previous resources",
83 );
84 yield* sharedStack.destroy();
85
86 yield* Effect.logInfo("MailManager test setup: deploying fixture");
87 const attrs = yield* sharedStack.deploy(
88 Effect.gen(function* () {
89 return yield* MailManagerTestFunction;
90 }).pipe(Effect.provide(MailManagerTestFunctionLive)),
91 );
92
93 expect(attrs.functionUrl).toBeTruthy();
94 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
95
96 const readinessUrl = `${baseUrl}/bindings`;
97 yield* Effect.logInfo(
98 `MailManager test setup: probing readiness at ${readinessUrl}`,
99 );
100 yield* HttpClient.get(readinessUrl).pipe(
101 Effect.flatMap((response) =>
102 response.status === 200
103 ? Effect.succeed(response)
104 : Effect.fail(
105 new Error(`Function not ready: ${response.status}`),
106 ),
107 ),
108 Effect.tapError((error) =>
109 Effect.logWarning(
110 `MailManager test setup: fixture not ready yet (${String(error)})`,
111 ),
112 ),
113 Effect.retry({ schedule: readinessPolicy }),
114 );
115 }),
116 { timeout: 240_000 },
117 );
118
119 afterAll(sharedStack.destroy(), { timeout: 120_000 });
120
121 describe("binding registration", () => {
122 test.provider(
123 "all ten capabilities initialize in the runtime",
124 (_stack) =>
125 Effect.gen(function* () {
126 const response = (yield* getJson("/bindings")) as {
127 bound: string[];
128 };
129 expect(response.bound).toHaveLength(10);
130 }),
131 );
132 });
133
134 describe("address list members", () => {
135 test.provider(
136 "register -> get -> list -> deregister roundtrip (injected list id)",
137 (_stack) =>
138 Effect.gen(function* () {
139 const response = (yield* postJson("/members/roundtrip")) as {
140 registered: boolean;
141 memberCount: number;
142 goneAfterDeregister: boolean;
143 };
144 expect(response.registered).toBe(true);
145 expect(response.memberCount).toBeGreaterThanOrEqual(1);
146 expect(response.goneAfterDeregister).toBe(true);
147 }),
148 { timeout: 60_000 },
149 );
150 });
151
152 describe("archive search", () => {
153 test.provider(
154 "start -> poll to completion -> zero rows on an empty archive",
155 (_stack) =>
156 Effect.gen(function* () {
157 const { searchId } = (yield* postJson("/search/start")) as {
158 searchId: string;
159 };
160 expect(searchId).toBeTruthy();
161
162 const status = yield* getJson(
163 `/search/status?searchId=${encodeURIComponent(searchId)}`,
164 ).pipe(
165 Effect.map((s) => s as { state?: string; errorMessage?: string }),
166 Effect.repeat({
167 schedule: Schedule.spaced("5 seconds"),
168 until: (s: { state?: string }): boolean =>
169 s.state === "COMPLETED" ||
170 s.state === "FAILED" ||
171 s.state === "CANCELLED",
172 times: 24,
173 }),
174 );
175 expect(status.state).toBe("COMPLETED");
176
177 const results = (yield* getJson(
178 `/search/results?searchId=${encodeURIComponent(searchId)}`,
179 )) as { rowCount: number };
180 expect(results.rowCount).toBe(0);
181 }),
182 { timeout: 180_000 },
183 );
184 });
185
186 describe("archive + import job enumeration", () => {
187 test.provider(
188 "lists searches, exports, and import jobs (archive-scoped grants)",
189 (_stack) =>
190 Effect.gen(function* () {
191 const response = (yield* getJson("/tasks")) as {
192 searchCount: number;
193 exportCount: number;
194 importJobCount: number;
195 };
196 // The search test above ran first (sequential) — at least one
197 // search is visible; no exports or import jobs were created.
198 expect(response.searchCount).toBeGreaterThanOrEqual(1);
199 expect(response.exportCount).toBe(0);
200 expect(response.importJobCount).toBe(0);
201 }),
202 );
203 });
204 },
205);