Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ jobs:
- name: Type check
run: bun run typecheck

test:
name: CLI - Unit Tests
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
cache: true
cache-dependency-path: bun.lock
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests
run: bun run test

build:
name: Build
runs-on: blacksmith-4vcpu-ubuntu-2404
Expand Down
185 changes: 148 additions & 37 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"format": "biome format --write .",
"format:check": "biome format .",
"check": "biome check --write .",
"check:ci": "biome ci ."
"check:ci": "biome ci .",
"test": "bun --filter '@calcom/cli' test"
},
"devDependencies": {
"@biomejs/biome": "2.3.10",
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"type-check:ci": "yarn generate && tsc --noEmit",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"prepack": "yarn build"
"prepack": "yarn build",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hey-api/client-fetch": "^0.6.0",
Expand All @@ -41,6 +43,7 @@
"@hey-api/openapi-ts": "^0.61.0",
"@types/node": "^20.17.23",
"ts-node": "10.9.2",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^2.0.0"
}
}
95 changes: 95 additions & 0 deletions packages/cli/src/__tests__/helpers/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
export const validConfig = {
apiKey: "cal_test_api_key_123",
apiUrl: "https://api.cal.com",
appUrl: "https://app.cal.com",
};

export const oauthConfig = {
oauth: {
clientId: "test-client-id",
clientSecret: "test-client-secret",
accessToken: "test-access-token",
refreshToken: "test-refresh-token",
accessTokenExpiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
},
};

export const expiredOauthConfig = {
oauth: {
clientId: "test-client-id",
clientSecret: "test-client-secret",
accessToken: "expired-access-token",
refreshToken: "test-refresh-token",
accessTokenExpiresAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
},
};

export const tokenRefreshResponse = {
access_token: "new-access-token",
refresh_token: "new-refresh-token",
expires_in: 3600,
};

export const validationErrorBody = {
status: "error",
error: {
code: "VALIDATION_ERROR",
message: "Validation failed",
details: {
errors: [
{
property: "email",
constraints: {
isEmail: "email must be a valid email address",
},
},
],
},
},
};

export const nestedValidationErrorBody = {
status: "error",
error: {
code: "VALIDATION_ERROR",
message: "Validation failed",
details: {
errors: [
{
property: "user",
children: [
{
property: "profile",
children: [
{
property: "name",
constraints: {
isNotEmpty: "name should not be empty",
},
},
],
},
],
},
],
},
},
};

export const simpleErrorBody = {
status: "error",
message: "Something went wrong",
};

export const errorWithStringError = {
status: "error",
error: "Not found",
};

export const sdkErrorObject = {
status: "error",
error: {
code: "NOT_FOUND",
message: "Resource not found",
},
};
3 changes: 3 additions & 0 deletions packages/cli/src/__tests__/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./mockFs";
export * from "./mockProcess";
export * from "./fixtures";
51 changes: 51 additions & 0 deletions packages/cli/src/__tests__/helpers/mockFs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { vi } from "vitest";

export interface MockFsState {
files: Map<string, string>;
directories: Set<string>;
}

export function createMockFs() {
const state: MockFsState = {
files: new Map(),
directories: new Set(),
};

const mockFs = {
existsSync: vi.fn((path: string) => {
return state.files.has(path) || state.directories.has(path);
}),
readFileSync: vi.fn((path: string, _encoding?: string) => {
const content = state.files.get(path);
if (content === undefined) {
const error = new Error(`ENOENT: no such file or directory, open '${path}'`) as NodeJS.ErrnoException;
error.code = "ENOENT";
throw error;
}
return content;
}),
writeFileSync: vi.fn((path: string, data: string, _options?: object) => {
state.files.set(path, data);
}),
mkdirSync: vi.fn((path: string, _options?: object) => {
state.directories.add(path);
}),
};

return { mockFs, state };
}

export function setupMockFs(state: MockFsState) {
return {
setFile: (path: string, content: string) => {
state.files.set(path, content);
},
setDirectory: (path: string) => {
state.directories.add(path);
},
clear: () => {
state.files.clear();
state.directories.clear();
},
};
}
22 changes: 22 additions & 0 deletions packages/cli/src/__tests__/helpers/mockProcess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { vi } from "vitest";

export class ProcessExitError extends Error {
constructor(public code: number | undefined) {
super(`process.exit(${code})`);
this.name = "ProcessExitError";
}
}

