1import { PrismaApiError } from "@/Prisma/Client";
2import { Credentials, fromApiToken } from "@distilled.cloud/prisma";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Result from "effect/Result";
6import * as HttpBody from "effect/http/HttpBody";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientResponse from "effect/http/HttpClientResponse";
9
10/**
11 * An in-memory Prisma Management API served over a fake `HttpClient`.
12 *
13 * This is `test/Prisma/Client.test.ts`'s harness (its `Captured` log,
14 * `HttpClient.make` stub, `HttpBody` decoding, and `json`/`data`/`page`
15 * helpers) lifted into a fixture so provider suites can share it. The only
16 * change is that the `fixtureResponse` if-chain is supplied by the caller
17 * instead of being hard-coded, which lets each suite declare exactly the
18 * routes the resources under test call — resource files migrate to distilled
19 * ops one dispatch at a time, so route coverage grows with them.
20 *
21 * The provider tests used to inject a fake `PrismaManagementClient`. Resource
22 * files calling generated distilled operations bypass that seam, so the fake
23 * moves one layer down and speaks the wire instead. Tests therefore exercise
24 * distilled's schema decoding and its status-to-tag matching, which the
25 * client-level fake never touched.
26 */
27
28export const FAKE_API_BASE_URL = "https://api.prisma.test";
29
30/** Shape of a captured request, as in `Client.test.ts`. */
31export interface Captured {
32 readonly url: string;
33 readonly method: string;
34 readonly pathname: string;
35 readonly search: string;
36 readonly authorization: string | undefined;
37 readonly bodyJson: unknown;
38}
39
40export const json = (value: unknown, init?: ResponseInit) =>
41 new Response(JSON.stringify(value), {
42 status: 200,
43 headers: { "content-type": "application/json" },
44 ...init,
45 });
46
47/** `{ data }` — the Management API's single-resource envelope. */
48export const data = <T>(value: T, init?: ResponseInit) =>
49 json({ data: value }, init);
50
51/** `{ data, pagination }` — the Management API's list envelope. */
52export const page = <T>(
53 items: ReadonlyArray<T>,
54 hasMore = false,
55 nextCursor: string | null = null,
56) => json({ data: items, pagination: { hasMore, nextCursor } });
57
58/** `204 No Content` — the Management API's delete responses. */
59export const noContent = () => new Response(null, { status: 204 });
60
61/** `{ error: { code, message } }` with a real status code. */
62export const failure = (
63 status: number,
64 code: string,
65 message: string,
66 hint?: string,
67) =>
68 json(
69 { error: hint === undefined ? { code, message } : { code, message, hint } },
70 { status },
71 );
72
73export const notFound = (message = "Not found") =>
74 failure(404, "not_found", message);
75
76export const conflict = (message = "Already exists") =>
77 failure(409, "already_exists", message);
78
79export const badRequest = (message = "Invalid request") =>
80 failure(400, "bad_request", message);
81
82export interface FakeManagementApi {
83 /** Fake transport plus the credentials the generated operations resolve. */
84 readonly layer: Layer.Layer<HttpClient.HttpClient | Credentials>;
85 /** Every request the fake served, in order. */
86 readonly captured: Captured[];
87}
88
89/**
90 * Build the fake from a `fixtureResponse`-style function: match on
91 * `request.method` / `request.pathname` and return a `Response`, exactly as
92 * `Client.test.ts` does. An unmatched request should return a failing status
93 * so the test fails loudly rather than proceeding on something plausible.
94 */
95export const makeFakeManagementApi = (
96 fixtureResponse: (request: Captured) => Response,
97): FakeManagementApi => {
98 const captured: Captured[] = [];
99
100 const client = HttpClient.make((request) =>
101 Effect.sync(() => {
102 const url = new URL(request.url);
103 const body = request.body as HttpBody.HttpBody;
104 const bodyText =
105 body._tag === "Uint8Array" ? new TextDecoder().decode(body.body) : "";
106 const entry: Captured = {
107 url: request.url,
108 method: request.method,
109 pathname: url.pathname,
110 search: url.search,
111 authorization: request.headers.authorization,
112 bodyJson: bodyText ? JSON.parse(bodyText) : undefined,
113 };
114 captured.push(entry);
115 return HttpClientResponse.fromWeb(request, fixtureResponse(entry));
116 }),
117 );
118
119 return {
120 layer: Layer.mergeAll(
121 Layer.succeed(HttpClient.HttpClient, client),
122 fromApiToken({
123 apiToken: "fake-service-token",
124 apiBaseUrl: FAKE_API_BASE_URL,
125 }),
126 ),
127 captured,
128 };
129};
130
131/**
132 * The catch-all arm of a suite's `fixtureResponse`, as in `Client.test.ts`.
133 *
134 * Deliberately a 400: an unregistered route is a harness bug and must fail
135 * the test immediately. A 5xx would be a transient category, so the retry
136 * policy would replay it eight times before surfacing anything.
137 */
138export const unhandled = (request: Captured) =>
139 json(
140 {
141 error: {
142 code: "unhandled",
143 message: `Unhandled fixture request ${request.method} ${request.pathname}${request.search}`,
144 },
145 },
146 { status: 400 },
147 );
148
149/** `METHOD /path` for every request served, for call-order assertions. */
150export const routesOf = (captured: ReadonlyArray<Captured>) =>
151 captured.map((request) => `${request.method} ${request.pathname}`);
152
153/**
154 * Adapter plumbing for suites whose route table is still expressed as
155 * client-shaped handlers.
156 *
157 * Those suites declare a plain object of synchronous `Effect` handlers and
158 * map them onto the wire. Every suite used to carry its own copy of this
159 * dispatch code, which drifted; it lives here instead, so all of them agree
160 * on how a handler's result becomes a response and on what a missing handler
161 * does.
162 */
163
164/** Run a synchronous fixture handler and capture its success or failure. */
165export const runHandler = (effect: Effect.Effect<any, any>) =>
166 Effect.runSync(Effect.result(effect));
167
168/**
169 * Injected `PrismaApiError`s keep their real status; anything else becomes a
170 * 400 — never a 5xx, whose transient category the retry policy would replay.
171 */
172const errorResponse = (error: unknown): Response =>
173 error instanceof PrismaApiError
174 ? failure(error.status, "error", error.message)
175 : failure(400, "error", String(error));
176
177/** A handler's result on the wire; `undefined` reads as a 404. */
178export const asResponse = (
179 outcome: ReturnType<typeof runHandler>,
180 wrap: (value: unknown) => Response,
181): Response =>
182 Result.isFailure(outcome)
183 ? errorResponse(outcome.failure)
184 : outcome.success === undefined
185 ? notFound("not found")
186 : wrap(outcome.success);
187
188/** A delete handler's result on the wire: `204`, or the injected failure. */
189export const voidResponse = (
190 outcome: ReturnType<typeof runHandler>,
191): Response =>
192 Result.isFailure(outcome) ? errorResponse(outcome.failure) : noContent();
193
194export interface HandlerDispatch {
195 /** Call a handler and envelope its value; a missing handler is `unhandled`. */
196 readonly call: (
197 handler: unknown,
198 args: ReadonlyArray<unknown>,
199 wrap?: (value: unknown) => Response,
200 ) => Response;
201 /** Same, for the delete routes that answer `204`. */
202 readonly callVoid: (
203 handler: unknown,
204 args: ReadonlyArray<unknown>,
205 ) => Response;
206 /** The `{ data, pagination }` wrap for list routes. */
207 readonly list: (value: unknown) => Response;
208}
209
210/**
211 * Bind the dispatch helpers to one captured request. Every verb — DELETE
212 * included — routes through here, so a route whose handler the suite never
213 * stubbed returns the diagnosable `unhandled` 400 instead of throwing.
214 */
215export const dispatchTo = (request: Captured): HandlerDispatch => ({
216 call: (handler, args, wrap = (value) => data(value)) =>
217 typeof handler === "function"
218 ? asResponse(runHandler((handler as any)(...args)), wrap)
219 : unhandled(request),
220 callVoid: (handler, args) =>
221 typeof handler === "function"
222 ? voidResponse(runHandler((handler as any)(...args)))
223 : unhandled(request),
224 list: (value) => page(value as unknown[]),
225});
226
227/**
228 * Wire-shaped payload builders.
229 *
230 * Distilled decodes every response against the generated schema, so a fake
231 * payload has to carry the fields the API really returns — including the
232 * deprecated ones the create routes still send (`apiKeys`,
233 * `connectionString`, `directConnection`). These builders supply that
234 * skeleton so suites only state what the assertion cares about.
235 */
236
237const REF_BASE = `${FAKE_API_BASE_URL}/v1`;
238
239const ref = (collection: string, id: string, name: string) => ({
240 id,
241 url: `${REF_BASE}/${collection}/${id}`,
242 name,
243});
244
245export const WIRE_CREATED_AT = "2024-01-01T00:00:00.000Z";
246
247export interface WireProjectOptions {
248 readonly id?: string;
249 readonly name?: string;
250 readonly logicalId?: string | null;
251 readonly defaultRegion?: string | null;
252 readonly workspaceId?: string;
253 readonly createdAt?: string;
254}
255
256export const wireProject = (options: WireProjectOptions = {}) => {
257 const id = options.id ?? "project-1";
258 return {
259 id,
260 type: "project",
261 url: `${REF_BASE}/projects/${id}`,
262 name: options.name ?? "app",
263 logicalId: options.logicalId ?? null,
264 createdAt: options.createdAt ?? WIRE_CREATED_AT,
265 defaultRegion:
266 options.defaultRegion === undefined ? "us-east-1" : options.defaultRegion,
267 workspace: ref("workspaces", options.workspaceId ?? "workspace-1", "team"),
268 };
269};
270
271export interface WireConnectionOptions {
272 readonly id?: string;
273 readonly name?: string;
274 readonly databaseId?: string;
275 readonly databaseName?: string;
276 readonly directConnectionString?: string;
277 readonly pooledConnectionString?: string;
278}
279
280export const wireConnection = (options: WireConnectionOptions = {}) => {
281 const id = options.id ?? "connection-1";
282 return {
283 id,
284 type: "connection",
285 url: `${REF_BASE}/connections/${id}`,
286 name: options.name ?? "default",
287 createdAt: WIRE_CREATED_AT,
288 kind: "postgres",
289 endpoints: {
290 direct: {
291 host: "db.prisma.test",
292 port: 5432,
293 ...(options.directConnectionString === undefined
294 ? {}
295 : { connectionString: options.directConnectionString }),
296 },
297 ...(options.pooledConnectionString === undefined
298 ? {}
299 : {
300 pooled: {
301 host: "pool.prisma.test",
302 port: 5432,
303 connectionString: options.pooledConnectionString,
304 },
305 }),
306 },
307 database: ref(
308 "databases",
309 options.databaseId ?? "database-1",
310 options.databaseName ?? "main",
311 ),
312 };
313};
314
315export interface WireDatabaseOptions {
316 readonly id?: string;
317 readonly name?: string;
318 readonly status?: "failure" | "provisioning" | "ready" | "recovering";
319 readonly isDefault?: boolean;
320 readonly defaultConnectionId?: string | null;
321 readonly connections?: ReadonlyArray<ReturnType<typeof wireConnection>>;
322 readonly projectId?: string;
323 readonly projectName?: string;
324 readonly regionId?: string | null;
325 readonly branchId?: string | null;
326}
327
328export const wireDatabase = (options: WireDatabaseOptions = {}) => {
329 const id = options.id ?? "database-1";
330 const regionId =
331 options.regionId === undefined ? "us-east-1" : options.regionId;
332 return {
333 id,
334 type: "database",
335 url: `${REF_BASE}/databases/${id}`,
336 name: options.name ?? "main",
337 status: options.status ?? "ready",
338 createdAt: WIRE_CREATED_AT,
339 isDefault: options.isDefault ?? false,
340 defaultConnectionId: options.defaultConnectionId ?? null,
341 connections: options.connections ?? [],
342 project: ref(
343 "projects",
344 options.projectId ?? "project-1",
345 options.projectName ?? "app",
346 ),
347 region: regionId === null ? null : { id: regionId, name: regionId },
348 source: { type: "empty" },
349 branchId: options.branchId ?? null,
350 };
351};
352
353/**
354 * The create/restore routes additionally return the deprecated top-level
355 * credential fields; distilled's schema requires them.
356 */
357export const wireCreatedDatabase = (options: WireDatabaseOptions = {}) => ({
358 ...wireDatabase(options),
359 apiKeys: [],
360 connectionString: "postgres://direct",
361 directConnection: {
362 host: "db.prisma.test",
363 user: "prisma",
364 pass: "secret",
365 },
366});