1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
5import * as quicksight from "@distilled.cloud/aws/quicksight";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import QuickSightBindingsFunctionLive, {
11 QuickSightBindingsFunction,
12} from "./bindings-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16
17// The testing account has no QuickSight subscription, so the live Lambda E2E
18// (which needs a real data source + dataset + dashboard) is gated behind
19// AWS_TEST_QUICKSIGHT=1 for entitled accounts. The ungated probes below prove
20// the typed error unions the bindings depend on, on every account.
21const SUBSCRIBED = !!process.env.AWS_TEST_QUICKSIGHT;
22
23// Ungated typed-error probes.
24test.provider(
25 "createIngestion on a nonexistent dataset fails with a typed tag",
26 () =>
27 Effect.gen(function* () {
28 const { accountId } = yield* AWSEnvironment.current;
29 const error = yield* Effect.flip(
30 quicksight.createIngestion({
31 AwsAccountId: accountId,
32 DataSetId: "alchemy-nonexistent-quicksight-dataset-probe",
33 IngestionId: "alchemy-quicksight-ingestion-probe",
34 }),
35 );
36 expect([
37 "ResourceNotFoundException",
38 "InvalidParameterValueException",
39 "AccessDeniedException",
40 ]).toContain(error._tag);
41 }),
42 {
43 tags: ["provider:aws", "provider:aws:quicksight", "live"],
44 timeout: 60_000,
45 },
46);
47
48test.provider(
49 "generateEmbedUrlForRegisteredUser for a nonexistent user fails with a typed tag",
50 () =>
51 Effect.gen(function* () {
52 const { accountId, region } = yield* AWSEnvironment.current;
53 const error = yield* Effect.flip(
54 quicksight.generateEmbedUrlForRegisteredUser({
55 AwsAccountId: accountId,
56 UserArn: `arn:aws:quicksight:${region}:${accountId}:user/default/alchemy-nonexistent-user-probe`,
57 ExperienceConfiguration: {
58 Dashboard: {
59 InitialDashboardId: "alchemy-nonexistent-dashboard-probe",
60 },
61 },
62 }),
63 );
64 expect([
65 "QuickSightUserNotFoundException",
66 "ResourceNotFoundException",
67 "InvalidParameterValueException",
68 "AccessDeniedException",
69 "UnsupportedUserEditionException",
70 ]).toContain(error._tag);
71 }),
72 {
73 tags: ["provider:aws", "provider:aws:quicksight", "live"],
74 timeout: 60_000,
75 },
76);
77
78test.provider(
79 "describeDashboardSnapshotJob on a nonexistent dashboard fails with a typed tag",
80 () =>
81 Effect.gen(function* () {
82 const { accountId } = yield* AWSEnvironment.current;
83 const error = yield* Effect.flip(
84 quicksight.describeDashboardSnapshotJob({
85 AwsAccountId: accountId,
86 DashboardId: "alchemy-nonexistent-quicksight-dashboard-probe",
87 SnapshotJobId: "alchemy-nonexistent-snapshot-job-probe",
88 }),
89 );
90 expect([
91 "ResourceNotFoundException",
92 "InvalidParameterValueException",
93 "AccessDeniedException",
94 "UnsupportedUserEditionException",
95 ]).toContain(error._tag);
96 }),
97 {
98 tags: ["provider:aws", "provider:aws:quicksight", "live"],
99 timeout: 60_000,
100 },
101);
102
103const sharedStack = Core.scratchStack(testOptions, "QuickSightBindings");
104
105let baseUrl: string;
106
107const get = (path: string) =>
108 HttpClient.get(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
109const post = (path: string) =>
110 HttpClient.post(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
111
112describe(
113 "QuickSight Bindings (E2E)",
114 {
115 tags: [
116 "provider:aws",
117 "provider:aws:lambda",
118 "provider:aws:quicksight",
119 "live",
120 ],
121 },
122 () => {
123 beforeAll(
124 Effect.gen(function* () {
125 if (!SUBSCRIBED) return;
126 yield* Effect.logInfo("QuickSight E2E setup: destroying previous run");
127 yield* sharedStack.destroy();
128
129 yield* Effect.logInfo(
130 "QuickSight E2E setup: deploying data source + dataset + dashboard + Lambda",
131 );
132 const { functionUrl } = yield* sharedStack.deploy(
133 Effect.gen(function* () {
134 return yield* QuickSightBindingsFunction;
135 }).pipe(Effect.provide(QuickSightBindingsFunctionLive)),
136 );
137
138 expect(functionUrl).toBeTruthy();
139 baseUrl = functionUrl!.replace(/\/+$/, "");
140
141 // Readiness probe — fresh function URLs take seconds to serve 200s.
142 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
143 Effect.flatMap((response) =>
144 response.status === 200
145 ? Effect.succeed(response)
146 : Effect.fail(
147 new Error(`Function not ready: ${response.status}`),
148 ),
149 ),
150 Effect.retry({
151 schedule: Schedule.max([
152 Schedule.fixed("2 seconds"),
153 Schedule.recurs(60),
154 ]),
155 }),
156 );
157 }),
158 { timeout: 300_000 },
159 );
160 afterAll(
161 Effect.gen(function* () {
162 if (!SUBSCRIBED) return;
163 yield* sharedStack.destroy();
164 }),
165 { timeout: 300_000 },
166 );
167
168 test.provider.skipIf(!SUBSCRIBED)(
169 "all 9 capabilities initialize in the runtime",
170 () =>
171 Effect.gen(function* () {
172 const response = (yield* get("/bindings")) as any;
173 expect(response.bound).toHaveLength(9);
174 }),
175 );
176
177 test.provider.skipIf(!SUBSCRIBED)(
178 "ListIngestions reads the bound dataset's refresh history",
179 () =>
180 Effect.gen(function* () {
181 const response = (yield* get("/ingestions")) as any;
182 expect(typeof response.count).toBe("number");
183 }),
184 );
185
186 test.provider.skipIf(!SUBSCRIBED)(
187 "CreateIngestion + DescribeIngestion + CancelIngestion round-trip",
188 () =>
189 Effect.gen(function* () {
190 const response = (yield* post("/ingestion")) as any;
191 // DIRECT_QUERY dataset → typed 400; SPICE dataset → started + status.
192 if (response.started) {
193 expect(typeof response.id).toBe("string");
194 } else {
195 expect([
196 "InvalidParameterValueException",
197 "ResourceNotFoundException",
198 ]).toContain(response.error);
199 }
200 }),
201 );
202
203 test.provider.skipIf(!SUBSCRIBED)(
204 "snapshot-job describe bindings surface the typed not-found",
205 () =>
206 Effect.gen(function* () {
207 const response = (yield* get("/snapshot-job/typed-not-found")) as any;
208 expect(response.describe).toBe(true);
209 expect(response.result).toBe(true);
210 }),
211 );
212
213 test.provider.skipIf(!SUBSCRIBED)(
214 "StartDashboardSnapshotJob starts (or typed-rejects) a PDF export",
215 () =>
216 Effect.gen(function* () {
217 const response = (yield* post("/snapshot-job")) as any;
218 if (response.started) {
219 expect(typeof response.jobId).toBe("string");
220 } else {
221 expect(typeof response.error).toBe("string");
222 }
223 }),
224 );
225
226 test.provider.skipIf(!SUBSCRIBED)(
227 "embed-URL bindings reach the API with the injected account id",
228 () =>
229 Effect.gen(function* () {
230 const registered = (yield* get("/embed-url")) as any;
231 expect(typeof registered.typed).toBe("string");
232 const anonymous = (yield* get("/embed-url-anon")) as any;
233 expect(
234 anonymous.ok === true || typeof anonymous.error === "string",
235 ).toBe(true);
236 }),
237 );
238 },
239);