1import * as AWS from "@/AWS";
2import { NotificationChannel } from "@/AWS/DevOpsGuru/NotificationChannel.ts";
3import { Topic } from "@/AWS/SNS/Topic.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as devopsguru from "@distilled.cloud/aws/devops-guru";
7import * as SNS from "@distilled.cloud/aws/sns";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// Ungated typed-error probe: prove distilled returns a typed tag (never an
17// untyped catch-all). A not-yet-onboarded account answers a bogus insight id
18// with ResourceNotFoundException; once DevOps Guru has been onboarded the
19// same request is rejected earlier as ValidationException — both are typed.
20test.provider(
21 "describeInsight on a nonexistent insight fails with a typed error",
22 () =>
23 Effect.gen(function* () {
24 const error = yield* Effect.flip(
25 devopsguru.describeInsight({
26 Id: "alchemy-nonexistent-devopsguru-insight-probe",
27 }),
28 );
29 expect(["ResourceNotFoundException", "ValidationException"]).toContain(
30 error._tag,
31 );
32 }),
33 { tags: ["provider:aws", "provider:aws:devopsguru", "live"] },
34);
35
36const listChannels = devopsguru.listNotificationChannels.items({}).pipe(
37 Stream.runCollect,
38 Effect.map((chunk) => Array.from(chunk)),
39);
40
41class TopicStillExists extends Data.TaggedError("TopicStillExists") {}
42
43// Assert-gone for the SNS topic the test deploys alongside the channel:
44// getTopicAttributes converges to the typed NotFoundException after destroy.
45const assertTopicDeleted = Effect.fn(function* (topicArn: string) {
46 yield* SNS.getTopicAttributes({ TopicArn: topicArn }).pipe(
47 Effect.flatMap(() => Effect.fail(new TopicStillExists())),
48 Effect.retry({
49 while: (error) => error._tag === "TopicStillExists",
50 schedule: Schedule.exponential(100),
51 times: 8,
52 }),
53 Effect.catchTag("NotFoundException", () => Effect.void),
54 );
55});
56
57test.provider(
58 "lifecycle: add channel, converge filters, remove",
59 (stack) =>
60 Effect.gen(function* () {
61 yield* stack.destroy();
62
63 // Create — an SNS topic and a channel with severity filters.
64 const created = yield* stack.deploy(
65 Effect.gen(function* () {
66 const topic = yield* Topic("Alerts", {});
67 const channel = yield* NotificationChannel("Channel", {
68 topicArn: topic.topicArn,
69 severities: ["HIGH"],
70 });
71 return { topic, channel };
72 }),
73 );
74 expect(created.channel.id).toBeDefined();
75 expect(created.channel.topicArn).toBe(created.topic.topicArn);
76
77 // Out-of-band verification via distilled.
78 const observed = yield* listChannels;
79 const live = observed.find((c) => c.Id === created.channel.id);
80 expect(live?.Config?.Sns?.TopicArn).toBe(created.topic.topicArn);
81 expect(live?.Config?.Filters?.Severities).toEqual(["HIGH"]);
82
83 // Canonical list() coverage.
84 const provider = yield* Provider.findProvider(NotificationChannel);
85 const all = yield* provider.list();
86 expect(all.some((c) => c.id === created.channel.id)).toBe(true);
87
88 // Update — change the filters. The AWS config is immutable, so the
89 // provider converges by remove + re-add (the channel id changes, the
90 // topic stays put).
91 const updated = yield* stack.deploy(
92 Effect.gen(function* () {
93 const topic = yield* Topic("Alerts", {});
94 const channel = yield* NotificationChannel("Channel", {
95 topicArn: topic.topicArn,
96 severities: ["HIGH", "MEDIUM"],
97 messageTypes: ["NEW_INSIGHT"],
98 });
99 return { topic, channel };
100 }),
101 );
102 expect(updated.channel.topicArn).toBe(created.topic.topicArn);
103 const afterUpdate = yield* listChannels;
104 const liveUpdated = afterUpdate.find((c) => c.Id === updated.channel.id);
105 expect(liveUpdated?.Config?.Sns?.TopicArn).toBe(created.topic.topicArn);
106 expect(
107 [...(liveUpdated?.Config?.Filters?.Severities ?? [])].sort(),
108 ).toEqual(["HIGH", "MEDIUM"]);
109 expect(liveUpdated?.Config?.Filters?.MessageTypes).toEqual([
110 "NEW_INSIGHT",
111 ]);
112 // The old channel is gone (not duplicated).
113 expect(
114 afterUpdate.filter(
115 (c) => c.Config?.Sns?.TopicArn === created.topic.topicArn,
116 ),
117 ).toHaveLength(1);
118
119 // Idempotent redeploy — no drift, id is unchanged.
120 const redeployed = yield* stack.deploy(
121 Effect.gen(function* () {
122 const topic = yield* Topic("Alerts", {});
123 const channel = yield* NotificationChannel("Channel", {
124 topicArn: topic.topicArn,
125 severities: ["HIGH", "MEDIUM"],
126 messageTypes: ["NEW_INSIGHT"],
127 });
128 return { topic, channel };
129 }),
130 );
131 expect(redeployed.channel.id).toBe(updated.channel.id);
132
133 // Destroy — the channel is removed and the SNS topic is gone.
134 yield* stack.destroy();
135 const afterDestroy = yield* listChannels;
136 expect(
137 afterDestroy.some(
138 (c) => c.Config?.Sns?.TopicArn === created.topic.topicArn,
139 ),
140 ).toBe(false);
141 yield* assertTopicDeleted(created.topic.topicArn);
142 }),
143 {
144 tags: [
145 "provider:aws",
146 "provider:aws:devopsguru",
147 "provider:aws:sns",
148 "live",
149 ],
150 timeout: 180_000,
151 },
152);