import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { deployToMain } from "./deploy.js"; import type { BuildConfig } from "./build.js"; import { BASE_URL, createDeploySuccessResponse, createDeploymentStatusResponse, createSetLiveSuccessResponse, createBuildFailureResponse, createBuildMultipleErrorsResponse, createDeploymentsListResponse, } from "../test/handlers.js"; import type { GeneratedResources } from "../generator/index.js"; const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); beforeEach(() => { // Set up default handler for deployments list (used by stale deployment cleanup) server.use( http.get(`${BASE_URL}/v1/deployments`, () => { return HttpResponse.json(createDeploymentsListResponse()); }) ); }); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); describe("Deploy API", () => { const config: BuildConfig = { baseUrl: BASE_URL, token: "p.test-token", }; const resources: GeneratedResources = { datasources: [ { name: "events", content: "SCHEMA > timestamp DateTime" }, ], pipes: [ { name: "top_events", content: "NODE main\nSQL > SELECT * FROM events" }, ], connections: [], }; // Helper to set up successful deploy flow function setupSuccessfulDeployFlow(deploymentId = "deploy-abc") { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( createDeploySuccessResponse({ deploymentId, status: "pending" }) ); }), http.get(`${BASE_URL}/v1/deployments/${deploymentId}`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId, status: "data_ready" }) ); }), http.post(`${BASE_URL}/v1/deployments/${deploymentId}/set-live`, () => { return HttpResponse.json(createSetLiveSuccessResponse()); }) ); } describe("deployToMain", () => { it("successfully deploys resources with full flow", async () => { setupSuccessfulDeployFlow("deploy-abc"); const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); expect(result.success).toBe(true); expect(result.result).toBe("success"); expect(result.buildId).toBe("deploy-abc"); expect(result.datasourceCount).toBe(1); expect(result.pipeCount).toBe(1); }); it("polls until deployment is ready", async () => { let pollCount = 0; server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-poll", status: "pending" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-poll`, () => { pollCount++; // Return pending for first 2 polls, then data_ready const status = pollCount < 3 ? "pending" : "data_ready"; return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-poll", status }) ); }), http.post(`${BASE_URL}/v1/deployments/deploy-poll/set-live`, () => { return HttpResponse.json(createSetLiveSuccessResponse()); }) ); const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); expect(result.success).toBe(true); expect(pollCount).toBe(3); }); it("handles deploy failure with single error", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( createBuildFailureResponse("Permission denied"), { status: 200 } ); }) ); const result = await deployToMain(config, resources); expect(result.success).toBe(false); expect(result.result).toBe("failed"); expect(result.error).toBe("Permission denied"); }); it("handles deploy failure with multiple errors", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( createBuildMultipleErrorsResponse([ { filename: "events.datasource", error: "Schema mismatch" }, { error: "General error without filename" }, ]), { status: 200 } ); }) ); const result = await deployToMain(config, resources); expect(result.success).toBe(false); expect(result.error).toContain("[events.datasource] Schema mismatch"); expect(result.error).toContain("General error without filename"); }); it("handles deployment feedback entries with null resource", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( { result: "failed", deployment: { id: "deploy-null-resource", status: "failed", feedback: [{ resource: null, level: "ERROR", message: "Invalid token" }], }, }, { status: 200 } ); }) ); const result = await deployToMain(config, resources); expect(result.success).toBe(false); expect(result.result).toBe("failed"); expect(result.error).toContain("unknown: Invalid token"); }); it("handles HTTP error responses", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( { result: "failed", error: "Forbidden" }, { status: 403 } ); }) ); const result = await deployToMain(config, resources); expect(result.success).toBe(false); expect(result.error).toBe("Forbidden"); }); it("handles malformed JSON response", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return new HttpResponse("invalid json {", { status: 200, headers: { "Content-Type": "text/plain" }, }); }) ); await expect(deployToMain(config, resources)).rejects.toThrow( "Failed to parse response" ); }); it("uses /v1/deploy endpoint (not /v1/build)", async () => { let capturedUrl: string | null = null; server.use( http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-url-test" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-url-test`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-url-test", status: "data_ready" }) ); }), http.post(`${BASE_URL}/v1/deployments/deploy-url-test/set-live`, () => { return HttpResponse.json(createSetLiveSuccessResponse()); }) ); await deployToMain(config, resources, { pollIntervalMs: 1 }); const parsed = new URL(capturedUrl ?? ""); expect(parsed.pathname).toBe("/v1/deploy"); expect(parsed.searchParams.get("from")).toBe("ts-sdk"); }); it("passes allow_destructive_operations when explicitly enabled", async () => { let capturedUrl: string | null = null; server.use( http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-destructive" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-destructive`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-destructive", status: "data_ready", }) ); }), http.post(`${BASE_URL}/v1/deployments/deploy-destructive/set-live`, () => { return HttpResponse.json(createSetLiveSuccessResponse()); }) ); await deployToMain(config, resources, { pollIntervalMs: 1, allowDestructiveOperations: true, }); const parsed = new URL(capturedUrl ?? ""); expect(parsed.searchParams.get("allow_destructive_operations")).toBe("true"); }); it("skips stale deployment cleanup in check mode", async () => { let listed = false; const deletedIds: string[] = []; let capturedUrl: string | null = null; server.use( http.get(`${BASE_URL}/v1/deployments`, () => { listed = true; return HttpResponse.json( createDeploymentsListResponse({ deployments: [ { id: "in-flight", status: "pending", live: false }, ], }) ); }), http.delete(`${BASE_URL}/v1/deployments/:id`, ({ params }) => { deletedIds.push(params.id as string); return HttpResponse.json({ result: "success" }); }), http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-check" }) ); }) ); await deployToMain(config, resources, { pollIntervalMs: 1, check: true, }); expect(listed).toBe(false); expect(deletedIds).toEqual([]); const parsed = new URL(capturedUrl ?? ""); expect(parsed.searchParams.get("check")).toBe("true"); }); it("deletes stale non-live deployments before deploy and previous live deployment after promotion", async () => { const deletedIds: string[] = []; server.use( http.get(`${BASE_URL}/v1/deployments`, () => { return HttpResponse.json( createDeploymentsListResponse({ deployments: [ { id: "stale-1", status: "pending", live: false }, { id: "live-1", status: "live", live: true }, { id: "stale-2", status: "failed", live: false }, ], }) ); }), http.delete(`${BASE_URL}/v1/deployments/:id`, ({ params }) => { deletedIds.push(params.id as string); return HttpResponse.json({ result: "success" }); }) ); setupSuccessfulDeployFlow("deploy-cleanup"); await deployToMain(config, resources, { pollIntervalMs: 1 }); expect(deletedIds).toEqual(["stale-1", "stale-2", "live-1"]); }); it("deletes the previous live deployment after promoting the new deployment", async () => { const events: string[] = []; server.use( http.get(`${BASE_URL}/v1/deployments`, () => { return HttpResponse.json( createDeploymentsListResponse({ deployments: [ { id: "previous-live", status: "live", live: true }, ], }) ); }), http.post(`${BASE_URL}/v1/deploy`, () => { events.push("create"); return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "new-deploy", status: "pending" }) ); }), http.get(`${BASE_URL}/v1/deployments/new-deploy`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "new-deploy", status: "data_ready" }) ); }), http.post(`${BASE_URL}/v1/deployments/new-deploy/set-live`, () => { events.push("set-live"); return HttpResponse.json(createSetLiveSuccessResponse()); }), http.delete(`${BASE_URL}/v1/deployments/:id`, ({ params }) => { events.push(`delete:${params.id as string}`); return HttpResponse.json({ result: "success" }); }) ); const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); expect(result.success).toBe(true); expect(events).toEqual(["create", "set-live", "delete:previous-live"]); }); it("adds actionable guidance to Forward/Classic workspace errors", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( { result: "failed", error: "This is a Tinybird Forward workspace, and this operation is only available for Tinybird Classic workspaces.", }, { status: 400 } ); }) ); const result = await deployToMain(config, resources); expect(result.success).toBe(false); expect(result.error).toContain("Tinybird Forward workspace"); expect(result.error).toContain( "Use the Tinybird Classic CLI (`tb`) from a Tinybird Classic workspace for this operation." ); }); it("handles failed deployment status", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-fail", status: "pending" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-fail`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-fail", status: "failed" }) ); }) ); const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); expect(result.success).toBe(false); expect(result.error).toContain("Deployment failed with status: failed"); }); it("handles set-live failure", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-setlive-fail" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-setlive-fail`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-setlive-fail", status: "data_ready" }) ); }), http.post(`${BASE_URL}/v1/deployments/deploy-setlive-fail/set-live`, () => { return HttpResponse.json({ error: "Set live failed" }, { status: 500 }); }) ); const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); expect(result.success).toBe(false); expect(result.error).toContain("Failed to set deployment as live"); }); it("normalizes baseUrl with trailing slash", async () => { let capturedUrl: string | null = null; server.use( http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-slash" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-slash`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-slash", status: "data_ready" }) ); }), http.post(`${BASE_URL}/v1/deployments/deploy-slash/set-live`, () => { return HttpResponse.json(createSetLiveSuccessResponse()); }) ); await deployToMain( { ...config, baseUrl: `${BASE_URL}/` }, resources, { pollIntervalMs: 1 } ); const parsed = new URL(capturedUrl ?? ""); expect(parsed.pathname).toBe("/v1/deploy"); expect(parsed.searchParams.get("from")).toBe("ts-sdk"); }); it("times out when deployment never becomes ready", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-timeout", status: "pending" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-timeout`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-timeout", status: "pending" }) ); }) ); const result = await deployToMain(config, resources, { pollIntervalMs: 1, maxPollAttempts: 3, }); expect(result.success).toBe(false); expect(result.error).toContain("Deployment timed out"); }); it("includes connections in deploy form data", async () => { const resourcesWithConnections: GeneratedResources = { ...resources, connections: [ { name: "my_kafka", content: "TYPE kafka\nKAFKA_BROKERS kafka:9092\nKAFKA_TOPIC events\n", }, ], }; let capturedFormData: FormData | null = null; server.use( http.post(`${BASE_URL}/v1/deploy`, async ({ request }) => { capturedFormData = await request.formData(); return HttpResponse.json( createDeploySuccessResponse({ deploymentId: "deploy-conn", status: "pending" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-conn`, () => { return HttpResponse.json( createDeploymentStatusResponse({ deploymentId: "deploy-conn", status: "data_ready" }) ); }), http.post(`${BASE_URL}/v1/deployments/deploy-conn/set-live`, () => { return HttpResponse.json(createSetLiveSuccessResponse()); }) ); const result = await deployToMain(config, resourcesWithConnections, { pollIntervalMs: 1, }); expect(result.success).toBe(true); expect(result.connectionCount).toBe(1); expect(capturedFormData).not.toBeNull(); // 1 datasource + 1 pipe + 1 connection const allValues = capturedFormData!.getAll("data_project://"); expect(allValues.length).toBe(3); }); }); });