export function mockProcessExit() {
return vi.spyOn(process, "exit").mockImplementation((code?: number) => {
throw new ProcessExitError(code);
});
}

export function mockConsoleError() {
return vi.spyOn(console, "error").mockImplementation(() => {});
}

export function mockConsoleLog() {
return vi.spyOn(console, "log").mockImplementation(() => {});
}
8 changes: 8 additions & 0 deletions packages/cli/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { afterEach, vi } from "vitest";

// Reset all mocks after each test
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
139 changes: 139 additions & 0 deletions packages/cli/src/shared/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { oauthConfig } from "../__tests__/helpers/fixtures";

// Mock node:fs before importing the module under test
vi.mock("node:fs");
vi.mock("node:os", () => ({
homedir: () => "/home/user",
}));

// Mock the output module
vi.mock("./output", () => ({
renderError: vi.fn(),
renderSuccess: vi.fn(),
}));

// Mock the client module
vi.mock("./client", () => ({
initializeClientWithoutAuth: vi.fn(),
}));

// Mock the generated SDK
vi.mock("../generated/sdk.gen", () => ({
oAuth2ControllerToken: vi.fn(),
}));

import * as fs from "node:fs";
import { oAuth2ControllerToken } from "../generated/sdk.gen";
import { ApiKeyAuth, OAuthAuth } from "./auth";
import { renderSuccess } from "./output";

describe("auth", () => {
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({}));
});

afterEach(() => {
vi.resetAllMocks();
});

describe("ApiKeyAuth", () => {
describe("login", () => {
it("saves provided API key", async () => {
const auth = new ApiKeyAuth({ apiKey: "my-api-key" });

await auth.login();

expect(fs.writeFileSync).toHaveBeenCalled();
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
expect(writeCall[0]).toBe("/home/user/.calcom/config.json");
const writtenConfig = JSON.parse(writeCall[1] as string);
expect(writtenConfig.apiKey).toBe("my-api-key");
expect(renderSuccess).toHaveBeenCalledWith("Logged in successfully.");
});

it("clears existing OAuth config", async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(oauthConfig));

const auth = new ApiKeyAuth({ apiKey: "my-api-key" });

await auth.login();

const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
const writtenConfig = JSON.parse(writeCall[1] as string);
expect(writtenConfig.oauth).toBeUndefined();
expect(writtenConfig.apiKey).toBe("my-api-key");
});

it("sets custom apiUrl", async () => {
const auth = new ApiKeyAuth({
apiKey: "my-api-key",
apiUrl: "https://custom.api.com",
});

await auth.login();

const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
const writtenConfig = JSON.parse(writeCall[1] as string);
expect(writtenConfig.apiKey).toBe("my-api-key");
expect(writtenConfig.apiUrl).toBe("https://custom.api.com");
});

// Note: Testing empty string without options requires mocking stdin
// which is complex. The core functionality is tested in other cases.
});
});

describe("OAuthAuth", () => {
describe("refreshToken", () => {
it("throws when no OAuth config exists", async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({}));

await expect(OAuthAuth.refreshToken()).rejects.toThrow(
"No OAuth credentials found. Please run 'calcom login --oauth' first."
);
});

it("refreshes token and saves new credentials", async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(oauthConfig));
vi.mocked(oAuth2ControllerToken).mockResolvedValue({
data: {
access_token: "new-access-token",
refresh_token: "new-refresh-token",
expires_in: 3600,
},
} as never);

await OAuthAuth.refreshToken();

expect(oAuth2ControllerToken).toHaveBeenCalledWith({
body: {
grant_type: "refresh_token",
client_id: "test-client-id",
client_secret: "test-client-secret",
refresh_token: "test-refresh-token",
},
});

const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
const writtenConfig = JSON.parse(writeCall[1] as string);
expect(writtenConfig.oauth.accessToken).toBe("new-access-token");
expect(writtenConfig.oauth.refreshToken).toBe("new-refresh-token");
});

it("throws when token exchange fails", async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(oauthConfig));
vi.mocked(oAuth2ControllerToken).mockResolvedValue({
data: undefined,
} as never);

await expect(OAuthAuth.refreshToken()).rejects.toThrow(
"Token refresh failed: no response"
);
});
});
});
});
Loading
Loading