1import * as SDK from "@distilled.cloud/neon";
2import * as Alchemy from "alchemy";
3import * as Neon from "alchemy/Neon";
4import * as SQL from "alchemy/SQL/Postgres";
5import * as Test from "alchemy/Test/Bun";
6import { expect } from "bun:test";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import Api from "../src/Api.ts";
13import Events from "../src/Events.ts";
14import { resources } from "../src/resources.ts";
15
16const { test } = Test.make({
17 providers: Neon.providers(),
18 state: Alchemy.localState(),
19 profile: process.env.ALCHEMY_PROFILE ?? "testing",
20 stage: "test-neon-compute-tutorial",
21});
22
23test.provider(
24 "a real private upload dispatches the Effect event and persists status",
25 (stack) =>
26 Effect.gen(function* () {
27 yield* stack.destroy();
28 const { project, branch, uploads, publicAssets, api, events } =
29 yield* stack.deploy(
30 Effect.gen(function* () {
31 const backend = yield* resources;
32 const api = yield* Api;
33 const events = yield* Events;
34 return { ...backend, api, events };
35 }),
36 );
37 const scope = {
38 project_id: project.projectId,
39 branch_id: branch.branchId,
40 };
41 const observed = yield* SDK.listProjectBranchBuckets(scope);
42 expect(
43 observed.buckets.find((bucket) => bucket.name === uploads.bucketName)
44 ?.access_level,
45 ).toBe("private");
46 expect(
47 observed.buckets.find(
48 (bucket) => bucket.name === publicAssets.bucketName,
49 )?.access_level,
50 ).toBe("public_read");
51 const triggers = yield* SDK.listProjectBranchTriggers(scope);
52 expect(
53 triggers.triggers.some(
54 (trigger) =>
55 trigger.type === "schedule" &&
56 trigger.function_slug === events.slug &&
57 trigger.function_path === "/__alchemy/neon/cron/Nightly" &&
58 trigger.enabled,
59 ),
60 ).toBe(true);
61 expect(
62 triggers.triggers.some(
63 (trigger) =>
64 trigger.type === "storage_object_created" &&
65 trigger.function_slug === api.slug &&
66 trigger.function_path === "/__alchemy/neon/bucket/ProcessUploads" &&
67 trigger.enabled,
68 ),
69 ).toBe(true);
70 const http = yield* HttpClient.HttpClient;
71 const apiUrl = api.url.replace(/\/$/, "");
72 const health = yield* http.get(`${apiUrl}/health`).pipe(
73 Effect.flatMap((response) =>
74 response.status === 200
75 ? Effect.succeed(response)
76 : Effect.fail(new Error(`Health HTTP ${response.status}`)),
77 ),
78 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 8 }),
79 );
80 expect(health.status).toBe(200);
81 for (const path of ["/api/uploads", "/api/settings", "/api/me"]) {
82 expect((yield* http.get(`${apiUrl}${path}`)).status).toBe(401);
83 }
84 expect(
85 (yield* http.get(`${apiUrl}/api/uploads`, {
86 headers: { authorization: "Bearer invalid.jwt.signature" },
87 })).status,
88 ).toBe(401);
89 expect(
90 (yield* http.post(`${apiUrl}/__alchemy/neon/bucket/ProcessUploads`))
91 .status,
92 ).toBe(403);
93
94 const sql = yield* SQL.Postgres({
95 url: Redacted.make(branch.connectionUri),
96 });
97 const files = yield* Neon.bucketStorageClient(uploads);
98 const id = "10000000-0000-4000-8000-000000000001";
99 const key = `incoming/tutorial-test/${id}`;
100 const body = "real upload event";
101 yield* sql`INSERT INTO uploads (id, owner_id, object_key, filename, content_type, expected_bytes)
102 VALUES (${id}, 'tutorial-test', ${key}, 'event.txt', 'text/plain', ${body.length})`;
103 const signed = yield* files.presign(key, "PUT", {
104 contentType: "text/plain",
105 expiresIn: 120,
106 });
107 const put = yield* http.execute(
108 HttpClientRequest.put(signed).pipe(
109 HttpClientRequest.bodyText(body, "text/plain"),
110 ),
111 );
112 expect(put.status).toBe(200);
113 const [processed] = yield* sql<{
114 status: string;
115 actual_bytes: string | number;
116 }>`SELECT status, actual_bytes FROM uploads WHERE id = ${id}`.pipe(
117 Effect.repeat({
118 schedule: Schedule.spaced("3 seconds"),
119 times: 8,
120 until: (rows) => rows[0]?.status === "ready",
121 }),
122 );
123 expect(processed?.status).toBe("ready");
124 expect(Number(processed?.actual_bytes)).toBe(body.length);
125 expect(
126 (yield* sql`SELECT invocation_id FROM upload_events WHERE object_key = ${key}`)
127 .length,
128 ).toBeGreaterThan(0);
129 const download = yield* files.presign(key, "GET", { expiresIn: 60 });
130 const response = yield* http.get(download);
131 expect(yield* response.text).toBe(body);
132 const anonymousUrl = yield* Effect.sync(() => {
133 const url = new URL(download);
134 url.search = "";
135 return url.href;
136 });
137 expect((yield* http.get(anonymousUrl)).status).not.toBe(200);
138 yield* stack.destroy();
139 const gone = yield* SDK.getProject({
140 project_id: project.projectId,
141 }).pipe(
142 Effect.map(() => false),
143 Effect.catchTag("NotFound", () => Effect.succeed(true)),
144 Effect.repeat({
145 schedule: Schedule.spaced("1 second"),
146 times: 8,
147 until: (value) => value,
148 }),
149 );
150 expect(gone).toBe(true);
151 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie))),
152 { timeout: 120_000 },
153);