feat: initial release of torrentclaw-mcp server
MCP (Model Context Protocol) server that wraps the TorrentClaw REST API, enabling LLMs (Claude Desktop, Claude Code, Cursor, etc.) to search movies and TV shows with torrent downloads and streaming availability. ## Tools (6) - search_content: primary search with filters (title, genre, year, rating, quality, language, sort). Returns content metadata + torrent magnet links. - get_popular: trending content ranked by clicks - get_recent: most recently added content - get_watch_providers: streaming availability by country (Netflix, Disney+, etc.) - get_credits: director and top 10 cast members - get_torrent_url: .torrent file download URL from info_hash ## Resources - torrentclaw://stats: catalog statistics (content/torrent counts, sources) ## Prompts (4) - search_movie, search_show, whats_new, where_to_watch ## LLM Usability - Server description with workflow guidance for tool chaining - Tool descriptions include trigger phrases, cross-tool references, and explicit parameter examples - Formatted output includes info_hash for tool chaining and call-syntax cross-references (e.g. "use with get_watch_providers(content_id=42)") - Popular/recent output hints to use search_content for torrents - Error messages include status-specific recovery guidance (400, 404, 429, 5xx) - Prompts reference tools by name for reliable LLM execution ## Security - SSRF protection: validates TORRENTCLAW_API_URL against private IPs, localhost, link-local, and IPv6 loopback addresses - Protocol whitelist: only http/https allowed - Error body sanitization: 4xx truncated to 200 chars, 5xx bodies omitted - Input validation: control char filter on queries, regex on country codes, genre character whitelist, content_id bounds ## Testing - 85 tests across 13 test files - Coverage: 99.5% statements, 95.2% branches, 98.1% functions, 99.5% lines - Vitest v4 with v8 coverage provider, 80% thresholds enforced ## Stack - TypeScript ESM, Node.js >= 18 (native fetch) - @modelcontextprotocol/sdk v1.12, zod v3.24 - STDIO transport, runnable via npx torrentclaw-mcp
This commit is contained in:
commit
d471c9b695
36 changed files with 6000 additions and 0 deletions
503
tests/formatters/content.test.ts
Normal file
503
tests/formatters/content.test.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatSearchResults,
|
||||
formatPopularResults,
|
||||
formatRecentResults,
|
||||
} from "../../src/formatters/content.js";
|
||||
import type {
|
||||
SearchResponse,
|
||||
PopularResponse,
|
||||
RecentResponse,
|
||||
} from "../../src/types.js";
|
||||
|
||||
describe("formatSearchResults", () => {
|
||||
it("formats empty results", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("No results found");
|
||||
});
|
||||
|
||||
it("formats a single movie with torrents", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 42,
|
||||
imdbId: "tt1375666",
|
||||
tmdbId: "27205",
|
||||
contentType: "movie",
|
||||
title: "Inception",
|
||||
titleOriginal: "Inception",
|
||||
year: 2010,
|
||||
overview: "A thief who steals corporate secrets through dream-sharing technology.",
|
||||
posterUrl: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
backdropUrl: null,
|
||||
genres: ["Action", "Science Fiction"],
|
||||
ratingImdb: "8.8",
|
||||
ratingTmdb: "8.4",
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
quality: "1080p",
|
||||
codec: "x265",
|
||||
sourceType: "BluRay",
|
||||
sizeBytes: "2147483648",
|
||||
seeders: 847,
|
||||
leechers: 23,
|
||||
magnetUrl: "magnet:?xt=urn:btih:aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
|
||||
source: "yts",
|
||||
qualityScore: 85,
|
||||
uploadedAt: "2024-03-15T12:00:00Z",
|
||||
languages: ["en"],
|
||||
audioCodec: "aac",
|
||||
hdrType: null,
|
||||
releaseGroup: "YTS",
|
||||
isProper: false,
|
||||
isRepack: false,
|
||||
isRemastered: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Found 1 results");
|
||||
expect(text).toContain("Inception (2010) [movie]");
|
||||
expect(text).toContain("IMDb: 8.8");
|
||||
expect(text).toContain("TMDB: 8.4");
|
||||
expect(text).toContain("Action, Science Fiction");
|
||||
expect(text).toContain("1080p BluRay x265");
|
||||
expect(text).toContain("2.0 GB");
|
||||
expect(text).toContain("847 seeders");
|
||||
expect(text).toContain("Score: 85");
|
||||
expect(text).toContain("magnet:");
|
||||
expect(text).toContain("Content ID: 42");
|
||||
expect(text).toContain("tt1375666");
|
||||
});
|
||||
|
||||
it("caps torrents at 5 and sorts by qualityScore", () => {
|
||||
const torrents = Array.from({ length: 8 }, (_, i) => ({
|
||||
infoHash: `${"a".repeat(39)}${i}`,
|
||||
quality: `${720 + i * 10}p`,
|
||||
codec: "x264",
|
||||
sourceType: "WEB-DL",
|
||||
sizeBytes: "1073741824",
|
||||
seeders: 100 + i,
|
||||
leechers: 10,
|
||||
magnetUrl: `magnet:?xt=urn:btih:${"a".repeat(39)}${i}`,
|
||||
source: "knaben:test",
|
||||
qualityScore: i * 10,
|
||||
uploadedAt: null,
|
||||
languages: ["en"],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: false,
|
||||
isRepack: false,
|
||||
isRemastered: false,
|
||||
}));
|
||||
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Test Movie",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
hasTorrents: true,
|
||||
torrents,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("8 total, top 5");
|
||||
// Highest score (70) should appear first
|
||||
expect(text).toContain("Score: 70");
|
||||
});
|
||||
|
||||
it("formats KB-sized torrent", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Small File",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "a".repeat(40),
|
||||
quality: null,
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "512000",
|
||||
seeders: 5,
|
||||
leechers: 0,
|
||||
magnetUrl: null,
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("500 KB");
|
||||
expect(text).toContain("Unknown quality");
|
||||
expect(text).not.toContain("Score:");
|
||||
});
|
||||
|
||||
it("formats MB-sized torrent", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Medium File",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "b".repeat(40),
|
||||
quality: "720p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "524288000",
|
||||
seeders: 10,
|
||||
leechers: 1,
|
||||
magnetUrl: null,
|
||||
source: "test",
|
||||
qualityScore: 50,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("500 MB");
|
||||
});
|
||||
|
||||
it("handles null and NaN sizeBytes", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "No Size",
|
||||
titleOriginal: null,
|
||||
year: null,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "c".repeat(40),
|
||||
quality: "1080p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: null,
|
||||
seeders: 1,
|
||||
leechers: 0,
|
||||
magnetUrl: null,
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
},
|
||||
{
|
||||
infoHash: "d".repeat(40),
|
||||
quality: "720p",
|
||||
codec: null,
|
||||
sourceType: null,
|
||||
sizeBytes: "not-a-number",
|
||||
seeders: 1,
|
||||
leechers: 0,
|
||||
magnetUrl: null,
|
||||
source: "test",
|
||||
qualityScore: null,
|
||||
uploadedAt: null,
|
||||
languages: [],
|
||||
audioCodec: null,
|
||||
hdrType: null,
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
// Both null and NaN should produce "?"
|
||||
expect(text).toContain("(?)");
|
||||
// No year in title
|
||||
expect(text).toContain("No Size [movie]");
|
||||
// No ratings
|
||||
expect(text).toContain("No ratings");
|
||||
});
|
||||
|
||||
it("truncates long overviews", () => {
|
||||
const longOverview = "A".repeat(300);
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "show",
|
||||
title: "Long Overview",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: longOverview,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
hasTorrents: false,
|
||||
torrents: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("...");
|
||||
expect(text).not.toContain("A".repeat(300));
|
||||
});
|
||||
|
||||
it("shows HDR type in torrent label", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "HDR Movie",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: "7.0",
|
||||
ratingTmdb: null,
|
||||
hasTorrents: true,
|
||||
torrents: [
|
||||
{
|
||||
infoHash: "e".repeat(40),
|
||||
quality: "2160p",
|
||||
codec: "x265",
|
||||
sourceType: "WEB-DL",
|
||||
sizeBytes: "10737418240",
|
||||
seeders: 50,
|
||||
leechers: 2,
|
||||
magnetUrl: "magnet:?xt=urn:btih:" + "e".repeat(40),
|
||||
source: "yts",
|
||||
qualityScore: 95,
|
||||
uploadedAt: null,
|
||||
languages: ["en"],
|
||||
audioCodec: null,
|
||||
hdrType: "hdr10",
|
||||
releaseGroup: null,
|
||||
isProper: null,
|
||||
isRepack: null,
|
||||
isRemastered: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("2160p WEB-DL x265 hdr10");
|
||||
expect(text).toContain("10.0 GB");
|
||||
});
|
||||
|
||||
it("shows streaming info when available", () => {
|
||||
const response: SearchResponse = {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
contentType: "movie",
|
||||
title: "Streaming Test",
|
||||
titleOriginal: null,
|
||||
year: 2024,
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
backdropUrl: null,
|
||||
genres: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: null,
|
||||
hasTorrents: false,
|
||||
torrents: [],
|
||||
streaming: {
|
||||
flatrate: [
|
||||
{ providerId: 8, name: "Netflix", logo: null, link: null },
|
||||
{ providerId: 337, name: "Disney+", logo: null, link: null },
|
||||
],
|
||||
rent: [],
|
||||
buy: [],
|
||||
free: [{ providerId: 100, name: "Tubi", logo: null, link: null }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const text = formatSearchResults(response);
|
||||
expect(text).toContain("Stream: Netflix, Disney+");
|
||||
expect(text).toContain("Free: Tubi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPopularResults", () => {
|
||||
it("formats empty results", () => {
|
||||
const response: PopularResponse = { items: [], total: 0, page: 1, pageSize: 10 };
|
||||
expect(formatPopularResults(response)).toContain("No popular content");
|
||||
});
|
||||
|
||||
it("formats popular items with click counts", () => {
|
||||
const response: PopularResponse = {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Popular Movie",
|
||||
year: 2024,
|
||||
contentType: "movie",
|
||||
posterUrl: null,
|
||||
ratingImdb: "7.5",
|
||||
ratingTmdb: "7.2",
|
||||
clickCount: 150,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const text = formatPopularResults(response);
|
||||
expect(text).toContain("Popular Movie (2024) [movie]");
|
||||
expect(text).toContain("150 clicks");
|
||||
expect(text).toContain("ID: 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRecentResults", () => {
|
||||
it("formats empty results", () => {
|
||||
const response: RecentResponse = { items: [], total: 0, page: 1, pageSize: 10 };
|
||||
expect(formatRecentResults(response)).toContain("No recent content");
|
||||
});
|
||||
|
||||
it("formats recent items with dates", () => {
|
||||
const response: RecentResponse = {
|
||||
items: [
|
||||
{
|
||||
id: 5,
|
||||
title: "New Show",
|
||||
year: 2025,
|
||||
contentType: "show",
|
||||
posterUrl: null,
|
||||
ratingImdb: null,
|
||||
ratingTmdb: "6.8",
|
||||
createdAt: "2025-12-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const text = formatRecentResults(response);
|
||||
expect(text).toContain("New Show (2025) [show]");
|
||||
expect(text).toContain("TMDB: 6.8");
|
||||
expect(text).toContain("Dec");
|
||||
expect(text).toContain("ID: 5");
|
||||
});
|
||||
});
|
||||
58
tests/formatters/credits.test.ts
Normal file
58
tests/formatters/credits.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { formatCredits } from "../../src/formatters/credits.js";
|
||||
import type { CreditsResponse } from "../../src/types.js";
|
||||
|
||||
describe("formatCredits", () => {
|
||||
it("formats director and cast", () => {
|
||||
const data: CreditsResponse = {
|
||||
contentId: 42,
|
||||
director: "Christopher Nolan",
|
||||
cast: [
|
||||
{ name: "Leonardo DiCaprio", character: "Cobb", profileUrl: null },
|
||||
{ name: "Tom Hardy", character: "Eames", profileUrl: null },
|
||||
],
|
||||
};
|
||||
|
||||
const text = formatCredits(data);
|
||||
expect(text).toContain("Credits for content #42");
|
||||
expect(text).toContain("Director: Christopher Nolan");
|
||||
expect(text).toContain("Leonardo DiCaprio as Cobb");
|
||||
expect(text).toContain("Tom Hardy as Eames");
|
||||
});
|
||||
|
||||
it("handles missing director", () => {
|
||||
const data: CreditsResponse = {
|
||||
contentId: 10,
|
||||
director: null,
|
||||
cast: [{ name: "Actor One", character: "Role", profileUrl: null }],
|
||||
};
|
||||
|
||||
const text = formatCredits(data);
|
||||
expect(text).not.toContain("Director:");
|
||||
expect(text).toContain("Actor One as Role");
|
||||
});
|
||||
|
||||
it("handles empty cast", () => {
|
||||
const data: CreditsResponse = {
|
||||
contentId: 5,
|
||||
director: "Some Director",
|
||||
cast: [],
|
||||
};
|
||||
|
||||
const text = formatCredits(data);
|
||||
expect(text).toContain("Director: Some Director");
|
||||
expect(text).toContain("No cast information available");
|
||||
});
|
||||
|
||||
it("handles cast member without character name", () => {
|
||||
const data: CreditsResponse = {
|
||||
contentId: 1,
|
||||
director: null,
|
||||
cast: [{ name: "Mystery Actor", character: "", profileUrl: null }],
|
||||
};
|
||||
|
||||
const text = formatCredits(data);
|
||||
expect(text).toContain("- Mystery Actor");
|
||||
expect(text).not.toContain(" as ");
|
||||
});
|
||||
});
|
||||
101
tests/formatters/providers.test.ts
Normal file
101
tests/formatters/providers.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { formatWatchProviders } from "../../src/formatters/providers.js";
|
||||
import type { WatchProvidersResponse } from "../../src/types.js";
|
||||
|
||||
describe("formatWatchProviders", () => {
|
||||
it("formats providers by availability type", () => {
|
||||
const data: WatchProvidersResponse = {
|
||||
contentId: 42,
|
||||
country: "ES",
|
||||
providers: {
|
||||
flatrate: [
|
||||
{ providerId: 8, name: "Netflix", logo: null, link: null, displayPriority: 1 },
|
||||
{ providerId: 337, name: "Disney+", logo: null, link: null, displayPriority: 2 },
|
||||
],
|
||||
rent: [
|
||||
{ providerId: 2, name: "Apple TV", logo: null, link: null, displayPriority: 1 },
|
||||
],
|
||||
buy: [
|
||||
{ providerId: 3, name: "Google Play", logo: null, link: null, displayPriority: 1 },
|
||||
],
|
||||
free: [],
|
||||
},
|
||||
attribution: "Watch provider data provided by JustWatch via TMDB.",
|
||||
};
|
||||
|
||||
const text = formatWatchProviders(data);
|
||||
expect(text).toContain("Watch providers for content #42 in ES");
|
||||
expect(text).toContain("Stream: Netflix, Disney+");
|
||||
expect(text).toContain("Rent: Apple TV");
|
||||
expect(text).toContain("Buy: Google Play");
|
||||
expect(text).not.toContain("Free:");
|
||||
expect(text).toContain("JustWatch");
|
||||
});
|
||||
|
||||
it("handles no providers", () => {
|
||||
const data: WatchProvidersResponse = {
|
||||
contentId: 10,
|
||||
country: "US",
|
||||
providers: { flatrate: [], rent: [], buy: [], free: [] },
|
||||
attribution: "JustWatch",
|
||||
};
|
||||
|
||||
const text = formatWatchProviders(data);
|
||||
expect(text).toContain("No watch providers found in US");
|
||||
});
|
||||
|
||||
it("shows VPN suggestion when available", () => {
|
||||
const data: WatchProvidersResponse = {
|
||||
contentId: 5,
|
||||
country: "AR",
|
||||
providers: { flatrate: [], rent: [], buy: [], free: [] },
|
||||
vpnSuggestion: {
|
||||
availableIn: ["US", "ES", "FR"],
|
||||
affiliateUrl: "https://example.com/vpn",
|
||||
},
|
||||
attribution: "JustWatch",
|
||||
};
|
||||
|
||||
const text = formatWatchProviders(data);
|
||||
expect(text).toContain("Available in other countries: US, ES, FR");
|
||||
});
|
||||
|
||||
it("sorts providers by display priority", () => {
|
||||
const data: WatchProvidersResponse = {
|
||||
contentId: 1,
|
||||
country: "GB",
|
||||
providers: {
|
||||
flatrate: [
|
||||
{ providerId: 2, name: "Second", logo: null, link: null, displayPriority: 20 },
|
||||
{ providerId: 1, name: "First", logo: null, link: null, displayPriority: 1 },
|
||||
],
|
||||
rent: [],
|
||||
buy: [],
|
||||
free: [],
|
||||
},
|
||||
attribution: "JustWatch",
|
||||
};
|
||||
|
||||
const text = formatWatchProviders(data);
|
||||
expect(text).toContain("Stream: First, Second");
|
||||
});
|
||||
|
||||
it("shows free providers", () => {
|
||||
const data: WatchProvidersResponse = {
|
||||
contentId: 1,
|
||||
country: "US",
|
||||
providers: {
|
||||
flatrate: [],
|
||||
rent: [],
|
||||
buy: [],
|
||||
free: [
|
||||
{ providerId: 100, name: "Tubi", logo: null, link: null, displayPriority: 1 },
|
||||
],
|
||||
},
|
||||
attribution: "JustWatch",
|
||||
};
|
||||
|
||||
const text = formatWatchProviders(data);
|
||||
expect(text).toContain("Free: Tubi");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue