1import * as Lambda from "@/AWS/Lambda";
2import * as MailManager from "@/AWS/MailManager";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export class MailManagerTestFunction extends Lambda.Function<Lambda.Function>()(
13 "MailManagerTestFunction",
14) {}
15
16export default MailManagerTestFunction.make(
17 {
18 main,
19 functionUrl: true,
20 timeout: Duration.seconds(30),
21 },
22 Effect.gen(function* () {
23 // The address list the member-scoped bindings are bound to.
24 const blockList = yield* MailManager.AddressList("BindingList", {
25 tags: { fixture: "mailmanager-bindings" },
26 });
27 // The archive the search-scoped bindings are bound to. Nothing routes
28 // mail into it, so searches complete with zero rows.
29 const archive = yield* MailManager.Archive("BindingArchive", {
30 retentionPeriod: "THREE_MONTHS",
31 tags: { fixture: "mailmanager-bindings" },
32 });
33
34 const registerMember =
35 yield* MailManager.RegisterMemberToAddressList(blockList);
36 const deregisterMember =
37 yield* MailManager.DeregisterMemberFromAddressList(blockList);
38 const getMember = yield* MailManager.GetMemberOfAddressList(blockList);
39 const listMembers = yield* MailManager.ListMembersOfAddressList(blockList);
40 const listImportJobs =
41 yield* MailManager.ListAddressListImportJobs(blockList);
42 const startSearch = yield* MailManager.StartArchiveSearch(archive);
43 const getSearch = yield* MailManager.GetArchiveSearch(archive);
44 const getSearchResults =
45 yield* MailManager.GetArchiveSearchResults(archive);
46 const listSearches = yield* MailManager.ListArchiveSearches(archive);
47 const listExports = yield* MailManager.ListArchiveExports(archive);
48
49 const bound = {
50 registerMember,
51 deregisterMember,
52 getMember,
53 listMembers,
54 listImportJobs,
55 startSearch,
56 getSearch,
57 getSearchResults,
58 listSearches,
59 listExports,
60 };
61
62 return {
63 fetch: Effect.gen(function* () {
64 const request = yield* HttpServerRequest;
65 const url = new URL(request.originalUrl);
66 const pathname = url.pathname;
67
68 if (request.method === "GET" && pathname === "/bindings") {
69 return yield* HttpServerResponse.json({
70 bound: Object.keys(bound),
71 });
72 }
73
74 // Full member roundtrip: register -> get -> list -> deregister ->
75 // get (typed not-found). Addresses are Sensitive/Redacted end-to-end
76 // so the route reports booleans and counts, never the values.
77 if (request.method === "POST" && pathname === "/members/roundtrip") {
78 const address = "blocked@example.com";
79 yield* registerMember({ Address: address });
80 const member = yield* getMember({ Address: address });
81 const { Addresses } = yield* listMembers({});
82 yield* deregisterMember({ Address: address });
83 const goneAfterDeregister = yield* getMember({
84 Address: address,
85 }).pipe(
86 Effect.map(() => false),
87 Effect.catchTag("ResourceNotFoundException", () =>
88 Effect.succeed(true),
89 ),
90 );
91 return yield* HttpServerResponse.json({
92 registered: member.CreatedTimestamp !== undefined,
93 memberCount: Addresses.length,
94 goneAfterDeregister,
95 });
96 }
97
98 // Kick off an archive search over the last hour (the archive is
99 // empty; the search completes with zero rows).
100 if (request.method === "POST" && pathname === "/search/start") {
101 const { SearchId } = yield* startSearch({
102 FromTimestamp: new Date(Date.now() - 3_600_000),
103 ToTimestamp: new Date(),
104 MaxResults: 5,
105 });
106 return yield* HttpServerResponse.json({ searchId: SearchId });
107 }
108
109 if (request.method === "GET" && pathname === "/search/status") {
110 const searchId = url.searchParams.get("searchId") ?? "";
111 const search = yield* getSearch({ SearchId: searchId });
112 return yield* HttpServerResponse.json({
113 state: search.Status?.State,
114 errorMessage: search.Status?.ErrorMessage,
115 });
116 }
117
118 if (request.method === "GET" && pathname === "/search/results") {
119 const searchId = url.searchParams.get("searchId") ?? "";
120 const { Rows } = yield* getSearchResults({ SearchId: searchId });
121 return yield* HttpServerResponse.json({
122 rowCount: (Rows ?? []).length,
123 });
124 }
125
126 // Cheap enumeration reads proving the remaining grants.
127 if (request.method === "GET" && pathname === "/tasks") {
128 const { Searches } = yield* listSearches({});
129 const { Exports } = yield* listExports({});
130 const { ImportJobs } = yield* listImportJobs({});
131 return yield* HttpServerResponse.json({
132 searchCount: (Searches ?? []).length,
133 exportCount: (Exports ?? []).length,
134 importJobCount: ImportJobs.length,
135 });
136 }
137
138 return yield* HttpServerResponse.json(
139 { error: "Not found", method: request.method, pathname },
140 { status: 404 },
141 );
142 }).pipe(Effect.orDie),
143 };
144 }).pipe(
145 Effect.provide(
146 Layer.mergeAll(
147 MailManager.RegisterMemberToAddressListHttp,
148 MailManager.DeregisterMemberFromAddressListHttp,
149 MailManager.GetMemberOfAddressListHttp,
150 MailManager.ListMembersOfAddressListHttp,
151 MailManager.ListAddressListImportJobsHttp,
152 MailManager.StartArchiveSearchHttp,
153 MailManager.GetArchiveSearchHttp,
154 MailManager.GetArchiveSearchResultsHttp,
155 MailManager.ListArchiveSearchesHttp,
156 MailManager.ListArchiveExportsHttp,
157 ),
158 ),
159 ),
160);