157 lines
12 KiB
TypeScript
157 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { defaultSettings } from "../src/main/constants";
|
|
import { DebridService, MEGA_DEBRID_STICKY_LINKS, primeMegaDebridRuntimeCooldownForTests, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
|
|
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|
import type { AppSettings } from "../src/shared/types";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); resetDebridLinkRuntimeStateForTests(); resetMegaDebridRuntimeStateForTests(); resetRealDebridRuntimeStateForTests(); });
|
|
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json" } });
|
|
const result = { fileName: "fixture.rar", directUrl: "https://download.example.test/fixture.rar", fileSize: 1234, retriesUsed: 0 };
|
|
const rdSettings = (): AppSettings => ({ ...defaultSettings(), token: "", realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_first", token: "first" }, { id: "rda_second", token: "second" }, { id: "rda_third", token: "third" }]), providerOrder: ["realdebrid"], providerPrimary: "realdebrid", autoProviderFallback: false, accountUsageRules: { realdebrid: { mode: "priority", accountIds: ["rda_second", "rda_first", "rda_third"] } } });
|
|
|
|
describe("fixed account priority routing", () => {
|
|
it("reserves one Mega-Debrid conversion per account, waits when full and cancels waiting work", async () => {
|
|
const first = getMegaDebridAccountId("first-slot");
|
|
const second = getMegaDebridAccountId("second-slot");
|
|
const settings: AppSettings = { ...defaultSettings(), megaDebridWebCredentials: "first-slot:pass-one\nsecond-slot:pass-two", megaDebridApiCredentials: "", megaDebridWebEnabled: true, megaDebridApiEnabled: false, providerOrder: ["megadebrid-web"], providerPrimary: "megadebrid-web", autoProviderFallback: false, accountUsageRules: { "megadebrid-web": { mode: "priority", accountIds: [second, first] } } };
|
|
const completions: Array<() => void> = [];
|
|
const web = vi.fn(() => new Promise<typeof result>((resolve) => completions.push(() => resolve(result))));
|
|
const service = new DebridService(settings, { megaWebUnrestrict: web });
|
|
const one = service.unrestrictLink("https://hoster.example/one.rar");
|
|
const two = service.unrestrictLink("https://hoster.example/two.rar");
|
|
const controller = new AbortController();
|
|
const waiting = service.unrestrictLink("https://hoster.example/wait.rar", controller.signal);
|
|
const cancelled = expect(waiting).rejects.toThrow(/aborted/i);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(web).toHaveBeenCalledTimes(2);
|
|
controller.abort();
|
|
await cancelled;
|
|
const queued = service.unrestrictLink("https://hoster.example/queued.rar");
|
|
completions[0]();
|
|
expect((await one).sourceAccountId).toBe(second);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(web).toHaveBeenCalledTimes(3);
|
|
completions[2]();
|
|
expect((await queued).sourceAccountId).toBe(second);
|
|
completions[1]();
|
|
expect((await two).sourceAccountId).toBe(first);
|
|
const next = service.unrestrictLink("https://hoster.example/next.rar");
|
|
completions.at(-1)!();
|
|
expect((await next).sourceAccountId).toBe(second);
|
|
});
|
|
it("applies an independent Mega-Debrid API order", async () => {
|
|
const first = getMegaDebridAccountId("first-api");
|
|
const second = getMegaDebridAccountId("second-api");
|
|
const settings: AppSettings = { ...defaultSettings(), megaDebridApiCredentials: "first-api:pass-one\nsecond-api:pass-two", megaDebridWebCredentials: "", megaDebridApiEnabled: true, megaDebridWebEnabled: false, providerOrder: ["megadebrid-api"], providerPrimary: "megadebrid-api", autoProviderFallback: false, accountUsageRules: { "megadebrid-api": { mode: "priority", accountIds: [second, first] }, "megadebrid-web": { mode: "priority", accountIds: [first, second] } } };
|
|
globalThis.fetch = vi.fn(async (input) => String(input).includes("action=connectUser")
|
|
? json({ response_code: "ok", token: "fixture-api-session", vip_end: Math.floor(Date.now() / 1000) + 999999 })
|
|
: json({ response_code: "ok", debridLink: result.directUrl, filename: result.fileName }));
|
|
expect((await new DebridService(settings).unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(second);
|
|
});
|
|
|
|
it("can prioritize a Real-Debrid web account ahead of API accounts", async () => {
|
|
const settings = rdSettings();
|
|
settings.realDebridWebAccountIds = ["rdw_priority"];
|
|
settings.realDebridUseWebLogin = true;
|
|
settings.accountUsageRules.realdebrid!.accountIds = ["rdw_priority", "rda_second", "rda_first", "rda_third"];
|
|
globalThis.fetch = vi.fn(async () => { throw new Error("API must not be used"); });
|
|
const web = vi.fn(async () => result);
|
|
expect((await new DebridService(settings, { realDebridWebUnrestrict: web }).unrestrictLink("https://hoster.example/web.rar")).sourceAccountId).toBe("rdw_priority");
|
|
expect(web).toHaveBeenCalledTimes(1);
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([false, true])("exhausts the preferred provider's accounts before provider fallback=%s", async (autoProviderFallback) => {
|
|
const settings = rdSettings();
|
|
settings.providerOrder = ["realdebrid", "debridlink"];
|
|
settings.debridLinkApiKeys = "fixture-fallback-key";
|
|
settings.autoProviderFallback = autoProviderFallback;
|
|
const used: string[] = [];
|
|
globalThis.fetch = vi.fn(async (input, init) => {
|
|
used.push(new Headers(init?.headers).get("Authorization")!);
|
|
return String(input).includes("real-debrid") ? json({ error: "bad_token", error_code: 8 }, 401) : json({ success: true, value: { downloadUrl: result.directUrl, name: result.fileName, size: 1234 } });
|
|
});
|
|
const request = new DebridService(settings).unrestrictLink("https://hoster.example/file.rar");
|
|
if (autoProviderFallback) expect((await request).provider).toBe("debridlink");
|
|
else await expect(request).rejects.toThrow();
|
|
expect(used).toEqual(["Bearer second", "Bearer first", "Bearer third", ...(autoProviderFallback ? ["Bearer fixture-fallback-key"] : [])]);
|
|
});
|
|
it("keeps Real-Debrid priority beyond sticky rotation and through parallel requests", async () => {
|
|
const tokens: string[] = [];
|
|
globalThis.fetch = vi.fn(async (_input, init) => {
|
|
tokens.push(new Headers(init?.headers).get("Authorization")!);
|
|
return json({ download: result.directUrl, filename: result.fileName, filesize: 1234 });
|
|
});
|
|
const service = new DebridService(rdSettings());
|
|
for (let i = 0; i < 8; i++) expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_second");
|
|
await Promise.all([1, 2, 3].map(() => service.unrestrictLink("https://hoster.example/file.rar")));
|
|
expect(tokens).toEqual(Array(11).fill("Bearer second"));
|
|
});
|
|
|
|
it("skips Real-Debrid cooldown, returns to the preferred account after expiry, and stops on link-wide errors", async () => {
|
|
let fail = true;
|
|
let offline = false;
|
|
const used: string[] = [];
|
|
globalThis.fetch = vi.fn(async (_input, init) => {
|
|
const token = new Headers(init?.headers).get("Authorization")!;
|
|
used.push(token);
|
|
if (offline) return json({ error: "hoster_unavailable", error_code: 9 }, 503);
|
|
if (fail && token === "Bearer second") return json({ error: "too_many_active_downloads", error_code: 20 }, 403);
|
|
return json({ download: result.directUrl, filename: result.fileName, filesize: 1234 });
|
|
});
|
|
const service = new DebridService(rdSettings());
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_first");
|
|
fail = false;
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_first");
|
|
const now = Date.now();
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 2 * 60 * 60 * 1000);
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_second");
|
|
offline = true;
|
|
used.length = 0;
|
|
await expect(service.unrestrictLink("https://hoster.example/offline.rar")).rejects.toThrow();
|
|
expect(new Set(used)).toEqual(new Set(["Bearer second"]));
|
|
});
|
|
|
|
it("skips disabled and daily-limited Real-Debrid accounts before calling the provider", async () => {
|
|
const settings = rdSettings();
|
|
settings.realDebridDisabledAccountIds = ["rda_second"];
|
|
settings.realDebridAccountDailyLimitBytes = { rda_first: 100 };
|
|
settings.realDebridAccountDailyUsageBytes = { rda_first: 100 };
|
|
settings.providerDailyUsageDay = getProviderUsageDayKey();
|
|
globalThis.fetch = vi.fn(async () => json({ download: result.directUrl, filename: result.fileName, filesize: 1234 }));
|
|
expect((await new DebridService(settings).unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe("rda_third");
|
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps Mega-Debrid Web priority beyond the automatic rotation threshold and restores it after cooldown", async () => {
|
|
const first = getMegaDebridAccountId("first");
|
|
const second = getMegaDebridAccountId("second");
|
|
const settings: AppSettings = { ...defaultSettings(), megaDebridWebCredentials: "first:pass-one\nsecond:pass-two", megaDebridApiCredentials: "", megaDebridWebEnabled: true, megaDebridApiEnabled: false, providerOrder: ["megadebrid-web"], providerPrimary: "megadebrid-web", autoProviderFallback: false, accountUsageRules: { "megadebrid-web": { mode: "priority", accountIds: [second, first] } } };
|
|
const service = new DebridService(settings, { megaWebUnrestrict: vi.fn(async () => result) });
|
|
for (let i = 0; i <= MEGA_DEBRID_STICKY_LINKS; i++) expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(second);
|
|
primeMegaDebridRuntimeCooldownForTests(`${second}:web`, 60000);
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(first);
|
|
const now = Date.now();
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 120000);
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(second);
|
|
});
|
|
|
|
it("uses Debrid-Link key priority, skips cooldown, and returns to the first usable key", async () => {
|
|
const debridLinkApiKeys = "fixture-key-one\nfixture-key-two\nfixture-key-three";
|
|
const keys = parseDebridLinkApiKeys(debridLinkApiKeys);
|
|
const settings: AppSettings = { ...defaultSettings(), debridLinkApiKeys, providerOrder: ["debridlink"], providerPrimary: "debridlink", autoProviderFallback: false, accountUsageRules: { debridlink: { mode: "priority", accountIds: [keys[2].id, keys[1].id, keys[0].id] } } };
|
|
globalThis.fetch = vi.fn(async () => json({ success: true, value: { downloadUrl: result.directUrl, name: result.fileName, size: 1234 } }));
|
|
const service = new DebridService(settings);
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(keys[2].id);
|
|
primeDebridLinkRuntimeCooldownForTests(keys[2].id, 60000);
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(keys[1].id);
|
|
const now = Date.now();
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 120000);
|
|
expect((await service.unrestrictLink("https://hoster.example/file.rar")).sourceAccountId).toBe(keys[2].id);
|
|
});
|
|
});
|