feat: expand API coverage with new tools, params, and 90% test threshold
This commit is contained in:
parent
8bb8e5507e
commit
fa913d1561
21 changed files with 1573 additions and 88 deletions
|
|
@ -82,7 +82,10 @@ describe("TorrentClawClient", () => {
|
|||
await expect(client.search({ query: "test" })).rejects.toThrow(ApiError);
|
||||
});
|
||||
|
||||
it("throws ApiError with rate limit message on 429", async () => {
|
||||
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();
|
||||
|
|
@ -119,6 +122,28 @@ describe("TorrentClawClient", () => {
|
|||
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,
|
||||
|
|
@ -216,4 +241,101 @@ describe("TorrentClawClient", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,20 +1,7 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateApiUrl } from "../src/config.js";
|
||||
|
||||
describe("validateApiUrl", () => {
|
||||
const originalEnv = process.env.TORRENTCLAW_ALLOW_PRIVATE;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.TORRENTCLAW_ALLOW_PRIVATE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.TORRENTCLAW_ALLOW_PRIVATE = originalEnv;
|
||||
} else {
|
||||
delete process.env.TORRENTCLAW_ALLOW_PRIVATE;
|
||||
}
|
||||
});
|
||||
it("accepts valid https URL", () => {
|
||||
expect(validateApiUrl("https://torrentclaw.com")).toBe(
|
||||
"https://torrentclaw.com",
|
||||
|
|
@ -91,21 +78,4 @@ describe("validateApiUrl", () => {
|
|||
it("rejects IPv6 loopback ::1", () => {
|
||||
expect(() => validateApiUrl("http://[::1]")).toThrow("private/reserved");
|
||||
});
|
||||
|
||||
it("allows localhost when TORRENTCLAW_ALLOW_PRIVATE=true", () => {
|
||||
process.env.TORRENTCLAW_ALLOW_PRIVATE = "true";
|
||||
expect(validateApiUrl("http://localhost:3030")).toBe(
|
||||
"http://localhost:3030",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows 192.168.x.x when TORRENTCLAW_ALLOW_PRIVATE=true", () => {
|
||||
process.env.TORRENTCLAW_ALLOW_PRIVATE = "true";
|
||||
expect(validateApiUrl("http://192.168.1.1")).toBe("http://192.168.1.1");
|
||||
});
|
||||
|
||||
it("still rejects ftp even when TORRENTCLAW_ALLOW_PRIVATE=true", () => {
|
||||
process.env.TORRENTCLAW_ALLOW_PRIVATE = "true";
|
||||
expect(() => validateApiUrl("ftp://localhost")).toThrow("only http/https");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -520,6 +520,468 @@ describe("formatSearchResults", () => {
|
|||
expect(full).not.toContain("Magnet:");
|
||||
});
|
||||
|
||||
it("shows season and episode in torrent line", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "show",
|
||||
title: "Breaking Bad",
|
||||
titleOriginal: null,
|
||||
year: 2008,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "a".repeat(40),
|
||||
rawTitle: null,
|
||||
quality: "1080p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "1073741824",
|
||||
seeders: 50,
|
||||
leechers: 2,
|
||||
magnetUrl: null,
|
||||
torrentUrl: null,
|
||||
source: "test",
|
||||
qualityScore: 70,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
season: 1,
|
||||
episode: 5,
|
||||
audioTracks: null,
|
||||
subtitleTracks: null,
|
||||
videoInfo: null,
|
||||
scanStatus: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("S01E05");
|
||||
});
|
||||
|
||||
it("shows season without episode", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "show",
|
||||
title: "Some Show",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "b".repeat(40),
|
||||
rawTitle: null,
|
||||
quality: "720p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "500000000",
|
||||
seeders: 10,
|
||||
leechers: 1,
|
||||
magnetUrl: null,
|
||||
torrentUrl: null,
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
season: 3,
|
||||
episode: null,
|
||||
audioTracks: null,
|
||||
subtitleTracks: null,
|
||||
videoInfo: null,
|
||||
scanStatus: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("S03");
|
||||
expect(text).not.toContain("S03E");
|
||||
});
|
||||
|
||||
it("shows torrentUrl when present", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Torrent URL Movie",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "c".repeat(40),
|
||||
rawTitle: null,
|
||||
quality: "1080p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "2000000000",
|
||||
seeders: 20,
|
||||
leechers: 1,
|
||||
magnetUrl: null,
|
||||
torrentUrl: "https://example.com/torrent/ccc.torrent",
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
season: null,
|
||||
episode: null,
|
||||
audioTracks: null,
|
||||
subtitleTracks: null,
|
||||
videoInfo: null,
|
||||
scanStatus: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Torrent: https://example.com/torrent/ccc.torrent");
|
||||
});
|
||||
|
||||
it("shows audio tracks with languages and codecs", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Audio Movie",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "d".repeat(40),
|
||||
rawTitle: null,
|
||||
quality: "1080p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "4000000000",
|
||||
seeders: 30,
|
||||
leechers: 2,
|
||||
magnetUrl: null,
|
||||
torrentUrl: null,
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
season: null,
|
||||
episode: null,
|
||||
audioTracks: [
|
||||
{ lang: "en", codec: "aac", channels: "5.1", title: "English", default: true },
|
||||
{ lang: "es", codec: "aac", channels: "5.1", title: "Spanish", default: false },
|
||||
{ lang: "en", codec: "ac3", channels: "2.0", title: "Commentary", default: false },
|
||||
],
|
||||
subtitleTracks: null,
|
||||
videoInfo: null,
|
||||
scanStatus: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Audio: en, es");
|
||||
expect(text).toContain("(aac, ac3)");
|
||||
});
|
||||
|
||||
it("shows audio tracks with null lang as ?", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Unknown Lang Movie",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "f".repeat(40),
|
||||
rawTitle: null,
|
||||
quality: "720p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "1000000000",
|
||||
seeders: 5,
|
||||
leechers: 0,
|
||||
magnetUrl: null,
|
||||
torrentUrl: null,
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
season: null,
|
||||
episode: null,
|
||||
audioTracks: [
|
||||
{ lang: null, codec: null, channels: null, title: null, default: null },
|
||||
],
|
||||
subtitleTracks: null,
|
||||
videoInfo: null,
|
||||
scanStatus: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Audio: ?");
|
||||
// No codecs listed after Audio line when all codecs are null
|
||||
expect(text).not.toMatch(/Audio: \?\s*\(/);
|
||||
});
|
||||
|
||||
it("shows subtitle tracks summary", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Sub Movie",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "e".repeat(40),
|
||||
rawTitle: null,
|
||||
quality: "1080p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "3000000000",
|
||||
seeders: 25,
|
||||
leechers: 3,
|
||||
magnetUrl: null,
|
||||
torrentUrl: null,
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
season: null,
|
||||
episode: null,
|
||||
audioTracks: null,
|
||||
subtitleTracks: [
|
||||
{ lang: "en", codec: "srt", title: "English", forced: false },
|
||||
{ lang: "es", codec: "srt", title: "Spanish", forced: false },
|
||||
{ lang: "fr", codec: "ass", title: "French", forced: false },
|
||||
],
|
||||
videoInfo: null,
|
||||
scanStatus: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Subtitles: en, es, fr");
|
||||
});
|
||||
|
||||
it("shows contentUrl when present", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 99,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "URL Movie",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: "https://torrentclaw.com/content/99",
|
||||
hasTorrents: false,
|
||||
torrents: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("URL: https://torrentclaw.com/content/99");
|
||||
});
|
||||
|
||||
it("shows parsedSeason and parsedEpisode in header", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 5,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
parsedSeason: 2,
|
||||
parsedEpisode: 7,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "show",
|
||||
title: "Test Show",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: false,
|
||||
torrents: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Detected season/episode: S02E07");
|
||||
});
|
||||
|
||||
it("shows parsedSeason only in header (no episode)", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 3,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
parsedSeason: 4,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "show",
|
||||
title: "Another Show",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
contentUrl: null,
|
||||
hasTorrents: false,
|
||||
torrents: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Detected season/episode: S04");
|
||||
expect(text).not.toContain("S04E");
|
||||
});
|
||||
|
||||
it("shows streaming info when available", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
|
|
|
|||
115
tests/tools/autocomplete.test.ts
Normal file
115
tests/tools/autocomplete.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createMockServer } from "../helpers.js";
|
||||
import { registerAutocomplete } from "../../src/tools/autocomplete.js";
|
||||
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
|
||||
|
||||
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
|
||||
return {
|
||||
search: vi.fn(),
|
||||
autocomplete: vi.fn(),
|
||||
getPopular: vi.fn(),
|
||||
getRecent: vi.fn(),
|
||||
getWatchProviders: vi.fn(),
|
||||
getCredits: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getTorrentDownloadUrl: vi.fn(),
|
||||
track: vi.fn(),
|
||||
submitScanRequest: vi.fn(),
|
||||
getScanStatus: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TorrentClawClient;
|
||||
}
|
||||
|
||||
describe("autocomplete tool", () => {
|
||||
it("returns formatted suggestions", async () => {
|
||||
const client = createMockClient({
|
||||
autocomplete: vi.fn().mockResolvedValue({
|
||||
suggestions: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Breaking Bad",
|
||||
year: 2008,
|
||||
contentType: "show",
|
||||
posterUrl: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "The Break-Up",
|
||||
year: 2006,
|
||||
contentType: "movie",
|
||||
posterUrl: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerAutocomplete(server, client);
|
||||
|
||||
const handler = getToolHandler("autocomplete");
|
||||
const result = await handler({ query: "break" });
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0].text).toContain("Breaking Bad");
|
||||
expect(result.content[0].text).toContain("The Break-Up");
|
||||
expect(result.content[0].text).toContain("[show]");
|
||||
expect(result.content[0].text).toContain("[movie]");
|
||||
});
|
||||
|
||||
it("returns message when no suggestions found", async () => {
|
||||
const client = createMockClient({
|
||||
autocomplete: vi.fn().mockResolvedValue({ suggestions: [] }),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerAutocomplete(server, client);
|
||||
|
||||
const handler = getToolHandler("autocomplete");
|
||||
const result = await handler({ query: "zzzzz" });
|
||||
|
||||
expect(result.content[0].text).toContain("No suggestions");
|
||||
expect(result.content[0].text).toContain("search_content");
|
||||
});
|
||||
|
||||
it("returns isError on ApiError", async () => {
|
||||
const client = createMockClient({
|
||||
autocomplete: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new ApiError(429, "Rate limited")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerAutocomplete(server, client);
|
||||
|
||||
const handler = getToolHandler("autocomplete");
|
||||
const result = await handler({ query: "test" });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("TorrentClaw API error (429)");
|
||||
});
|
||||
|
||||
it("returns isError on generic error", async () => {
|
||||
const client = createMockClient({
|
||||
autocomplete: vi.fn().mockRejectedValue(new Error("Network timeout")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerAutocomplete(server, client);
|
||||
|
||||
const handler = getToolHandler("autocomplete");
|
||||
const result = await handler({ query: "test" });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Request failed: Network timeout");
|
||||
});
|
||||
|
||||
it("handles non-Error throw", async () => {
|
||||
const client = createMockClient({
|
||||
autocomplete: vi.fn().mockRejectedValue("string error"),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerAutocomplete(server, client);
|
||||
|
||||
const handler = getToolHandler("autocomplete");
|
||||
const result = await handler({ query: "test" });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Unknown error");
|
||||
});
|
||||
});
|
||||
|
|
@ -47,12 +47,12 @@ describe("get_popular tool", () => {
|
|||
expect(result.content[0].text).toContain("200 clicks");
|
||||
});
|
||||
|
||||
it("defaults limit to 10", async () => {
|
||||
it("defaults limit to 12", async () => {
|
||||
const getPopularMock = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 12,
|
||||
});
|
||||
const client = createMockClient({ getPopular: getPopularMock });
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
|
|
@ -61,7 +61,7 @@ describe("get_popular tool", () => {
|
|||
const handler = getToolHandler("get_popular");
|
||||
await handler({});
|
||||
|
||||
expect(getPopularMock).toHaveBeenCalledWith(10, undefined);
|
||||
expect(getPopularMock).toHaveBeenCalledWith(12, undefined, undefined);
|
||||
});
|
||||
|
||||
it("returns isError on ApiError", async () => {
|
||||
|
|
|
|||
|
|
@ -47,12 +47,12 @@ describe("get_recent tool", () => {
|
|||
expect(result.content[0].text).toContain("[show]");
|
||||
});
|
||||
|
||||
it("defaults limit to 10", async () => {
|
||||
it("defaults limit to 12", async () => {
|
||||
const getRecentMock = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 12,
|
||||
});
|
||||
const client = createMockClient({ getRecent: getRecentMock });
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
|
|
@ -61,7 +61,7 @@ describe("get_recent tool", () => {
|
|||
const handler = getToolHandler("get_recent");
|
||||
await handler({});
|
||||
|
||||
expect(getRecentMock).toHaveBeenCalledWith(10, undefined);
|
||||
expect(getRecentMock).toHaveBeenCalledWith(12, undefined, undefined);
|
||||
});
|
||||
|
||||
it("returns isError on ApiError", async () => {
|
||||
|
|
|
|||
190
tests/tools/scan-request.test.ts
Normal file
190
tests/tools/scan-request.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createMockServer } from "../helpers.js";
|
||||
import { registerScanRequest } from "../../src/tools/scan-request.js";
|
||||
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
|
||||
|
||||
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
|
||||
return {
|
||||
search: vi.fn(),
|
||||
autocomplete: vi.fn(),
|
||||
getPopular: vi.fn(),
|
||||
getRecent: vi.fn(),
|
||||
getWatchProviders: vi.fn(),
|
||||
getCredits: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getTorrentDownloadUrl: vi.fn(),
|
||||
track: vi.fn(),
|
||||
submitScanRequest: vi.fn(),
|
||||
getScanStatus: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TorrentClawClient;
|
||||
}
|
||||
|
||||
describe("submit_scan_request tool", () => {
|
||||
it("submits scan request successfully", async () => {
|
||||
const submitMock = vi.fn().mockResolvedValue({
|
||||
status: "pending",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const client = createMockClient({ submitScanRequest: submitMock });
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("submit_scan_request");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
email: "test@example.com",
|
||||
});
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0].text).toContain("Scan request submitted");
|
||||
expect(result.content[0].text).toContain("pending");
|
||||
expect(submitMock).toHaveBeenCalledWith(
|
||||
"aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
"test@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns isError on ApiError", async () => {
|
||||
const client = createMockClient({
|
||||
submitScanRequest: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new ApiError(429, "Rate limited")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("submit_scan_request");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
email: "test@example.com",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("TorrentClaw API error (429)");
|
||||
});
|
||||
|
||||
it("returns isError on generic error", async () => {
|
||||
const client = createMockClient({
|
||||
submitScanRequest: vi.fn().mockRejectedValue(new Error("Timeout")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("submit_scan_request");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
email: "test@example.com",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Request failed: Timeout");
|
||||
});
|
||||
|
||||
it("returns isError on non-Error throw", async () => {
|
||||
const client = createMockClient({
|
||||
submitScanRequest: vi.fn().mockRejectedValue("string error"),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("submit_scan_request");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
email: "test@example.com",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_scan_status tool", () => {
|
||||
it("returns scan status", async () => {
|
||||
const statusMock = vi.fn().mockResolvedValue({
|
||||
status: "completed",
|
||||
source: "scan_request",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
completedAt: "2026-01-01T00:05:00Z",
|
||||
});
|
||||
const client = createMockClient({ getScanStatus: statusMock });
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("get_scan_status");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
});
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0].text).toContain("completed");
|
||||
expect(result.content[0].text).toContain("scan_request");
|
||||
});
|
||||
|
||||
it("returns minimal status when fields are missing", async () => {
|
||||
const statusMock = vi.fn().mockResolvedValue({
|
||||
status: "not_scanned",
|
||||
});
|
||||
const client = createMockClient({ getScanStatus: statusMock });
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("get_scan_status");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("not_scanned");
|
||||
expect(result.content[0].text).not.toContain("Source:");
|
||||
});
|
||||
|
||||
it("returns isError on ApiError", async () => {
|
||||
const client = createMockClient({
|
||||
getScanStatus: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new ApiError(404, "Not found")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("get_scan_status");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("TorrentClaw API error (404)");
|
||||
});
|
||||
|
||||
it("returns isError on generic error", async () => {
|
||||
const client = createMockClient({
|
||||
getScanStatus: vi.fn().mockRejectedValue(new Error("DNS failure")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("get_scan_status");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("DNS failure");
|
||||
});
|
||||
|
||||
it("returns isError on non-Error throw", async () => {
|
||||
const client = createMockClient({
|
||||
getScanStatus: vi.fn().mockRejectedValue(42),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerScanRequest(server, client);
|
||||
|
||||
const handler = getToolHandler("get_scan_status");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Unknown error");
|
||||
});
|
||||
});
|
||||
|
|
@ -38,6 +38,7 @@ describe("search_content tool", () => {
|
|||
genres: ["Action"],
|
||||
ratingImdb: "8.8",
|
||||
ratingTmdb: "8.4",
|
||||
contentUrl: null,
|
||||
hasTorrents: true,
|
||||
torrents: [],
|
||||
},
|
||||
|
|
@ -79,6 +80,12 @@ describe("search_content tool", () => {
|
|||
min_rating: 7,
|
||||
quality: "1080p",
|
||||
language: "es",
|
||||
audio: "atmos",
|
||||
hdr: "dolby_vision",
|
||||
availability: "available",
|
||||
locale: "es",
|
||||
season: 1,
|
||||
episode: 5,
|
||||
sort: "seeders",
|
||||
page: 2,
|
||||
limit: 15,
|
||||
|
|
@ -94,6 +101,12 @@ describe("search_content tool", () => {
|
|||
min_rating: 7,
|
||||
quality: "1080p",
|
||||
language: "es",
|
||||
audio: "atmos",
|
||||
hdr: "dolby_vision",
|
||||
availability: "available",
|
||||
locale: "es",
|
||||
season: 1,
|
||||
episode: 5,
|
||||
sort: "seeders",
|
||||
page: 2,
|
||||
limit: 15,
|
||||
|
|
@ -101,11 +114,11 @@ describe("search_content tool", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("defaults limit to 10", async () => {
|
||||
it("defaults limit to 20", async () => {
|
||||
const searchMock = vi.fn().mockResolvedValue({
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
results: [],
|
||||
});
|
||||
const client = createMockClient({ search: searchMock });
|
||||
|
|
@ -116,7 +129,7 @@ describe("search_content tool", () => {
|
|||
await handler({ query: "test" });
|
||||
|
||||
expect(searchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 10 }),
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
112
tests/tools/track-interaction.test.ts
Normal file
112
tests/tools/track-interaction.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createMockServer } from "../helpers.js";
|
||||
import { registerTrackInteraction } from "../../src/tools/track-interaction.js";
|
||||
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
|
||||
|
||||
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
|
||||
return {
|
||||
search: vi.fn(),
|
||||
autocomplete: vi.fn(),
|
||||
getPopular: vi.fn(),
|
||||
getRecent: vi.fn(),
|
||||
getWatchProviders: vi.fn(),
|
||||
getCredits: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getTorrentDownloadUrl: vi.fn(),
|
||||
track: vi.fn(),
|
||||
submitScanRequest: vi.fn(),
|
||||
getScanStatus: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TorrentClawClient;
|
||||
}
|
||||
|
||||
describe("track_interaction tool", () => {
|
||||
it("tracks magnet interaction successfully", async () => {
|
||||
const trackMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
const client = createMockClient({ track: trackMock });
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerTrackInteraction(server, client);
|
||||
|
||||
const handler = getToolHandler("track_interaction");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
action: "magnet",
|
||||
});
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0].text).toContain("Tracked magnet");
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
"aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
"magnet",
|
||||
);
|
||||
});
|
||||
|
||||
it("lowercases info_hash", async () => {
|
||||
const trackMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
const client = createMockClient({ track: trackMock });
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerTrackInteraction(server, client);
|
||||
|
||||
const handler = getToolHandler("track_interaction");
|
||||
await handler({
|
||||
info_hash: "AAF1E71C0A0E3B1C0F1A2B3C4D5E6F7A8B9C0D1E",
|
||||
action: "copy",
|
||||
});
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
"aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
"copy",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns isError on ApiError", async () => {
|
||||
const client = createMockClient({
|
||||
track: vi.fn().mockRejectedValue(new ApiError(500, "Server error")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerTrackInteraction(server, client);
|
||||
|
||||
const handler = getToolHandler("track_interaction");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
action: "torrent_download",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("TorrentClaw API error (500)");
|
||||
});
|
||||
|
||||
it("returns isError on generic error", async () => {
|
||||
const client = createMockClient({
|
||||
track: vi.fn().mockRejectedValue(new Error("Network failure")),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerTrackInteraction(server, client);
|
||||
|
||||
const handler = getToolHandler("track_interaction");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
action: "magnet",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Request failed: Network failure");
|
||||
});
|
||||
|
||||
it("returns isError on non-Error throw", async () => {
|
||||
const client = createMockClient({
|
||||
track: vi.fn().mockRejectedValue("string error"),
|
||||
});
|
||||
const { server, getToolHandler } = createMockServer();
|
||||
registerTrackInteraction(server, client);
|
||||
|
||||
const handler = getToolHandler("track_interaction");
|
||||
const result = await handler({
|
||||
info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
action: "magnet",
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Unknown error");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue