1import * as AWS from "@/AWS";
2import { Role } from "@/AWS/IAM/Role.ts";
3import { Bot, BotAlias, BotLocale, Intent, SlotType } from "@/AWS/LexV2";
4import * as Test from "@/Test/Alchemy";
5import * as lexm from "@distilled.cloud/aws/lex-models-v2";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Ungated typed-error probe: prove the distilled error union carries the
13// not-found tag the provider's read/observe paths depend on. Runs in every
14// CI pass at near-zero cost.
15test.provider(
16 "describeBot on a nonexistent bot fails with ResourceNotFoundException",
17 () =>
18 Effect.gen(function* () {
19 const error = yield* Effect.flip(
20 lexm.describeBot({ botId: "BOGUSBOT01" }),
21 );
22 expect(error._tag).toBe("ResourceNotFoundException");
23 }),
24 { tags: ["provider:aws", "provider:aws:lexv2", "live"] },
25);
26
27const lexBotRole = Role("LexBotRole", {
28 assumeRolePolicyDocument: {
29 Version: "2012-10-17",
30 Statement: [
31 {
32 Effect: "Allow",
33 Principal: { Service: "lexv2.amazonaws.com" },
34 Action: ["sts:AssumeRole"],
35 },
36 ],
37 },
38});
39
40/** Deletion is asynchronous — verify the bot is fully gone out-of-band. */
41const assertBotGone = (botId: string) =>
42 Effect.gen(function* () {
43 const status = yield* lexm.describeBot({ botId }).pipe(
44 Effect.map((bot) => bot.botStatus ?? "unknown"),
45 Effect.catchTag("ResourceNotFoundException", () =>
46 Effect.succeed("gone" as const),
47 ),
48 );
49 if (status !== "gone") {
50 return yield* Effect.fail(
51 new Error(`bot '${botId}' still exists (status: ${status})`),
52 );
53 }
54 }).pipe(
55 Effect.retry({
56 schedule: Schedule.max([
57 Schedule.fixed("5 seconds"),
58 Schedule.recurs(24),
59 ]),
60 }),
61 );
62
63test.provider(
64 "bot + locale + slot type + intent + alias lifecycle (create, update, destroy)",
65 (stack) =>
66 Effect.gen(function* () {
67 yield* stack.destroy();
68
69 // ---- create ----
70 const created = yield* stack.deploy(
71 Effect.gen(function* () {
72 const role = yield* lexBotRole;
73 const bot = yield* Bot("TestBot", {
74 roleArn: role.roleArn,
75 description: "alchemy lex test bot",
76 idleSessionTTL: "300 seconds",
77 tags: { fixture: "lexv2-bot" },
78 });
79 const locale = yield* BotLocale("En", {
80 botId: bot.botId,
81 localeId: "en_US",
82 nluIntentConfidenceThreshold: 0.4,
83 });
84 const size = yield* SlotType("Size", {
85 botId: locale.botId,
86 localeId: locale.localeId,
87 slotTypeValues: [
88 { value: "small", synonyms: ["tiny"] },
89 { value: "large", synonyms: ["big"] },
90 ],
91 resolutionStrategy: "TopResolution",
92 });
93 const greet = yield* Intent("Greet", {
94 botId: size.botId,
95 localeId: size.localeId,
96 sampleUtterances: ["hello", "hi"],
97 description: "greets the user",
98 });
99 const alias = yield* BotAlias("Staging", {
100 botId: greet.botId,
101 description: "unassociated staging alias",
102 tags: { fixture: "lexv2-alias" },
103 });
104 return { bot, locale, size, greet, alias };
105 }),
106 );
107
108 expect(created.bot.botId).toBeDefined();
109 expect(created.bot.botArn).toContain(":bot/");
110 expect(created.bot.botStatus).toBe("Available");
111 expect(created.locale.localeId).toBe("en_US");
112 expect(created.locale.botVersion).toBe("DRAFT");
113 expect(created.size.slotTypeId).toBeDefined();
114 expect(created.greet.intentId).toBeDefined();
115 expect(created.alias.botAliasId).toBeDefined();
116 expect(created.alias.botAliasArn).toContain(":bot-alias/");
117 expect(created.alias.botAliasStatus).toBe("Available");
118
119 // Out-of-band verification via distilled.
120 const bot = yield* lexm.describeBot({ botId: created.bot.botId });
121 expect(bot.botStatus).toBe("Available");
122 expect(bot.description).toBe("alchemy lex test bot");
123 const intent = yield* lexm.describeIntent({
124 botId: created.bot.botId,
125 botVersion: "DRAFT",
126 localeId: "en_US",
127 intentId: created.greet.intentId,
128 });
129 expect(
130 (intent.sampleUtterances ?? []).map((u) => u.utterance).sort(),
131 ).toEqual(["hello", "hi"]);
132 const slotType = yield* lexm.describeSlotType({
133 botId: created.bot.botId,
134 botVersion: "DRAFT",
135 localeId: "en_US",
136 slotTypeId: created.size.slotTypeId,
137 });
138 expect(slotType.valueSelectionSetting?.resolutionStrategy).toBe(
139 "TopResolution",
140 );
141 const tags = yield* lexm.listTagsForResource({
142 resourceARN: created.bot.botArn,
143 });
144 expect(tags.tags?.fixture).toBe("lexv2-bot");
145 expect(tags.tags?.["alchemy::id"]).toBe("TestBot");
146
147 // ---- update in place (same identities) ----
148 const updated = yield* stack.deploy(
149 Effect.gen(function* () {
150 const role = yield* lexBotRole;
151 const bot = yield* Bot("TestBot", {
152 roleArn: role.roleArn,
153 description: "alchemy lex test bot (updated)",
154 idleSessionTTL: "10 minutes",
155 tags: { fixture: "lexv2-bot", pass: "two" },
156 });
157 const locale = yield* BotLocale("En", {
158 botId: bot.botId,
159 localeId: "en_US",
160 nluIntentConfidenceThreshold: 0.5,
161 });
162 const size = yield* SlotType("Size", {
163 botId: locale.botId,
164 localeId: locale.localeId,
165 slotTypeValues: [
166 { value: "small", synonyms: ["tiny"] },
167 { value: "medium" },
168 { value: "large", synonyms: ["big"] },
169 ],
170 resolutionStrategy: "TopResolution",
171 });
172 const greet = yield* Intent("Greet", {
173 botId: size.botId,
174 localeId: size.localeId,
175 sampleUtterances: ["hello", "hi", "good morning"],
176 description: "greets the user politely",
177 dialogCodeHook: true,
178 fulfillmentCodeHook: true,
179 });
180 const alias = yield* BotAlias("Staging", {
181 botId: greet.botId,
182 description: "still unassociated",
183 tags: { fixture: "lexv2-alias" },
184 });
185 return { bot, locale, size, greet, alias };
186 }),
187 );
188
189 // Identities are stable across the update.
190 expect(updated.bot.botId).toBe(created.bot.botId);
191 expect(updated.greet.intentId).toBe(created.greet.intentId);
192 expect(updated.size.slotTypeId).toBe(created.size.slotTypeId);
193 expect(updated.alias.botAliasId).toBe(created.alias.botAliasId);
194
195 const updatedBot = yield* lexm.describeBot({ botId: updated.bot.botId });
196 expect(updatedBot.description).toBe("alchemy lex test bot (updated)");
197 expect(updatedBot.idleSessionTTLInSeconds).toBe(600);
198 const updatedLocale = yield* lexm.describeBotLocale({
199 botId: updated.bot.botId,
200 botVersion: "DRAFT",
201 localeId: "en_US",
202 });
203 expect(updatedLocale.nluIntentConfidenceThreshold).toBe(0.5);
204 const updatedIntent = yield* lexm.describeIntent({
205 botId: updated.bot.botId,
206 botVersion: "DRAFT",
207 localeId: "en_US",
208 intentId: updated.greet.intentId,
209 });
210 expect(
211 (updatedIntent.sampleUtterances ?? []).map((u) => u.utterance),
212 ).toContain("good morning");
213 // Code hook flags synced onto the DRAFT intent by the update.
214 expect(updatedIntent.dialogCodeHook?.enabled).toBe(true);
215 expect(updatedIntent.fulfillmentCodeHook?.enabled).toBe(true);
216 const updatedTags = yield* lexm.listTagsForResource({
217 resourceARN: updated.bot.botArn,
218 });
219 expect(updatedTags.tags?.pass).toBe("two");
220
221 // ---- destroy + typed wait-until-gone ----
222 yield* stack.destroy();
223 yield* assertBotGone(created.bot.botId);
224 }),
225 // Observed ~15s live; headroom for slow deletes and the wait-until-gone.
226 {
227 tags: ["provider:aws", "provider:aws:iam", "provider:aws:lexv2", "live"],
228 timeout: 180_000,
229 },
230);