1import * as AWS from "@/AWS";
2import { ServiceLevelObjective } from "@/AWS/ApplicationSignals";
3import * as Test from "@/Test/Alchemy";
4import * as appsignals from "@distilled.cloud/aws/application-signals";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Ungated typed-error probe: prove the distilled error union carries the
12// not-found tag this provider's read/delete paths depend on. Runs in every
13// CI pass at near-zero cost.
14test.provider(
15 "getServiceLevelObjective on a bogus id fails with ResourceNotFoundException",
16 () =>
17 Effect.gen(function* () {
18 const error = yield* Effect.flip(
19 appsignals.getServiceLevelObjective({
20 Id: "alchemy-nonexistent-slo-probe",
21 }),
22 );
23 expect(error._tag).toBe("ResourceNotFoundException");
24 }),
25 { tags: ["provider:aws", "provider:aws:applicationsignals", "live"] },
26);
27
28// A period-based SLI over an arbitrary CloudWatch metric (the metric does
29// not need to exist for the SLO to be valid).
30const sliConfig = (
31 threshold: number,
32): appsignals.ServiceLevelIndicatorConfig => ({
33 SliMetricConfig: {
34 MetricDataQueries: [
35 {
36 Id: "m1",
37 MetricStat: {
38 Metric: {
39 Namespace: "Alchemy/Test",
40 MetricName: "AppSignalsSloLatency",
41 Dimensions: [{ Name: "Fixture", Value: "slo" }],
42 },
43 Period: 60,
44 Stat: "Average",
45 },
46 ReturnData: true,
47 },
48 ],
49 },
50 MetricThreshold: threshold,
51 ComparisonOperator: "LessThanOrEqualTo",
52});
53
54const goal = (attainmentGoal: number): appsignals.Goal => ({
55 Interval: { RollingInterval: { DurationUnit: "DAY", Duration: 7 } },
56 AttainmentGoal: attainmentGoal,
57 WarningThreshold: 50,
58});
59
60// Deletion is synchronous but allow brief eventual consistency.
61const assertSloGone = (id: string) =>
62 appsignals.getServiceLevelObjective({ Id: id }).pipe(
63 Effect.flatMap((r) =>
64 Effect.fail(new Error(`slo '${r.Slo.Name}' still exists`)),
65 ),
66 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
67 Effect.retry({
68 schedule: Schedule.max([
69 Schedule.fixed("2 seconds"),
70 Schedule.recurs(10),
71 ]),
72 }),
73 );
74
75test.provider(
76 "create, update in place, replace on rename, destroy SLO",
77 (stack) =>
78 Effect.gen(function* () {
79 yield* stack.destroy();
80
81 // CREATE — engine-generated deterministic name.
82 const { slo } = yield* stack.deploy(
83 Effect.gen(function* () {
84 const slo = yield* ServiceLevelObjective("Slo", {
85 description: "alchemy application-signals test slo",
86 sliConfig: sliConfig(2000),
87 goal: goal(99),
88 tags: { fixture: "application-signals-slo" },
89 });
90 return { slo };
91 }),
92 );
93
94 expect(slo.sloName).toBeDefined();
95 expect(slo.sloArn).toContain(":slo/");
96 expect(slo.evaluationType).toBe("PeriodBased");
97
98 // Out-of-band verification via distilled.
99 const observed = yield* appsignals
100 .getServiceLevelObjective({ Id: slo.sloName })
101 .pipe(Effect.map((r) => r.Slo));
102 expect(observed.Arn).toBe(slo.sloArn);
103 expect(observed.Description).toBe("alchemy application-signals test slo");
104 expect(observed.Goal.AttainmentGoal).toBe(99);
105 expect(observed.Sli?.MetricThreshold).toBe(2000);
106
107 // Tags: user tag + internal Alchemy branding.
108 const tags = yield* appsignals
109 .listTagsForResource({ ResourceArn: slo.sloArn })
110 .pipe(
111 Effect.map((r) =>
112 Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value])),
113 ),
114 );
115 expect(tags.fixture).toBe("application-signals-slo");
116 expect(tags["alchemy::id"]).toBe("Slo");
117
118 // UPDATE IN PLACE — new description, threshold, attainment, and tag.
119 const { slo: updated } = yield* stack.deploy(
120 Effect.gen(function* () {
121 const slo = yield* ServiceLevelObjective("Slo", {
122 description: "alchemy application-signals test slo (updated)",
123 sliConfig: sliConfig(3000),
124 goal: goal(99.5),
125 tags: { fixture: "application-signals-slo", updated: "true" },
126 });
127 return { slo };
128 }),
129 );
130
131 expect(updated.sloArn).toBe(slo.sloArn);
132 const observedUpdated = yield* appsignals
133 .getServiceLevelObjective({ Id: updated.sloName })
134 .pipe(Effect.map((r) => r.Slo));
135 expect(observedUpdated.Description).toBe(
136 "alchemy application-signals test slo (updated)",
137 );
138 expect(observedUpdated.Goal.AttainmentGoal).toBe(99.5);
139 expect(observedUpdated.Sli?.MetricThreshold).toBe(3000);
140 const updatedTags = yield* appsignals
141 .listTagsForResource({ ResourceArn: updated.sloArn })
142 .pipe(
143 Effect.map((r) =>
144 Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value])),
145 ),
146 );
147 expect(updatedTags.updated).toBe("true");
148
149 // REPLACE — an explicit name change replaces the SLO (no rename API).
150 const { slo: replaced } = yield* stack.deploy(
151 Effect.gen(function* () {
152 const slo = yield* ServiceLevelObjective("Slo", {
153 sloName: "alchemy-appsignals-slo-replaced",
154 description: "alchemy application-signals test slo (replaced)",
155 sliConfig: sliConfig(3000),
156 goal: goal(99.5),
157 tags: { fixture: "application-signals-slo" },
158 });
159 return { slo };
160 }),
161 );
162
163 expect(replaced.sloName).toBe("alchemy-appsignals-slo-replaced");
164 expect(replaced.sloArn).not.toBe(slo.sloArn);
165 // The old SLO is deleted by the replacement.
166 yield* assertSloGone(slo.sloName);
167
168 // DESTROY — and verify the SLO is gone out-of-band.
169 yield* stack.destroy();
170 yield* assertSloGone(replaced.sloName);
171 }),
172 {
173 tags: ["provider:aws", "provider:aws:applicationsignals", "live"],
174 timeout: 120_000,
175 },
176);