import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { RipplingClient } from "../../src/clients/rippling-client.js";
import { RipplingConfigError, RipplingApiError } from "../../src/utils/errors.js";
describe("RipplingClient", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
describe("constructor", () => {
it("throws RipplingConfigError when no token provided", () => {
delete process.env.RIPPLING_API_TOKEN;
expect(() => new RipplingClient()).toThrow(RipplingConfigError);
});
it("accepts token from config", () => {
delete process.env.RIPPLING_API_TOKEN;
const client = new RipplingClient({ apiToken: "test-token" });
expect(client).toBeDefined();
});
it("accepts token from environment", () => {
process.env.RIPPLING_API_TOKEN = "env-token";
const client = new RipplingClient();
expect(client).toBeDefined();
});
it("uses custom base URL from config", () => {
const client = new RipplingClient({
apiToken: "test",
baseUrl: "https://sandbox.rippling.com/api/platform/api",
});
expect(client).toBeDefined();
});
it("uses custom base URL from environment", () => {
process.env.RIPPLING_API_TOKEN = "test";
process.env.RIPPLING_BASE_URL =
"https://sandbox.rippling.com/api/platform/api";
const client = new RipplingClient();
expect(client).toBeDefined();
});
});
describe("API calls", () => {
let client: RipplingClient;
beforeEach(() => {
client = new RipplingClient({ apiToken: "test-token-123" });
});
it("sends correct authorization header", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ id: "company_1" }),
});
vi.stubGlobal("fetch", mockFetch);
await client.getCompany();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/companies/current"),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-token-123",
}),
})
);
});
it("handles pagination params correctly", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve([]),
});
vi.stubGlobal("fetch", mockFetch);
await client.listEmployees({ limit: 25, offset: 50 });
const calledUrl = mockFetch.mock.calls[0][0];
expect(calledUrl).toContain("limit=25");
expect(calledUrl).toContain("offset=50");
});
it("throws RipplingApiError on 401", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
headers: new Headers(),
text: () => Promise.resolve("Unauthorized"),
});
vi.stubGlobal("fetch", mockFetch);
await expect(client.getCompany()).rejects.toThrow(RipplingApiError);
});
it("throws RipplingApiError on 403", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
headers: new Headers(),
text: () => Promise.resolve("Forbidden"),
});
vi.stubGlobal("fetch", mockFetch);
await expect(client.listEmployees()).rejects.toThrow(RipplingApiError);
});
it("throws RipplingApiError on 429", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers: new Headers(),
text: () => Promise.resolve("Rate limited"),
});
vi.stubGlobal("fetch", mockFetch);
await expect(client.listDepartments()).rejects.toThrow(RipplingApiError);
});
it("handles 204 No Content for delete", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
headers: new Headers(),
});
vi.stubGlobal("fetch", mockFetch);
await expect(client.deleteGroup("grp_001")).resolves.toBeUndefined();
});
it("sends JSON body for POST requests", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () =>
Promise.resolve({
id: "grp_new",
name: "Test",
spokeId: "test",
userIds: ["u1"],
}),
});
vi.stubGlobal("fetch", mockFetch);
await client.createGroup({
name: "Test",
spokeId: "test",
userIds: ["u1"],
});
const callArgs = mockFetch.mock.calls[0];
expect(callArgs[1].method).toBe("POST");
expect(callArgs[1].headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(callArgs[1].body)).toEqual({
name: "Test",
spokeId: "test",
userIds: ["u1"],
});
});
it("encodes employee IDs in URL", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ id: "role/special" }),
});
vi.stubGlobal("fetch", mockFetch);
await client.getEmployee("role/special");
const calledUrl = mockFetch.mock.calls[0][0];
expect(calledUrl).toContain("role%2Fspecial");
});
});
});
describe("Error Classes", () => {
it("RipplingApiError generates actionable 401 message", () => {
const error = new RipplingApiError(
"Unauthorized",
401,
"/employees",
"Invalid token"
);
const result = error.toToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("RIPPLING_API_TOKEN");
});
it("RipplingApiError generates actionable 429 message", () => {
const error = new RipplingApiError(
"Rate limited",
429,
"/employees"
);
const result = error.toToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Rate limit");
});
it("RipplingConfigError generates setup instructions", () => {
const error = new RipplingConfigError("Token missing");
const result = error.toToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("RIPPLING_API_TOKEN");
});
});