skillhub/skillhub-cli/tests/api-client.test.ts
chenbaowang 6c0d4f4f16 feat(skillhub-cli): add skillhub CLI with interactive multi-agent support
- Interactive agent selection for install/uninstall/list
- Namespace search selection for unspecified namespace
- --skill-version option to skip interactive selection
- --from option for local/github source installation
- Remove deprecated add command (use install --from instead)
- Path-based grouping for uninstall results
2026-04-14 10:21:09 +08:00

179 lines
5.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("undici", () => ({
request: vi.fn(),
FormData: class FormData {
private _data = new Map<string, string>();
set(k: string, v: string) { this._data.set(k, v); }
append(k: string, v: unknown) { this._data.set(k, String(v)); }
},
}));
import { ApiClient, ApiError } from "../src/core/api-client.js";
import { request } from "undici";
const mockRequest = request as ReturnType<typeof vi.fn>;
function mockResponse(statusCode: number, body: unknown) {
mockRequest.mockResolvedValueOnce({
statusCode,
body: { json: async () => body },
});
}
describe("ApiClient", () => {
let client: ApiClient;
beforeEach(() => {
vi.clearAllMocks();
client = new ApiClient({ baseUrl: "http://localhost:8080" });
});
describe("ApiResponse unwrapping", () => {
it("unwraps Native API success response", async () => {
mockResponse(200, {
code: 0,
msg: "success",
data: { id: 1, name: "test" },
timestamp: "2026-01-01T00:00:00Z",
});
const result = await client.get<{ id: number; name: string }>("/api/v1/test");
expect(result).toEqual({ id: 1, name: "test" });
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it("throws ApiError on Native API error response", async () => {
mockResponse(200, {
code: 403,
msg: "Forbidden",
data: null,
timestamp: "2026-01-01T00:00:00Z",
});
await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError);
});
it("returns raw response for Compat layer (no code/data)", async () => {
mockResponse(200, {
user: { handle: "test-user", displayName: "Test", image: null },
});
const result = await client.get<{ user: { handle: string } }>("/api/v1/whoami");
expect(result).toEqual({
user: { handle: "test-user", displayName: "Test", image: null },
});
});
it("returns raw response for Compat search results", async () => {
mockResponse(200, {
results: [
{ slug: "test-skill", displayName: "Test", summary: "A test", version: "1.0.0" },
],
});
const result = await client.get<{ results: Array<{ slug: string }> }>("/api/v1/search");
expect(result.results).toHaveLength(1);
expect(result.results[0].slug).toBe("test-skill");
});
it("returns raw response for Compat publish result", async () => {
mockResponse(200, { ok: true, skillId: "1", versionId: "1" });
const result = await client.postForm<{ ok: boolean; skillId: string }>("/api/v1/skills", {} as any);
expect(result.ok).toBe(true);
expect(result.skillId).toBe("1");
});
});
describe("HTTP error handling", () => {
it("throws ApiError on HTTP 404", async () => {
mockResponse(404, { code: 404, msg: "Not found", data: null });
await expect(client.get("/api/v1/nonexistent")).rejects.toThrow(ApiError);
});
it("throws ApiError on HTTP 500", async () => {
mockResponse(500, { code: 500, msg: "Internal error", data: null });
await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError);
});
});
describe("Authorization header", () => {
it("includes Bearer token when provided", async () => {
mockResponse(200, { code: 0, data: {}, msg: "ok" });
const clientWithToken = new ApiClient({
baseUrl: "http://localhost:8080",
token: "sk_test123",
});
await clientWithToken.get("/api/v1/test");
expect(mockRequest).toHaveBeenCalledWith(
"http://localhost:8080/api/v1/test",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer sk_test123",
}),
})
);
});
it("omits Authorization header when no token", async () => {
mockResponse(200, { results: [] });
await client.get("/api/v1/search");
const callArgs = mockRequest.mock.calls[0][1];
expect(callArgs.headers).not.toHaveProperty("Authorization");
});
});
describe("POST method", () => {
it("unwraps Native API POST response", async () => {
mockResponse(200, {
code: 0,
data: { id: 1 },
msg: "created",
});
const result = await client.post<{ id: number }>("/api/v1/test", { body: "{}" });
expect(result).toEqual({ id: 1 });
});
it("returns raw Compat POST response", async () => {
mockResponse(200, { ok: true, skillId: "2" });
const result = await client.post<{ ok: boolean }>("/api/v1/skills", { body: "{}" });
expect(result.ok).toBe(true);
});
});
describe("PUT method", () => {
it("unwraps Native API PUT response", async () => {
mockResponse(200, { code: 0, data: { updated: true }, msg: "ok" });
const result = await client.put<{ updated: boolean }>("/api/v1/test", { body: "{}" });
expect(result.updated).toBe(true);
});
});
describe("DELETE method", () => {
it("unwraps Native API DELETE response", async () => {
mockResponse(200, { code: 0, data: { deleted: true }, msg: "ok" });
const result = await client.delete<{ deleted: boolean }>("/api/v1/test");
expect(result.deleted).toBe(true);
});
});
});