1import * as AWS from "@/AWS";
2import { QApp } from "@/AWS/QApps";
3import * as Test from "@/Test/Alchemy";
4import * as qapps from "@distilled.cloud/aws/qapps";
5import { describe, 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// Amazon Q Apps live inside an Amazon Q Business application environment
12// (the `instance-id` header), which itself requires IAM Identity Center —
13// neither is provisioned in the shared testing account. The ungated probe
14// asserts the distilled wiring surfaces the typed error the provider's
15// read/observe paths depend on; the full lifecycle is gated behind
16// AWS_TEST_QAPPS=1 (an account with a Q Business application, passed as
17// QAPPS_INSTANCE_ID).
18describe(
19 "AWS.QApps.QApp",
20 { tags: ["provider:aws", "provider:aws:qapps", "live"] },
21 () => {
22 // Without a Q Business instance, the API rejects at the door with a typed
23 // UnauthorizedException ("Unauthorized") — the caller isn't an Identity
24 // Center user of any instance. An entitled account with bogus ids surfaces
25 // ResourceNotFoundException/AccessDeniedException instead; all three are
26 // typed tags in the distilled union.
27 test.provider(
28 "getQApp against a nonexistent Q Business instance yields a typed error",
29 (_stack) =>
30 Effect.gen(function* () {
31 const error = yield* qapps
32 .getQApp({
33 instanceId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
34 appId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
35 })
36 .pipe(Effect.flip);
37 expect([
38 "UnauthorizedException",
39 "ResourceNotFoundException",
40 "AccessDeniedException",
41 ]).toContain(error._tag);
42 }),
43 { timeout: 60_000 },
44 );
45
46 test.provider(
47 "deleteQApp against a nonexistent Q Business instance yields a typed error",
48 (_stack) =>
49 Effect.gen(function* () {
50 const error = yield* qapps
51 .deleteQApp({
52 instanceId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
53 appId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
54 })
55 .pipe(Effect.flip);
56 expect([
57 "UnauthorizedException",
58 "ResourceNotFoundException",
59 "AccessDeniedException",
60 ]).toContain(error._tag);
61 }),
62 { timeout: 60_000 },
63 );
64
65 test.provider.skipIf(!process.env.AWS_TEST_QAPPS)(
66 "create Q App, update definition, destroy, verify gone",
67 (stack) =>
68 Effect.gen(function* () {
69 yield* stack.destroy();
70
71 const instanceId = process.env.QAPPS_INSTANCE_ID;
72 if (!instanceId) {
73 return yield* Effect.die(
74 new Error("AWS_TEST_QAPPS runs require QAPPS_INSTANCE_ID"),
75 );
76 }
77
78 const textCardId = "11111111-1111-4111-8111-111111111111";
79 const queryCardId = "22222222-2222-4222-8222-222222222222";
80
81 const deploy = (props: { description?: string; prompt: string }) =>
82 stack.deploy(
83 Effect.gen(function* () {
84 const app = yield* QApp("Summarizer", {
85 instanceId,
86 description: props.description,
87 appDefinition: {
88 cards: [
89 {
90 textInput: {
91 id: textCardId,
92 title: "Source Text",
93 type: "text-input",
94 },
95 },
96 {
97 qQuery: {
98 id: queryCardId,
99 title: "Summary",
100 type: "q-query",
101 prompt: props.prompt,
102 },
103 },
104 ],
105 },
106 tags: { Environment: "test" },
107 });
108 return { app };
109 }),
110 );
111
112 // Create.
113 const { app } = yield* deploy({
114 prompt: "Summarize the following text: @Source Text",
115 });
116 expect(app.appId).toBeDefined();
117 expect(app.appArn).toContain(":qapps:");
118 expect(app.status).toBeDefined();
119
120 // Out-of-band verification via distilled.
121 const observed = yield* qapps.getQApp({
122 instanceId,
123 appId: app.appId,
124 });
125 expect(observed.appId).toBe(app.appId);
126 expect(observed.appDefinition.cards).toHaveLength(2);
127
128 // Update in place — description + card prompt flow through UpdateQApp.
129 const updated = yield* deploy({
130 description: "updated by test",
131 prompt:
132 "Summarize the following text in one sentence: @Source Text",
133 });
134 expect(updated.app.appId).toBe(app.appId);
135 const reobserved = yield* qapps.getQApp({
136 instanceId,
137 appId: app.appId,
138 });
139 expect(reobserved.description).toBe("updated by test");
140 expect(reobserved.appVersion).toBeGreaterThan(observed.appVersion);
141
142 yield* stack.destroy();
143
144 // Typed wait-until-gone.
145 yield* Effect.gen(function* () {
146 const gone = yield* qapps
147 .getQApp({ instanceId, appId: app.appId })
148 .pipe(
149 Effect.map((d) => d.status === "DELETED"),
150 Effect.catchTag("ResourceNotFoundException", () =>
151 Effect.succeed(true),
152 ),
153 );
154 if (!gone) {
155 return yield* Effect.fail({ _tag: "StillExists" as const });
156 }
157 }).pipe(
158 Effect.retry({
159 while: (e: { _tag: string }) => e._tag === "StillExists",
160 schedule: Schedule.max([
161 Schedule.spaced("5 seconds"),
162 Schedule.recurs(10),
163 ]),
164 }),
165 );
166 }),
167 { timeout: 600_000 },
168 );
169 },
170);