1import * as AWS from "@/AWS";
2import { CustomAction } from "@/AWS/Chatbot";
3import * as Test from "@/Test/Alchemy";
4import * as chatbot from "@distilled.cloud/aws/chatbot";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const findAction = (arn: string) =>
13 chatbot.getCustomAction({ CustomActionArn: arn }).pipe(
14 Effect.map((r) => r.CustomAction),
15 Effect.catchTag("ResourceNotFoundException", () =>
16 Effect.succeed(undefined),
17 ),
18 );
19
20class CustomActionStillExists extends Data.TaggedError(
21 "CustomActionStillExists",
22)<{ readonly arn: string }> {}
23
24const assertActionDeleted = (arn: string) =>
25 findAction(arn).pipe(
26 Effect.flatMap((action) =>
27 action === undefined
28 ? Effect.void
29 : Effect.fail(new CustomActionStillExists({ arn })),
30 ),
31 Effect.retry({
32 while: (e) => e._tag === "CustomActionStillExists",
33 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
34 }),
35 );
36
37// Ungated typed-error probe: prove the distilled error union carries the
38// not-found tag this provider's read/delete paths depend on.
39test.provider(
40 "getCustomAction on a nonexistent action fails with ResourceNotFoundException",
41 () =>
42 Effect.gen(function* () {
43 const { accountId } = yield* AWS.AWSEnvironment.current;
44 const error = yield* Effect.flip(
45 chatbot.getCustomAction({
46 CustomActionArn: `arn:aws:chatbot::${accountId}:custom-action/alchemy-probe-nonexistent`,
47 }),
48 );
49 expect(error._tag).toBe("ResourceNotFoundException");
50 }),
51 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 30_000 },
52);
53
54test.provider(
55 "create, update definition + alias + attachments + tags, delete",
56 (stack) =>
57 Effect.gen(function* () {
58 yield* stack.destroy();
59
60 const action = yield* stack.deploy(
61 Effect.gen(function* () {
62 return yield* CustomAction("TestAction", {
63 commandText: "aws lambda list-functions",
64 tags: { Environment: "test" },
65 });
66 }),
67 );
68
69 expect(action.actionName).toBeDefined();
70 expect(action.customActionArn).toContain(":custom-action/");
71
72 // out-of-band verification via distilled
73 const created = yield* findAction(action.customActionArn);
74 expect(created?.Definition.CommandText).toBe("aws lambda list-functions");
75 expect(created?.AliasName).toBeUndefined();
76 const tags = yield* chatbot
77 .listTagsForResource({ ResourceARN: action.customActionArn })
78 .pipe(
79 Effect.map((r) =>
80 Object.fromEntries(
81 (r.Tags ?? []).map((t) => [t.TagKey, t.TagValue]),
82 ),
83 ),
84 );
85 expect(tags.Environment).toBe("test");
86 expect(tags["alchemy::id"]).toBe("TestAction");
87
88 // update the command, add an alias + a notification attachment, and
89 // change the user tags
90 const updated = yield* stack.deploy(
91 Effect.gen(function* () {
92 return yield* CustomAction("TestAction", {
93 commandText:
94 "aws cloudwatch describe-alarms --alarm-names $AlarmName",
95 aliasName: "alchemy-test-describe-alarm",
96 attachments: [
97 {
98 // NotificationType is required by the live API even though
99 // the model marks it optional (patched in distilled).
100 notificationType: "CloudWatch",
101 buttonText: "Describe alarm",
102 criteria: [
103 { operator: "HAS_VALUE", variableName: "AlarmName" },
104 ],
105 },
106 ],
107 tags: { Environment: "production" },
108 });
109 }),
110 );
111 expect(updated.actionName).toBe(action.actionName);
112 expect(updated.customActionArn).toBe(action.customActionArn);
113
114 const afterUpdate = yield* findAction(action.customActionArn);
115 expect(afterUpdate?.Definition.CommandText).toBe(
116 "aws cloudwatch describe-alarms --alarm-names $AlarmName",
117 );
118 expect(afterUpdate?.AliasName).toBe("alchemy-test-describe-alarm");
119 expect(afterUpdate?.Attachments?.[0]?.ButtonText).toBe("Describe alarm");
120 expect(afterUpdate?.Attachments?.[0]?.Criteria?.[0]?.VariableName).toBe(
121 "AlarmName",
122 );
123 const updatedTags = yield* chatbot
124 .listTagsForResource({ ResourceARN: action.customActionArn })
125 .pipe(
126 Effect.map((r) =>
127 Object.fromEntries(
128 (r.Tags ?? []).map((t) => [t.TagKey, t.TagValue]),
129 ),
130 ),
131 );
132 expect(updatedTags.Environment).toBe("production");
133
134 yield* stack.destroy();
135 yield* assertActionDeleted(action.customActionArn);
136 }),
137 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 120_000 },
138);
139
140test.provider(
141 "custom action name and replacement on rename",
142 (stack) =>
143 Effect.gen(function* () {
144 yield* stack.destroy();
145
146 const first = yield* stack.deploy(
147 Effect.gen(function* () {
148 return yield* CustomAction("NamedAction", {
149 actionName: "alchemy-test-action-a",
150 commandText: "aws s3 ls",
151 });
152 }),
153 );
154 expect(first.actionName).toBe("alchemy-test-action-a");
155 expect(first.customActionArn).toContain(
156 ":custom-action/alchemy-test-action-a",
157 );
158
159 // renaming triggers a replacement: new physical action, old one gone
160 const second = yield* stack.deploy(
161 Effect.gen(function* () {
162 return yield* CustomAction("NamedAction", {
163 actionName: "alchemy-test-action-b",
164 commandText: "aws s3 ls",
165 });
166 }),
167 );
168 expect(second.actionName).toBe("alchemy-test-action-b");
169
170 const observed = yield* findAction(second.customActionArn);
171 expect(observed?.Definition.CommandText).toBe("aws s3 ls");
172 yield* assertActionDeleted(first.customActionArn);
173
174 yield* stack.destroy();
175 yield* assertActionDeleted(second.customActionArn);
176 }),
177 { tags: ["provider:aws", "provider:aws:chatbot", "live"], timeout: 120_000 },
178);