| 1 | import { PrismaApiError, type PrismaManagementClient } from "@/Prisma/Client"; |
| 2 | import { |
| 3 | destroyApp, |
| 4 | destroyDeployment, |
| 5 | destroyProjectApps, |
| 6 | waitForDeploymentStatus, |
| 7 | } from "@/Prisma/ComputeLifecycle"; |
| 8 | import { describe, expect, it } from "alchemy-test"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as TestClock from "effect/testing/TestClock"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as HttpClient from "effect/http/HttpClient"; |
| 14 | import { Retry, fromApiToken } from "@distilled.cloud/prisma"; |
| 15 | import { |
| 16 | type Captured, |
| 17 | data, |
| 18 | dispatchTo, |
| 19 | FAKE_API_BASE_URL, |
| 20 | makeFakeManagementApi, |
| 21 | page, |
| 22 | unhandled, |
| 23 | } from "./fixtures/FakeManagementApi.ts"; |
| 24 | import { PrismaPaginationError } from "@/Prisma/Internal/Pagination"; |
| 25 | |
| 26 | const apiError = ( |
| 27 | method: "GET" | "POST" | "DELETE", |
| 28 | path: string, |
| 29 | status: number, |
| 30 | ) => new PrismaApiError({ method, path, status, message: `HTTP ${status}` }); |
| 31 | |
| 32 | const deployment = (id: string, status: string) => ({ |
| 33 | id, |
| 34 | type: "deployment" as const, |
| 35 | serviceId: "app-1", |
| 36 | url: `https://api.prisma.test/v1/deployments/${id}`, |
| 37 | foundryVersionId: `foundry-${id}`, |
| 38 | status, |
| 39 | previewDomain: `${id}.preview.prisma.build`, |
| 40 | createdAt: "2026-01-01T00:00:00Z", |
| 41 | }); |
| 42 | |
| 43 | const appItem = (id: string) => ({ |
| 44 | id, |
| 45 | type: "app" as const, |
| 46 | url: `https://api.prisma.test/v1/services/${id}`, |
| 47 | name: id, |
| 48 | region: { id: "us-east-1", name: "US East" }, |
| 49 | projectId: "project-1", |
| 50 | branchId: null, |
| 51 | latestDeploymentId: null, |
| 52 | appEndpointDomain: `${id}.prisma.build`, |
| 53 | createdAt: "2026-01-01T00:00:00Z", |
| 54 | }); |
| 55 | |
| 56 | /** |
| 57 | * Serve the Management API from the same hermetic client-shaped handlers |
| 58 | * these tests declare, for the lifecycle routes now reached through distilled |
| 59 | * operations. `dispatchTo` maps each handler's result onto the wire (see the |
| 60 | * fixture). |
| 61 | */ |
| 62 | const clientBackedApi = (client: any) => |
| 63 | makeFakeManagementApi((request: Captured) => { |
| 64 | // segments[0] is the "v1" prefix. |
| 65 | const [head, id, tail] = request.pathname |
| 66 | .split("/") |
| 67 | .filter((segment) => segment.length > 0) |
| 68 | .slice(1); |
| 69 | const query = Object.fromEntries(new URLSearchParams(request.search)); |
| 70 | const { call, callVoid, list } = dispatchTo(request); |
| 71 | |
| 72 | if (head === "deployments" && id !== undefined) { |
| 73 | if (tail === "stop") return callVoid(client.stopDeployment, [id]); |
| 74 | if (request.method === "GET") return call(client.getDeployment, [id]); |
| 75 | if (request.method === "DELETE") { |
| 76 | return callVoid(client.deleteDeployment, [id]); |
| 77 | } |
| 78 | } |
| 79 | if (head === "services") { |
| 80 | if (id === undefined && request.method === "GET") { |
| 81 | return call(client.listApps, [query], list); |
| 82 | } |
| 83 | if (id !== undefined && tail === "deployments") { |
| 84 | return call(client.listAppDeployments, [id, query], list); |
| 85 | } |
| 86 | if (id !== undefined && request.method === "DELETE") { |
| 87 | return callVoid(client.deleteApp, [id]); |
| 88 | } |
| 89 | } |
| 90 | if ( |
| 91 | head === "projects" && |
| 92 | id !== undefined && |
| 93 | request.method === "DELETE" |
| 94 | ) { |
| 95 | return callVoid(client.deleteProject, [id]); |
| 96 | } |
| 97 | return unhandled(request); |
| 98 | }); |
| 99 | |
| 100 | const provide = (client: unknown) => |
| 101 | Effect.provide(clientBackedApi(client).layer); |
| 102 | |
| 103 | describe( |
| 104 | "Prisma canonical Compute lifecycle", |
| 105 | { tags: ["unit", "provider:prisma", "provider:prisma:compute", "local"] }, |
| 106 | () => { |
| 107 | it.live("waits for a deployment to reach its target status", () => { |
| 108 | let observed = 0; |
| 109 | const client = { |
| 110 | getDeployment: (id: string) => |
| 111 | Effect.sync(() => |
| 112 | deployment(id, observed++ === 0 ? "provisioning" : "running"), |
| 113 | ), |
| 114 | } as unknown as PrismaManagementClient; |
| 115 | |
| 116 | return Effect.gen(function* () { |
| 117 | const result = yield* waitForDeploymentStatus( |
| 118 | "deployment-1", |
| 119 | "running", |
| 120 | { |
| 121 | pollIntervalMs: 1, |
| 122 | timeoutSeconds: 10, |
| 123 | }, |
| 124 | ); |
| 125 | expect(result.status).toBe("running"); |
| 126 | expect(observed).toBe(2); |
| 127 | }).pipe(provide(client)); |
| 128 | }); |
| 129 | |
| 130 | it.effect("fails immediately for a failed deployment", () => { |
| 131 | const client = { |
| 132 | getDeployment: (id: string) => Effect.succeed(deployment(id, "failed")), |
| 133 | } as unknown as PrismaManagementClient; |
| 134 | |
| 135 | return Effect.gen(function* () { |
| 136 | const error = yield* waitForDeploymentStatus( |
| 137 | "deployment-1", |
| 138 | "running", |
| 139 | ).pipe(Effect.flip); |
| 140 | expect(error.message).toContain("deployment-1"); |
| 141 | expect(error.message).toContain("failed"); |
| 142 | }).pipe(provide(client)); |
| 143 | }); |
| 144 | |
| 145 | it.live("times out with the last observed deployment status", () => { |
| 146 | const client = { |
| 147 | getDeployment: (id: string) => |
| 148 | Effect.succeed(deployment(id, "provisioning")), |
| 149 | } as unknown as PrismaManagementClient; |
| 150 | |
| 151 | return Effect.gen(function* () { |
| 152 | const error = yield* waitForDeploymentStatus( |
| 153 | "deployment-1", |
| 154 | "running", |
| 155 | { |
| 156 | timeoutSeconds: 0.01, |
| 157 | pollIntervalMs: 1, |
| 158 | }, |
| 159 | ).pipe(Effect.flip); |
| 160 | expect(error.message).toContain("Timed out"); |
| 161 | expect(error.message).toContain("provisioning"); |
| 162 | }).pipe(provide(client)); |
| 163 | }); |
| 164 | |
| 165 | it.live( |
| 166 | "caps a hung deployment observation at the polling deadline", |
| 167 | () => { |
| 168 | // A handler cannot hang inside the synchronous dispatch fake, so the hang |
| 169 | // is modeled at the transport: an HTTP client that never answers. |
| 170 | const hungTransport = Layer.mergeAll( |
| 171 | Layer.succeed( |
| 172 | HttpClient.HttpClient, |
| 173 | HttpClient.make(() => Effect.never), |
| 174 | ), |
| 175 | fromApiToken({ |
| 176 | apiToken: "fake-service-token", |
| 177 | apiBaseUrl: FAKE_API_BASE_URL, |
| 178 | }), |
| 179 | ); |
| 180 | |
| 181 | return Effect.gen(function* () { |
| 182 | const startedAt = Date.now(); |
| 183 | const error = yield* waitForDeploymentStatus( |
| 184 | "deployment-hung", |
| 185 | "running", |
| 186 | { timeoutSeconds: 0.05, pollIntervalMs: 1 }, |
| 187 | ).pipe(Effect.flip); |
| 188 | const elapsed = Date.now() - startedAt; |
| 189 | |
| 190 | expect(error.message).toContain("Timed out"); |
| 191 | expect(error.message).toContain("last status: 'unknown'"); |
| 192 | expect(elapsed).toBeLessThan(500); |
| 193 | }).pipe(Effect.provide(hungTransport)); |
| 194 | }, |
| 195 | ); |
| 196 | |
| 197 | it.effect("rejects invalid deployment polling timings", () => { |
| 198 | const client = { |
| 199 | getDeployment: () => Effect.die("invalid options must fail first"), |
| 200 | } as unknown as PrismaManagementClient; |
| 201 | |
| 202 | return Effect.gen(function* () { |
| 203 | const timeoutError = yield* waitForDeploymentStatus( |
| 204 | "deployment-1", |
| 205 | "running", |
| 206 | { timeoutSeconds: 0 }, |
| 207 | ).pipe(Effect.flip); |
| 208 | const intervalError = yield* waitForDeploymentStatus( |
| 209 | "deployment-1", |
| 210 | "running", |
| 211 | { pollIntervalMs: Number.NaN }, |
| 212 | ).pipe(Effect.flip); |
| 213 | |
| 214 | expect(timeoutError.message).toContain("timeoutSeconds"); |
| 215 | expect(intervalError.message).toContain("pollIntervalMs"); |
| 216 | }).pipe(provide(client)); |
| 217 | }); |
| 218 | |
| 219 | it.effect("preserves a not-found observation while waiting", () => { |
| 220 | const client = { |
| 221 | getDeployment: () => |
| 222 | Effect.fail(apiError("GET", "/v1/deployments/deployment-1", 404)), |
| 223 | } as unknown as PrismaManagementClient; |
| 224 | |
| 225 | return Effect.gen(function* () { |
| 226 | const error = yield* waitForDeploymentStatus( |
| 227 | "deployment-1", |
| 228 | "running", |
| 229 | ).pipe(Effect.flip); |
| 230 | // Over the wire the injected 404 decodes into the typed error. |
| 231 | expect(error._tag).toBe("NotFound"); |
| 232 | expect(error.message).toBe("HTTP 404"); |
| 233 | }).pipe(provide(client)); |
| 234 | }); |
| 235 | |
| 236 | it.effect("treats an already deleted deployment as deleted", () => { |
| 237 | const client = { |
| 238 | getDeployment: (id: string) => |
| 239 | Effect.fail(apiError("GET", `/v1/deployments/${id}`, 404)), |
| 240 | } as unknown as PrismaManagementClient; |
| 241 | |
| 242 | return Effect.gen(function* () { |
| 243 | const result = yield* destroyDeployment("deployment-missing"); |
| 244 | expect(result).toEqual({ |
| 245 | deploymentId: "deployment-missing", |
| 246 | previousStatus: undefined, |
| 247 | stopped: false, |
| 248 | deleted: true, |
| 249 | }); |
| 250 | }).pipe(provide(client)); |
| 251 | }); |
| 252 | |
| 253 | it.effect("stops a running deployment before deleting it", () => { |
| 254 | const calls: string[] = []; |
| 255 | let status = "running"; |
| 256 | const client = { |
| 257 | getDeployment: (id: string) => |
| 258 | Effect.sync(() => { |
| 259 | calls.push(`get:${id}`); |
| 260 | return deployment(id, status); |
| 261 | }), |
| 262 | stopDeployment: (id: string) => |
| 263 | Effect.sync(() => { |
| 264 | calls.push(`stop:${id}`); |
| 265 | status = "stopped"; |
| 266 | }), |
| 267 | deleteDeployment: (id: string) => |
| 268 | Effect.sync(() => { |
| 269 | calls.push(`delete:${id}`); |
| 270 | }), |
| 271 | } as unknown as PrismaManagementClient; |
| 272 | |
| 273 | return Effect.gen(function* () { |
| 274 | const result = yield* destroyDeployment("deployment-1"); |
| 275 | expect(result).toMatchObject({ |
| 276 | deploymentId: "deployment-1", |
| 277 | previousStatus: "running", |
| 278 | stopped: true, |
| 279 | deleted: true, |
| 280 | }); |
| 281 | expect(calls).toEqual([ |
| 282 | "get:deployment-1", |
| 283 | "stop:deployment-1", |
| 284 | "get:deployment-1", |
| 285 | "delete:deployment-1", |
| 286 | ]); |
| 287 | }).pipe(provide(client)); |
| 288 | }); |
| 289 | |
| 290 | it.effect("reports only the canonical deployment cleanup route", () => { |
| 291 | const client = { |
| 292 | getDeployment: () => |
| 293 | Effect.succeed(deployment("deployment-1", "stopped")), |
| 294 | deleteDeployment: (id: string) => |
| 295 | Effect.fail(apiError("DELETE", `/v1/deployments/${id}`, 400)), |
| 296 | } as unknown as PrismaManagementClient; |
| 297 | |
| 298 | return Effect.gen(function* () { |
| 299 | const error = yield* destroyDeployment("deployment-1").pipe( |
| 300 | Effect.flip, |
| 301 | ); |
| 302 | expect(error.message).toContain("DELETE /v1/deployments/deployment-1"); |
| 303 | }).pipe(provide(client)); |
| 304 | }); |
| 305 | |
| 306 | it.effect( |
| 307 | "explains a stopped deployment whose delete fails with a server error", |
| 308 | () => { |
| 309 | const client = { |
| 310 | getDeployment: () => |
| 311 | Effect.succeed(deployment("deployment-1", "stopped")), |
| 312 | deleteDeployment: (id: string) => |
| 313 | // An unmapped status decodes into the catch-all error, which |
| 314 | // carries the ServerError category the diagnostic branch reads. |
| 315 | Effect.fail(apiError("DELETE", `/v1/deployments/${id}`, 507)), |
| 316 | } as unknown as PrismaManagementClient; |
| 317 | |
| 318 | return Effect.gen(function* () { |
| 319 | const error = yield* destroyDeployment("deployment-1").pipe( |
| 320 | Effect.flip, |
| 321 | ); |
| 322 | expect(error.message).toContain( |
| 323 | "Stopped Prisma deployments are expected to be deletable", |
| 324 | ); |
| 325 | expect(error.message).toContain( |
| 326 | "DELETE /v1/deployments/deployment-1", |
| 327 | ); |
| 328 | }).pipe( |
| 329 | provide(client), |
| 330 | // Server-category errors are replayed by the default policy; this |
| 331 | // test examines the terminal diagnostic, so retries are disabled. |
| 332 | Effect.provide(Layer.succeed(Retry.Retry, { while: () => false })), |
| 333 | ); |
| 334 | }, |
| 335 | ); |
| 336 | |
| 337 | it.effect( |
| 338 | "uses the App delete cascade without enumerating deployments", |
| 339 | () => { |
| 340 | const calls: string[] = []; |
| 341 | const client = { |
| 342 | listAppDeployments: () => |
| 343 | Effect.die("must not enumerate deployments"), |
| 344 | deleteApp: (id: string) => |
| 345 | Effect.sync(() => { |
| 346 | calls.push(`delete-app:${id}`); |
| 347 | }), |
| 348 | } as unknown as PrismaManagementClient; |
| 349 | |
| 350 | return Effect.gen(function* () { |
| 351 | const result = yield* destroyApp("app-1"); |
| 352 | expect(result).toEqual({ appId: "app-1", appDeleted: true }); |
| 353 | expect(calls).toEqual(["delete-app:app-1"]); |
| 354 | }).pipe(provide(client)); |
| 355 | }, |
| 356 | ); |
| 357 | |
| 358 | it.live("retries a bounded App deletion conflict", () => { |
| 359 | let attempts = 0; |
| 360 | const client = { |
| 361 | deleteApp: (id: string) => |
| 362 | Effect.suspend(() => { |
| 363 | attempts += 1; |
| 364 | return attempts < 3 |
| 365 | ? Effect.fail(apiError("DELETE", `/v1/services/${id}`, 409)) |
| 366 | : Effect.void; |
| 367 | }), |
| 368 | } as unknown as PrismaManagementClient; |
| 369 | |
| 370 | return Effect.gen(function* () { |
| 371 | const result = yield* destroyApp("app-1"); |
| 372 | expect(result.appDeleted).toBe(true); |
| 373 | expect(attempts).toBe(3); |
| 374 | }).pipe(provide(client)); |
| 375 | }); |
| 376 | |
| 377 | it.effect("treats an already deleted App as deleted", () => { |
| 378 | const client = { |
| 379 | deleteApp: (id: string) => |
| 380 | Effect.fail(apiError("DELETE", `/v1/services/${id}`, 404)), |
| 381 | } as unknown as PrismaManagementClient; |
| 382 | |
| 383 | return Effect.gen(function* () { |
| 384 | expect(yield* destroyApp("app-missing")).toEqual({ |
| 385 | appId: "app-missing", |
| 386 | appDeleted: true, |
| 387 | }); |
| 388 | }).pipe(provide(client)); |
| 389 | }); |
| 390 | |
| 391 | it.effect("surfaces the final App deletion conflict", () => { |
| 392 | let attempts = 0; |
| 393 | const conflict = apiError("DELETE", "/v1/services/app-1", 409); |
| 394 | const client = { |
| 395 | deleteApp: () => |
| 396 | Effect.sync(() => { |
| 397 | attempts += 1; |
| 398 | }).pipe(Effect.flatMap(() => Effect.fail(conflict))), |
| 399 | } as unknown as PrismaManagementClient; |
| 400 | |
| 401 | return Effect.gen(function* () { |
| 402 | const fiber = yield* destroyApp("app-1").pipe( |
| 403 | Effect.flip, |
| 404 | Effect.forkChild, |
| 405 | ); |
| 406 | yield* TestClock.adjust("30 seconds"); |
| 407 | const error = yield* Fiber.join(fiber); |
| 408 | // Over the wire the injected 409 decodes into the typed error. |
| 409 | expect(error._tag).toBe("Conflict"); |
| 410 | expect(error.message).toBe("HTTP 409"); |
| 411 | expect(attempts).toBe(5); |
| 412 | }).pipe(provide(client)); |
| 413 | }); |
| 414 | |
| 415 | it.effect("deletes project Apps before deleting the project", () => { |
| 416 | const calls: string[] = []; |
| 417 | const client = { |
| 418 | listApps: (query: unknown) => |
| 419 | Effect.sync(() => { |
| 420 | calls.push(`list:${JSON.stringify(query)}`); |
| 421 | return [appItem("app-1"), appItem("app-2")]; |
| 422 | }), |
| 423 | deleteApp: (id: string) => |
| 424 | Effect.sync(() => { |
| 425 | calls.push(`delete-app:${id}`); |
| 426 | }), |
| 427 | deleteProject: (id: string) => |
| 428 | Effect.sync(() => { |
| 429 | calls.push(`delete-project:${id}`); |
| 430 | }), |
| 431 | } as unknown as PrismaManagementClient; |
| 432 | |
| 433 | return Effect.gen(function* () { |
| 434 | const result = yield* destroyProjectApps("project-1"); |
| 435 | expect(result).toEqual({ |
| 436 | projectId: "project-1", |
| 437 | deletedAppIds: ["app-1", "app-2"], |
| 438 | projectDeleted: true, |
| 439 | }); |
| 440 | expect(calls).toEqual([ |
| 441 | // Over the wire, query params arrive as strings. |
| 442 | 'list:{"limit":"100","projectId":"project-1"}', |
| 443 | "delete-app:app-1", |
| 444 | "delete-app:app-2", |
| 445 | "delete-project:project-1", |
| 446 | ]); |
| 447 | }).pipe(provide(client)); |
| 448 | }); |
| 449 | |
| 450 | it.effect("treats an already deleted project as deleted", () => { |
| 451 | const client = { |
| 452 | listApps: () => |
| 453 | Effect.fail(apiError("GET", "/v1/services?projectId=project-1", 404)), |
| 454 | deleteProject: (id: string) => |
| 455 | Effect.fail(apiError("DELETE", `/v1/projects/${id}`, 404)), |
| 456 | } as unknown as PrismaManagementClient; |
| 457 | |
| 458 | return Effect.gen(function* () { |
| 459 | expect(yield* destroyProjectApps("project-1")).toEqual({ |
| 460 | projectId: "project-1", |
| 461 | deletedAppIds: [], |
| 462 | projectDeleted: true, |
| 463 | }); |
| 464 | }).pipe(provide(client)); |
| 465 | }); |
| 466 | |
| 467 | it.live("re-cleans Apps after a blocked project deletion", () => { |
| 468 | const calls: string[] = []; |
| 469 | let lists = 0; |
| 470 | let deletes = 0; |
| 471 | const client = { |
| 472 | listApps: () => |
| 473 | Effect.sync(() => { |
| 474 | lists += 1; |
| 475 | calls.push(`list:${lists}`); |
| 476 | return lists === 1 ? [appItem("app-1")] : []; |
| 477 | }), |
| 478 | deleteApp: (id: string) => |
| 479 | Effect.sync(() => calls.push(`delete-app:${id}`)), |
| 480 | deleteProject: (id: string) => |
| 481 | Effect.suspend(() => { |
| 482 | deletes += 1; |
| 483 | calls.push(`delete-project:${id}:${deletes}`); |
| 484 | return deletes === 1 |
| 485 | ? Effect.fail(apiError("DELETE", `/v1/projects/${id}`, 409)) |
| 486 | : Effect.void; |
| 487 | }), |
| 488 | } as unknown as PrismaManagementClient; |
| 489 | |
| 490 | return Effect.gen(function* () { |
| 491 | const result = yield* destroyProjectApps("project-1"); |
| 492 | expect(result).toEqual({ |
| 493 | projectId: "project-1", |
| 494 | deletedAppIds: ["app-1"], |
| 495 | projectDeleted: true, |
| 496 | }); |
| 497 | expect(calls).toEqual([ |
| 498 | "list:1", |
| 499 | "delete-app:app-1", |
| 500 | "delete-project:project-1:1", |
| 501 | "list:2", |
| 502 | "delete-project:project-1:2", |
| 503 | ]); |
| 504 | }).pipe(provide(client)); |
| 505 | }); |
| 506 | |
| 507 | it.effect("supports keeping the project after deleting its Apps", () => { |
| 508 | const calls: string[] = []; |
| 509 | const client = { |
| 510 | listApps: () => Effect.succeed([appItem("app-1")]), |
| 511 | deleteApp: (id: string) => |
| 512 | Effect.sync(() => calls.push(`delete-app:${id}`)), |
| 513 | deleteProject: () => Effect.die("must not delete project"), |
| 514 | } as unknown as PrismaManagementClient; |
| 515 | |
| 516 | return Effect.gen(function* () { |
| 517 | const result = yield* destroyProjectApps("project-1", { |
| 518 | keepProject: true, |
| 519 | }); |
| 520 | expect(result.projectDeleted).toBe(false); |
| 521 | expect(result.deletedAppIds).toEqual(["app-1"]); |
| 522 | expect(calls).toEqual(["delete-app:app-1"]); |
| 523 | }).pipe(provide(client)); |
| 524 | }); |
| 525 | |
| 526 | it.effect("cleans every App returned across canonical pagination", () => { |
| 527 | const apps = Array.from({ length: 101 }, (_, index) => |
| 528 | appItem(`app-${index}`), |
| 529 | ); |
| 530 | const deleted: string[] = []; |
| 531 | const client = { |
| 532 | // The fake serves the full set in one page; the inline walk stops when |
| 533 | // the pagination envelope reports no more pages. |
| 534 | listApps: () => Effect.succeed(apps), |
| 535 | deleteApp: (id: string) => |
| 536 | Effect.sync(() => { |
| 537 | deleted.push(id); |
| 538 | }), |
| 539 | } as unknown as PrismaManagementClient; |
| 540 | |
| 541 | return Effect.gen(function* () { |
| 542 | const result = yield* destroyProjectApps("project-1", { |
| 543 | keepProject: true, |
| 544 | }); |
| 545 | expect(result.deletedAppIds).toHaveLength(101); |
| 546 | expect(deleted).toHaveLength(101); |
| 547 | }).pipe(provide(client)); |
| 548 | }); |
| 549 | |
| 550 | it.effect("supports keeping Apps during project-scoped inspection", () => { |
| 551 | const client = { |
| 552 | listApps: () => Effect.succeed([appItem("app-1")]), |
| 553 | deleteApp: () => Effect.die("must not delete App"), |
| 554 | deleteProject: () => Effect.die("must not delete project"), |
| 555 | } as unknown as PrismaManagementClient; |
| 556 | |
| 557 | return Effect.gen(function* () { |
| 558 | const result = yield* destroyProjectApps("project-1", { |
| 559 | keepApp: true, |
| 560 | keepProject: true, |
| 561 | }); |
| 562 | expect(result).toEqual({ |
| 563 | projectId: "project-1", |
| 564 | deletedAppIds: [], |
| 565 | projectDeleted: false, |
| 566 | }); |
| 567 | }).pipe(provide(client)); |
| 568 | }); |
| 569 | |
| 570 | it.effect( |
| 571 | "refuses a truncated App listing that promises another page", |
| 572 | () => { |
| 573 | // `hasMore: true` with no cursor is a malformed page. Reading it as the |
| 574 | // end of the list would let the cleanup miss live Apps, so the walk fails |
| 575 | // loudly instead. |
| 576 | const fake = makeFakeManagementApi((request: Captured) => |
| 577 | request.pathname === "/v1/services" && request.method === "GET" |
| 578 | ? page([appItem("app-1")], true, null) |
| 579 | : unhandled(request), |
| 580 | ); |
| 581 | |
| 582 | return Effect.gen(function* () { |
| 583 | const error = yield* destroyProjectApps("project-1").pipe( |
| 584 | Effect.flip, |
| 585 | ); |
| 586 | expect(error).toBeInstanceOf(PrismaPaginationError); |
| 587 | expect(error.message).toContain( |
| 588 | "hasMore was true without a non-empty nextCursor", |
| 589 | ); |
| 590 | }).pipe(Effect.provide(fake.layer)); |
| 591 | }, |
| 592 | ); |
| 593 | }, |
| 594 | ); |