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 ChatbotTestFunctionLive, { ChatbotTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "ChatbotBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// The shared Lambda fixture occasionally answers a transient 5xx under load
31// (cold re-init, IAM propagation on the freshly attached policy). Retry only
32// 5xx; a genuine 4xx/assertion failure surfaces immediately.
33const send = (request: HttpClientRequest.HttpClientRequest) =>
34 HttpClient.execute(request).pipe(
35 Effect.flatMap((response) =>
36 response.status >= 500
37 ? response.text.pipe(
38 Effect.flatMap((body) =>
39 Effect.fail(
40 new TransientUpstream({ status: response.status, body }),
41 ),
42 ),
43 )
44 : Effect.succeed(response),
45 ),
46 Effect.retry({
47 while: (e) => e._tag === "TransientUpstream",
48 schedule: Schedule.max([
49 Schedule.exponential("500 millis"),
50 Schedule.recurs(6),
51 ]),
52 }),
53 );
54
55const getJson = (path: string) =>
56 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
57 Effect.flatMap((r) => r.json),
58 );
59
60const postJson = (path: string) =>
61 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
62 Effect.flatMap((r) => r.json),
63 );
64
65// The testing account has no Slack workspace or Teams team onboarded, so the
66// reads answer real empty lists (a REAL data-plane success through each
67// binding's IAM grant) and the identity deletes answer typed error tags for
68// the nonexistent identities.
69describe.sequential(
70 "Chatbot Bindings",
71 {
72 tags: [
73 "provider:aws",
74 "provider:aws:chatbot",
75 "provider:aws:lambda",
76 "live",
77 ],
78 },
79 () => {
80 beforeAll(
81 Effect.gen(function* () {
82 yield* Effect.logInfo(
83 "Chatbot test setup: destroying previous resources",
84 );
85 yield* sharedStack.destroy();
86
87 yield* Effect.logInfo("Chatbot test setup: deploying fixture");
88 const { functionUrl } = yield* sharedStack.deploy(
89 Effect.gen(function* () {
90 return yield* ChatbotTestFunction;
91 }).pipe(Effect.provide(ChatbotTestFunctionLive)),
92 );
93
94 expect(functionUrl).toBeTruthy();
95 baseUrl = functionUrl!.replace(/\/+$/, "");
96
97 const readinessUrl = `${baseUrl}/bindings`;
98 yield* Effect.logInfo(
99 `Chatbot test setup: probing readiness at ${readinessUrl}`,
100 );
101 yield* HttpClient.get(readinessUrl).pipe(
102 Effect.flatMap((response) =>
103 response.status === 200
104 ? Effect.succeed(response)
105 : Effect.fail(
106 new Error(`Function not ready: ${response.status}`),
107 ),
108 ),
109 Effect.tapError((error) =>
110 Effect.logWarning(
111 `Chatbot test setup: fixture not ready yet (${String(error)})`,
112 ),
113 ),
114 Effect.retry({ schedule: readinessPolicy }),
115 );
116 }),
117 { timeout: 240_000 },
118 );
119
120 afterAll(sharedStack.destroy(), { timeout: 120_000 });
121
122 describe("binding registration", () => {
123 test.provider("all 10 account-level capabilities initialize", (_stack) =>
124 Effect.gen(function* () {
125 const response = yield* getJson("/bindings");
126 expect((response as any).bound).toHaveLength(10);
127 }),
128 );
129 });
130
131 describe("GetAccountPreferences", () => {
132 test.provider(
133 "succeeds end-to-end through the binding's IAM grant",
134 (_stack) =>
135 Effect.gen(function* () {
136 const response = (yield* getJson("/account-preferences")) as {
137 ok: boolean;
138 hasPreferences: boolean;
139 };
140 expect(response.ok).toBe(true);
141 expect(response.hasPreferences).toBe(true);
142 }),
143 );
144 });
145
146 describe("UpdateAccountPreferences", () => {
147 test.provider(
148 "no-op round-trip write succeeds through the binding",
149 (_stack) =>
150 Effect.gen(function* () {
151 const response = (yield* postJson(
152 "/account-preferences-roundtrip",
153 )) as { ok: boolean; tag?: string };
154 expect(response.ok).toBe(true);
155 }),
156 );
157 });
158
159 describe("DescribeSlackWorkspaces", () => {
160 test.provider("answers the workspace list", (_stack) =>
161 Effect.gen(function* () {
162 const response = (yield* getJson("/slack-workspaces")) as {
163 ok: boolean;
164 count: number;
165 };
166 expect(response.ok).toBe(true);
167 expect(response.count).toBeGreaterThanOrEqual(0);
168 }),
169 );
170 });
171
172 describe("DescribeSlackUserIdentities", () => {
173 test.provider("answers the identity list", (_stack) =>
174 Effect.gen(function* () {
175 const response = (yield* getJson("/slack-user-identities")) as {
176 ok: boolean;
177 count: number;
178 };
179 expect(response.ok).toBe(true);
180 expect(response.count).toBeGreaterThanOrEqual(0);
181 }),
182 );
183 });
184
185 describe("ListMicrosoftTeamsConfiguredTeams", () => {
186 test.provider("answers the configured team list", (_stack) =>
187 Effect.gen(function* () {
188 const response = (yield* getJson("/teams-configured-teams")) as {
189 ok: boolean;
190 count: number;
191 };
192 expect(response.ok).toBe(true);
193 expect(response.count).toBeGreaterThanOrEqual(0);
194 }),
195 );
196 });
197
198 describe("ListMicrosoftTeamsUserIdentities", () => {
199 test.provider("answers the identity list", (_stack) =>
200 Effect.gen(function* () {
201 const response = (yield* getJson("/teams-user-identities")) as {
202 ok: boolean;
203 count: number;
204 };
205 expect(response.ok).toBe(true);
206 expect(response.count).toBeGreaterThanOrEqual(0);
207 }),
208 );
209 });
210
211 describe("DeleteSlackUserIdentity", () => {
212 test.provider(
213 "answers a typed tag for a nonexistent identity",
214 (_stack) =>
215 Effect.gen(function* () {
216 const { accountId } = yield* AWS.AWSEnvironment.current;
217 const response = (yield* postJson(
218 `/delete-slack-user-identity?account=${accountId}`,
219 )) as {
220 ok: boolean;
221 tag?: string;
222 };
223 expect(response.ok).toBe(false);
224 expect([
225 "DeleteSlackUserIdentityException",
226 "InvalidParameterException",
227 "ResourceNotFoundException",
228 ]).toContain(response.tag);
229 }),
230 );
231 });
232
233 describe("DeleteSlackWorkspaceAuthorization", () => {
234 test.provider(
235 "answers idempotently or with a typed tag for a workspace that was never onboarded",
236 (_stack) =>
237 Effect.gen(function* () {
238 const response = (yield* postJson(
239 "/delete-slack-workspace-authorization",
240 )) as { ok: boolean; tag?: string };
241 if (!response.ok) {
242 expect([
243 "DeleteSlackWorkspaceAuthorizationFault",
244 "InvalidParameterException",
245 ]).toContain(response.tag);
246 }
247 }),
248 );
249 });
250
251 describe("DeleteMicrosoftTeamsUserIdentity", () => {
252 test.provider(
253 "answers a typed tag for a nonexistent identity",
254 (_stack) =>
255 Effect.gen(function* () {
256 const { accountId } = yield* AWS.AWSEnvironment.current;
257 const response = (yield* postJson(
258 `/delete-teams-user-identity?account=${accountId}`,
259 )) as {
260 ok: boolean;
261 tag?: string;
262 };
263 expect(response.ok).toBe(false);
264 expect([
265 "DeleteMicrosoftTeamsUserIdentityException",
266 "InvalidParameterException",
267 "ResourceNotFoundException",
268 ]).toContain(response.tag);
269 }),
270 );
271 });
272
273 describe("DeleteMicrosoftTeamsConfiguredTeam", () => {
274 test.provider(
275 "answers the typed not-found tag for a team that was never onboarded",
276 (_stack) =>
277 Effect.gen(function* () {
278 // ResourceNotFoundException is outside the Smithy model's union
279 // for this operation — patched in distilled patches/chatbot.json.
280 const response = (yield* postJson(
281 "/delete-teams-configured-team",
282 )) as { ok: boolean; tag?: string };
283 if (!response.ok) {
284 expect([
285 "DeleteTeamsConfiguredTeamException",
286 "InvalidParameterException",
287 "ResourceNotFoundException",
288 ]).toContain(response.tag);
289 }
290 }),
291 );
292 });
293 },
294);