import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TorrentClawClient, ApiError } from "../src/api-client.js";
describe("TorrentClawClient", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function mockFetch(body: unknown, status = 200) {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
);
}
function mockFetchError(body: string, status: number) {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(body, { status }),
);
}
it("builds correct search URL with all parameters", async () => {
mockFetch({ total: 0, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
await client.search({
query: "inception",
type: "movie",
sort: "seeders",
quality: "1080p",
min_rating: 7,
});
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("q=inception");
expect(calledUrl).toContain("type=movie");
expect(calledUrl).toContain("sort=seeders");
expect(calledUrl).toContain("quality=1080p");
expect(calledUrl).toContain("min_rating=7");
});
it("omits undefined parameters from URL", async () => {
mockFetch({ total: 0, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
await client.search({ query: "test" });
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("q=test");
expect(calledUrl).not.toContain("type=");
expect(calledUrl).not.toContain("genre=");
});
it("includes correct headers", async () => {
mockFetch({ total: 0, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
await client.search({ query: "test" });
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const options = fetchMock.mock.calls[0][1] as RequestInit;
const headers = options.headers as Record<string, string>;
expect(headers["Accept"]).toBe("application/json");
expect(headers["X-Search-Source"]).toBe("mcp");
expect(headers["User-Agent"]).toMatch(/^torrentclaw-mcp\//);
});
it("throws ApiError on 400 response", async () => {
mockFetchError("Bad request", 400);
const client = new TorrentClawClient();
await expect(client.search({ query: "test" })).rejects.toThrow(ApiError);
});
it("throws ApiError with rate limit message on 429 after retries", async () => {
// With retry logic (MAX_RETRIES=2), need 3 consecutive 429 responses
mockFetchError("Too many requests", 429);
mockFetchError("Too many requests", 429);
mockFetchError("Too many requests", 429);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).message).toContain("Rate limit exceeded");
expect((e as ApiError).status).toBe(429);
}
});
it("constructs torrent download URL", () => {
const client = new TorrentClawClient();
const url = client.getTorrentDownloadUrl(
"aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
);
expect(url).toBe(
"https://torrentclaw.com/api/v1/torrent/aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
);
});
it("calls correct endpoint for popular", async () => {
mockFetch({ items: [], total: 0, page: 1, pageSize: 10 });
const client = new TorrentClawClient();
await client.getPopular(5, 2);
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/popular");
expect(calledUrl).toContain("limit=5");
expect(calledUrl).toContain("page=2");
});
it("includes locale param for popular", async () => {
mockFetch({ items: [], total: 0, page: 1, pageSize: 10 });
const client = new TorrentClawClient();
await client.getPopular(10, 1, "es");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("locale=es");
});
it("includes locale param for recent", async () => {
mockFetch({ items: [], total: 0, page: 1, pageSize: 10 });
const client = new TorrentClawClient();
await client.getRecent(10, 1, "fr");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("locale=fr");
});
it("calls correct endpoint for watch providers", async () => {
mockFetch({
contentId: 42,
country: "ES",
providers: { flatrate: [], rent: [], buy: [], free: [] },
attribution: "JustWatch",
});
const client = new TorrentClawClient();
await client.getWatchProviders(42, "ES");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/content/42/watch-providers");
expect(calledUrl).toContain("country=ES");
});
it("calls correct endpoint for recent", async () => {
mockFetch({ items: [], total: 0, page: 1, pageSize: 10 });
const client = new TorrentClawClient();
await client.getRecent(10, 3);
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/recent");
expect(calledUrl).toContain("limit=10");
expect(calledUrl).toContain("page=3");
});
it("calls correct endpoint for credits", async () => {
mockFetch({ contentId: 7, director: "Nolan", cast: [] });
const client = new TorrentClawClient();
await client.getCredits(7);
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/content/7/credits");
});
it("calls correct endpoint for stats", async () => {
mockFetch({
content: { movies: 100, shows: 50, tmdbEnriched: 80 },
torrents: { total: 1000, withSeeders: 500, bySource: {} },
recentIngestions: [],
});
const client = new TorrentClawClient();
const result = await client.getStats();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/stats");
expect(result.content.movies).toBe(100);
});
it("includes 4xx body truncated to 200 chars", async () => {
const longBody = "x".repeat(300);
mockFetchError(longBody, 422);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).body.length).toBeLessThanOrEqual(200);
}
});
it("omits body for 5xx responses", async () => {
mockFetchError("Internal server error with stack trace", 500);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).body).toBe("");
expect((e as ApiError).status).toBe(500);
}
});
it("omits body for 502 responses", async () => {
mockFetchError("Bad gateway details", 502);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).body).toBe("");
}
});
it("calls correct endpoint for autocomplete", async () => {
mockFetch({ suggestions: [{ id: 1, title: "Test", year: 2024, contentType: "movie", posterUrl: null }] });
const client = new TorrentClawClient();
const result = await client.autocomplete("test");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/autocomplete");
expect(calledUrl).toContain("q=test");
expect(result.suggestions).toHaveLength(1);
});
it("calls correct endpoint for track (POST)", async () => {
mockFetch({ ok: true });
const client = new TorrentClawClient();
const result = await client.track("abc123def456abc123def456abc123def456abc1", "magnet");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
const options = fetchMock.mock.calls[0][1] as RequestInit;
expect(calledUrl).toContain("/api/v1/track");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
infoHash: "abc123def456abc123def456abc123def456abc1",
action: "magnet",
});
expect(result.ok).toBe(true);
});
it("calls correct endpoint for submitScanRequest (POST)", async () => {
mockFetch({ status: "pending" });
const client = new TorrentClawClient();
const result = await client.submitScanRequest("abc123def456abc123def456abc123def456abc1", "test@example.com");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
const options = fetchMock.mock.calls[0][1] as RequestInit;
expect(calledUrl).toContain("/api/v1/scan-request");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
infoHash: "abc123def456abc123def456abc123def456abc1",
email: "test@example.com",
website: "",
});
expect(result.status).toBe("pending");
});
it("calls correct endpoint for getScanStatus", async () => {
mockFetch({ status: "completed", source: "scan_request" });
const client = new TorrentClawClient();
const result = await client.getScanStatus("abc123def456abc123def456abc123def456abc1");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/scan-request/abc123def456abc123def456abc123def456abc1");
expect(result.status).toBe("completed");
});
it("retries on 429 and succeeds", async () => {
mockFetchError("Too many requests", 429);
mockFetch({ total: 1, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
const result = await client.search({ query: "test" });
expect(result.total).toBe(1);
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("includes Authorization header when apiKey is set", async () => {
mockFetch({ total: 0, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(client as any).apiKey = "test-api-key-123";
await client.search({ query: "test" });
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const options = fetchMock.mock.calls[0][1] as RequestInit;
const headers = options.headers as Record<string, string>;
expect(headers["Authorization"]).toBe("Bearer test-api-key-123");
});
it("throws ApiError on POST 400 response", async () => {
mockFetchError("Invalid body", 400);
const client = new TorrentClawClient();
await expect(
client.track("abc123def456abc123def456abc123def456abc1", "magnet"),
).rejects.toThrow(ApiError);
});
});