UNPKG

convex

Version:

Client for the Convex Cloud

532 lines (472 loc) 18.4 kB
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import path from "path"; import { nodeFs } from "../bundler/fs.js"; import { deploymentSelect } from "./deploymentSelect.js"; import { bigBrainAPI, bigBrainAPIMaybeThrows } from "./lib/utils/utils.js"; import { runSystemQuery } from "./lib/run.js"; import { globalConfigPath } from "./lib/utils/globalConfig.js"; // Mock typedPlatformClient GET function — can be configured per test const mockPlatformGet = vi.fn(); // In-memory filesystem — populated in beforeEach, written to by real configure code let testFiles: Map<string, string>; vi.mock("../bundler/fs.js", async (importOriginal) => { const actual = await importOriginal<typeof import("../bundler/fs.js")>(); return { ...actual, nodeFs: { ...actual.nodeFs, exists: vi.fn(), readUtf8File: vi.fn(), writeUtf8File: vi.fn(), mkdir: vi.fn(), }, }; }); vi.mock("./lib/utils/utils.js", async (importOriginal) => { const actual = await importOriginal<typeof import("./lib/utils/utils.js")>(); return { ...actual, bigBrainAPI: vi.fn(), bigBrainAPIMaybeThrows: vi.fn(), typedPlatformClient: vi.fn(() => ({ GET: mockPlatformGet })), }; }); vi.mock("dotenv", async (importOriginal) => { const actual = await importOriginal<typeof import("dotenv")>(); return { ...actual, config: vi.fn(), }; }); vi.mock("@sentry/node", () => ({ captureException: vi.fn(), close: vi.fn(), })); vi.mock("./lib/run.js", () => ({ runSystemQuery: vi.fn(), })); /** * Routes mock Big Brain API calls by path. * Both `bigBrainAPI` and `bigBrainAPIMaybeThrows` delegate to this. */ function setupBigBrainRoutes(routes: Record<string, (data?: any) => any>) { const handler = (args: { path: string; data?: any }) => { for (const [routePath, routeHandler] of Object.entries(routes)) { if (args.path === routePath || args.path.startsWith(routePath)) { return routeHandler(args.data); } } throw new Error(`Unmocked Big Brain route: ${args.path}`); }; vi.mocked(bigBrainAPI).mockImplementation(handler as any); vi.mocked(bigBrainAPIMaybeThrows).mockImplementation(handler as any); } describe("npx convex select", () => { let savedEnv: NodeJS.ProcessEnv; beforeEach(() => { savedEnv = { ...process.env }; process.env = {}; // Start with minimal filesystem: package.json for readProjectConfig fallback testFiles = new Map([[path.resolve("package.json"), "{}"]]); vi.resetAllMocks(); // Wire up the in-memory filesystem to the nodeFs mock vi.mocked(nodeFs.exists).mockImplementation((p: string) => testFiles.has(path.resolve(p)), ); vi.mocked(nodeFs.readUtf8File).mockImplementation((p: string) => { const content = testFiles.get(path.resolve(p)); if (content === undefined) { const err: any = new Error( `ENOENT: no such file or directory, open '${p}'`, ); err.code = "ENOENT"; throw err; } return content; }); vi.mocked(nodeFs.writeUtf8File).mockImplementation( (p: string, content: string) => { testFiles.set(path.resolve(p), content); }, ); // runSystemQuery is called by fetchDeploymentCanonicalSiteUrl to look up CONVEX_SITE_URL vi.mocked(runSystemQuery).mockResolvedValue({ name: "CONVEX_SITE_URL", value: "https://example.convex.site", }); // typedPlatformClient is used for reference-based deployment resolution vi.mocked(mockPlatformGet).mockResolvedValue({ data: undefined }); }); afterEach(() => { process.env = savedEnv; }); // Suppress process.exit and stderr beforeEach(() => { vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit called"); }) as any); vi.spyOn(process.stderr, "write").mockImplementation(() => true); }); afterEach(() => { vi.restoreAllMocks(); }); describe("with project configured", () => { beforeEach(() => { process.env.CONVEX_DEPLOYMENT = "dev:joyful-capybara-123"; testFiles.set( globalConfigPath(), JSON.stringify({ accessToken: "test-token" }), ); }); it("selects a dev deployment by name (abc-xyz-123)", async () => { // For a deployment name selector, the system looks up the *selected* // deployment's team/project (not the current CONVEX_DEPLOYMENT's). setupBigBrainRoutes({ "deployment/clever-otter-890/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/authorize_within_current_project": () => ({ adminKey: "dev-key", url: "https://clever-otter-890.convex.cloud", deploymentName: "clever-otter-890", deploymentType: "dev", }), }); await deploymentSelect.parseAsync(["clever-otter-890"], { from: "user" }); expect(bigBrainAPI).toHaveBeenCalledWith( expect.objectContaining({ path: "deployment/authorize_within_current_project", data: expect.objectContaining({ selectedDeploymentName: "clever-otter-890", }), }), ); const envContent = testFiles.get(path.resolve(".env.local"))!; expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:clever-otter-890"); expect(envContent).toContain("team: my-team, project: my-project"); expect(envContent).toContain( "CONVEX_URL=https://clever-otter-890.convex.cloud", ); }); it("selects dev deployment with 'dev' selector", async () => { setupBigBrainRoutes({ "deployment/joyful-capybara-123/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/provision_and_authorize": () => ({ adminKey: "dev-key", url: "https://joyful-capybara-123.convex.cloud", deploymentName: "joyful-capybara-123", deploymentType: "dev", }), }); await deploymentSelect.parseAsync(["dev"], { from: "user" }); expect(bigBrainAPIMaybeThrows).toHaveBeenCalledWith( expect.objectContaining({ path: "deployment/provision_and_authorize", data: expect.objectContaining({ deploymentType: "dev" }), }), ); const envContent = testFiles.get(path.resolve(".env.local"))!; expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:joyful-capybara-123"); }); it("selects dev deployment by reference 'dev/nicolas'", async () => { setupBigBrainRoutes({ "deployment/joyful-capybara-123/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "teams/my-team/projects/my-project/deployments": () => true, "deployment/authorize_within_current_project": () => ({ adminKey: "nicolas-key", url: "https://nicolas-dev-123.convex.cloud", deploymentName: "nicolas-dev-123", deploymentType: "dev", }), }); mockPlatformGet.mockResolvedValue({ data: { name: "nicolas-dev-123" }, error: undefined, }); await deploymentSelect.parseAsync(["dev/nicolas"], { from: "user" }); expect(mockPlatformGet).toHaveBeenCalledWith( "/teams/{team_id_or_slug}/projects/{project_slug}/deployment", expect.objectContaining({ params: expect.objectContaining({ path: { team_id_or_slug: "my-team", project_slug: "my-project" }, query: { reference: "dev/nicolas" }, }), }), ); expect(bigBrainAPI).toHaveBeenCalledWith( expect.objectContaining({ path: "deployment/authorize_within_current_project", data: expect.objectContaining({ selectedDeploymentName: "nicolas-dev-123", }), }), ); expect(testFiles.has(path.resolve(".env.local"))).toBe(true); }); it("selects a preview deployment in another project 'other-project:preview/my-feature'", async () => { setupBigBrainRoutes({ "deployment/joyful-capybara-123/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "teams/my-team/projects/other-project/deployments": () => true, "deployment/authorize_within_current_project": () => ({ adminKey: "preview-key", url: "https://feature-preview-123.convex.cloud", deploymentName: "feature-preview-123", deploymentType: "preview", }), }); mockPlatformGet.mockResolvedValue({ data: { name: "feature-preview-123" }, error: undefined, }); await deploymentSelect.parseAsync(["other-project:preview/my-feature"], { from: "user", }); expect(mockPlatformGet).toHaveBeenCalledWith( "/teams/{team_id_or_slug}/projects/{project_slug}/deployment", expect.objectContaining({ params: expect.objectContaining({ path: { team_id_or_slug: "my-team", project_slug: "other-project", }, query: { reference: "preview/my-feature" }, }), }), ); const envContent = testFiles.get(path.resolve(".env.local"))!; expect(envContent).toContain( "CONVEX_DEPLOYMENT=preview:feature-preview-123", ); }); describe("prod deployment restrictions", () => { it("fails with an error message when 'prod' selector is used", async () => { setupBigBrainRoutes({ "deployment/joyful-capybara-123/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/authorize_prod": () => ({ adminKey: "prod-key", url: "https://graceful-puffin-456.convex.cloud", deploymentName: "graceful-puffin-456", deploymentType: "prod", }), }); await expect( deploymentSelect.parseAsync(["prod"], { from: "user" }), ).rejects.toThrow(); expect(process.stderr.write).toHaveBeenCalledWith( expect.stringContaining("--deployment prod"), ); expect(testFiles.has(path.resolve(".env.local"))).toBe(false); }); it("fails with an error message when a deployment name resolves to a prod deployment", async () => { setupBigBrainRoutes({ "deployment/graceful-puffin-456/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/authorize_within_current_project": () => ({ adminKey: "prod-key", url: "https://graceful-puffin-456.convex.cloud", deploymentName: "graceful-puffin-456", deploymentType: "prod", }), }); await expect( deploymentSelect.parseAsync(["graceful-puffin-456"], { from: "user", }), ).rejects.toThrow(); expect(process.stderr.write).toHaveBeenCalledWith( expect.stringContaining("--deployment graceful-puffin-456"), ); expect(testFiles.has(path.resolve(".env.local"))).toBe(false); }); }); describe("side effects on successful selection", () => { it("fetches the site URL using the resolved deployment credentials", async () => { setupBigBrainRoutes({ "deployment/joyful-capybara-123/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/provision_and_authorize": () => ({ adminKey: "dev-key", url: "https://joyful-capybara-123.convex.cloud", deploymentName: "joyful-capybara-123", deploymentType: "dev", }), }); await deploymentSelect.parseAsync(["dev"], { from: "user" }); expect(runSystemQuery).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ adminKey: "dev-key", deploymentUrl: "https://joyful-capybara-123.convex.cloud", }), ); }); it("writes the fetched site URL to the env file", async () => { vi.mocked(runSystemQuery).mockResolvedValue({ name: "CONVEX_SITE_URL", value: "https://joyful-capybara-123.convex.site", }); setupBigBrainRoutes({ "deployment/joyful-capybara-123/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/provision_and_authorize": () => ({ adminKey: "dev-key", url: "https://joyful-capybara-123.convex.cloud", deploymentName: "joyful-capybara-123", deploymentType: "dev", }), }); await deploymentSelect.parseAsync(["dev"], { from: "user" }); const envContent = testFiles.get(path.resolve(".env.local"))!; expect(envContent).toContain( "CONVEX_SITE_URL=https://joyful-capybara-123.convex.site", ); }); it("uses the existing deployment name to detect unchanged selections", async () => { // deploymentNameFromSelection(currentSelection) extracts "joyful-capybara-123" // from process.env.CONVEX_DEPLOYMENT ("dev:joyful-capybara-123") and passes // it as existingValue to configure so it can detect whether the selection changed. // Here we verify the full chain ran: the correct name is written to .env.local. setupBigBrainRoutes({ "deployment/joyful-capybara-123/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/provision_and_authorize": () => ({ adminKey: "dev-key", url: "https://joyful-capybara-123.convex.cloud", deploymentName: "joyful-capybara-123", deploymentType: "dev", }), }); await deploymentSelect.parseAsync(["dev"], { from: "user" }); const envContent = testFiles.get(path.resolve(".env.local"))!; expect(envContent).toContain( "CONVEX_DEPLOYMENT=dev:joyful-capybara-123", ); }); }); }); describe("without project configured", () => { beforeEach(() => { delete process.env.CONVEX_DEPLOYMENT; testFiles.set( globalConfigPath(), JSON.stringify({ accessToken: "test-token" }), ); }); it("fails with 'No project configured' for the 'dev' selector", async () => { await expect( deploymentSelect.parseAsync(["dev"], { from: "user" }), ).rejects.toThrow(); expect(process.stderr.write).toHaveBeenCalledWith( expect.stringContaining("No project configured"), ); }); it("fails with 'No project configured' for a simple reference selector", async () => { await expect( deploymentSelect.parseAsync(["staging"], { from: "user" }), ).rejects.toThrow(); expect(process.stderr.write).toHaveBeenCalledWith( expect.stringContaining("No project configured"), ); }); it("fails with 'No project configured' for a project:reference selector (needs team context)", async () => { await expect( deploymentSelect.parseAsync(["my-project:staging"], { from: "user" }), ).rejects.toThrow(); expect(process.stderr.write).toHaveBeenCalledWith( expect.stringContaining("No project configured"), ); }); it("succeeds with a fully-qualified 'team:project:ref' selector", async () => { setupBigBrainRoutes({ "deployment/authorize_within_current_project": () => ({ adminKey: "fq-key", url: "https://fully-qualified-123.convex.cloud", deploymentName: "fully-qualified-123", deploymentType: "dev", }), "teams/my-team/projects/my-project/deployments": () => true, }); mockPlatformGet.mockResolvedValue({ data: { name: "fully-qualified-123" }, error: undefined, }); await deploymentSelect.parseAsync(["my-team:my-project:staging"], { from: "user", }); expect(mockPlatformGet).toHaveBeenCalledWith( "/teams/{team_id_or_slug}/projects/{project_slug}/deployment", expect.objectContaining({ params: expect.objectContaining({ path: { team_id_or_slug: "my-team", project_slug: "my-project" }, query: { reference: "staging" }, }), }), ); const envContent = testFiles.get(path.resolve(".env.local"))!; expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:fully-qualified-123"); }); it("succeeds with a deployment name directly (does not need project context)", async () => { // Deployment names (abc-xyz-123 pattern) don't require a project to // already be configured — they look up their own team/project info. setupBigBrainRoutes({ "deployment/clever-otter-890/team_and_project": () => ({ team: "my-team", project: "my-project", teamId: 1, projectId: 1, }), "deployment/authorize_within_current_project": () => ({ adminKey: "dev-key", url: "https://clever-otter-890.convex.cloud", deploymentName: "clever-otter-890", deploymentType: "dev", }), }); await deploymentSelect.parseAsync(["clever-otter-890"], { from: "user" }); // deploymentNameFromSelection(currentSelection) returns null when there // is no CONVEX_DEPLOYMENT configured (kind === "chooseProject"), meaning // configure treats this as a brand-new selection. const envContent = testFiles.get(path.resolve(".env.local"))!; expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:clever-otter-890"); }); }); });