1import { adopt } from "@/AdoptPolicy";
2import * as AWS from "@/AWS";
3import { AWSEnvironment } from "@/AWS/Environment.ts";
4import { Topic } from "@/AWS/SNS";
5import * as Provider from "@/Provider";
6import { State } from "@/State";
7import * as Test from "@/Test/Alchemy";
8import * as SNS from "@distilled.cloud/aws/sns";
9import { describe, expect } from "alchemy-test";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// Idempotent out-of-band delete for topics that adoption tests leave
17// unmanaged (state wiped) mid-test. DeleteTopic is idempotent per AWS, but
18// tolerate NotFoundException defensively.
19const deleteTopicIdempotent = (topicArn: string) =>
20 SNS.deleteTopic({ TopicArn: topicArn }).pipe(
21 Effect.catchTag("NotFoundException", () => Effect.void),
22 Effect.catchTag("InvalidParameterException", () => Effect.void),
23 // Best-effort out-of-band cleanup finalizer: any unexpected error is a
24 // defect (surfaces a genuinely stuck delete) so the error channel is
25 // `never` and this is a valid `Effect.ensuring` finalizer.
26 Effect.orDie,
27 );
28
29const topicArnFor = Effect.fn(function* (topicName: string) {
30 const { accountId, region } = yield* AWSEnvironment.current;
31 return `arn:aws:sns:${region}:${accountId}:${topicName}`;
32});
33
34describe(
35 "AWS.SNS.Topic",
36 { tags: ["provider:aws", "provider:aws:sns", "live"] },
37 () => {
38 test.provider("create and delete topic with default props", (stack) =>
39 Effect.gen(function* () {
40 yield* stack.destroy();
41
42 const topic = yield* stack.deploy(
43 Effect.gen(function* () {
44 return yield* Topic("DefaultTopic");
45 }),
46 );
47
48 expect(topic.topicName).toBeDefined();
49 expect(topic.topicArn).toBeDefined();
50
51 const attributes = yield* SNS.getTopicAttributes({
52 TopicArn: topic.topicArn,
53 });
54 expect(attributes.Attributes?.TopicArn).toBe(topic.topicArn);
55
56 yield* stack.destroy();
57 yield* assertTopicDeleted(topic.topicArn);
58 }),
59 );
60
61 test.provider("create, update, delete topic attributes and tags", (stack) =>
62 Effect.gen(function* () {
63 yield* stack.destroy();
64
65 const topic = yield* stack.deploy(
66 Effect.gen(function* () {
67 return yield* Topic("ManagedTopic", {
68 attributes: {
69 DisplayName: "managed-topic-v1",
70 },
71 tags: {
72 env: "test",
73 },
74 });
75 }),
76 );
77
78 const initialAttributes = yield* SNS.getTopicAttributes({
79 TopicArn: topic.topicArn,
80 });
81 expect(initialAttributes.Attributes?.DisplayName).toBe(
82 "managed-topic-v1",
83 );
84
85 const updatedTopic = yield* stack.deploy(
86 Effect.gen(function* () {
87 return yield* Topic("ManagedTopic", {
88 attributes: {
89 DisplayName: "managed-topic-v2",
90 },
91 tags: {
92 updated: "true",
93 },
94 });
95 }),
96 );
97
98 const updatedAttributes = yield* SNS.getTopicAttributes({
99 TopicArn: updatedTopic.topicArn,
100 });
101 expect(updatedAttributes.Attributes?.DisplayName).toBe(
102 "managed-topic-v2",
103 );
104
105 const tagResponse = yield* SNS.listTagsForResource({
106 ResourceArn: updatedTopic.topicArn,
107 });
108 const tags = Object.fromEntries(
109 (tagResponse.Tags ?? []).map((tag) => [tag.Key, tag.Value]),
110 );
111 expect(tags.updated).toBe("true");
112 expect(tags.env).toBeUndefined();
113
114 yield* stack.destroy();
115 yield* assertTopicDeleted(updatedTopic.topicArn);
116 }),
117 );
118
119 test.provider("create and delete fifo topic", (stack) =>
120 Effect.gen(function* () {
121 yield* stack.destroy();
122
123 const topic = yield* stack.deploy(
124 Effect.gen(function* () {
125 return yield* Topic("FifoTopic", {
126 fifo: true,
127 attributes: {
128 ContentBasedDeduplication: "true",
129 },
130 });
131 }),
132 );
133
134 expect(topic.topicName).toContain(".fifo");
135
136 const attributes = yield* SNS.getTopicAttributes({
137 TopicArn: topic.topicArn,
138 });
139 expect(attributes.Attributes?.FifoTopic).toBe("true");
140 expect(attributes.Attributes?.ContentBasedDeduplication).toBe("true");
141
142 yield* stack.destroy();
143 yield* assertTopicDeleted(topic.topicArn);
144 }),
145 );
146
147 // Engine-level adoption tests for SNS Topic.
148 test.provider(
149 "owned topic (matching alchemy tags) is silently adopted without --adopt",
150 (stack) =>
151 Effect.gen(function* () {
152 yield* stack.destroy();
153
154 // Deterministic name so a re-run reclaims any orphan from a
155 // previously-killed run instead of creating a new one.
156 const topicName = "alchemy-test-sns-adopt";
157 const topicArn = yield* topicArnFor(topicName);
158
159 // Pre-clean: reclaim a leftover unmanaged topic from a prior run.
160 yield* deleteTopicIdempotent(topicArn);
161
162 yield* Effect.gen(function* () {
163 const initial = yield* stack.deploy(
164 Effect.gen(function* () {
165 return yield* Topic("AdoptableTopic", { topicName });
166 }),
167 );
168 expect(initial.topicName).toEqual(topicName);
169
170 // Wipe state — the topic stays in SNS (unmanaged from here until
171 // the re-deploy below adopts it; the ensuring below covers that
172 // window on failure/interruption).
173 yield* Effect.gen(function* () {
174 const state = yield* yield* State;
175 yield* state.delete({
176 stack: stack.name,
177 stage: stack.stage,
178 fqn: "AdoptableTopic",
179 });
180 }).pipe(Effect.provide(stack.state));
181
182 const adopted = yield* stack.deploy(
183 Effect.gen(function* () {
184 return yield* Topic("AdoptableTopic", { topicName });
185 }),
186 );
187
188 expect(adopted.topicArn).toEqual(initial.topicArn);
189
190 yield* stack.destroy();
191 yield* assertTopicDeleted(initial.topicArn);
192 }).pipe(Effect.ensuring(deleteTopicIdempotent(topicArn)));
193 }),
194 );
195
196 test.provider(
197 "foreign-tagged topic requires adopt(true) to take over",
198 (stack) =>
199 Effect.gen(function* () {
200 yield* stack.destroy();
201
202 // Deterministic name so a re-run reclaims any orphan from a
203 // previously-killed run instead of creating a new one.
204 const topicName = "alchemy-test-sns-takeover";
205 const topicArn = yield* topicArnFor(topicName);
206
207 // Pre-clean: reclaim a leftover unmanaged topic from a prior run.
208 yield* deleteTopicIdempotent(topicArn);
209
210 yield* Effect.gen(function* () {
211 const original = yield* stack.deploy(
212 Effect.gen(function* () {
213 return yield* Topic("Original", { topicName });
214 }),
215 );
216
217 yield* Effect.gen(function* () {
218 const state = yield* yield* State;
219 yield* state.delete({
220 stack: stack.name,
221 stage: stack.stage,
222 fqn: "Original",
223 });
224 }).pipe(Effect.provide(stack.state));
225
226 const takenOver = yield* stack
227 .deploy(
228 Effect.gen(function* () {
229 return yield* Topic("Different", { topicName });
230 }),
231 )
232 .pipe(adopt(true));
233
234 expect(takenOver.topicArn).toEqual(original.topicArn);
235
236 const tagsResp = yield* SNS.listTagsForResource({
237 ResourceArn: takenOver.topicArn,
238 });
239 const tagMap = Object.fromEntries(
240 (tagsResp.Tags ?? [])
241 .filter(
242 (t): t is { Key: string; Value: string } =>
243 typeof t.Value === "string",
244 )
245 .map((t) => [t.Key, t.Value]),
246 );
247 expect(tagMap["alchemy::id"]).toEqual("Different");
248
249 yield* stack.destroy();
250 yield* assertTopicDeleted(takenOver.topicArn);
251 }).pipe(Effect.ensuring(deleteTopicIdempotent(topicArn)));
252 }),
253 );
254
255 // Canonical `list()` test (AWS account/region-scoped collection): deploy a
256 // real topic, resolve the provider from context via the typed
257 // `Provider.findProvider`, call `list()`, and assert the deployed topic
258 // appears in the exhaustively-paginated result.
259 test.provider("list enumerates the deployed topic", (stack) =>
260 Effect.gen(function* () {
261 yield* stack.destroy();
262
263 const topic = yield* stack.deploy(
264 Effect.gen(function* () {
265 return yield* Topic("ListTopic", {
266 topicName: "alchemy-test-sns-topic-list",
267 });
268 }),
269 );
270
271 const provider = yield* Provider.findProvider(Topic);
272 const all = yield* provider.list();
273
274 expect(all.some((t) => t.topicArn === topic.topicArn)).toBe(true);
275
276 yield* stack.destroy();
277 yield* assertTopicDeleted(topic.topicArn);
278 }),
279 );
280
281 class TopicStillExists extends Data.TaggedError("TopicStillExists") {}
282
283 const assertTopicDeleted = Effect.fn(function* (topicArn: string) {
284 yield* SNS.getTopicAttributes({
285 TopicArn: topicArn,
286 }).pipe(
287 Effect.flatMap(() => Effect.fail(new TopicStillExists())),
288 Effect.retry({
289 while: (error) => error._tag === "TopicStillExists",
290 schedule: Schedule.exponential(100),
291 times: 8,
292 }),
293 Effect.catchTag("NotFoundException", () => Effect.void),
294 Effect.catchTag("InvalidParameterException", () => Effect.void),
295 );
296 });
297 },
298);