| 1 | import { PrismaApiError } from "@/Prisma/Client"; |
| 2 | import { Credentials, fromApiToken } from "@distilled.cloud/prisma"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Result from "effect/Result"; |
| 6 | import * as HttpBody from "effect/http/HttpBody"; |
| 7 | import * as HttpClient from "effect/http/HttpClient"; |
| 8 | import * 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 | |
| 28 | export const FAKE_API_BASE_URL = "https://api.prisma.test"; |
| 29 | |
| 30 | /** Shape of a captured request, as in `Client.test.ts`. */ |
| 31 | export 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 | |
| 40 | export 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. */ |
| 48 | export const data = <T>(value: T, init?: ResponseInit) => |
| 49 | json({ data: value }, init); |
| 50 | |
| 51 | /** `{ data, pagination }` — the Management API's list envelope. */ |
| 52 | export 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. */ |
| 59 | export const noContent = () => new Response(null, { status: 204 }); |
| 60 | |
| 61 | /** `{ error: { code, message } }` with a real status code. */ |
| 62 | export 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 | |
| 73 | export const notFound = (message = "Not found") => |
| 74 | failure(404, "not_found", message); |
| 75 | |
| 76 | export const conflict = (message = "Already exists") => |
| 77 | failure(409, "already_exists", message); |
| 78 | |
| 79 | export const badRequest = (message = "Invalid request") => |
| 80 | failure(400, "bad_request", message); |
| 81 | |
| 82 | export 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 | */ |
| 95 | export 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 | */ |
| 138 | export 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. */ |
| 150 | export 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. */ |
| 165 | export 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 | */ |
| 172 | const 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. */ |
| 178 | export 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. */ |
| 189 | export const voidResponse = ( |
| 190 | outcome: ReturnType<typeof runHandler>, |
| 191 | ): Response => |
| 192 | Result.isFailure(outcome) ? errorResponse(outcome.failure) : noContent(); |
| 193 | |
| 194 | export 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 | */ |
| 215 | export 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 | |
| 237 | const REF_BASE = `${FAKE_API_BASE_URL}/v1`; |
| 238 | |
| 239 | const ref = (collection: string, id: string, name: string) => ({ |
| 240 | id, |
| 241 | url: `${REF_BASE}/${collection}/${id}`, |
| 242 | name, |
| 243 | }); |
| 244 | |
| 245 | export const WIRE_CREATED_AT = "2024-01-01T00:00:00.000Z"; |
| 246 | |
| 247 | export 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 | |
| 256 | export 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 | |
| 271 | export 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 | |
| 280 | export 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 | |
| 315 | export 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 | |
| 328 | export 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 | */ |
| 357 | export 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 | }); |