1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as neptunegraph from "@distilled.cloud/aws/neptune-graph";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import NeptuneGraphTestFunctionLive, {
10 FixtureGraph,
11 NeptuneGraphTestFunction,
12} from "./handler.ts";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// Ungated typed-error probe: proves the distilled error union carries the
17// not-found tag this provider's read/delete/wait paths depend on. Runs in
18// every CI pass at near-zero cost, unlike the gated lifecycle below.
19test.provider(
20 "getGraph on a nonexistent graph fails with ResourceNotFoundException",
21 () =>
22 Effect.gen(function* () {
23 const error = yield* Effect.flip(
24 // Well-formed but nonexistent graph id (g-<10 alphanumerics>).
25 neptunegraph.getGraph({ graphIdentifier: "g-0123456789" }),
26 );
27 expect(error._tag).toBe("ResourceNotFoundException");
28 }),
29 { tags: ["provider:aws", "provider:aws:neptunegraph", "live"] },
30);
31
32// The snapshot / import-task / export-task bindings address resources by
33// server-generated ids that only exist at runtime — probe their typed
34// not-found tags so the account-level grants and error unions stay proven
35// without provisioning a graph.
36test.provider(
37 "getGraphSnapshot on a nonexistent snapshot fails with ResourceNotFoundException",
38 () =>
39 Effect.gen(function* () {
40 const error = yield* Effect.flip(
41 // Well-formed but nonexistent snapshot id (gs-<10 alphanumerics>).
42 neptunegraph.getGraphSnapshot({ snapshotIdentifier: "gs-0123456789" }),
43 );
44 expect(error._tag).toBe("ResourceNotFoundException");
45 }),
46 { tags: ["provider:aws", "provider:aws:neptunegraph", "live"] },
47);
48
49test.provider(
50 "getImportTask on a nonexistent task fails with ResourceNotFoundException",
51 () =>
52 Effect.gen(function* () {
53 const error = yield* Effect.flip(
54 neptunegraph.getImportTask({ taskIdentifier: "t-0123456789" }),
55 );
56 expect(error._tag).toBe("ResourceNotFoundException");
57 }),
58 { tags: ["provider:aws", "provider:aws:neptunegraph", "live"] },
59);
60
61test.provider(
62 "getExportTask on a nonexistent task fails with ResourceNotFoundException",
63 () =>
64 Effect.gen(function* () {
65 const error = yield* Effect.flip(
66 neptunegraph.getExportTask({ taskIdentifier: "t-0123456789" }),
67 );
68 expect(error._tag).toBe("ResourceNotFoundException");
69 }),
70 { tags: ["provider:aws", "provider:aws:neptunegraph", "live"] },
71);
72
73const assertGraphGone = (graphId: string) =>
74 neptunegraph.getGraph({ graphIdentifier: graphId }).pipe(
75 Effect.flatMap((graph) =>
76 Effect.fail(
77 new Error(`graph '${graphId}' still exists (status: ${graph.status})`),
78 ),
79 ),
80 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
81 Effect.retry({
82 schedule: Schedule.max([
83 Schedule.fixed("10 seconds"),
84 Schedule.recurs(30),
85 ]),
86 }),
87 );
88
89// A Neptune Analytics graph takes ~5-10 minutes to provision and bills per
90// m-NCU-hour (16 m-NCUs here) while it exists. The full lifecycle — graph +
91// Lambda running openCypher through the ExecuteQuery binding — is gated
92// behind AWS_TEST_SLOW=1 and always destroys what it created.
93test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
94 "create graph, query it from Lambda via ExecuteQuery, destroy",
95 (stack) =>
96 Effect.gen(function* () {
97 // Clean slate in case a previous run died mid-flight.
98 yield* stack.destroy();
99
100 const { graph, fn } = yield* stack.deploy(
101 Effect.gen(function* () {
102 const { graph } = yield* FixtureGraph;
103 const fn = yield* NeptuneGraphTestFunction;
104 return { graph, fn };
105 }).pipe(Effect.provide(NeptuneGraphTestFunctionLive)),
106 );
107
108 expect(graph.graphId).toMatch(/^g-/);
109 expect(graph.graphArn).toContain(":graph/");
110 expect(graph.status).toBe("AVAILABLE");
111 expect(graph.provisionedMemory).toBe(16);
112 expect(graph.publicConnectivity).toBe(true);
113 expect(graph.replicaCount).toBe(0);
114 expect(graph.deletionProtection).toBe(false);
115 expect(graph.endpoint).toContain("neptune-graph");
116
117 // Out-of-band verification via distilled.
118 const observed = yield* neptunegraph.getGraph({
119 graphIdentifier: graph.graphId,
120 });
121 expect(observed.status).toBe("AVAILABLE");
122 expect(observed.name).toBe(graph.graphName);
123
124 // Drive the deployed Lambda. First request rides URL propagation —
125 // bounded retry.
126 const baseUrl = fn.functionUrl!.replace(/\/+$/, "");
127
128 const graphInfo = yield* HttpClient.get(`${baseUrl}/graph`).pipe(
129 Effect.flatMap((res) =>
130 res.status === 200
131 ? res.json
132 : Effect.fail(new Error(`/graph returned ${res.status}`)),
133 ),
134 Effect.retry({
135 schedule: Schedule.max([
136 Schedule.fixed("3 seconds"),
137 Schedule.recurs(40),
138 ]),
139 }),
140 );
141 expect((graphInfo as { graphId: string }).graphId).toBe(graph.graphId);
142
143 // openCypher through the ExecuteQuery binding: write a node, read it
144 // back.
145 const query = (body: {
146 query: string;
147 parameters?: Record<string, unknown>;
148 }) =>
149 HttpClient.execute(
150 HttpClientRequest.post(`${baseUrl}/query`).pipe(
151 HttpClientRequest.bodyJsonUnsafe(body),
152 ),
153 ).pipe(
154 Effect.flatMap((res) =>
155 res.status === 200
156 ? res.json
157 : res.text.pipe(
158 Effect.flatMap((text) =>
159 Effect.fail(
160 new Error(`/query returned ${res.status}: ${text}`),
161 ),
162 ),
163 ),
164 ),
165 Effect.retry({
166 schedule: Schedule.max([
167 Schedule.fixed("3 seconds"),
168 Schedule.recurs(10),
169 ]),
170 }),
171 );
172
173 yield* query({
174 query: "CREATE (n:Person {name: $name})",
175 parameters: { name: "Ada" },
176 });
177 const read = (yield* query({
178 query: "MATCH (n:Person) RETURN n.name AS name",
179 })) as { results: Array<{ name: string }> };
180 expect(read.results).toEqual([{ name: "Ada" }]);
181
182 const getJson = (path: string) =>
183 HttpClient.get(`${baseUrl}${path}`).pipe(
184 Effect.flatMap((res) =>
185 res.status === 200
186 ? res.json
187 : res.text.pipe(
188 Effect.flatMap((text) =>
189 Effect.fail(
190 new Error(`${path} returned ${res.status}: ${text}`),
191 ),
192 ),
193 ),
194 ),
195 Effect.retry({
196 schedule: Schedule.max([
197 Schedule.fixed("3 seconds"),
198 Schedule.recurs(10),
199 ]),
200 }),
201 );
202
203 // GetGraphSummary binding: the summary document reflects the graph.
204 const summary = (yield* getJson("/summary")) as {
205 graphSummary?: { numNodes?: number };
206 };
207 expect(summary.graphSummary).toBeDefined();
208
209 // ListQueries binding: returns a well-formed (usually empty) list.
210 const queries = (yield* getJson("/queries")) as {
211 queries: unknown[];
212 };
213 expect(Array.isArray(queries.queries)).toBe(true);
214
215 // ListGraphSnapshots binding: fresh graph has no snapshots.
216 const snapshots = (yield* getJson("/snapshots")) as {
217 graphSnapshots: unknown[];
218 };
219 expect(Array.isArray(snapshots.graphSnapshots)).toBe(true);
220
221 // Destroy immediately — the graph bills while it exists — and verify
222 // it is fully gone out-of-band.
223 yield* stack.destroy();
224 yield* assertGraphGone(graph.graphId);
225 }),
226 // graph create (~5-10 min) + lambda deploy + delete-until-gone, one test.
227 {
228 tags: [
229 "provider:aws",
230 "provider:aws:lambda",
231 "provider:aws:neptunegraph",
232 "live",
233 ],
234 timeout: 1_500_000,
235 },
236);