Apply account and provider changes to active conversions without restarting, isolate API and Web state, and abort the exact fallback attempt when settings change. Bound resume recovery, make disk reservations abortable, preserve cleanup totals and history, stabilize compact UI state, and canonicalize RapidGator host aliases. Expand bounded support diagnostics while redacting account identities, local paths, package names, and file names from current and rotated logs. Add regression coverage for rotation, live settings, HTTP 416 recovery, disk waits, cleanup, context menus, history failures, and support bundle privacy.
3676 lines
150 KiB
TypeScript
3676 lines
150 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
|
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeDebridLinkRuntimeCooldownForTests, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
resetDebridLinkRuntimeStateForTests();
|
|
resetMegaDebridRuntimeStateForTests();
|
|
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
|
delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("leadProviderChainWith", () => {
|
|
it("leaves the order unchanged when no preferred provider is given", () => {
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], null)).toEqual(["realdebrid", "debridlink", "alldebrid"]);
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink"], undefined)).toEqual(["realdebrid", "debridlink"]);
|
|
});
|
|
|
|
it("leads with the preferred provider but keeps every other provider as a later fallback", () => {
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], "alldebrid")).toEqual(["alldebrid", "realdebrid", "debridlink"]);
|
|
});
|
|
|
|
it("does not drop or strand any provider when the preferred one is not in the order", () => {
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink"], "alldebrid")).toEqual(["realdebrid", "debridlink"]);
|
|
});
|
|
});
|
|
|
|
describe("debrid service", () => {
|
|
it("falls back to Mega web when Real-Debrid fails", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
bestToken: "",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "bestdebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
return new Response(JSON.stringify({ error: "traffic_limit" }), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "file.bin",
|
|
directUrl: "https://mega-web.example/file.bin",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part1.rar.html");
|
|
expect(result.provider).toBe("megadebrid");
|
|
expect(result.directUrl).toBe("https://mega-web.example/file.bin");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not fallback when auto fallback is disabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "bestdebrid" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
return new Response("traffic exhausted", { status: 429 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "unused.bin",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow();
|
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("skips a provider whose daily limit is already reached and uses the next provider", async () => {
|
|
const calledUrls: string[] = [];
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
debridLinkApiKeys: "dl-token",
|
|
providerOrder: ["realdebrid", "debridlink"] as const,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "debridlink" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true,
|
|
providerDailyLimitBytes: { realdebrid: 100 },
|
|
providerDailyUsageBytes: { realdebrid: 100 },
|
|
providerDailyUsageDay: getProviderUsageDayKey()
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/file.bin",
|
|
name: "file.bin",
|
|
size: 1234
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
throw new Error("Real-Debrid should have been skipped due to daily limit");
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/file.bin");
|
|
expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false);
|
|
});
|
|
|
|
it("leads the provider chain with the preferred (non-cooled) provider when one is hinted", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
debridLinkApiKeys: "dl-token",
|
|
providerOrder: ["realdebrid", "debridlink"] as const,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "debridlink" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: { downloadUrl: "https://debrid-link.example/file.bin", name: "file.bin", size: 1234 }
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
return new Response(JSON.stringify({
|
|
download: "https://rd.example/file.bin",
|
|
filename: "file.bin",
|
|
filesize: 1234
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const control = await service.unrestrictLink("https://hoster.example/lead-control.bin");
|
|
expect(control.provider).toBe("realdebrid");
|
|
|
|
const preferred = await service.unrestrictLink("https://hoster.example/lead-pref.bin", undefined, undefined, "debridlink");
|
|
expect(preferred.provider).toBe("debridlink");
|
|
});
|
|
|
|
it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => {
|
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
let usedAuthHeader = "";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
debridLinkApiKeyDailyLimitBytes: {
|
|
[keys[0].id]: 100
|
|
},
|
|
debridLinkApiKeyDailyUsageBytes: {
|
|
[keys[0].id]: 100
|
|
},
|
|
providerDailyUsageDay: getProviderUsageDayKey()
|
|
};
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
if (headers instanceof Headers) {
|
|
usedAuthHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
usedAuthHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/file.bin",
|
|
name: "file.bin",
|
|
size: 1234
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
|
|
expect(usedAuthHeader).toBe("Bearer dl-key-two");
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
});
|
|
|
|
it("uses JSON add payload and refreshes missing Debrid-Link downloadUrl via downloader/list", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let addBody = "";
|
|
let addContentType = "";
|
|
let addAccept = "";
|
|
const calledUrls: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
const headers = init?.headers;
|
|
if (headers instanceof Headers) {
|
|
addContentType = headers.get("Content-Type") || "";
|
|
addAccept = headers.get("Accept") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
addContentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] || "";
|
|
addAccept = headers.find(([key]) => key.toLowerCase() === "accept")?.[1] || "";
|
|
} else {
|
|
addContentType = String((headers as Record<string, unknown> | undefined)?.["Content-Type"] || "");
|
|
addAccept = String((headers as Record<string, unknown> | undefined)?.Accept || "");
|
|
}
|
|
addBody = String(init?.body || "");
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
id: "dl-link-1",
|
|
url: "https://hoster.example/file.bin",
|
|
name: "file.bin",
|
|
expired: true
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: [
|
|
{
|
|
id: "dl-link-1",
|
|
url: "https://hoster.example/file.bin",
|
|
name: "file.bin",
|
|
downloadUrl: "https://debrid-link.example/file.bin",
|
|
size: 1234,
|
|
expired: false
|
|
}
|
|
]
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
|
|
expect(addContentType).toBe("application/json");
|
|
expect(addAccept).toBe("application/json");
|
|
expect(addBody).toBe(JSON.stringify({ url: "https://hoster.example/file.bin" }));
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/file.bin");
|
|
expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true);
|
|
});
|
|
|
|
it("rotates to the next Debrid-Link key when the first key is invalid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
authHeaders.push(authHeader);
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badToken",
|
|
error_description: "token expired"
|
|
}), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/valid.bin",
|
|
name: "valid.bin",
|
|
size: 2048
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/needs-rotation.bin");
|
|
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/valid.bin");
|
|
});
|
|
|
|
it("clears Debrid-Link runtime cooldown when a key is reactivated live", () => {
|
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
debridLinkDisabledKeyIds: [keys[0].id]
|
|
};
|
|
primeDebridLinkRuntimeCooldownForTests(keys[0].id, 60_000, "stale cooldown");
|
|
const service = new DebridService(settings);
|
|
|
|
service.setSettings({ ...settings, debridLinkDisabledKeyIds: [] });
|
|
|
|
expect(getDebridLinkKeyCooldownStateForTests(keys[0].id)).toBeNull();
|
|
});
|
|
|
|
it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let limitCalls = 0;
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits")) {
|
|
limitCalls += 1;
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
nextResetSeconds: { value: 900 }
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
authHeaders.push(authHeader);
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "maxDataHost",
|
|
error_description: "host quota reached"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/quota-ok.bin",
|
|
name: "quota-ok.bin",
|
|
size: 4096
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/quota-test");
|
|
|
|
expect(limitCalls).toBe(1);
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin");
|
|
});
|
|
|
|
it("scopes Debrid-Link maxDataHost cooldown to the (key, host) pair so the key stays usable for other hosters", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const unrestrictAuthHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: { nextResetSeconds: { value: 900 } }
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
if (url.includes("/downloader/add")) {
|
|
unrestrictAuthHeaders.push(authHeader);
|
|
const bodyText = init?.body ? String(init.body) : "";
|
|
const isRapidgator = /rapidgator/i.test(bodyText);
|
|
if (authHeader === "Bearer dl-key-one" && isRapidgator) {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "maxDataHost",
|
|
error_description: "host quota reached"
|
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: `https://debrid-link.example/${authHeader.slice(-3)}-${isRapidgator ? "rg" : "ot"}.bin`,
|
|
name: "ok.bin",
|
|
size: 1024
|
|
}
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
|
|
const r1 = await service.unrestrictLink("https://rapidgator.net/file/first");
|
|
expect(r1.providerLabel).toContain("Key 2");
|
|
|
|
unrestrictAuthHeaders.length = 0;
|
|
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
|
|
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
|
|
expect(r2.providerLabel).toContain("Key 2");
|
|
|
|
unrestrictAuthHeaders.length = 0;
|
|
const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
|
|
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
|
|
expect(r3.providerLabel).toContain("Key 1");
|
|
});
|
|
|
|
it("does not mark Debrid-Link key as errored when the API returns fileNotAvailable (link-level, not key-level)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
|
|
if (!url.includes("/downloader/add")) {
|
|
return new Response("not-found", { status: 404 });
|
|
}
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "fileNotAvailable",
|
|
error_description: "link is currently not available"
|
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/ok.bin",
|
|
name: "ok.bin",
|
|
size: 1024
|
|
}
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}) as typeof fetch;
|
|
|
|
const key1Id = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
const key2Id = parseDebridLinkApiKeys("dl-key-two")[0].id;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
|
|
expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error");
|
|
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
|
|
});
|
|
|
|
it("does NOT cool down a Debrid-Link key on a quick user-cancel abort (below the min-run threshold)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "",
|
|
megaPassword: "",
|
|
megaCredentials: "",
|
|
debridLinkApiKeys: "dl-key-one",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const controller = new AbortController();
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/downloader/add")) {
|
|
controller.abort();
|
|
throw new Error("aborted");
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
const service = new DebridService(settings);
|
|
await expect(
|
|
service.unrestrictLink("https://rapidgator.net/file/dl-quick-cancel", controller.signal)
|
|
).rejects.toThrow();
|
|
|
|
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
|
|
});
|
|
|
|
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "",
|
|
megaPassword: "",
|
|
megaCredentials: "",
|
|
debridLinkApiKeys: "dl-key-one",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const controller = new AbortController();
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/downloader/add")) {
|
|
controller.abort();
|
|
throw new Error("aborted");
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
const service = new DebridService(settings);
|
|
await expect(
|
|
service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal)
|
|
).rejects.toThrow();
|
|
|
|
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
|
|
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000);
|
|
});
|
|
|
|
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
authHeaders.push(authHeader);
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badFilePassword",
|
|
error_description: "wrong password"
|
|
}), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/protected.bin")).rejects.toThrow("wrong password");
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
|
|
});
|
|
|
|
it("returns a cooldown marker when all Debrid-Link keys are temporarily cooling down", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let addCalls = 0;
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (!url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
return new Response("not-found", { status: 404 });
|
|
}
|
|
addCalls += 1;
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "floodDetected",
|
|
error_description: "too many requests"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow("API-Rate-Limit erreicht");
|
|
await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow(/debrid_link_cooldown:\d+:/i);
|
|
expect(addCalls).toBe(2);
|
|
});
|
|
|
|
it("returns an invalid-all marker when all Debrid-Link keys are invalid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
authHeaders.push(authHeader);
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badToken",
|
|
error_description: "token expired"
|
|
}), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/all-invalid.bin")).rejects.toThrow(/debrid_link_invalid_all:/i);
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
});
|
|
|
|
it("returns a clear error when all Debrid-Link keys are locally exhausted", async () => {
|
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
debridLinkApiKeyDailyLimitBytes: {
|
|
[keys[0].id]: 100,
|
|
[keys[1].id]: 100
|
|
},
|
|
debridLinkApiKeyDailyUsageBytes: {
|
|
[keys[0].id]: 100,
|
|
[keys[1].id]: 100
|
|
},
|
|
providerDailyUsageDay: getProviderUsageDayKey()
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/no-key-left.bin")).rejects.toThrow(/debrid-link nicht verfuegbar|kein aktiver api-key/i);
|
|
});
|
|
|
|
it("stops rotation immediately on Debrid-Link notDebrid (provider-wide) — does NOT burn remaining keys", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
authHeaders.push(String((init?.headers as Record<string, string> | undefined)?.Authorization || ""));
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "notDebrid",
|
|
error_description: "notDebrid"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/);
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
|
|
});
|
|
|
|
it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const authHeader = String((init?.headers as Record<string, string> | undefined)?.Authorization || "");
|
|
authHeaders.push(authHeader);
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "noServerHost",
|
|
error_description: "host temporarily unavailable"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/second-key.bin",
|
|
name: "second-key.bin",
|
|
size: 4096
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/skip-key.bin");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/second-key.bin");
|
|
expect(result.sourceAccountLabel).toBe("Key 2");
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
});
|
|
|
|
it("uses BestDebrid auth header without token query fallback", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "best-token",
|
|
providerPrimary: "bestdebrid" as const,
|
|
providerSecondary: "realdebrid" as const,
|
|
providerTertiary: "megadebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const calledUrls: string[] = [];
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("/api/v1/generateLink?link=")) {
|
|
return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 2048 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part3.rar.html");
|
|
expect(result.provider).toBe("bestdebrid");
|
|
expect(result.fileSize).toBe(2048);
|
|
expect(calledUrls.some((url) => url.includes("auth="))).toBe(false);
|
|
});
|
|
|
|
it("sends Bearer auth header to BestDebrid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "best-token",
|
|
providerPrimary: "bestdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let authHeader = "";
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/api/v1/generateLink?link=")) {
|
|
const headers = init?.headers;
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
const tuple = headers.find(([key]) => key.toLowerCase() === "authorization");
|
|
authHeader = tuple?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 42 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file/abc");
|
|
expect(result.provider).toBe("bestdebrid");
|
|
expect(authHeader).toBe("Bearer best-token");
|
|
});
|
|
|
|
it("does not retry BestDebrid auth failures (401)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "best-token",
|
|
providerPrimary: "bestdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let calls = 0;
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/api/v1/generateLink?link=")) {
|
|
calls += 1;
|
|
return new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/file/no-retry")).rejects.toThrow();
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("does not retry AllDebrid auth failures (403)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let calls = 0;
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.alldebrid.com/v4/link/unlock")) {
|
|
calls += 1;
|
|
return new Response(JSON.stringify({ status: "error", error: { message: "forbidden" } }), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow();
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("supports AllDebrid unlock", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "ad-token",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "realdebrid" as const,
|
|
providerTertiary: "megadebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.alldebrid.com/v4/link/unlock")) {
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
link: "https://alldebrid.example/file.bin",
|
|
filename: "file.bin",
|
|
filesize: 4096
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html");
|
|
expect(result.provider).toBe("alldebrid");
|
|
expect(result.directUrl).toBe("https://alldebrid.example/file.bin");
|
|
expect(result.fileSize).toBe(4096);
|
|
});
|
|
|
|
it("loads AllDebrid host info via api", async () => {
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.alldebrid.com/v4.1/user/hosts")) {
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
hosts: {
|
|
rapidgator: {
|
|
name: "rapidgator",
|
|
status: false,
|
|
quota: 1200,
|
|
quotaMax: 2400,
|
|
quotaType: "traffic",
|
|
limitSimuDl: 2
|
|
}
|
|
}
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchAllDebridHostInfo("ad-token", "rapidgator");
|
|
expect(info.source).toBe("api");
|
|
expect(info.host).toBe("rapidgator");
|
|
expect(info.state).toBe("down");
|
|
expect(info.statusLabel).toBe("Unverfügbar");
|
|
expect(info.quota).toBe(1200);
|
|
expect(info.quotaMax).toBe(2400);
|
|
expect(info.quotaType).toBe("traffic");
|
|
expect(info.limitSimuDl).toBe(2);
|
|
});
|
|
|
|
it("loads Debrid-Link rapidgator limits per api key", async () => {
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "rapidgator",
|
|
daySize: { current: 0, value: 150323855360 },
|
|
dayCount: { current: 0, value: 500 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info).toHaveLength(1);
|
|
expect(info[0].keyLabel).toBe("Key 1");
|
|
expect(info[0].host).toBe("rapidgator");
|
|
expect(info[0].trafficCurrentBytes).toBe(0);
|
|
expect(info[0].trafficMaxBytes).toBe(150323855360);
|
|
expect(info[0].linksCurrent).toBe(0);
|
|
expect(info[0].linksMax).toBe(500);
|
|
});
|
|
|
|
it("falls back from Debrid-Link limits/all to limits when the host is only present in limits", async () => {
|
|
const calledUrls: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "uploaded",
|
|
daySize: { current: 1, value: 2 },
|
|
dayCount: { current: 3, value: 4 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "rapidgator",
|
|
displayName: "Rapidgator",
|
|
daySize: { current: 2147483648, value: 150323855360 },
|
|
dayCount: { current: 42, value: 500 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info).toHaveLength(1);
|
|
expect(info[0].host).toBe("rapidgator");
|
|
expect(info[0].trafficCurrentBytes).toBe(2147483648);
|
|
expect(info[0].trafficMaxBytes).toBe(150323855360);
|
|
expect(info[0].linksCurrent).toBe(42);
|
|
expect(info[0].linksMax).toBe(500);
|
|
expect(calledUrls.some((url) => url.includes("/limits/all"))).toBe(true);
|
|
expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true);
|
|
});
|
|
|
|
it("includes Debrid-Link host and key state diagnostics in host limits", async () => {
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/hosts")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: [
|
|
{
|
|
name: "rapidgator",
|
|
status: 1,
|
|
domains: ["rapidgator.net", "rg.to"]
|
|
}
|
|
]
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "rapidgator",
|
|
daySize: { current: 1024, value: 2048 },
|
|
dayCount: { current: 2, value: 5 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info[0].state).toBe("ready");
|
|
expect(info[0].stateLabel).toBe("Bereit");
|
|
expect(info[0].hostState).toBe("up");
|
|
expect(info[0].hostStateLabel).toBe("Online");
|
|
});
|
|
|
|
it("returns invalid Debrid-Link key diagnostics instead of failing the whole popup request", async () => {
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/hosts")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: [
|
|
{
|
|
name: "rapidgator",
|
|
status: 0,
|
|
domains: ["rapidgator.net"]
|
|
}
|
|
]
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badToken",
|
|
error_description: "token expired"
|
|
}), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info).toHaveLength(1);
|
|
expect(info[0].state).toBe("invalid");
|
|
expect(info[0].cooldownRemainingMs).toBeGreaterThan(0);
|
|
expect(info[0].hostState).toBe("down");
|
|
expect(info[0].hostStateLabel).toBe("Offline");
|
|
});
|
|
|
|
it("uses AllDebrid web path when enabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
allDebridUseWebLogin: true,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
|
|
const allDebridWeb = vi.fn(async () => ({
|
|
fileName: "from-web.rar",
|
|
directUrl: "https://df4ea4.debrid.it/dl/example/from-web.rar",
|
|
fileSize: 1234,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { allDebridWebUnrestrict: allDebridWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html");
|
|
expect(result.provider).toBe("alldebrid");
|
|
expect(result.directUrl).toContain("debrid.it/dl/");
|
|
expect(result.fileSize).toBe(1234);
|
|
expect(allDebridWeb).toHaveBeenCalledTimes(1);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("treats AllDebrid web mode as not configured when callback is unavailable", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "",
|
|
allDebridUseWebLogin: true,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i);
|
|
});
|
|
|
|
it("uses Real-Debrid web path when enabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
realDebridUseWebLogin: true,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
|
|
const realDebridWeb = vi.fn(async () => ({
|
|
fileName: "from-rd-web.rar",
|
|
directUrl: "https://download.real-debrid.com/d/example/from-rd-web.rar",
|
|
fileSize: 5678,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html");
|
|
expect(result.provider).toBe("realdebrid");
|
|
expect(result.directUrl).toContain("real-debrid.com/d/");
|
|
expect(result.fileSize).toBe(5678);
|
|
expect(realDebridWeb).toHaveBeenCalledTimes(1);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it.each([
|
|
["Real-Debrid", "realdebrid", "realDebridWebUnrestrict", { token: "rd-token", realDebridUseWebLogin: true }],
|
|
["AllDebrid", "alldebrid", "allDebridWebUnrestrict", { allDebridToken: "ad-token", allDebridUseWebLogin: true }],
|
|
["BestDebrid", "bestdebrid", "bestDebridWebUnrestrict", { bestToken: "best-token", bestDebridUseWebLogin: true }]
|
|
] as const)("aborts a hanging %s Web provider callback even when it ignores the signal", async (_label, providerName, callbackName, settingsPatch) => {
|
|
let markStarted: () => void = () => {};
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const providerCallback = vi.fn(() => {
|
|
markStarted();
|
|
return new Promise<never>(() => {});
|
|
});
|
|
const settings = {
|
|
...defaultSettings(),
|
|
...settingsPatch,
|
|
providerOrder: [] as const,
|
|
providerPrimary: providerName,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const service = new DebridService(settings, { [callbackName]: providerCallback });
|
|
const controller = new AbortController();
|
|
const outcome = service.unrestrictLink("https://rapidgator.net/file/hanging-web-provider", controller.signal).then(
|
|
() => "fulfilled",
|
|
(error: unknown) => String(error)
|
|
);
|
|
|
|
await started;
|
|
controller.abort("pause");
|
|
const result = await Promise.race([
|
|
outcome,
|
|
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 100))
|
|
]);
|
|
|
|
expect(result).toMatch(/aborted/i);
|
|
});
|
|
|
|
it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
realDebridUseWebLogin: true,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-rd-web")).rejects.toThrow(/nicht konfiguriert/i);
|
|
});
|
|
|
|
it("falls back to API token when Real-Debrid web login is disabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
realDebridUseWebLogin: false,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response(JSON.stringify({
|
|
download: "https://download.real-debrid.com/d/test/file.rar",
|
|
filename: "file.rar",
|
|
filesize: 9999
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
})) as typeof fetch;
|
|
|
|
const realDebridWeb = vi.fn(async () => null);
|
|
const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/test.rar.html");
|
|
expect(result.provider).toBe("realdebrid");
|
|
expect(realDebridWeb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats MegaDebrid as not configured when no credentials are set", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaLogin: "",
|
|
megaPassword: "",
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
|
|
});
|
|
|
|
it("keeps dedicated Mega-Debrid pools disabled when both explicit mode flags are false", async () => {
|
|
const fetchSpy = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "disabled-api-token" }), { status: 200 });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({
|
|
response_code: "ok",
|
|
debridLink: "https://mega-cdn.example/disabled-api.rar",
|
|
filename: "disabled-api.rar"
|
|
}), { status: 200 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
});
|
|
globalThis.fetch = fetchSpy as typeof fetch;
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "disabled-web.rar",
|
|
directUrl: "https://mega-web.example/disabled-web.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
for (const preferApi of [true, false]) {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaLogin: "legacy-user",
|
|
megaPassword: "legacy-pass",
|
|
megaCredentials: "legacy-user:legacy-pass\napi-user:api-pass\nweb-user:web-pass",
|
|
megaDebridApiCredentials: "api-user:api-pass",
|
|
megaDebridWebCredentials: "web-user:web-pass",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: false,
|
|
megaDebridPreferApi: preferApi,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink(`https://rapidgator.net/file/dedicated-disabled-${preferApi}`)).rejects.toThrow(/nicht konfiguriert/i);
|
|
}
|
|
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(megaWeb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the preferred API fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaLogin: "legacy-api-user",
|
|
megaPassword: "legacy-api-pass",
|
|
megaCredentials: "legacy-api-user:legacy-api-pass",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: false,
|
|
megaDebridPreferApi: true,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
|
|
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "legacy-api-token" }), { status: 200 });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({
|
|
response_code: "ok",
|
|
debridLink: "https://mega-cdn.example/legacy-api.rar",
|
|
filename: "legacy-api.rar"
|
|
}), { status: 200 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-api");
|
|
expect(result.directUrl).toBe("https://mega-cdn.example/legacy-api.rar");
|
|
});
|
|
|
|
it("keeps the preferred Web fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaLogin: "legacy-web-user",
|
|
megaPassword: "legacy-web-pass",
|
|
megaCredentials: "legacy-web-user:legacy-web-pass",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: false,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
|
|
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "legacy-web.rar",
|
|
directUrl: "https://mega-web.example/legacy-web.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-web");
|
|
expect(result.directUrl).toBe("https://mega-web.example/legacy-web.rar");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reports each provider as soon as its conversion attempt starts", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerOrder: ["realdebrid", "megadebrid"] as const,
|
|
autoProviderFallback: true
|
|
};
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
return new Response(JSON.stringify({ error: "traffic_limit" }), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
const service = new DebridService(settings, {
|
|
megaWebUnrestrict: vi.fn(async () => ({
|
|
fileName: "file.bin",
|
|
directUrl: "https://mega-web.example/file.bin",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}))
|
|
});
|
|
const attempts: string[] = [];
|
|
|
|
await service.unrestrictLink(
|
|
"https://rapidgator.net/file/provider-attempts.rar.html",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
(provider) => attempts.push(provider)
|
|
);
|
|
|
|
expect(attempts).toEqual(["realdebrid", "megadebrid"]);
|
|
});
|
|
|
|
it("uses Mega web fallback when API fails", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "megadebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "from-web.rar",
|
|
directUrl: "https://www11.unrestrict.link/download/file/abc/from-web.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/abc/from-web.rar.html");
|
|
expect(result.provider).toBe("megadebrid");
|
|
expect(result.directUrl).toContain("unrestrict.link/download/file/");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not fallback from Mega API to Mega Web unless Mega Web is a separate provider in the order", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: true,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("not-found", { status: 404 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "should-not-run.rar",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/mega-api-only.rar.html")).rejects.toThrow(/mega-debrid api/i);
|
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("isolates Mega-Debrid API single-flight consumers so one abort does not cancel the shared connect", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaCredentials: "single-flight-user:single-flight-pass",
|
|
megaDebridApiCredentials: "single-flight-user:single-flight-pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
let releaseConnect: () => void = () => {};
|
|
let markConnectStarted: () => void = () => {};
|
|
const connectStarted = new Promise<void>((resolve) => {
|
|
markConnectStarted = resolve;
|
|
});
|
|
let connectSignal: AbortSignal | undefined;
|
|
let connectCalls = 0;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
connectCalls += 1;
|
|
connectSignal = init?.signal as AbortSignal | undefined;
|
|
markConnectStarted();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onAbort = (): void => reject(new Error("shared connect aborted"));
|
|
releaseConnect = (): void => {
|
|
connectSignal?.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
};
|
|
if (connectSignal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
connectSignal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "shared-token" }), { status: 200 });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({
|
|
response_code: "ok",
|
|
debridLink: "https://mega-cdn.example/survivor.rar",
|
|
filename: "survivor.rar"
|
|
}), { status: 200 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const firstController = new AbortController();
|
|
const secondController = new AbortController();
|
|
const first = service.unrestrictLink("https://rapidgator.net/file/first-consumer", firstController.signal);
|
|
await connectStarted;
|
|
const second = service.unrestrictLink("https://rapidgator.net/file/second-consumer", secondController.signal);
|
|
const firstOutcome = first.then(() => "fulfilled", () => "rejected");
|
|
const secondOutcome = second.then(
|
|
(value) => ({ status: "fulfilled" as const, value }),
|
|
(error: unknown) => ({ status: "rejected" as const, error })
|
|
);
|
|
|
|
try {
|
|
firstController.abort("cancel-first-consumer");
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
const firstState = await Promise.race([
|
|
firstOutcome,
|
|
new Promise<"pending">((resolve) => {
|
|
timeout = setTimeout(() => resolve("pending"), 100);
|
|
})
|
|
]);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
expect(firstState).toBe("rejected");
|
|
expect(connectSignal?.aborted).toBe(false);
|
|
expect(secondController.signal.aborted).toBe(false);
|
|
|
|
releaseConnect();
|
|
const survivor = await secondOutcome;
|
|
expect(survivor.status).toBe("fulfilled");
|
|
if (survivor.status === "fulfilled") {
|
|
expect(survivor.value.directUrl).toBe("https://mega-cdn.example/survivor.rar");
|
|
}
|
|
expect(connectCalls).toBe(1);
|
|
} finally {
|
|
releaseConnect();
|
|
await Promise.all([firstOutcome, secondOutcome]);
|
|
}
|
|
});
|
|
|
|
it("releases Mega-Debrid Web in-flight state when the provider ignores caller abort", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaCredentials: "ignored-web-user:ignored-web-pass",
|
|
megaDebridApiCredentials: "",
|
|
megaDebridWebCredentials: "ignored-web-user:ignored-web-pass",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: true,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-web" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
let markProviderStarted: () => void = () => {};
|
|
const providerStarted = new Promise<void>((resolve) => {
|
|
markProviderStarted = resolve;
|
|
});
|
|
let rejectProvider: (reason?: unknown) => void = () => {};
|
|
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
|
|
rejectProvider = reject;
|
|
});
|
|
const megaWeb = vi.fn(() => {
|
|
markProviderStarted();
|
|
return ignoredProviderPromise;
|
|
});
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const controller = new AbortController();
|
|
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-abort", controller.signal);
|
|
const outcome = request.then(
|
|
() => ({ status: "fulfilled" as const, error: null }),
|
|
(error: unknown) => ({ status: "rejected" as const, error })
|
|
);
|
|
await providerStarted;
|
|
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
|
|
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
controller.abort("settings_refresh");
|
|
const settled = await Promise.race([
|
|
outcome,
|
|
new Promise<null>((resolve) => {
|
|
timeout = setTimeout(() => resolve(null), 100);
|
|
})
|
|
]);
|
|
expect(settled).not.toBeNull();
|
|
expect(settled?.status).toBe("rejected");
|
|
expect(String(settled?.error)).toMatch(/aborted/i);
|
|
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
rejectProvider(new Error("late Mega-Web provider failure"));
|
|
await outcome;
|
|
}
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
|
});
|
|
|
|
it("releases Mega-Debrid Web in-flight state when the provider ignores the account timeout", async () => {
|
|
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaCredentials: "ignored-timeout-user:ignored-timeout-pass",
|
|
megaDebridApiCredentials: "",
|
|
megaDebridWebCredentials: "ignored-timeout-user:ignored-timeout-pass",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: true,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-web" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
let markProviderStarted: () => void = () => {};
|
|
const providerStarted = new Promise<void>((resolve) => {
|
|
markProviderStarted = resolve;
|
|
});
|
|
let rejectProvider: (reason?: unknown) => void = () => {};
|
|
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
|
|
rejectProvider = reject;
|
|
});
|
|
const megaWeb = vi.fn(() => {
|
|
markProviderStarted();
|
|
return ignoredProviderPromise;
|
|
});
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-timeout");
|
|
const outcome = request.then(
|
|
() => ({ status: "fulfilled" as const, error: null }),
|
|
(error: unknown) => ({ status: "rejected" as const, error })
|
|
);
|
|
await providerStarted;
|
|
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
|
|
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const settled = await Promise.race([
|
|
outcome,
|
|
new Promise<null>((resolve) => {
|
|
timeout = setTimeout(() => resolve(null), 200);
|
|
})
|
|
]);
|
|
expect(settled).not.toBeNull();
|
|
expect(settled?.status).toBe("rejected");
|
|
expect(String(settled?.error)).toMatch(/mega_debrid_slow_link|aborted/i);
|
|
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
rejectProvider(new Error("late Mega-Web timeout failure"));
|
|
await outcome;
|
|
}
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
|
});
|
|
|
|
it("releases Mega-Debrid API in-flight state when getLink ignores caller abort", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaCredentials: "ignored-api-user:ignored-api-pass",
|
|
megaDebridApiCredentials: "ignored-api-user:ignored-api-pass",
|
|
megaDebridWebCredentials: "",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
let markGetLinkStarted: () => void = () => {};
|
|
const getLinkStarted = new Promise<void>((resolve) => {
|
|
markGetLinkStarted = resolve;
|
|
});
|
|
let rejectGetLink: (reason?: unknown) => void = () => {};
|
|
const ignoredGetLinkPromise = new Promise<Response>((_resolve, reject) => {
|
|
rejectGetLink = reject;
|
|
});
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "ignored-api-token" }), { status: 200 });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
markGetLinkStarted();
|
|
return ignoredGetLinkPromise;
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
const service = new DebridService(settings);
|
|
const controller = new AbortController();
|
|
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-api-abort", controller.signal);
|
|
const outcome = request.then(
|
|
() => ({ status: "fulfilled" as const, error: null }),
|
|
(error: unknown) => ({ status: "rejected" as const, error })
|
|
);
|
|
await getLinkStarted;
|
|
expect(getMegaDebridInFlightCountForMode("api")).toBe(1);
|
|
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
controller.abort("reset");
|
|
const settled = await Promise.race([
|
|
outcome,
|
|
new Promise<null>((resolve) => {
|
|
timeout = setTimeout(() => resolve(null), 100);
|
|
})
|
|
]);
|
|
expect(settled).not.toBeNull();
|
|
expect(settled?.status).toBe("rejected");
|
|
expect(String(settled?.error)).toMatch(/aborted/i);
|
|
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
rejectGetLink(new Error("late Mega-Debrid API provider failure"));
|
|
await outcome;
|
|
}
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
|
|
});
|
|
|
|
it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => {
|
|
const oldSettings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaCredentials: "generation-user:old-pass",
|
|
megaDebridApiCredentials: "generation-user:old-pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const newSettings = {
|
|
...oldSettings,
|
|
megaCredentials: "generation-user:new-pass",
|
|
megaDebridApiCredentials: "generation-user:new-pass"
|
|
};
|
|
let releaseOldConnect: () => void = () => {};
|
|
let markOldConnectStarted: () => void = () => {};
|
|
const oldConnectStarted = new Promise<void>((resolve) => {
|
|
markOldConnectStarted = resolve;
|
|
});
|
|
const connectPasswords: string[] = [];
|
|
const getLinkTokens: string[] = [];
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
const parsed = new URL(url);
|
|
const password = parsed.searchParams.get("password") || "";
|
|
connectPasswords.push(password);
|
|
if (password === "old-pass") {
|
|
markOldConnectStarted();
|
|
await new Promise<void>((resolve) => {
|
|
releaseOldConnect = resolve;
|
|
});
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "old-token" }), { status: 200 });
|
|
}
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "new-token" }), { status: 200 });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
const token = new URL(url).searchParams.get("token") || "";
|
|
getLinkTokens.push(token);
|
|
return new Response(JSON.stringify({
|
|
response_code: "ok",
|
|
debridLink: `https://mega-cdn.example/${token}.rar`,
|
|
filename: `${token}.rar`
|
|
}), { status: 200 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(oldSettings);
|
|
const oldRequest = service.unrestrictLink("https://rapidgator.net/file/old-credentials");
|
|
const oldOutcome = oldRequest.then(
|
|
(value) => ({ status: "fulfilled" as const, value }),
|
|
(error: unknown) => ({ status: "rejected" as const, error })
|
|
);
|
|
await oldConnectStarted;
|
|
|
|
try {
|
|
service.setSettings(newSettings);
|
|
const current = await service.unrestrictLink("https://rapidgator.net/file/new-credentials");
|
|
expect(current.directUrl).toBe("https://mega-cdn.example/new-token.rar");
|
|
|
|
releaseOldConnect();
|
|
await oldOutcome;
|
|
|
|
const subsequent = await service.unrestrictLink("https://rapidgator.net/file/subsequent");
|
|
expect(subsequent.directUrl).toBe("https://mega-cdn.example/new-token.rar");
|
|
expect(connectPasswords).toEqual(["old-pass", "new-pass"]);
|
|
expect(getLinkTokens.at(-1)).toBe("new-token");
|
|
} finally {
|
|
releaseOldConnect();
|
|
await oldOutcome;
|
|
}
|
|
});
|
|
|
|
it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "error", response_text: "Fichier supprimé chez l'hébergeur" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/maybe-dead.rar.html").then(() => null, (e: unknown) => e);
|
|
expect(err).toBeTruthy();
|
|
expect(String(err)).toMatch(/nicht abrufbar/i);
|
|
expect(String(err)).not.toMatch(/supprim/i);
|
|
expect(getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`)).toBeNull();
|
|
});
|
|
|
|
it("does NOT slap a 60-minute 'invalid' cooldown on a working account when the API returns no result (transient)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "ok" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/no-result.rar.html").then(() => null, (e: unknown) => e);
|
|
expect(err).toBeTruthy();
|
|
expect(String(err)).not.toMatch(/Login oder Unrestrict/i);
|
|
|
|
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
if (cooldown) {
|
|
expect(cooldown.category).not.toBe("invalid");
|
|
expect(cooldown.remainingMs).toBeLessThan(5 * 60 * 1000);
|
|
}
|
|
});
|
|
|
|
it("treats a Mega-Debrid API 'Token error, please log-in' as a short transient cooldown, not invalid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "error_token", response_text: "Token error, please log-in" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await service.unrestrictLink("https://rapidgator.net/file/token-collision.rar.html").then(() => null, (e: unknown) => e);
|
|
|
|
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
if (cooldown) {
|
|
expect(cooldown.category).not.toBe("invalid");
|
|
expect(cooldown.remainingMs).toBeLessThan(60 * 1000);
|
|
}
|
|
});
|
|
|
|
it("categorizes a Mega-Debrid 'rate limit' error as rate_limit, not quota (regex ordering)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "error", response_text: "Rate limit exceeded, too many requests" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await service.unrestrictLink("https://rapidgator.net/file/rl.rar.html").then(() => null, (e: unknown) => e);
|
|
|
|
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
expect(cooldown).not.toBeNull();
|
|
expect(cooldown!.category).toBe("rate_limit");
|
|
});
|
|
|
|
it("does not fall through a failed 1fichier link to the provider chain when autoProviderFallback is off", async () => {
|
|
let megaGetLinkCalled = false;
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
oneFichierApiKey: "1f-key",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
megaDebridPreferApi: true,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.1fichier.com")) {
|
|
return new Response(JSON.stringify({ status: "KO", message: "not available" }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
megaGetLinkCalled = true;
|
|
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/file.rar", filename: "file.rar" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://1fichier.com/?abc12345xyz").then((r) => ({ ok: true, r }), (e: unknown) => ({ ok: false, e }));
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(megaGetLinkCalled).toBe(false);
|
|
});
|
|
|
|
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiCredentials: "user:pass",
|
|
megaDebridWebCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: true,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "megadebrid-web" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("not-found", { status: 404 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "from-separate-web.rar",
|
|
directUrl: "https://mega-web.example/from-separate-web.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/from-separate-web.rar.html");
|
|
expect(result.provider).toBe("megadebrid-web");
|
|
expect(result.directUrl).toBe("https://mega-web.example/from-separate-web.rar");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("aborts Mega web unrestrict when caller signal is cancelled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => {
|
|
const onAbort = (): void => reject(new Error("aborted:mega-web-test"));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const controller = new AbortController();
|
|
const abortTimer = setTimeout(() => {
|
|
controller.abort("test");
|
|
}, 200);
|
|
|
|
try {
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
clearTimeout(abortTimer);
|
|
}
|
|
});
|
|
|
|
it("bleibt klebrig bei einem funktionierenden Account (kein Account-Wechsel pro Link)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const usedIds: (string | undefined)[] = [];
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/sticky-${i}`);
|
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
|
}
|
|
|
|
expect(usedIds).toEqual(new Array(5).fill(getMegaDebridAccountId("user1")));
|
|
}, 30000);
|
|
|
|
it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const usedIds: (string | undefined)[] = [];
|
|
for (let i = 0; i < MEGA_DEBRID_STICKY_LINKS + 1; i += 1) {
|
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/chunk-${i}`);
|
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
|
}
|
|
|
|
expect(usedIds.slice(0, MEGA_DEBRID_STICKY_LINKS)).toEqual(new Array(MEGA_DEBRID_STICKY_LINKS).fill(getMegaDebridAccountId("user1")));
|
|
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
|
|
}, 30000);
|
|
|
|
it("keeps the Mega-Debrid Web cursor independent from API rotation", async () => {
|
|
const apiSettings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2",
|
|
megaDebridApiCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2",
|
|
megaDebridWebCredentials: "",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = String(input);
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "cursor-api-token" }), { status: 200 });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/cursor.rar", filename: "cursor.rar" }), { status: 200 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const apiService = new DebridService(apiSettings);
|
|
for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS; index += 1) {
|
|
await apiService.unrestrictLink(`https://rapidgator.net/file/api-cursor-${index}`);
|
|
}
|
|
|
|
const webLogins: string[] = [];
|
|
const webSettings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2",
|
|
megaDebridApiCredentials: "",
|
|
megaDebridWebCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: true,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid-web" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const webService = new DebridService(webSettings, {
|
|
megaWebUnrestrict: async (_link, _signal, account) => {
|
|
webLogins.push(account?.login || "");
|
|
return { fileName: "web-cursor.rar", directUrl: "https://mega-web.example/web-cursor.rar", fileSize: null, retriesUsed: 0 };
|
|
}
|
|
});
|
|
|
|
await webService.unrestrictLink("https://rapidgator.net/file/web-cursor");
|
|
|
|
expect(webLogins).toEqual(["cursor-web-1"]);
|
|
}, 30000);
|
|
|
|
it("keeps the Mega-Debrid Web sticky counter independent from API successes", async () => {
|
|
const apiSettings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2",
|
|
megaDebridApiCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2",
|
|
megaDebridWebCredentials: "",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = String(input);
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "sticky-api-token" }), { status: 200 });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/sticky.rar", filename: "sticky.rar" }), { status: 200 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const apiService = new DebridService(apiSettings);
|
|
for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS - 1; index += 1) {
|
|
await apiService.unrestrictLink(`https://rapidgator.net/file/api-sticky-${index}`);
|
|
}
|
|
|
|
const webLogins: string[] = [];
|
|
const webSettings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2",
|
|
megaDebridApiCredentials: "",
|
|
megaDebridWebCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2",
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: true,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid-web" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const webService = new DebridService(webSettings, {
|
|
megaWebUnrestrict: async (_link, _signal, account) => {
|
|
webLogins.push(account?.login || "");
|
|
return { fileName: "web-sticky.rar", directUrl: "https://mega-web.example/web-sticky.rar", fileSize: null, retriesUsed: 0 };
|
|
}
|
|
});
|
|
|
|
await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-1");
|
|
await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-2");
|
|
|
|
expect(webLogins).toEqual(["sticky-web-1", "sticky-web-1"]);
|
|
}, 30000);
|
|
|
|
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
|
|
|
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user1")}:web`);
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const usedIds: (string | undefined)[] = [];
|
|
for (let i = 0; i < 3; i += 1) {
|
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/skip-${i}`);
|
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
|
}
|
|
|
|
expect(usedIds).toEqual(new Array(3).fill(getMegaDebridAccountId("user2")));
|
|
}, 30000);
|
|
|
|
it("verteilt gleichzeitige Umwandlungen auf verschiedene Accounts (parallel, in-flight-Routing)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const accountsSeen = new Set<string>();
|
|
const allInFlight = new Promise<void>((resolve) => {
|
|
const check = setInterval(() => { if (active >= 4) { clearInterval(check); resolve(); } }, 5);
|
|
});
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
if (account) accountsSeen.add(account.login);
|
|
await allInFlight;
|
|
active -= 1;
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const results = await Promise.all([0, 1, 2, 3].map((i) => service.unrestrictLink(`https://rapidgator.net/file/conc-${i}`)));
|
|
|
|
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
|
|
expect(accountsSeen.size).toBe(4);
|
|
expect(maxActive).toBe(4);
|
|
}, 15000);
|
|
|
|
it("verteilt Ueberzahl-Umwandlungen (mehr gleichzeitig als Accounts) gleichmaessig statt sie zu stapeln", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let active = 0;
|
|
const callsPerAccount = new Map<string, number>();
|
|
const allInFlight = new Promise<void>((resolve) => {
|
|
const check = setInterval(() => { if (active >= 8) { clearInterval(check); resolve(); } }, 5);
|
|
});
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
active += 1;
|
|
if (account) callsPerAccount.set(account.login, (callsPerAccount.get(account.login) ?? 0) + 1);
|
|
await allInFlight;
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await Promise.all(Array.from({ length: 8 }, (_unused, i) => service.unrestrictLink(`https://rapidgator.net/file/ov-${i}`)));
|
|
|
|
// 8 gleichzeitige Aufloesungen auf 2 Accounts → 4 je Account (statt 7/1 beim alten belegt/frei-Set).
|
|
expect(callsPerAccount.get("user1")).toBe(4);
|
|
expect(callsPerAccount.get("user2")).toBe(4);
|
|
}, 15000);
|
|
|
|
it("faellt ein Account in der Mitte aus (1,2,4 ok, 3 nicht): parallel nur ueber die funktionierenden, alle Links loesen auf", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let active = 0;
|
|
const used = new Set<string>();
|
|
const threeInFlight = new Promise<void>((resolve) => {
|
|
const check = setInterval(() => { if (active >= 3) { clearInterval(check); resolve(); } }, 5);
|
|
});
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
active += 1;
|
|
if (account) used.add(account.login);
|
|
await threeInFlight;
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
// Account 3 ist ausgefallen (z.B. zuvor fehlgeschlagen -> gesperrt) und faellt aus der Rotation.
|
|
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user3")}:web`);
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const results = await Promise.all([0, 1, 2].map((i) => service.unrestrictLink(`https://rapidgator.net/file/dead3-${i}`)));
|
|
|
|
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
|
|
expect(used.has("user3")).toBe(false);
|
|
expect(used.has("user1")).toBe(true);
|
|
expect(used.has("user2")).toBe(true);
|
|
expect(used.has("user4")).toBe(true);
|
|
}, 15000);
|
|
|
|
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let webCalls = 0;
|
|
const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => {
|
|
webCalls += 1;
|
|
if (webCalls <= 3) {
|
|
throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)");
|
|
}
|
|
return {
|
|
fileName: "rotated-to-acc2.rar",
|
|
directUrl: "https://mega-web.example/rotated-to-acc2.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
};
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/limit-rotation-test");
|
|
|
|
expect(result.directUrl).toBe("https://mega-web.example/rotated-to-acc2.rar");
|
|
expect(webCalls).toBeGreaterThanOrEqual(4);
|
|
}, 30000);
|
|
|
|
it("skips a manually disabled Mega-Debrid account and uses the next one", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")],
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "from-acc2.rar",
|
|
directUrl: "https://mega-web.example/from-acc2.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/disabled-acc-test");
|
|
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
expect(result.directUrl).toBe("https://mega-web.example/from-acc2.rar");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
}, 20000);
|
|
|
|
it("fails fast on Mega-Debrid hoster quota ('Kein Server') and rotates to the next account", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let calls = 0;
|
|
const megaWeb = vi.fn(async () => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
|
|
}
|
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/quota-rotate-test");
|
|
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
expect(result.directUrl).toBe("https://mega-web.example/acc2.rar");
|
|
expect(calls).toBe(2);
|
|
}, 20000);
|
|
|
|
it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const accountsSeen: Array<string | undefined> = [];
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
accountsSeen.push(account?.login);
|
|
if (account?.login === "user1") {
|
|
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
|
|
}
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/per-account-creds");
|
|
|
|
expect(accountsSeen).toContain("user1");
|
|
expect(accountsSeen).toContain("user2");
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
|
|
}, 20000);
|
|
|
|
it("setzt KEINEN Account-Cooldown, wenn der Mega-Web-Abbruch nur ein Queue-Timeout war (Account war belegt, nicht ungesund)", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const controller = new AbortController();
|
|
let calls = 0;
|
|
const megaWeb = vi.fn(async () => {
|
|
calls += 1;
|
|
controller.abort("caller-timeout");
|
|
throw new Error("Mega-Web Queue-Timeout (abgebrochen nach 60s Wartezeit, Account war belegt)");
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(
|
|
service.unrestrictLink("https://rapidgator.net/file/queue-timeout-no-cooldown", controller.signal)
|
|
).rejects.toThrow();
|
|
|
|
const key = `${getMegaDebridAccountId("user")}:web`;
|
|
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
|
|
expect(calls).toBeGreaterThanOrEqual(1);
|
|
}, 20000);
|
|
|
|
it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => {
|
|
const accId = getMegaDebridAccountId("user");
|
|
const key = `${accId}:web`;
|
|
expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull();
|
|
|
|
primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s");
|
|
|
|
const snap = getProviderRuntimeSnapshot();
|
|
expect(typeof snap.capturedAtMs).toBe("number");
|
|
const acc = snap.megaDebrid.accounts.find((a) => a.key === key);
|
|
expect(acc).toBeTruthy();
|
|
expect(acc!.cooldown).not.toBeNull();
|
|
expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0);
|
|
expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000);
|
|
expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs);
|
|
expect(acc!.cooldown!.message).toContain("Abbruch");
|
|
});
|
|
|
|
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let calls = 0;
|
|
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
return new Promise((_resolve, reject) => {
|
|
const onAbort = (): void => reject(new Error("aborted"));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
fileName: "healthy.rar",
|
|
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
});
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e);
|
|
expect(err).toBeTruthy();
|
|
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
|
|
|
const key = `${getMegaDebridAccountId("user")}:web`;
|
|
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
|
|
|
|
const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html");
|
|
expect(second.provider).toBe("megadebrid");
|
|
expect(calls).toBeGreaterThanOrEqual(2);
|
|
}, 20000);
|
|
|
|
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
|
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
|
clearMegaDebridEmptyResponseStreak(key);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
|
});
|
|
|
|
it("self-heals a daily-limit park after the daily reset instead of staying locked until process restart", () => {
|
|
const key = `${getMegaDebridAccountId("user1")}:api`;
|
|
primeMegaDebridUntilRestartForTests(key);
|
|
const active = getMegaDebridAccountCooldownState(key);
|
|
expect(active?.untilRestart).toBe(true);
|
|
expect(getMegaDebridAccountCooldownState(key, Date.now() + 60_000)?.untilRestart).toBe(true);
|
|
const afterReset = Date.now() + 25 * 60 * 60 * 1000;
|
|
expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull();
|
|
});
|
|
|
|
it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => {
|
|
const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host"));
|
|
expect(noServer.limitSignal).toBeFalsy();
|
|
expect(noServer.category).toBe("quota");
|
|
expect(noServer.cooldownMs).toBeGreaterThan(0);
|
|
|
|
const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer"));
|
|
expect(genuineEmpty.limitSignal).toBe(true);
|
|
});
|
|
|
|
it("sanitizes provider-supplied account failures before they leave Mega-Debrid rotation", async () => {
|
|
const login = "private-user@example.test";
|
|
const password = "provider-password-secret";
|
|
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: login,
|
|
megaPassword: password,
|
|
megaCredentials: `${login}:${password}`,
|
|
megaDebridApiCredentials: "",
|
|
megaDebridWebCredentials: `${login}:${password}`,
|
|
megaDebridApiEnabled: false,
|
|
megaDebridWebEnabled: true,
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-web" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const megaWeb = vi.fn(async () => {
|
|
throw new Error(`Incorrect password for ${login} login=${login} password=${password} source=${sourceUrl}`);
|
|
});
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
|
|
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-error").then(() => null, (caught: unknown) => caught as Error);
|
|
const message = String(error?.message || error || "");
|
|
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId(login)}:web`);
|
|
expect(message).toContain("ungueltiger Account");
|
|
expect(message).toContain("Account 1/1");
|
|
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
|
expect(cooldown?.category).toBe("invalid");
|
|
for (const sensitive of [login, password, "source-user", "source-pass", "query-secret", sourceUrl]) {
|
|
expect(message).not.toContain(sensitive);
|
|
expect(cooldown?.message || "").not.toContain(sensitive);
|
|
}
|
|
expect(message).not.toContain("*");
|
|
});
|
|
|
|
it("sanitizes provider-supplied API key failures before they leave Debrid-Link rotation", async () => {
|
|
const apiKey = "provider-debrid-link-secret";
|
|
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
debridLinkApiKeys: apiKey,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badToken",
|
|
error_description: `Rejected api_key=${apiKey} source=${sourceUrl}`
|
|
}), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
})) as typeof fetch;
|
|
const service = new DebridService(settings);
|
|
|
|
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-key-error").then(() => null, (caught: unknown) => caught as Error);
|
|
const message = String(error?.message || error || "");
|
|
const keyId = parseDebridLinkApiKeys(apiKey)[0].id;
|
|
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
|
|
expect(message).toContain("ungueltiger oder deaktivierter API-Key");
|
|
expect(message).toContain("Key 1/1");
|
|
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
|
expect(getDebridLinkKeyRuntimeStateForTests(keyId)).toBe("invalid");
|
|
for (const sensitive of [apiKey, "source-user", "source-pass", "query-secret", sourceUrl]) {
|
|
expect(message).not.toContain(sensitive);
|
|
expect(cooldown?.message || "").not.toContain(sensitive);
|
|
}
|
|
expect(message).not.toContain("*");
|
|
});
|
|
|
|
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
|
|
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
|
|
expect(result.fatal).toBe(false);
|
|
expect(result.cooldownMs).toBe(0);
|
|
expect(result.limitSignal).toBeFalsy();
|
|
expect(isMegaDebridTransientResolveFailure(result.message)).toBe(true);
|
|
});
|
|
|
|
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const user1 = getMegaDebridAccountId("user1");
|
|
primeMegaDebridUntilRestartForTests(`${user1}:api`);
|
|
primeMegaDebridUntilRestartForTests(`${user1}:web`);
|
|
|
|
const loginsSeen: Array<string | undefined> = [];
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
loginsSeen.push(account?.login);
|
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test");
|
|
|
|
expect(loginsSeen).not.toContain("user1");
|
|
expect(loginsSeen).toContain("user2");
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
}, 20000);
|
|
|
|
it("emits an until-Tagesreset park token (so the manager parks, not 2min-retries) when ALL Mega-Debrid accounts are parked until restart", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
for (const login of ["user1", "user2"]) {
|
|
const id = getMegaDebridAccountId(login);
|
|
primeMegaDebridUntilRestartForTests(`${id}:api`);
|
|
primeMegaDebridUntilRestartForTests(`${id}:web`);
|
|
}
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/all-parked-test").then(() => null, (e: unknown) => e as Error);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err!.message).toMatch(/bis zum Tagesreset gesperrt/i);
|
|
expect(err!.message).toMatch(/mega_debrid_reset_park:\d+:/);
|
|
expect(megaWeb).not.toHaveBeenCalled();
|
|
}, 20000);
|
|
|
|
it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
|
for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) {
|
|
recordMegaDebridEmptyResponseStreak(key);
|
|
}
|
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
|
|
|
|
const megaWeb = vi.fn(async () => null);
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined);
|
|
|
|
expect(megaWeb).toHaveBeenCalled();
|
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
|
}, 20000);
|
|
|
|
it("rotates to the next Mega-Web account in the same unrestrict after an account timeout", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const loginsSeen: Array<string | undefined> = [];
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
loginsSeen.push(account?.login);
|
|
if (account?.login === "user1") {
|
|
throw new Error("aborted:debrid");
|
|
}
|
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
|
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-1");
|
|
expect(loginsSeen).toContain("user1");
|
|
expect(loginsSeen).toContain("user2");
|
|
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
}, 20000);
|
|
|
|
it("gives every Mega-Web account a fresh timeout budget after a queue timeout", async () => {
|
|
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const loginsSeen: string[] = [];
|
|
const accountSignals: AbortSignal[] = [];
|
|
const outerController = new AbortController();
|
|
const megaWeb = vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => {
|
|
loginsSeen.push(account?.login || "");
|
|
if (signal) {
|
|
accountSignals.push(signal);
|
|
}
|
|
if (account?.login === "user1") {
|
|
await new Promise<void>((_resolve, reject) => {
|
|
const onAbort = (): void => reject(new Error("aborted:debrid"));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
if (signal?.aborted) {
|
|
throw new Error("account 2 received an aborted signal");
|
|
}
|
|
return {
|
|
directUrl: "https://mega-web.example/fresh-account.rar",
|
|
fileName: "fresh-account.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
};
|
|
});
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/queue-timeout-rotation", outerController.signal);
|
|
|
|
expect(loginsSeen).toEqual(["user1", "user2"]);
|
|
expect(accountSignals).toHaveLength(2);
|
|
expect(accountSignals[0]).not.toBe(accountSignals[1]);
|
|
expect(accountSignals[0].aborted).toBe(true);
|
|
expect(accountSignals[1].aborted).toBe(false);
|
|
expect(outerController.signal.aborted).toBe(false);
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
}, 20000);
|
|
|
|
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); });
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
|
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
|
|
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
|
|
}, 20000);
|
|
|
|
it("respects provider selection and does not append hidden providers", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "ad-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "megadebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let allDebridCalls = 0;
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.alldebrid.com/v4/link/unlock")) {
|
|
allDebridCalls += 1;
|
|
return new Response(JSON.stringify({ status: "success", data: { link: "https://alldebrid.example/file.bin" } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => null);
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow();
|
|
expect(allDebridCalls).toBe(0);
|
|
});
|
|
|
|
it("does not use secondary provider when fallback is disabled and primary is missing", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "should-not-run.bin",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(/nicht konfiguriert/i);
|
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("allows disabling secondary and tertiary providers", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
return new Response(JSON.stringify({ error: "traffic_limit" }), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "unused.bin",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part6.rar.html")).rejects.toThrow();
|
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("resolves rapidgator filename from page when provider returns hash", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
return new Response(JSON.stringify({
|
|
download: "https://cdn.example/file.bin",
|
|
filename: "6f09df2984fe01378537c7cd8d7fa7ce",
|
|
filesize: 2048
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce")) {
|
|
return new Response("<html><head><title>download file Banshee.S04E01.German.DL.720p.part01.rar - Rapidgator</title></head></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce");
|
|
expect(result.provider).toBe("realdebrid");
|
|
expect(result.fileName).toBe("Banshee.S04E01.German.DL.720p.part01.rar");
|
|
});
|
|
|
|
it("resolves filenames for rg.to links", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: ""
|
|
};
|
|
|
|
const link = "https://rg.to/file/685cec6dcc1837dc725755fc9c726dd9";
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url === link) {
|
|
return new Response("<html><head><title>Download file Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar</title></head></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.get(link)).toBe("Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar");
|
|
});
|
|
|
|
it("does not unrestrict non-rapidgator links during filename scan", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true,
|
|
allDebridToken: ""
|
|
};
|
|
|
|
const linkFromPage = "https://rapidgator.net/file/11111111111111111111111111111111";
|
|
const linkFromProvider = "https://hoster.example/file/22222222222222222222222222222222";
|
|
let unrestrictCalls = 0;
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
|
|
if (url === linkFromPage) {
|
|
return new Response("<html><head><title>Download file from-page.part1.rar</title></head></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
const body = init?.body;
|
|
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
|
|
const linkValue = new URLSearchParams(bodyText).get("link") || "";
|
|
if (linkValue === linkFromProvider) {
|
|
return new Response(JSON.stringify({
|
|
download: "https://cdn.example/from-provider",
|
|
filename: "from-provider.part2.rar",
|
|
filesize: 1024
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
}
|
|
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const events: Array<{ link: string; fileName: string }> = [];
|
|
const resolved = await service.resolveFilenames([linkFromPage, linkFromProvider], (link, fileName) => {
|
|
events.push({ link, fileName });
|
|
});
|
|
|
|
expect(resolved.get(linkFromPage)).toBe("from-page.part1.rar");
|
|
expect(resolved.has(linkFromProvider)).toBe(false);
|
|
expect(unrestrictCalls).toBe(0);
|
|
expect(events).toEqual(expect.arrayContaining([
|
|
{ link: linkFromPage, fileName: "from-page.part1.rar" }
|
|
]));
|
|
});
|
|
|
|
it("does not unrestrict rapidgator links during filename scan after page lookup miss", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
allDebridToken: ""
|
|
};
|
|
|
|
const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
let unrestrictCalls = 0;
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(JSON.stringify({ error: "should-not-be-called" }), {
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url === link) {
|
|
return new Response("not found", { status: 404 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.size).toBe(0);
|
|
expect(unrestrictCalls).toBe(0);
|
|
});
|
|
|
|
it("maps AllDebrid filename infos by index when response link is missing", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "ad-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const linkA = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
const linkB = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.alldebrid.com/v4/link/infos")) {
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
infos: [
|
|
{ filename: "wrong-a.mkv" },
|
|
{ filename: "wrong-b.mkv" }
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url === linkA || url === linkB) {
|
|
return new Response("no title", { status: 404 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([linkA, linkB]);
|
|
expect(resolved.get(linkA)).toBe("wrong-a.mkv");
|
|
expect(resolved.get(linkB)).toBe("wrong-b.mkv");
|
|
expect(resolved.size).toBe(2);
|
|
});
|
|
|
|
it("retries AllDebrid filename infos after transient server error", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token"
|
|
};
|
|
|
|
const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
let infoCalls = 0;
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.alldebrid.com/v4/link/infos")) {
|
|
infoCalls += 1;
|
|
if (infoCalls === 1) {
|
|
return new Response("temporary error", { status: 500 });
|
|
}
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
infos: [
|
|
{ link, filename: "resolved-from-infos.mkv" }
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.get(link)).toBe("resolved-from-infos.mkv");
|
|
expect(infoCalls).toBe(2);
|
|
});
|
|
|
|
it("retries AllDebrid filename infos when HTML challenge is returned", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token"
|
|
};
|
|
|
|
const link = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
let infoCalls = 0;
|
|
let pageCalls = 0;
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("api.alldebrid.com/v4/link/infos")) {
|
|
infoCalls += 1;
|
|
return new Response("<html><title>cf challenge</title></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
if (url === link) {
|
|
pageCalls += 1;
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.size).toBe(0);
|
|
expect(infoCalls).toBe(REQUEST_RETRIES);
|
|
expect(pageCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("normalizeResolvedFilename", () => {
|
|
it("strips HTML entities", () => {
|
|
expect(normalizeResolvedFilename("Show.S01E01.German.DL.720p.part01.rar")).toBe("Show.S01E01.German.DL.720p.part01.rar");
|
|
expect(normalizeResolvedFilename("File&Name.part1.rar")).toBe("File&Name.part1.rar");
|
|
expect(normalizeResolvedFilename("File"Name".part1.rar")).toBe('File"Name".part1.rar');
|
|
});
|
|
|
|
it("strips HTML tags and collapses whitespace", () => {
|
|
const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar");
|
|
expect(result).toBe("Show.S01E01 .part01.rar");
|
|
|
|
const entityTagResult = normalizeResolvedFilename("File<Tag>.part1.rar");
|
|
expect(entityTagResult).toBe("File .part1.rar");
|
|
});
|
|
|
|
it("strips 'download file' prefix", () => {
|
|
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar")).toBe("Show.S01E01.part01.rar");
|
|
expect(normalizeResolvedFilename("download file Movie.2024.mkv")).toBe("Movie.2024.mkv");
|
|
});
|
|
|
|
it("strips Rapidgator suffix", () => {
|
|
expect(normalizeResolvedFilename("Show.S01E01.part01.rar - Rapidgator")).toBe("Show.S01E01.part01.rar");
|
|
expect(normalizeResolvedFilename("Movie.mkv | Rapidgator.net")).toBe("Movie.mkv");
|
|
});
|
|
|
|
it("returns empty for opaque or non-filename values", () => {
|
|
expect(normalizeResolvedFilename("")).toBe("");
|
|
expect(normalizeResolvedFilename("just some text")).toBe("");
|
|
expect(normalizeResolvedFilename("e51f6809bb6ca615601f5ac5db433737")).toBe("");
|
|
expect(normalizeResolvedFilename("download.bin")).toBe("");
|
|
});
|
|
|
|
it("handles combined transforms", () => {
|
|
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
|
|
.toBe("Show.S01E01.part01.rar");
|
|
});
|
|
});
|
|
|
|
describe("parseRapidgatorFileSize", () => {
|
|
it("converts hoster size labels to bytes", () => {
|
|
expect(parseRapidgatorFileSize("1.50 GB")).toBe(1_610_612_736);
|
|
expect(parseRapidgatorFileSize("658,25 MB")).toBe(690_225_152);
|
|
expect(parseRapidgatorFileSize("1024 B")).toBe(1024);
|
|
});
|
|
|
|
it("rejects missing and malformed values", () => {
|
|
expect(parseRapidgatorFileSize(null)).toBeNull();
|
|
expect(parseRapidgatorFileSize("unknown")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("checkRapidgatorOnline", () => {
|
|
it("loads metadata directly without waiting for a separate HEAD request", async () => {
|
|
const methods: string[] = [];
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
methods.push(String(init?.method || "GET"));
|
|
return new Response('<html><title>episode.part01.rar</title><div>File size: <strong>1.50 GB</strong></div></html>', { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
const result = await checkRapidgatorOnline("https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
|
|
expect(methods).toEqual(["GET"]);
|
|
expect(result).toEqual({
|
|
online: true,
|
|
fileName: "episode.part01.rar",
|
|
fileSizeBytes: 1_610_612_736
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("filenameFromRapidgatorUrlPath", () => {
|
|
it("extracts filename from standard rapidgator URL", () => {
|
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
|
.toBe("Show.S01E01.part01.rar");
|
|
});
|
|
|
|
it("extracts filename without .html suffix", () => {
|
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Movie.2024.mkv"))
|
|
.toBe("Movie.2024.mkv");
|
|
});
|
|
|
|
it("returns empty for hash-only URL paths", () => {
|
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/e51f6809bb6ca615601f5ac5db433737"))
|
|
.toBe("");
|
|
});
|
|
|
|
it("returns empty for invalid URLs", () => {
|
|
expect(filenameFromRapidgatorUrlPath("not-a-url")).toBe("");
|
|
expect(filenameFromRapidgatorUrlPath("")).toBe("");
|
|
});
|
|
|
|
it("handles URL-encoded path segments", () => {
|
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/id/Show%20Name.S01E01.part01.rar.html"))
|
|
.toBe("Show Name.S01E01.part01.rar");
|
|
});
|
|
});
|
|
|
|
describe("extractRapidgatorFilenameFromHtml", () => {
|
|
it("extracts filename from title tag", () => {
|
|
const html = "<html><head><title>Download file Show.S01E01.German.DL.720p.part01.rar - Rapidgator</title></head></html>";
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S01E01.German.DL.720p.part01.rar");
|
|
});
|
|
|
|
it("extracts filename from og:title meta tag", () => {
|
|
const html = '<html><head><meta property="og:title" content="Movie.2024.German.DL.1080p.mkv"></head></html>';
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv");
|
|
});
|
|
|
|
it("extracts filename from reversed og:title attribute order", () => {
|
|
const html = '<html><head><meta content="Movie.2024.German.DL.1080p.mkv" property="og:title"></head></html>';
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv");
|
|
});
|
|
|
|
it("returns empty for HTML without recognizable filenames", () => {
|
|
const html = "<html><head><title>Rapidgator: Fast, Pair and Unlimited</title></head><body>No file here</body></html>";
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("");
|
|
});
|
|
|
|
it("returns empty for empty HTML", () => {
|
|
expect(extractRapidgatorFilenameFromHtml("")).toBe("");
|
|
});
|
|
|
|
it("ignores broad body text that is not a labeled filename", () => {
|
|
const html = "<html><body>Please download file now from mirror.mkv</body></html>";
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("");
|
|
});
|
|
|
|
it("extracts from File name label in page body", () => {
|
|
const html = '<html><body>File name: <b>Show.S02E03.720p.part01.rar</b></body></html>';
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar");
|
|
});
|
|
});
|