diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58624a4..e43cf6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ concurrency: permissions: contents: read + pull-requests: write jobs: # ── Change Detection ─────────────────────────────────────────────── @@ -84,7 +85,12 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup - - run: pnpm --filter @yavio/shared test + - run: pnpm --filter @yavio/shared exec vitest run --coverage + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: coverage-shared + path: packages/shared/coverage/ # ── Tests: db (requires PostgreSQL + ClickHouse) ─────────────────── test-db: @@ -121,11 +127,16 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup - run: pnpm --filter @yavio/shared build - - run: pnpm --filter @yavio/db test + - run: pnpm --filter @yavio/db exec vitest run --coverage env: DATABASE_URL: postgres://yavio_service:test@localhost:5432/yavio_test CLICKHOUSE_URL: http://localhost:8123 CLICKHOUSE_PASSWORD: test + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: coverage-db + path: packages/db/coverage/ # ── Tests: ingest (requires ClickHouse for e2e) ──────────────────── test-ingest: @@ -149,10 +160,15 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup - run: pnpm turbo build --filter=@yavio/ingest... - - run: pnpm --filter @yavio/ingest test + - run: pnpm --filter @yavio/ingest exec vitest run --coverage env: CLICKHOUSE_URL: http://localhost:8123 CLICKHOUSE_PASSWORD: test + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: coverage-ingest + path: packages/ingest/coverage/ # ── Tests: dashboard (requires PostgreSQL + ClickHouse) ──────────── test-dashboard: @@ -189,11 +205,16 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup - run: pnpm turbo build --filter=@yavio/dashboard... - - run: pnpm --filter @yavio/dashboard test + - run: pnpm --filter @yavio/dashboard exec vitest run --coverage env: DATABASE_URL: postgres://yavio_service:test@localhost:5432/yavio_test CLICKHOUSE_URL: http://localhost:8123 CLICKHOUSE_PASSWORD: test + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: coverage-dashboard + path: packages/dashboard/coverage/ # ── Tests: cli ───────────────────────────────────────────────────── test-cli: @@ -204,7 +225,12 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup - run: pnpm turbo build --filter=@yavio/cli... - - run: pnpm --filter @yavio/cli test + - run: pnpm --filter @yavio/cli exec vitest run --coverage + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: coverage-cli + path: packages/cli/coverage/ # ── Tests: sdk ───────────────────────────────────────────────────── test-sdk: @@ -215,7 +241,71 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup - run: pnpm turbo build --filter=@yavio/sdk... - - run: pnpm --filter @yavio/sdk test + - run: pnpm --filter @yavio/sdk exec vitest run --coverage + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: coverage-sdk + path: packages/sdk/coverage/ + + # ── Coverage Report (PR comment) ────────────────────────────────── + coverage-report: + if: github.event_name == 'pull_request' && always() + needs: [test-shared, test-db, test-ingest, test-dashboard, test-cli, test-sdk] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + pattern: coverage-* + merge-multiple: false + + - name: Coverage report — shared + if: needs.test-shared.result == 'success' + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2.9.3 + with: + name: shared + json-summary-path: coverage-shared/coverage-summary.json + json-final-path: coverage-shared/coverage-final.json + + - name: Coverage report — db + if: needs.test-db.result == 'success' + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2.9.3 + with: + name: db + json-summary-path: coverage-db/coverage-summary.json + json-final-path: coverage-db/coverage-final.json + + - name: Coverage report — ingest + if: needs.test-ingest.result == 'success' + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2.9.3 + with: + name: ingest + json-summary-path: coverage-ingest/coverage-summary.json + json-final-path: coverage-ingest/coverage-final.json + + - name: Coverage report — dashboard + if: needs.test-dashboard.result == 'success' + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2.9.3 + with: + name: dashboard + json-summary-path: coverage-dashboard/coverage-summary.json + json-final-path: coverage-dashboard/coverage-final.json + + - name: Coverage report — cli + if: needs.test-cli.result == 'success' + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2.9.3 + with: + name: cli + json-summary-path: coverage-cli/coverage-summary.json + json-final-path: coverage-cli/coverage-final.json + + - name: Coverage report — sdk + if: needs.test-sdk.result == 'success' + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2.9.3 + with: + name: sdk + json-summary-path: coverage-sdk/coverage-summary.json + json-final-path: coverage-sdk/coverage-final.json # ── Build ────────────────────────────────────────────────────────── build: diff --git a/package.json b/package.json index d0fed95..61db9b7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", + "@vitest/coverage-v8": "^3.2.4", "dotenv-cli": "^11.0.0", "tsup": "^8.3.6", "turbo": "^2.3.3", diff --git a/packages/cli/src/__tests__/docker.test.ts b/packages/cli/src/__tests__/docker.test.ts index 4b0319a..e243bdc 100644 --- a/packages/cli/src/__tests__/docker.test.ts +++ b/packages/cli/src/__tests__/docker.test.ts @@ -135,5 +135,142 @@ describe("docker utilities", () => { const version = await getComposeVersion(); expect(version).toBe("2.28.0"); }); + + it("returns null when version format is unrecognized", async () => { + const { execa } = await import("execa"); + vi.mocked(execa).mockResolvedValueOnce({ + stdout: "Docker Compose unknown", + } as never); + + const { getComposeVersion } = await import("../util/docker.js"); + const version = await getComposeVersion(); + expect(version).toBeNull(); + }); + + it("returns null on failure", async () => { + const { execa } = await import("execa"); + vi.mocked(execa).mockRejectedValueOnce(new Error("not found")); + + const { getComposeVersion } = await import("../util/docker.js"); + const version = await getComposeVersion(); + expect(version).toBeNull(); + }); + }); + + describe("hasDockerCompose", () => { + it("returns true when docker compose is available", async () => { + const { execa } = await import("execa"); + vi.mocked(execa).mockResolvedValueOnce({ + stdout: "Docker Compose version v2.28.0", + } as never); + + const { hasDockerCompose } = await import("../util/docker.js"); + expect(await hasDockerCompose()).toBe(true); + }); + + it("returns false when docker compose is not available", async () => { + const { execa } = await import("execa"); + vi.mocked(execa).mockRejectedValueOnce(new Error("not found")); + + const { hasDockerCompose } = await import("../util/docker.js"); + expect(await hasDockerCompose()).toBe(false); + }); + }); + + describe("removeVolumes", () => { + it("calls docker volume rm with volume names", async () => { + const { execa } = await import("execa"); + vi.mocked(execa).mockResolvedValueOnce({} as never); + + const { removeVolumes } = await import("../util/docker.js"); + await removeVolumes(["vol1", "vol2"]); + + expect(execa).toHaveBeenCalledWith("docker", ["volume", "rm", "--force", "vol1", "vol2"]); + }); + + it("does nothing for empty array", async () => { + const { execa } = await import("execa"); + + const { removeVolumes } = await import("../util/docker.js"); + await removeVolumes([]); + + expect(execa).not.toHaveBeenCalled(); + }); + }); + + describe("getContainerStatus", () => { + it("parses container JSON output", async () => { + const { execa } = await import("execa"); + const composePath = join(tempDir, "docker-compose.yml"); + writeFileSync(composePath, "services: {}"); + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '{"Name":"c1","Service":"web","State":"running","Status":"Up","Health":"healthy"}', + } as never); + + const { getContainerStatus } = await import("../util/docker.js"); + const result = await getContainerStatus(composePath); + expect(result).toHaveLength(1); + expect(result[0].Service).toBe("web"); + }); + + it("returns empty array on empty stdout", async () => { + const { execa } = await import("execa"); + const composePath = join(tempDir, "docker-compose.yml"); + writeFileSync(composePath, "services: {}"); + vi.mocked(execa).mockResolvedValueOnce({ stdout: "" } as never); + + const { getContainerStatus } = await import("../util/docker.js"); + const result = await getContainerStatus(composePath); + expect(result).toEqual([]); + }); + + it("returns empty array on failure", async () => { + const { getContainerStatus } = await import("../util/docker.js"); + const result = await getContainerStatus("/nonexistent/compose.yml"); + expect(result).toEqual([]); + }); + }); + + describe("execCompose", () => { + it("uses files array when provided", async () => { + const { execa } = await import("execa"); + const composePath = join(tempDir, "docker-compose.yml"); + writeFileSync(composePath, "services: {}"); + vi.mocked(execa).mockResolvedValueOnce({ stdout: "", stderr: "" } as never); + + const { execCompose } = await import("../util/docker.js"); + await execCompose(["up", "-d"], { files: [composePath] }); + + expect(execa).toHaveBeenCalledWith("docker", ["compose", "-f", composePath, "up", "-d"], { + stdio: "pipe", + }); + }); + + it("passes stdio option through", async () => { + const { execa } = await import("execa"); + const composePath = join(tempDir, "docker-compose.yml"); + writeFileSync(composePath, "services: {}"); + vi.mocked(execa).mockResolvedValueOnce({ stdout: "", stderr: "" } as never); + + const { execCompose } = await import("../util/docker.js"); + await execCompose(["logs"], { files: [composePath], stdio: "inherit" }); + + expect(execa).toHaveBeenCalledWith("docker", ["compose", "-f", composePath, "logs"], { + stdio: "inherit", + }); + }); + }); + + describe("getDockerVersion (extended)", () => { + it("returns null when version format is unrecognized", async () => { + const { execa } = await import("execa"); + vi.mocked(execa).mockResolvedValueOnce({ + stdout: "Docker unknown format", + } as never); + + const { getDockerVersion } = await import("../util/docker.js"); + const version = await getDockerVersion(); + expect(version).toBeNull(); + }); }); }); diff --git a/packages/cli/src/__tests__/doctor.test.ts b/packages/cli/src/__tests__/doctor.test.ts index 5cc8945..0bb2c33 100644 --- a/packages/cli/src/__tests__/doctor.test.ts +++ b/packages/cli/src/__tests__/doctor.test.ts @@ -126,4 +126,97 @@ describe("doctor command", () => { const output = consoleLogs.join("\n"); expect(output).toContain("Docker not found"); }); + + it("warns when Docker version cannot be parsed", async () => { + mockGetDockerVersion.mockResolvedValue(null); + mockHasDocker.mockResolvedValue(true); + + const program = new Command(); + registerDoctor(program); + + await program.parseAsync(["node", "yavio", "doctor"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("could not parse version"); + }); + + it("warns when compose version cannot be parsed", async () => { + mockGetComposeVersion.mockResolvedValue(null); + mockHasDockerCompose.mockResolvedValue(true); + + const program = new Command(); + registerDoctor(program); + + await program.parseAsync(["node", "yavio", "doctor"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("could not parse version"); + }); + + it("reports missing docker compose", async () => { + mockGetComposeVersion.mockResolvedValue(null); + mockHasDockerCompose.mockResolvedValue(false); + + const program = new Command(); + registerDoctor(program); + + await program.parseAsync(["node", "yavio", "doctor"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("docker compose not found"); + }); + + it("warns about HTTP endpoint on non-localhost", async () => { + writeFileSync( + join(tempDir, ".yaviorc.json"), + JSON.stringify({ + version: 1, + apiKey: "yav_test_key_12345", + endpoint: "http://example.com/v1/events", + }), + ); + + const program = new Command(); + registerDoctor(program); + + await program.parseAsync(["node", "yavio", "doctor"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("HTTP on non-localhost"); + }); + + it("reports all checks passed when everything is healthy", async () => { + const program = new Command(); + registerDoctor(program); + + await program.parseAsync(["node", "yavio", "doctor"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("All critical checks passed"); + }); + + it("warns about unreachable endpoints", async () => { + mockCheckHealth.mockResolvedValue({ ok: false, status: 0, latency: 0 }); + + const program = new Command(); + registerDoctor(program); + + await program.parseAsync(["node", "yavio", "doctor"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("not reachable"); + }); + + it("reports some checks failed when critical issues found", async () => { + mockGetDockerVersion.mockResolvedValue(null); + mockHasDocker.mockResolvedValue(false); + + const program = new Command(); + registerDoctor(program); + + await program.parseAsync(["node", "yavio", "doctor"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("Some critical checks failed"); + }); }); diff --git a/packages/cli/src/__tests__/down.test.ts b/packages/cli/src/__tests__/down.test.ts index 3c96bf5..7438971 100644 --- a/packages/cli/src/__tests__/down.test.ts +++ b/packages/cli/src/__tests__/down.test.ts @@ -66,4 +66,14 @@ describe("down command", () => { await program.parseAsync(["node", "yavio", "down", "--file", "/some/compose.yml"]); expect(process.exitCode).toBe(1); }); + + it("fails when docker compose is not available", async () => { + mockHasDockerCompose.mockResolvedValueOnce(false); + + const program = new Command(); + registerDown(program); + + await program.parseAsync(["node", "yavio", "down"]); + expect(process.exitCode).toBe(1); + }); }); diff --git a/packages/cli/src/__tests__/index.test.ts b/packages/cli/src/__tests__/index.test.ts new file mode 100644 index 0000000..8ce1008 --- /dev/null +++ b/packages/cli/src/__tests__/index.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../commands/init.js", () => ({ registerInit: vi.fn() })); +vi.mock("../commands/up.js", () => ({ registerUp: vi.fn() })); +vi.mock("../commands/down.js", () => ({ registerDown: vi.fn() })); +vi.mock("../commands/status.js", () => ({ registerStatus: vi.fn() })); +vi.mock("../commands/logs.js", () => ({ registerLogs: vi.fn() })); +vi.mock("../commands/update.js", () => ({ registerUpdate: vi.fn() })); +vi.mock("../commands/reset.js", () => ({ registerReset: vi.fn() })); +vi.mock("../commands/doctor.js", () => ({ registerDoctor: vi.fn() })); + +describe("CLI entry point", () => { + const originalArgv = process.argv; + + beforeEach(() => { + process.argv = ["node", "yavio"]; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.restoreAllMocks(); + }); + + it("registers all commands and configures the program", async () => { + await import("../index.js"); + + const { registerInit } = await import("../commands/init.js"); + const { registerUp } = await import("../commands/up.js"); + const { registerDown } = await import("../commands/down.js"); + const { registerStatus } = await import("../commands/status.js"); + const { registerLogs } = await import("../commands/logs.js"); + const { registerUpdate } = await import("../commands/update.js"); + const { registerReset } = await import("../commands/reset.js"); + const { registerDoctor } = await import("../commands/doctor.js"); + + expect(registerInit).toHaveBeenCalledOnce(); + expect(registerUp).toHaveBeenCalledOnce(); + expect(registerDown).toHaveBeenCalledOnce(); + expect(registerStatus).toHaveBeenCalledOnce(); + expect(registerLogs).toHaveBeenCalledOnce(); + expect(registerUpdate).toHaveBeenCalledOnce(); + expect(registerReset).toHaveBeenCalledOnce(); + expect(registerDoctor).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/__tests__/init.test.ts b/packages/cli/src/__tests__/init.test.ts index 1038be1..d5863a7 100644 --- a/packages/cli/src/__tests__/init.test.ts +++ b/packages/cli/src/__tests__/init.test.ts @@ -108,4 +108,40 @@ describe("init command", () => { expect(process.exitCode).toBe(1); }); + + it("warns when ingestion API is not reachable", async () => { + mockCheckHealth.mockResolvedValue({ ok: false, status: 0, latency: 0 }); + + const program = new Command(); + registerInit(program); + + await program.parseAsync([ + "node", + "yavio", + "init", + "--api-key", + "yav_test_key_12345", + "--endpoint", + "http://localhost:3001/v1/events", + ]); + + expect(process.exitCode).toBeUndefined(); + }); + + it("skips health check when no endpoint is provided", async () => { + const program = new Command(); + registerInit(program); + + await program.parseAsync([ + "node", + "yavio", + "init", + "--api-key", + "yav_test_key_12345", + "--endpoint", + "http://localhost:3001", + ]); + + expect(mockCheckHealth).toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/__tests__/logs.test.ts b/packages/cli/src/__tests__/logs.test.ts index 9f1e2d4..48c2a54 100644 --- a/packages/cli/src/__tests__/logs.test.ts +++ b/packages/cli/src/__tests__/logs.test.ts @@ -89,4 +89,24 @@ describe("logs command", () => { await program.parseAsync(["node", "yavio", "logs", "unknown-service"]); expect(process.exitCode).toBe(1); }); + + it("fails when Docker is not available", async () => { + mockHasDocker.mockResolvedValueOnce(false); + + const program = new Command(); + registerLogs(program); + + await program.parseAsync(["node", "yavio", "logs"]); + expect(process.exitCode).toBe(1); + }); + + it("handles compose failure gracefully", async () => { + mockExecCompose.mockRejectedValueOnce(new Error("compose failed")); + + const program = new Command(); + registerLogs(program); + + await program.parseAsync(["node", "yavio", "logs", "--file", "/some/compose.yml"]); + expect(process.exitCode).toBe(1); + }); }); diff --git a/packages/cli/src/__tests__/reset.test.ts b/packages/cli/src/__tests__/reset.test.ts index 0a6f342..30e77c4 100644 --- a/packages/cli/src/__tests__/reset.test.ts +++ b/packages/cli/src/__tests__/reset.test.ts @@ -108,4 +108,87 @@ describe("reset command", () => { await program.parseAsync(["node", "yavio", "reset", "--yes", "--confirm-destructive"]); expect(process.exitCode).toBe(1); }); + + it("handles stop services failure", async () => { + mockExecCompose.mockRejectedValueOnce(new Error("stop failed")); + + const program = new Command(); + registerReset(program); + + await program.parseAsync([ + "node", + "yavio", + "reset", + "--yes", + "--confirm-destructive", + "--file", + "/some/compose.yml", + ]); + + expect(process.exitCode).toBe(1); + }); + + it("handles volume removal failure", async () => { + mockExecCompose + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockRejectedValueOnce(new Error("volume removal failed")); + + const program = new Command(); + registerReset(program); + + await program.parseAsync([ + "node", + "yavio", + "reset", + "--yes", + "--confirm-destructive", + "--file", + "/some/compose.yml", + ]); + + expect(process.exitCode).toBe(1); + }); + + it("handles restart failure", async () => { + mockExecCompose + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockRejectedValueOnce(new Error("restart failed")); + + const program = new Command(); + registerReset(program); + + await program.parseAsync([ + "node", + "yavio", + "reset", + "--yes", + "--confirm-destructive", + "--file", + "/some/compose.yml", + ]); + + expect(process.exitCode).toBe(1); + }); + + it("handles selective volume removal failure with --keep-config", async () => { + mockExecCompose.mockResolvedValueOnce({ stdout: "", stderr: "" }); + mockRemoveVolumes.mockRejectedValueOnce(new Error("volume rm failed")); + + const program = new Command(); + registerReset(program); + + await program.parseAsync([ + "node", + "yavio", + "reset", + "--yes", + "--confirm-destructive", + "--keep-config", + "--file", + "/some/compose.yml", + ]); + + expect(process.exitCode).toBe(1); + }); }); diff --git a/packages/cli/src/__tests__/status.test.ts b/packages/cli/src/__tests__/status.test.ts index b2ade21..3cbddaf 100644 --- a/packages/cli/src/__tests__/status.test.ts +++ b/packages/cli/src/__tests__/status.test.ts @@ -106,4 +106,36 @@ describe("status command", () => { await program.parseAsync(["node", "yavio", "status"]); expect(process.exitCode).toBe(1); }); + + it("shows PostgreSQL not running when container is missing", async () => { + mockGetContainerStatus.mockResolvedValue( + defaultContainers.filter((c) => c.Service !== "postgres"), + ); + + const program = new Command(); + registerStatus(program); + + await program.parseAsync(["node", "yavio", "status", "--file", "/some/compose.yml"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("not running"); + }); + + it("shows PostgreSQL unhealthy when state is bad", async () => { + mockGetContainerStatus.mockResolvedValue( + defaultContainers.map((c) => + c.Service === "postgres" + ? { ...c, Health: "unhealthy", State: "exited", Status: "Exited (1)" } + : c, + ), + ); + + const program = new Command(); + registerStatus(program); + + await program.parseAsync(["node", "yavio", "status", "--file", "/some/compose.yml"]); + + const output = consoleLogs.join("\n"); + expect(output).toContain("unhealthy"); + }); }); diff --git a/packages/cli/src/__tests__/up.test.ts b/packages/cli/src/__tests__/up.test.ts index adcb757..9e6fe88 100644 --- a/packages/cli/src/__tests__/up.test.ts +++ b/packages/cli/src/__tests__/up.test.ts @@ -133,4 +133,74 @@ describe("up command", () => { files: [composePath, prodPath], }); }); + + it("handles compose file resolution failure", async () => { + mockHasDocker.mockResolvedValue(true); + mockHasDockerCompose.mockResolvedValue(true); + mockResolveComposeFile.mockImplementation(() => { + throw new Error("Compose file not found"); + }); + + const program = new Command(); + registerUp(program); + + await program.parseAsync(["node", "yavio", "up"]); + expect(process.exitCode).toBe(1); + }); + + it("handles compose start failure", async () => { + const composePath = join(tempDir, "docker-compose.yml"); + writeFileSync(composePath, "services: {}"); + mockHasDocker.mockResolvedValue(true); + mockHasDockerCompose.mockResolvedValue(true); + mockResolveComposeFile.mockReturnValue(composePath); + mockExecCompose.mockRejectedValueOnce(new Error("start failed")); + + const program = new Command(); + registerUp(program); + + await program.parseAsync(["node", "yavio", "up", "--file", composePath]); + expect(process.exitCode).toBe(1); + }); + + it("warns when services are not healthy within deadline", async () => { + const composePath = join(tempDir, "docker-compose.yml"); + writeFileSync(composePath, "services: {}"); + mockHasDocker.mockResolvedValue(true); + mockHasDockerCompose.mockResolvedValue(true); + mockResolveComposeFile.mockReturnValue(composePath); + mockExecCompose.mockResolvedValue({ stdout: "", stderr: "" }); + mockCheckHealth.mockResolvedValue({ ok: false, status: 0, latency: 0 }); + + let dateCallCount = 0; + vi.spyOn(Date, "now").mockImplementation(() => { + dateCallCount++; + return dateCallCount === 1 ? 0 : 100_000; + }); + + const program = new Command(); + registerUp(program); + + await program.parseAsync(["node", "yavio", "up", "--file", composePath]); + expect(process.exitCode).toBeUndefined(); + }); + + it("warns when --prod file does not exist", async () => { + const composePath = join(tempDir, "docker-compose.yml"); + writeFileSync(composePath, "services: {}"); + mockHasDocker.mockResolvedValue(true); + mockHasDockerCompose.mockResolvedValue(true); + mockResolveComposeFile.mockReturnValue(composePath); + mockExecCompose.mockResolvedValue({ stdout: "", stderr: "" }); + mockCheckHealth.mockResolvedValue({ ok: true, status: 200, latency: 5 }); + + const program = new Command(); + registerUp(program); + + await program.parseAsync(["node", "yavio", "up", "--file", composePath, "--prod"]); + + expect(mockExecCompose).toHaveBeenCalledWith(["up", "-d"], { + files: [composePath], + }); + }); }); diff --git a/packages/cli/src/__tests__/update.test.ts b/packages/cli/src/__tests__/update.test.ts index c846182..9341b7c 100644 --- a/packages/cli/src/__tests__/update.test.ts +++ b/packages/cli/src/__tests__/update.test.ts @@ -76,4 +76,36 @@ describe("update command", () => { expect(output).toContain("ingest"); expect(output).toContain("dashboard"); }); + + it("fails when Docker is not available", async () => { + mockHasDocker.mockResolvedValueOnce(false); + + const program = new Command(); + registerUpdate(program); + + await program.parseAsync(["node", "yavio", "update", "--file", "/some/compose.yml"]); + expect(process.exitCode).toBe(1); + }); + + it("handles pull failure", async () => { + mockExecCompose.mockRejectedValueOnce(new Error("pull failed")); + + const program = new Command(); + registerUpdate(program); + + await program.parseAsync(["node", "yavio", "update", "--file", "/some/compose.yml"]); + expect(process.exitCode).toBe(1); + }); + + it("handles restart failure after successful pull", async () => { + mockExecCompose + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockRejectedValueOnce(new Error("restart failed")); + + const program = new Command(); + registerUpdate(program); + + await program.parseAsync(["node", "yavio", "update", "--file", "/some/compose.yml"]); + expect(process.exitCode).toBe(1); + }); }); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d913ed3..4229970 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -3,5 +3,18 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "json"], + reportsDirectory: "./coverage", + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**"], + thresholds: { + lines: 80, + branches: 70, + functions: 80, + statements: 80, + }, + }, }, }); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index a9f2627..060403a 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -9,5 +9,17 @@ export default defineConfig({ }, test: { passWithNoTests: true, + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "json"], + reportsDirectory: "./coverage", + include: ["lib/**/*.ts"], + thresholds: { + lines: 80, + branches: 70, + functions: 80, + statements: 80, + }, + }, }, }); diff --git a/packages/db/src/__tests__/clickhouse-client.test.ts b/packages/db/src/__tests__/clickhouse-client.test.ts new file mode 100644 index 0000000..3f137ff --- /dev/null +++ b/packages/db/src/__tests__/clickhouse-client.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockClient = { query: vi.fn() }; + +vi.mock("@clickhouse/client", () => ({ + createClient: vi.fn(() => mockClient), +})); + +const { createClient } = await import("@clickhouse/client"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createClickHouseClient", () => { + const savedUrl = process.env.CLICKHOUSE_URL; + + afterEach(() => { + if (savedUrl !== undefined) { + process.env.CLICKHOUSE_URL = savedUrl; + } else { + // biome-ignore lint/performance/noDelete: env vars require delete to truly unset + delete process.env.CLICKHOUSE_URL; + } + }); + + it("creates a client with the explicitly provided URL", async () => { + const { createClickHouseClient } = await import("../clickhouse-client.js"); + const client = createClickHouseClient("http://ch:8123"); + expect(createClient).toHaveBeenCalledWith({ url: "http://ch:8123" }); + expect(client).toBe(mockClient); + }); + + it("falls back to CLICKHOUSE_URL env var when no URL is provided", async () => { + process.env.CLICKHOUSE_URL = "http://env-ch:8123"; + const { createClickHouseClient } = await import("../clickhouse-client.js"); + const client = createClickHouseClient(); + expect(createClient).toHaveBeenCalledWith({ url: "http://env-ch:8123" }); + expect(client).toBe(mockClient); + }); + + it("prefers explicit URL over env var", async () => { + process.env.CLICKHOUSE_URL = "http://env-ch:8123"; + const { createClickHouseClient } = await import("../clickhouse-client.js"); + createClickHouseClient("http://explicit:8123"); + expect(createClient).toHaveBeenCalledWith({ url: "http://explicit:8123" }); + }); + + it("throws YavioError when no URL is available", async () => { + // biome-ignore lint/performance/noDelete: env vars require delete to truly unset + delete process.env.CLICKHOUSE_URL; + const { createClickHouseClient } = await import("../clickhouse-client.js"); + expect(() => createClickHouseClient()).toThrow("CLICKHOUSE_URL is not set"); + }); + + it("throws with YAVIO error code for missing URL", async () => { + // biome-ignore lint/performance/noDelete: env vars require delete to truly unset + delete process.env.CLICKHOUSE_URL; + const { createClickHouseClient } = await import("../clickhouse-client.js"); + try { + createClickHouseClient(); + expect.unreachable("Should have thrown"); + } catch (err) { + expect((err as Record).code).toBe("YAVIO-7200"); + } + }); +}); diff --git a/packages/db/src/__tests__/client.test.ts b/packages/db/src/__tests__/client.test.ts new file mode 100644 index 0000000..ab32ca2 --- /dev/null +++ b/packages/db/src/__tests__/client.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSql = { query: vi.fn() }; +const mockDrizzleDb = { select: vi.fn() }; + +vi.mock("postgres", () => ({ + default: vi.fn(() => mockSql), +})); + +vi.mock("drizzle-orm/postgres-js", () => ({ + drizzle: vi.fn(() => mockDrizzleDb), +})); + +const { default: postgres } = await import("postgres"); +const { drizzle } = await import("drizzle-orm/postgres-js"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createDb", () => { + it("calls postgres with the provided URL", async () => { + const { createDb } = await import("../client.js"); + createDb("postgres://localhost:5432/test"); + expect(postgres).toHaveBeenCalledWith("postgres://localhost:5432/test"); + }); + + it("passes the sql client and schema to drizzle", async () => { + const { createDb } = await import("../client.js"); + createDb("postgres://localhost:5432/test"); + expect(drizzle).toHaveBeenCalledWith(mockSql, { schema: expect.any(Object) }); + }); + + it("returns the drizzle database instance", async () => { + const { createDb } = await import("../client.js"); + const db = createDb("postgres://localhost:5432/test"); + expect(db).toBe(mockDrizzleDb); + }); +}); diff --git a/packages/db/src/__tests__/index.test.ts b/packages/db/src/__tests__/index.test.ts new file mode 100644 index 0000000..c7be11d --- /dev/null +++ b/packages/db/src/__tests__/index.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +describe("@yavio/db barrel exports", () => { + it("exports createDb and Database type", async () => { + // Use dynamic import to avoid side effects from mocked modules + const mod = await import("../index.js"); + expect(mod.createDb).toBeTypeOf("function"); + }); + + it("exports withRLS", async () => { + const mod = await import("../index.js"); + expect(mod.withRLS).toBeTypeOf("function"); + }); + + it("re-exports all schema tables", async () => { + const mod = await import("../index.js"); + expect(mod.users).toBeDefined(); + expect(mod.workspaces).toBeDefined(); + expect(mod.projects).toBeDefined(); + expect(mod.apiKeys).toBeDefined(); + expect(mod.sessions).toBeDefined(); + expect(mod.oauthAccounts).toBeDefined(); + expect(mod.workspaceMembers).toBeDefined(); + expect(mod.invitations).toBeDefined(); + expect(mod.verificationTokens).toBeDefined(); + expect(mod.loginAttempts).toBeDefined(); + expect(mod.stripeWebhookEvents).toBeDefined(); + }); +}); diff --git a/packages/db/src/__tests__/rls.test.ts b/packages/db/src/__tests__/rls.test.ts new file mode 100644 index 0000000..0b4fe5f --- /dev/null +++ b/packages/db/src/__tests__/rls.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Database } from "../client.js"; +import { withRLS } from "../rls.js"; + +describe("withRLS", () => { + it("opens a transaction and calls the callback", async () => { + const mockExecute = vi.fn(); + const mockTx = { execute: mockExecute }; + const mockDb = { + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => cb(mockTx)), + }; + + const result = await withRLS(mockDb as unknown as Database, "user-123", async () => { + return "test-result"; + }); + + expect(mockDb.transaction).toHaveBeenCalledOnce(); + expect(result).toBe("test-result"); + }); + + it("executes set_config with the provided userId", async () => { + const mockExecute = vi.fn(); + const mockTx = { execute: mockExecute }; + const mockDb = { + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => cb(mockTx)), + }; + + await withRLS(mockDb as unknown as Database, "user-abc-123", async () => "ok"); + + expect(mockExecute).toHaveBeenCalledOnce(); + // The argument is a drizzle SQL template — verify it was called + const sqlArg = mockExecute.mock.calls[0][0]; + expect(sqlArg).toBeDefined(); + }); + + it("passes the transaction as Database to the callback", async () => { + const mockExecute = vi.fn(); + const mockTx = { execute: mockExecute, select: vi.fn() }; + const mockDb = { + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => cb(mockTx)), + }; + + let receivedTx: unknown; + await withRLS(mockDb as unknown as Database, "user-123", async (tx) => { + receivedTx = tx; + return "ok"; + }); + + expect(receivedTx).toBe(mockTx); + }); + + it("propagates errors from the callback", async () => { + const mockDb = { + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => cb({ execute: vi.fn() })), + }; + + await expect( + withRLS(mockDb as unknown as Database, "user-123", async () => { + throw new Error("callback error"); + }), + ).rejects.toThrow("callback error"); + }); + + it("propagates the return value from the callback", async () => { + const mockDb = { + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => cb({ execute: vi.fn() })), + }; + + const items = [{ id: 1 }, { id: 2 }]; + const result = await withRLS(mockDb as unknown as Database, "user-123", async () => items); + + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + }); +}); diff --git a/packages/db/src/__tests__/schema.test.ts b/packages/db/src/__tests__/schema.test.ts new file mode 100644 index 0000000..8a122a0 --- /dev/null +++ b/packages/db/src/__tests__/schema.test.ts @@ -0,0 +1,220 @@ +import { getTableName } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import * as schema from "../schema.js"; + +describe("schema tables", () => { + it("exports all 11 tables", () => { + const tables = [ + schema.users, + schema.oauthAccounts, + schema.sessions, + schema.workspaces, + schema.workspaceMembers, + schema.invitations, + schema.projects, + schema.apiKeys, + schema.verificationTokens, + schema.loginAttempts, + schema.stripeWebhookEvents, + ]; + expect(tables).toHaveLength(11); + for (const table of tables) { + expect(getTableName(table)).toBeTruthy(); + } + }); + + it("users table has correct SQL name and columns", () => { + expect(getTableName(schema.users)).toBe("users"); + const config = getTableConfig(schema.users); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("id"); + expect(colNames).toContain("email"); + expect(colNames).toContain("name"); + expect(colNames).toContain("password_hash"); + expect(colNames).toContain("avatar_url"); + expect(colNames).toContain("email_verified"); + expect(colNames).toContain("created_at"); + expect(colNames).toContain("updated_at"); + }); + + it("oauth_accounts table has provider and account id columns", () => { + expect(getTableName(schema.oauthAccounts)).toBe("oauth_accounts"); + const config = getTableConfig(schema.oauthAccounts); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("provider"); + expect(colNames).toContain("provider_account_id"); + expect(colNames).toContain("user_id"); + }); + + it("sessions table has token and expiry columns", () => { + expect(getTableName(schema.sessions)).toBe("sessions"); + const config = getTableConfig(schema.sessions); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("token"); + expect(colNames).toContain("expires_at"); + expect(colNames).toContain("user_id"); + }); + + it("workspaces table has slug and billing columns", () => { + expect(getTableName(schema.workspaces)).toBe("workspaces"); + const config = getTableConfig(schema.workspaces); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("slug"); + expect(colNames).toContain("owner_id"); + expect(colNames).toContain("plan"); + expect(colNames).toContain("stripe_customer_id"); + expect(colNames).toContain("spending_cap"); + expect(colNames).toContain("billing_status"); + }); + + it("workspace_members table has composite primary key columns", () => { + expect(getTableName(schema.workspaceMembers)).toBe("workspace_members"); + const config = getTableConfig(schema.workspaceMembers); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("workspace_id"); + expect(colNames).toContain("user_id"); + expect(colNames).toContain("role"); + }); + + it("invitations table has token and expiry columns", () => { + expect(getTableName(schema.invitations)).toBe("invitations"); + const config = getTableConfig(schema.invitations); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("email"); + expect(colNames).toContain("role"); + expect(colNames).toContain("token"); + expect(colNames).toContain("expires_at"); + expect(colNames).toContain("invited_by"); + }); + + it("projects table has workspace-scoped slug", () => { + expect(getTableName(schema.projects)).toBe("projects"); + const config = getTableConfig(schema.projects); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("workspace_id"); + expect(colNames).toContain("slug"); + expect(colNames).toContain("name"); + }); + + it("api_keys table has hash and prefix columns", () => { + expect(getTableName(schema.apiKeys)).toBe("api_keys"); + const config = getTableConfig(schema.apiKeys); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("key_hash"); + expect(colNames).toContain("key_prefix"); + expect(colNames).toContain("project_id"); + expect(colNames).toContain("workspace_id"); + expect(colNames).toContain("revoked_at"); + }); + + it("verification_tokens table has hash and type columns", () => { + expect(getTableName(schema.verificationTokens)).toBe("verification_tokens"); + const config = getTableConfig(schema.verificationTokens); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("token_hash"); + expect(colNames).toContain("type"); + expect(colNames).toContain("user_id"); + expect(colNames).toContain("expires_at"); + expect(colNames).toContain("used_at"); + }); + + it("login_attempts table has email and IP columns", () => { + expect(getTableName(schema.loginAttempts)).toBe("login_attempts"); + const config = getTableConfig(schema.loginAttempts); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("email"); + expect(colNames).toContain("ip_address"); + }); + + it("stripe_webhook_events table has event_id primary key", () => { + expect(getTableName(schema.stripeWebhookEvents)).toBe("stripe_webhook_events"); + const config = getTableConfig(schema.stripeWebhookEvents); + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("event_id"); + expect(colNames).toContain("event_type"); + expect(colNames).toContain("processed_at"); + }); +}); + +describe("schema foreign keys", () => { + it("oauth_accounts references users", () => { + const config = getTableConfig(schema.oauthAccounts); + expect(config.foreignKeys).toHaveLength(1); + const ref = config.foreignKeys[0].reference(); + expect(getTableName(ref.foreignTable)).toBe("users"); + }); + + it("sessions references users", () => { + const config = getTableConfig(schema.sessions); + expect(config.foreignKeys).toHaveLength(1); + const ref = config.foreignKeys[0].reference(); + expect(getTableName(ref.foreignTable)).toBe("users"); + }); + + it("workspace_members references workspaces and users", () => { + const config = getTableConfig(schema.workspaceMembers); + expect(config.foreignKeys).toHaveLength(2); + const tableNames = config.foreignKeys.map((fk) => getTableName(fk.reference().foreignTable)); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("users"); + }); + + it("invitations references workspaces and users", () => { + const config = getTableConfig(schema.invitations); + expect(config.foreignKeys).toHaveLength(2); + const tableNames = config.foreignKeys.map((fk) => getTableName(fk.reference().foreignTable)); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("users"); + }); + + it("projects references workspaces", () => { + const config = getTableConfig(schema.projects); + expect(config.foreignKeys).toHaveLength(1); + const ref = config.foreignKeys[0].reference(); + expect(getTableName(ref.foreignTable)).toBe("workspaces"); + }); + + it("api_keys references projects and workspaces", () => { + const config = getTableConfig(schema.apiKeys); + expect(config.foreignKeys).toHaveLength(2); + const tableNames = config.foreignKeys.map((fk) => getTableName(fk.reference().foreignTable)); + expect(tableNames).toContain("projects"); + expect(tableNames).toContain("workspaces"); + }); + + it("verification_tokens references users", () => { + const config = getTableConfig(schema.verificationTokens); + expect(config.foreignKeys).toHaveLength(1); + const ref = config.foreignKeys[0].reference(); + expect(getTableName(ref.foreignTable)).toBe("users"); + }); +}); + +describe("schema indexes", () => { + it("workspaces has slug index", () => { + const config = getTableConfig(schema.workspaces); + const indexNames = config.indexes.map((i) => i.config.name); + expect(indexNames).toContain("idx_workspaces_slug"); + }); + + it("api_keys has hash, project, and workspace indexes", () => { + const config = getTableConfig(schema.apiKeys); + const indexNames = config.indexes.map((i) => i.config.name); + expect(indexNames).toContain("idx_api_keys_hash"); + expect(indexNames).toContain("idx_api_keys_project"); + expect(indexNames).toContain("idx_api_keys_workspace"); + }); + + it("projects has workspace-slug unique index", () => { + const config = getTableConfig(schema.projects); + const indexNames = config.indexes.map((i) => i.config.name); + expect(indexNames).toContain("projects_workspace_slug_unique"); + }); + + it("oauth_accounts has provider-account unique index", () => { + const config = getTableConfig(schema.oauthAccounts); + const indexNames = config.indexes.map((i) => i.config.name); + expect(indexNames).toContain("oauth_accounts_provider_account_unique"); + }); +}); diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts index c748270..2acecb0 100644 --- a/packages/db/vitest.config.ts +++ b/packages/db/vitest.config.ts @@ -7,5 +7,18 @@ export default defineConfig({ hookTimeout: 60_000, pool: "forks", fileParallelism: false, + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "json"], + reportsDirectory: "./coverage", + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**", "src/migrate.ts", "src/migrate-clickhouse.ts"], + thresholds: { + lines: 80, + branches: 70, + functions: 80, + statements: 80, + }, + }, }, }); diff --git a/packages/ingest/vitest.config.ts b/packages/ingest/vitest.config.ts index d913ed3..4229970 100644 --- a/packages/ingest/vitest.config.ts +++ b/packages/ingest/vitest.config.ts @@ -3,5 +3,18 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "json"], + reportsDirectory: "./coverage", + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**"], + thresholds: { + lines: 80, + branches: 70, + functions: 80, + statements: 80, + }, + }, }, }); diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts index a344e15..7a1385a 100644 --- a/packages/sdk/vitest.config.ts +++ b/packages/sdk/vitest.config.ts @@ -3,6 +3,19 @@ import { defineConfig, defineProject } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "json"], + reportsDirectory: "./coverage", + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**"], + thresholds: { + lines: 80, + branches: 70, + functions: 80, + statements: 80, + }, + }, projects: [ defineProject({ test: { diff --git a/packages/shared/src/__tests__/error-codes.test.ts b/packages/shared/src/__tests__/error-codes.test.ts new file mode 100644 index 0000000..496f7d0 --- /dev/null +++ b/packages/shared/src/__tests__/error-codes.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { ErrorCode } from "../error-codes.js"; +import type { ErrorCodeValue } from "../error-codes.js"; + +describe("ErrorCode", () => { + const groups = Object.keys(ErrorCode) as (keyof typeof ErrorCode)[]; + + it("has expected top-level groups", () => { + expect(groups).toEqual( + expect.arrayContaining(["SDK", "INGEST", "DASHBOARD", "INTELLIGENCE", "DB", "CLI", "INFRA"]), + ); + }); + + it("every code matches YAVIO-NNNN format", () => { + for (const group of groups) { + const codes = Object.values(ErrorCode[group]); + for (const code of codes) { + expect(code).toMatch(/^YAVIO-\d{4}$/); + } + } + }); + + it("all codes are globally unique", () => { + const seen = new Set(); + for (const group of groups) { + for (const code of Object.values(ErrorCode[group])) { + expect(seen.has(code)).toBe(false); + seen.add(code); + } + } + }); + + it("SDK codes are in the 1000–1999 range", () => { + for (const code of Object.values(ErrorCode.SDK)) { + const num = Number.parseInt(code.replace("YAVIO-", ""), 10); + expect(num).toBeGreaterThanOrEqual(1000); + expect(num).toBeLessThan(2000); + } + }); + + it("INGEST codes are in the 2000–2999 range", () => { + for (const code of Object.values(ErrorCode.INGEST)) { + const num = Number.parseInt(code.replace("YAVIO-", ""), 10); + expect(num).toBeGreaterThanOrEqual(2000); + expect(num).toBeLessThan(3000); + } + }); + + it("DASHBOARD codes are in the 3000–3999 range", () => { + for (const code of Object.values(ErrorCode.DASHBOARD)) { + const num = Number.parseInt(code.replace("YAVIO-", ""), 10); + expect(num).toBeGreaterThanOrEqual(3000); + expect(num).toBeLessThan(4000); + } + }); + + it("INTELLIGENCE codes are in the 4000–4999 range", () => { + for (const code of Object.values(ErrorCode.INTELLIGENCE)) { + const num = Number.parseInt(code.replace("YAVIO-", ""), 10); + expect(num).toBeGreaterThanOrEqual(4000); + expect(num).toBeLessThan(5000); + } + }); + + it("DB codes are in the 5000–5999 range", () => { + for (const code of Object.values(ErrorCode.DB)) { + const num = Number.parseInt(code.replace("YAVIO-", ""), 10); + expect(num).toBeGreaterThanOrEqual(5000); + expect(num).toBeLessThan(6000); + } + }); + + it("CLI codes are in the 6000–6999 range", () => { + for (const code of Object.values(ErrorCode.CLI)) { + const num = Number.parseInt(code.replace("YAVIO-", ""), 10); + expect(num).toBeGreaterThanOrEqual(6000); + expect(num).toBeLessThan(7000); + } + }); + + it("INFRA codes are in the 7000–7999 range", () => { + for (const code of Object.values(ErrorCode.INFRA)) { + const num = Number.parseInt(code.replace("YAVIO-", ""), 10); + expect(num).toBeGreaterThanOrEqual(7000); + expect(num).toBeLessThan(8000); + } + }); + + it("ErrorCodeValue type accepts valid codes", () => { + const code: ErrorCodeValue = ErrorCode.SDK.NO_API_KEY; + expect(code).toBe("YAVIO-1000"); + }); +}); diff --git a/packages/shared/src/__tests__/errors.test.ts b/packages/shared/src/__tests__/errors.test.ts new file mode 100644 index 0000000..cc1ba12 --- /dev/null +++ b/packages/shared/src/__tests__/errors.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { ErrorCode } from "../error-codes.js"; +import { YavioError, isYavioError } from "../errors.js"; + +describe("YavioError", () => { + it("extends Error", () => { + const err = new YavioError(ErrorCode.SDK.NO_API_KEY, "missing key", 401); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(YavioError); + }); + + it("sets name to YavioError", () => { + const err = new YavioError(ErrorCode.SDK.NO_API_KEY, "msg", 400); + expect(err.name).toBe("YavioError"); + }); + + it("stores code, message, and status", () => { + const err = new YavioError(ErrorCode.INGEST.MISSING_AUTH_HEADER, "Auth header required", 401); + expect(err.code).toBe("YAVIO-2000"); + expect(err.message).toBe("Auth header required"); + expect(err.status).toBe(401); + }); + + it("stores optional metadata", () => { + const meta = { slug: "my-workspace" }; + const err = new YavioError(ErrorCode.DASHBOARD.WORKSPACE_SLUG_EXISTS, "exists", 409, meta); + expect(err.metadata).toEqual(meta); + }); + + it("metadata is undefined when not provided", () => { + const err = new YavioError(ErrorCode.SDK.NO_API_KEY, "msg", 400); + expect(err.metadata).toBeUndefined(); + }); + + describe("toJSON()", () => { + it("serializes without requestId or metadata", () => { + const err = new YavioError(ErrorCode.SDK.NO_API_KEY, "no key", 401); + expect(err.toJSON()).toEqual({ + code: "YAVIO-1000", + message: "no key", + status: 401, + }); + }); + + it("includes requestId when provided", () => { + const err = new YavioError(ErrorCode.SDK.NO_API_KEY, "no key", 401); + const json = err.toJSON("req-123"); + expect(json).toEqual({ + code: "YAVIO-1000", + message: "no key", + status: 401, + requestId: "req-123", + }); + }); + + it("includes metadata when present", () => { + const err = new YavioError(ErrorCode.DASHBOARD.WORKSPACE_SLUG_EXISTS, "exists", 409, { + slug: "test", + }); + expect(err.toJSON()).toEqual({ + code: "YAVIO-3150", + message: "exists", + status: 409, + metadata: { slug: "test" }, + }); + }); + + it("includes both requestId and metadata", () => { + const err = new YavioError(ErrorCode.INGEST.INTERNAL_ERROR, "boom", 500, { detail: "oops" }); + const json = err.toJSON("req-456"); + expect(json).toEqual({ + code: "YAVIO-2999", + message: "boom", + status: 500, + requestId: "req-456", + metadata: { detail: "oops" }, + }); + }); + }); +}); + +describe("isYavioError()", () => { + it("returns true for YavioError instances", () => { + const err = new YavioError(ErrorCode.SDK.NO_API_KEY, "msg", 400); + expect(isYavioError(err)).toBe(true); + }); + + it("returns false for plain Error", () => { + expect(isYavioError(new Error("nope"))).toBe(false); + }); + + it("returns false for non-error values", () => { + expect(isYavioError(null)).toBe(false); + expect(isYavioError(undefined)).toBe(false); + expect(isYavioError("string")).toBe(false); + expect(isYavioError(42)).toBe(false); + expect(isYavioError({ code: "YAVIO-1000" })).toBe(false); + }); +}); diff --git a/packages/shared/src/__tests__/events.test.ts b/packages/shared/src/__tests__/events.test.ts new file mode 100644 index 0000000..19d0bb4 --- /dev/null +++ b/packages/shared/src/__tests__/events.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it } from "vitest"; +import { + BaseEvent, + ConnectionEvent, + ConversionEvent, + ElicitationEvent, + EventSource, + EventType, + IdentifyEvent, + IngestBatch, + IngestEvent, + PromptUsageEvent, + ResourceAccessEvent, + SamplingCallEvent, + StepEvent, + ToolCallEvent, + ToolDiscoveryEvent, + TrackEvent, + WidgetClickEvent, + WidgetErrorEvent, + WidgetFocusEvent, + WidgetFormFieldEvent, + WidgetFormSubmitEvent, + WidgetLinkClickEvent, + WidgetNavigationEvent, + WidgetPerformanceEvent, + WidgetRageClickEvent, + WidgetRenderEvent, + WidgetResponseEvent, + WidgetScrollEvent, + WidgetVisibilityEvent, +} from "../events.js"; + +/** Minimal valid base event fields. */ +function base(overrides: Record = {}) { + return { + event_id: "550e8400-e29b-41d4-a716-446655440000", + trace_id: "tr_abc123", + session_id: "ses_xyz789", + timestamp: "2025-01-15T10:30:00Z", + source: "server" as const, + ...overrides, + }; +} + +describe("EventSource", () => { + it("accepts 'server' and 'widget'", () => { + expect(EventSource.parse("server")).toBe("server"); + expect(EventSource.parse("widget")).toBe("widget"); + }); + + it("rejects invalid values", () => { + expect(() => EventSource.parse("browser")).toThrow(); + }); +}); + +describe("EventType", () => { + it("accepts all defined event types", () => { + const types = [ + "tool_call", + "connection", + "resource_access", + "prompt_usage", + "sampling_call", + "elicitation", + "widget_response", + "tool_discovery", + "step", + "track", + "conversion", + "identify", + "widget_render", + "widget_error", + "widget_visibility", + "widget_click", + "widget_scroll", + "widget_form_field", + "widget_form_submit", + "widget_link_click", + "widget_navigation", + "widget_focus", + "widget_performance", + "widget_rage_click", + ]; + for (const t of types) { + expect(EventType.parse(t)).toBe(t); + } + }); + + it("rejects unknown event types", () => { + expect(() => EventType.parse("unknown_event")).toThrow(); + }); +}); + +describe("BaseEvent", () => { + it("validates a minimal base event", () => { + const data = base({ event_type: "track" }); + const result = BaseEvent.parse(data); + expect(result.event_id).toBe(data.event_id); + expect(result.event_type).toBe("track"); + }); + + it("accepts optional fields", () => { + const data = base({ + event_type: "track", + event_name: "test", + metadata: { key: "val" }, + user_id: "user-1", + platform: "cursor", + sdk_version: "0.1.0", + }); + const result = BaseEvent.parse(data); + expect(result.event_name).toBe("test"); + expect(result.metadata).toEqual({ key: "val" }); + expect(result.user_id).toBe("user-1"); + }); + + it("rejects non-UUID event_id", () => { + expect(() => BaseEvent.parse(base({ event_type: "track", event_id: "bad" }))).toThrow(); + }); + + it("rejects invalid timestamp", () => { + expect(() => BaseEvent.parse(base({ event_type: "track", timestamp: "not-a-date" }))).toThrow(); + }); +}); + +describe("ToolCallEvent", () => { + it("validates a tool_call event", () => { + const data = base({ + event_type: "tool_call", + event_name: "search", + latency_ms: 150, + status: "success", + }); + const result = ToolCallEvent.parse(data); + expect(result.event_type).toBe("tool_call"); + expect(result.latency_ms).toBe(150); + expect(result.status).toBe("success"); + }); + + it("accepts error fields", () => { + const data = base({ + event_type: "tool_call", + event_name: "fail", + status: "error", + error_category: "timeout", + error_message: "timed out", + is_retry: 1, + }); + const result = ToolCallEvent.parse(data); + expect(result.error_category).toBe("timeout"); + expect(result.is_retry).toBe(1); + }); + + it("accepts token and input fields", () => { + const data = base({ + event_type: "tool_call", + event_name: "chat", + tokens_in: 100, + tokens_out: 200, + input_keys: { query: true }, + input_types: { query: "string" }, + input_values: { query: "hello" }, + output_content: { result: "world" }, + intent_signals: { intent: "search" }, + country_code: "US", + }); + const result = ToolCallEvent.parse(data); + expect(result.tokens_in).toBe(100); + expect(result.country_code).toBe("US"); + }); + + it("rejects invalid country_code length", () => { + expect(() => + ToolCallEvent.parse(base({ event_type: "tool_call", event_name: "x", country_code: "USA" })), + ).toThrow(); + }); + + it("rejects invalid error_category", () => { + expect(() => + ToolCallEvent.parse( + base({ event_type: "tool_call", event_name: "x", error_category: "invalid" }), + ), + ).toThrow(); + }); +}); + +describe("ConnectionEvent", () => { + it("validates with optional fields", () => { + const data = base({ + event_type: "connection", + protocol_version: "1.0", + client_name: "cursor", + client_version: "0.5.0", + connection_duration_ms: 5000, + }); + const result = ConnectionEvent.parse(data); + expect(result.client_name).toBe("cursor"); + }); +}); + +describe("ResourceAccessEvent", () => { + it("validates a resource_access event", () => { + const result = ResourceAccessEvent.parse(base({ event_type: "resource_access" })); + expect(result.event_type).toBe("resource_access"); + }); +}); + +describe("PromptUsageEvent", () => { + it("validates a prompt_usage event", () => { + const result = PromptUsageEvent.parse(base({ event_type: "prompt_usage" })); + expect(result.event_type).toBe("prompt_usage"); + }); +}); + +describe("SamplingCallEvent", () => { + it("validates with optional fields", () => { + const data = base({ + event_type: "sampling_call", + latency_ms: 300, + tokens_in: 50, + tokens_out: 100, + }); + const result = SamplingCallEvent.parse(data); + expect(result.latency_ms).toBe(300); + }); +}); + +describe("ElicitationEvent", () => { + it("validates with optional latency", () => { + const result = ElicitationEvent.parse(base({ event_type: "elicitation", latency_ms: 200 })); + expect(result.latency_ms).toBe(200); + }); +}); + +describe("WidgetResponseEvent", () => { + it("validates a widget_response event", () => { + const result = WidgetResponseEvent.parse(base({ event_type: "widget_response" })); + expect(result.event_type).toBe("widget_response"); + }); +}); + +describe("ToolDiscoveryEvent", () => { + it("validates a tool_discovery event", () => { + const data = base({ + event_type: "tool_discovery", + tool_name: "search", + description: "Searches the web", + input_schema: { type: "object" }, + }); + const result = ToolDiscoveryEvent.parse(data); + expect(result.tool_name).toBe("search"); + }); + + it("rejects empty tool_name", () => { + expect(() => + ToolDiscoveryEvent.parse(base({ event_type: "tool_discovery", tool_name: "" })), + ).toThrow(); + }); +}); + +describe("StepEvent", () => { + it("validates with optional step_sequence", () => { + const result = StepEvent.parse( + base({ event_type: "step", event_name: "checkout", step_sequence: 2 }), + ); + expect(result.step_sequence).toBe(2); + }); +}); + +describe("TrackEvent", () => { + it("validates a track event", () => { + const result = TrackEvent.parse(base({ event_type: "track", event_name: "button_click" })); + expect(result.event_type).toBe("track"); + }); +}); + +describe("ConversionEvent", () => { + it("validates with conversion fields", () => { + const data = base({ + event_type: "conversion", + event_name: "purchase", + conversion_value: 49.99, + conversion_currency: "USD", + }); + const result = ConversionEvent.parse(data); + expect(result.conversion_value).toBe(49.99); + expect(result.conversion_currency).toBe("USD"); + }); + + it("rejects invalid currency length", () => { + expect(() => + ConversionEvent.parse(base({ event_type: "conversion", conversion_currency: "US" })), + ).toThrow(); + }); +}); + +describe("IdentifyEvent", () => { + it("validates with user_traits", () => { + const data = base({ + event_type: "identify", + user_traits: { name: "Alice", plan: "pro" }, + }); + const result = IdentifyEvent.parse(data); + expect(result.user_traits).toEqual({ name: "Alice", plan: "pro" }); + }); +}); + +describe("Widget events", () => { + it("WidgetRenderEvent validates with device fields", () => { + const data = base({ + event_type: "widget_render", + source: "widget", + viewport_width: 1920, + viewport_height: 1080, + device_pixel_ratio: 2, + device_touch: 0, + connection_type: "4g", + }); + const result = WidgetRenderEvent.parse(data); + expect(result.viewport_width).toBe(1920); + }); + + it("WidgetErrorEvent validates", () => { + const result = WidgetErrorEvent.parse(base({ event_type: "widget_error", source: "widget" })); + expect(result.event_type).toBe("widget_error"); + }); + + it("WidgetVisibilityEvent validates with duration", () => { + const result = WidgetVisibilityEvent.parse( + base({ event_type: "widget_visibility", source: "widget", visible_duration_ms: 3000 }), + ); + expect(result.visible_duration_ms).toBe(3000); + }); + + it("WidgetClickEvent validates with click_count", () => { + const result = WidgetClickEvent.parse( + base({ event_type: "widget_click", source: "widget", click_count: 2 }), + ); + expect(result.click_count).toBe(2); + }); + + it("WidgetScrollEvent validates with scroll_depth_pct", () => { + const result = WidgetScrollEvent.parse( + base({ event_type: "widget_scroll", source: "widget", scroll_depth_pct: 75 }), + ); + expect(result.scroll_depth_pct).toBe(75); + }); + + it("WidgetScrollEvent rejects out-of-range scroll_depth_pct", () => { + expect(() => + WidgetScrollEvent.parse( + base({ event_type: "widget_scroll", source: "widget", scroll_depth_pct: 150 }), + ), + ).toThrow(); + }); + + it("WidgetFormFieldEvent validates with field_name", () => { + const result = WidgetFormFieldEvent.parse( + base({ event_type: "widget_form_field", source: "widget", field_name: "email" }), + ); + expect(result.field_name).toBe("email"); + }); + + it("WidgetFormSubmitEvent validates with status", () => { + const result = WidgetFormSubmitEvent.parse( + base({ event_type: "widget_form_submit", source: "widget", status: "success" }), + ); + expect(result.status).toBe("success"); + }); + + it("WidgetLinkClickEvent validates", () => { + const result = WidgetLinkClickEvent.parse( + base({ event_type: "widget_link_click", source: "widget" }), + ); + expect(result.event_type).toBe("widget_link_click"); + }); + + it("WidgetNavigationEvent validates with nav fields", () => { + const result = WidgetNavigationEvent.parse( + base({ + event_type: "widget_navigation", + source: "widget", + nav_from: "/home", + nav_to: "/about", + }), + ); + expect(result.nav_from).toBe("/home"); + expect(result.nav_to).toBe("/about"); + }); + + it("WidgetFocusEvent validates", () => { + const result = WidgetFocusEvent.parse(base({ event_type: "widget_focus", source: "widget" })); + expect(result.event_type).toBe("widget_focus"); + }); + + it("WidgetPerformanceEvent validates with load_time_ms", () => { + const result = WidgetPerformanceEvent.parse( + base({ event_type: "widget_performance", source: "widget", load_time_ms: 500 }), + ); + expect(result.load_time_ms).toBe(500); + }); + + it("WidgetRageClickEvent validates", () => { + const result = WidgetRageClickEvent.parse( + base({ event_type: "widget_rage_click", source: "widget" }), + ); + expect(result.event_type).toBe("widget_rage_click"); + }); +}); + +describe("IngestEvent (discriminated union)", () => { + it("parses a tool_call event", () => { + const result = IngestEvent.parse(base({ event_type: "tool_call", event_name: "search" })); + expect(result.event_type).toBe("tool_call"); + }); + + it("parses a widget_render event", () => { + const result = IngestEvent.parse(base({ event_type: "widget_render", source: "widget" })); + expect(result.event_type).toBe("widget_render"); + }); + + it("rejects unknown event_type", () => { + expect(() => IngestEvent.parse(base({ event_type: "unknown" }))).toThrow(); + }); +}); + +describe("IngestBatch", () => { + it("validates a batch with one event", () => { + const batch = IngestBatch.parse({ + events: [base({ event_type: "track", event_name: "test" })], + }); + expect(batch.events).toHaveLength(1); + }); + + it("rejects an empty batch", () => { + expect(() => IngestBatch.parse({ events: [] })).toThrow(); + }); + + it("rejects a batch exceeding 1000 events", () => { + const events = Array.from({ length: 1001 }, (_, i) => + base({ + event_type: "track", + event_name: `event-${i}`, + event_id: `550e8400-e29b-41d4-a716-${String(i).padStart(12, "0")}`, + }), + ); + expect(() => IngestBatch.parse({ events })).toThrow(); + }); +}); diff --git a/packages/shared/src/__tests__/index.test.ts b/packages/shared/src/__tests__/index.test.ts new file mode 100644 index 0000000..83d9710 --- /dev/null +++ b/packages/shared/src/__tests__/index.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { YavioError, isYavioError } from "../index.js"; +import { BaseEvent, EventType, IngestBatch } from "../index.js"; +import { Uuid, WorkspaceRole, WorkspaceSlug } from "../index.js"; + +describe("barrel exports", () => { + it("re-exports errors module", () => { + expect(YavioError).toBeDefined(); + expect(isYavioError).toBeDefined(); + }); + + it("re-exports events module", () => { + expect(BaseEvent).toBeDefined(); + expect(EventType).toBeDefined(); + expect(IngestBatch).toBeDefined(); + }); + + it("re-exports validation module", () => { + expect(Uuid).toBeDefined(); + expect(WorkspaceRole).toBeDefined(); + expect(WorkspaceSlug).toBeDefined(); + }); +}); diff --git a/packages/shared/src/__tests__/validation.test.ts b/packages/shared/src/__tests__/validation.test.ts new file mode 100644 index 0000000..0982c4a --- /dev/null +++ b/packages/shared/src/__tests__/validation.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { + ApiKeyFormat, + ApiKeyPrefix, + CurrencyCode, + PaginationParams, + ProjectSlug, + SessionId, + TimeRange, + Uuid, + WorkspaceRole, + WorkspaceSlug, +} from "../validation.js"; + +describe("Uuid", () => { + it("accepts a valid UUID v4", () => { + expect(Uuid.parse("550e8400-e29b-41d4-a716-446655440000")).toBe( + "550e8400-e29b-41d4-a716-446655440000", + ); + }); + + it("rejects non-UUID strings", () => { + expect(() => Uuid.parse("not-a-uuid")).toThrow(); + expect(() => Uuid.parse("")).toThrow(); + }); +}); + +describe("WorkspaceSlug", () => { + it("accepts valid slugs", () => { + expect(WorkspaceSlug.parse("my-workspace")).toBe("my-workspace"); + expect(WorkspaceSlug.parse("abc")).toBe("abc"); + expect(WorkspaceSlug.parse("a0b")).toBe("a0b"); + }); + + it("rejects slugs shorter than 3 chars", () => { + expect(() => WorkspaceSlug.parse("ab")).toThrow(); + }); + + it("rejects slugs longer than 48 chars", () => { + expect(() => WorkspaceSlug.parse("a".repeat(49))).toThrow(); + }); + + it("rejects slugs starting with a hyphen", () => { + expect(() => WorkspaceSlug.parse("-abc")).toThrow(); + }); + + it("rejects slugs ending with a hyphen", () => { + expect(() => WorkspaceSlug.parse("abc-")).toThrow(); + }); + + it("rejects uppercase letters", () => { + expect(() => WorkspaceSlug.parse("MyWorkspace")).toThrow(); + }); +}); + +describe("ProjectSlug", () => { + it("accepts valid slugs", () => { + expect(ProjectSlug.parse("my-project")).toBe("my-project"); + expect(ProjectSlug.parse("ab")).toBe("ab"); + }); + + it("rejects slugs shorter than 2 chars", () => { + expect(() => ProjectSlug.parse("a")).toThrow(); + }); + + it("rejects slugs longer than 48 chars", () => { + expect(() => ProjectSlug.parse("a".repeat(49))).toThrow(); + }); + + it("rejects slugs with invalid characters", () => { + expect(() => ProjectSlug.parse("my_project")).toThrow(); + expect(() => ProjectSlug.parse("my project")).toThrow(); + }); +}); + +describe("ApiKeyFormat", () => { + it("accepts a valid API key", () => { + const key = `yav_${"a".repeat(32)}`; + expect(ApiKeyFormat.parse(key)).toBe(key); + }); + + it("accepts keys longer than 32 hex chars", () => { + const key = `yav_${"f".repeat(64)}`; + expect(ApiKeyFormat.parse(key)).toBe(key); + }); + + it("rejects keys without yav_ prefix", () => { + expect(() => ApiKeyFormat.parse(`key_${"a".repeat(32)}`)).toThrow(); + }); + + it("rejects keys with non-hex characters", () => { + expect(() => ApiKeyFormat.parse(`yav_${"g".repeat(32)}`)).toThrow(); + }); + + it("rejects keys shorter than 32 hex chars", () => { + expect(() => ApiKeyFormat.parse("yav_abc")).toThrow(); + }); +}); + +describe("ApiKeyPrefix", () => { + it("accepts a valid prefix", () => { + expect(ApiKeyPrefix.parse("yav_abcd1234")).toBe("yav_abcd1234"); + }); + + it("rejects prefixes with wrong length", () => { + expect(() => ApiKeyPrefix.parse("yav_abc")).toThrow(); + expect(() => ApiKeyPrefix.parse("yav_abcdefghi")).toThrow(); + }); + + it("rejects prefixes without yav_ prefix", () => { + expect(() => ApiKeyPrefix.parse("key_abcd1234")).toThrow(); + }); +}); + +describe("SessionId", () => { + it("accepts a valid session ID", () => { + expect(SessionId.parse("ses_abc123")).toBe("ses_abc123"); + }); + + it("rejects IDs without ses_ prefix", () => { + expect(() => SessionId.parse("tr_abc123")).toThrow(); + }); + + it("rejects IDs with special characters", () => { + expect(() => SessionId.parse("ses_abc-123")).toThrow(); + }); +}); + +describe("WorkspaceRole", () => { + it("accepts all valid roles", () => { + expect(WorkspaceRole.parse("owner")).toBe("owner"); + expect(WorkspaceRole.parse("admin")).toBe("admin"); + expect(WorkspaceRole.parse("member")).toBe("member"); + expect(WorkspaceRole.parse("viewer")).toBe("viewer"); + }); + + it("rejects invalid roles", () => { + expect(() => WorkspaceRole.parse("superadmin")).toThrow(); + }); +}); + +describe("PaginationParams", () => { + it("uses defaults when no values provided", () => { + const result = PaginationParams.parse({}); + expect(result.limit).toBe(20); + expect(result.offset).toBe(0); + }); + + it("accepts valid values", () => { + const result = PaginationParams.parse({ limit: 50, offset: 10 }); + expect(result.limit).toBe(50); + expect(result.offset).toBe(10); + }); + + it("coerces string values", () => { + const result = PaginationParams.parse({ limit: "30", offset: "5" }); + expect(result.limit).toBe(30); + expect(result.offset).toBe(5); + }); + + it("rejects limit below 1", () => { + expect(() => PaginationParams.parse({ limit: 0 })).toThrow(); + }); + + it("rejects limit above 100", () => { + expect(() => PaginationParams.parse({ limit: 101 })).toThrow(); + }); + + it("rejects negative offset", () => { + expect(() => PaginationParams.parse({ offset: -1 })).toThrow(); + }); +}); + +describe("TimeRange", () => { + it("accepts valid ISO datetime strings", () => { + const result = TimeRange.parse({ + from: "2025-01-01T00:00:00Z", + to: "2025-01-31T23:59:59Z", + }); + expect(result.from).toBe("2025-01-01T00:00:00Z"); + expect(result.to).toBe("2025-01-31T23:59:59Z"); + }); + + it("rejects invalid datetime strings", () => { + expect(() => TimeRange.parse({ from: "2025-01-01", to: "2025-01-31" })).toThrow(); + expect(() => TimeRange.parse({ from: "not-a-date", to: "2025-01-31T00:00:00Z" })).toThrow(); + }); + + it("rejects missing fields", () => { + expect(() => TimeRange.parse({ from: "2025-01-01T00:00:00Z" })).toThrow(); + expect(() => TimeRange.parse({ to: "2025-01-31T00:00:00Z" })).toThrow(); + }); +}); + +describe("CurrencyCode", () => { + it("accepts a 3-letter code and uppercases it", () => { + expect(CurrencyCode.parse("usd")).toBe("USD"); + expect(CurrencyCode.parse("EUR")).toBe("EUR"); + }); + + it("rejects codes not exactly 3 chars", () => { + expect(() => CurrencyCode.parse("US")).toThrow(); + expect(() => CurrencyCode.parse("EURO")).toThrow(); + }); +}); diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index d913ed3..4229970 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -3,5 +3,18 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "json"], + reportsDirectory: "./coverage", + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**"], + thresholds: { + lines: 80, + branches: 70, + functions: 80, + statements: 80, + }, + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 905489f..24d9623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@biomejs/biome': specifier: ^1.9.4 version: 1.9.4 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv-cli: specifier: ^11.0.0 version: 11.0.0 @@ -339,6 +342,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@4.1.2': resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} @@ -366,14 +373,31 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@1.9.4': resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} engines: {node: '>=14.21.3'} @@ -1141,6 +1165,14 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1269,6 +1301,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2582,6 +2618,15 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -2659,10 +2704,18 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2680,6 +2733,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -2694,6 +2750,13 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -2710,6 +2773,13 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.3: + resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + engines: {node: 18 || 20 || >=22} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2794,6 +2864,13 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -3114,12 +3191,21 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -3306,6 +3392,10 @@ packages: foreach@2.0.6: resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3489,6 +3579,11 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3496,6 +3591,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3542,6 +3641,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -3617,6 +3719,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -3653,6 +3759,25 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3664,6 +3789,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3803,6 +3931,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -3824,6 +3955,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -4016,9 +4154,21 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -4156,6 +4306,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -4184,6 +4337,10 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -4659,6 +4816,14 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -4666,6 +4831,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -4704,6 +4873,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + swr@2.4.0: resolution: {integrity: sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==} peerDependencies: @@ -4722,6 +4895,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5070,6 +5247,14 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5113,6 +5298,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@4.1.2': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -5147,10 +5337,23 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/runtime@7.28.6': {} + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@1.9.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.9.4 @@ -5631,6 +5834,17 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5824,6 +6038,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -7120,6 +7337,25 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -7128,14 +7364,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -7204,8 +7432,14 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} argparse@2.0.1: {} @@ -7220,6 +7454,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} atomic-sleep@1.0.0: {} @@ -7231,6 +7471,10 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.0: {} bcryptjs@3.0.3: {} @@ -7253,6 +7497,14 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.3: + dependencies: + balanced-match: 4.0.4 + buffer-from@1.1.2: {} bundle-require@5.1.0(esbuild@0.27.3): @@ -7322,6 +7574,12 @@ snapshots: collapse-white-space@2.1.0: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} commander@13.1.0: {} @@ -7535,10 +7793,16 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} enhanced-resolve@5.19.0: @@ -7853,6 +8117,11 @@ snapshots: foreach@2.0.6: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + forwarded@0.2.0: {} framer-motion@12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -8056,10 +8325,21 @@ snapshots: github-slugger@2.0.0: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + gopd@1.2.0: {} graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} hasown@2.0.2: @@ -8186,6 +8466,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -8260,6 +8542,8 @@ snapshots: is-decimal@2.0.1: {} + is-fullwidth-code-point@3.0.0: {} + is-hexadecimal@2.0.1: {} is-interactive@2.0.0: {} @@ -8280,12 +8564,41 @@ snapshots: isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.6.1: {} jose@6.1.3: {} joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -8411,6 +8724,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.2.6: {} lucide-react@0.570.0(react@19.2.4): @@ -8427,6 +8742,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -8876,8 +9201,18 @@ snapshots: mimic-function@5.0.1: {} + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.3 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.3: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -9005,6 +9340,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + package-json-from-dist@1.0.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9036,6 +9373,11 @@ snapshots: path-key@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -9652,6 +9994,18 @@ snapshots: stdin-discarder@0.2.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -9663,6 +10017,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 @@ -9698,6 +10056,10 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + swr@2.4.0(react@19.2.4): dependencies: dequal: 2.0.3 @@ -9712,6 +10074,12 @@ snapshots: tapable@2.3.0: {} + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.4 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -10032,7 +10400,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -10145,6 +10513,18 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} xml-js@1.6.11: