import { afterEach, describe, expect, it, vi } from "vitest"; import { MegaWebFallback } from "../src/main/mega-web-fallback"; const originalFetch = globalThis.fetch; describe("mega-web-fallback", () => { afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); describe("MegaWebFallback class", () => { it("returns null when credentials are empty", async () => { const fallback = new MegaWebFallback(() => ({ login: "", password: "" })); const result = await fallback.unrestrict("https://mega.debrid/test"); expect(result).toBeNull(); }); it("does not restore an invalidated session when an older login finishes later", async () => { let finishLogin: (cookie: string) => void = () => {}; const loginResult = new Promise((resolve) => { finishLogin = resolve; }); const fallback = new MegaWebFallback(() => ({ login: "old-user", password: "old-pass" })); const internals = fallback as unknown as { ensureSession: (key: string, login: string, password: string) => Promise; login: (login: string, password: string) => Promise; sessions: Map; }; vi.spyOn(internals, "login").mockReturnValue(loginResult); const pending = internals.ensureSession("old-user", "old-user", "old-pass"); await Promise.resolve(); fallback.invalidateSession(); finishLogin("stale-old-cookie"); await expect(pending).resolves.toBe("stale-old-cookie"); expect(internals.sessions.size).toBe(0); }); it("does not cache an old session when an invalidated request retries login", async () => { const fallback = new MegaWebFallback(() => ({ login: "old-user", password: "old-pass" })); const internals = fallback as unknown as { login: (login: string, password: string) => Promise; generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>; sessions: Map; }; const login = vi.spyOn(internals, "login") .mockResolvedValueOnce("first-stale-cookie") .mockResolvedValueOnce("second-stale-cookie"); vi.spyOn(internals, "generate") .mockImplementationOnce(async () => { fallback.invalidateSession(); return null; }) .mockResolvedValueOnce({ directUrl: "https://mega.direct/retry", fileName: "retry.bin" }); await expect(fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" })) .rejects.toThrow(/aborted/i); expect(login).toHaveBeenCalledTimes(1); expect(internals.sessions.size).toBe(0); }); it("keeps a fresh session when an invalidated older request finishes later", async () => { let releaseOldGenerate: () => void = () => {}; let markOldGenerateStarted: () => void = () => {}; const oldGenerateGate = new Promise((resolve) => { releaseOldGenerate = resolve; }); const oldGenerateStarted = new Promise((resolve) => { markOldGenerateStarted = resolve; }); const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); const internals = fallback as unknown as { login: (login: string, password: string, signal?: AbortSignal) => Promise; generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string } | null>; sessions: Map; }; const login = vi.spyOn(internals, "login") .mockResolvedValueOnce("old-cookie") .mockResolvedValueOnce("fresh-cookie"); vi.spyOn(internals, "generate").mockImplementation(async (link) => { if (link.includes("old")) { markOldGenerateStarted(); await oldGenerateGate; return null; } return { directUrl: "https://mega.direct/fresh", fileName: "fresh.bin" }; }); const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" }); const oldSettled = oldRequest.catch((error: unknown) => error); await oldGenerateStarted; fallback.invalidateSession(); const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" }); try { await expect(freshRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/fresh" }); expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie"); } finally { releaseOldGenerate(); } await oldSettled; expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie"); expect(login).toHaveBeenCalledTimes(2); }); it("does not cache old credentials from a queued request after invalidation", async () => { let releaseFirstLogin: () => void = () => {}; let markFirstLoginStarted: () => void = () => {}; const firstLoginGate = new Promise((resolve) => { releaseFirstLogin = resolve; }); const firstLoginStarted = new Promise((resolve) => { markFirstLoginStarted = resolve; }); const fallback = new MegaWebFallback(() => ({ login: "old-user", password: "old-pass" })); const internals = fallback as unknown as { login: (login: string, password: string) => Promise; generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string }>; sessions: Map; }; let loginCount = 0; vi.spyOn(internals, "login").mockImplementation(async () => { loginCount += 1; if (loginCount === 1) { markFirstLoginStarted(); await firstLoginGate; } return `old-cookie-${loginCount}`; }); vi.spyOn(internals, "generate").mockResolvedValue({ directUrl: "https://mega.direct/queued", fileName: "queued.bin" }); const first = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "old-user", password: "old-pass" }); const firstSettled = first.catch((error: unknown) => error); await firstLoginStarted; const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" }); const queuedSettled = queued.catch((error: unknown) => error); fallback.invalidateSession(); releaseFirstLogin(); await expect(firstSettled).resolves.toBeInstanceOf(Error); await expect(queuedSettled).resolves.toBeInstanceOf(Error); expect(loginCount).toBe(1); expect(internals.sessions.size).toBe(0); }); it("does not let a blocked old account job block a new request after session invalidation", async () => { let releaseOldLogin: () => void = () => {}; let markOldLoginStarted: () => void = () => {}; const oldLoginGate = new Promise((resolve) => { releaseOldLogin = resolve; }); const oldLoginStarted = new Promise((resolve) => { markOldLoginStarted = resolve; }); const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); const internals = fallback as unknown as { login: (login: string, password: string, signal?: AbortSignal) => Promise; generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>; }; const login = vi.spyOn(internals, "login") .mockImplementationOnce(async () => { markOldLoginStarted(); await oldLoginGate; return "old-cookie"; }) .mockResolvedValueOnce("new-cookie"); vi.spyOn(internals, "generate").mockImplementation(async (link) => ({ directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old", fileName: link.includes("fresh") ? "fresh.bin" : "old.bin" })); const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" }); const oldSettled = oldRequest.catch((error: unknown) => error); await oldLoginStarted; fallback.invalidateSession(); const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" }); try { await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 }); await expect(freshRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/fresh", fileName: "fresh.bin" }); } finally { releaseOldLogin(); await Promise.allSettled([oldSettled, freshRequest]); } }); it("does not execute provider work for a queued request aborted before it starts", async () => { let releaseFirstLogin: () => void = () => {}; let markFirstLoginStarted: () => void = () => {}; const firstLoginGate = new Promise((resolve) => { releaseFirstLogin = resolve; }); const firstLoginStarted = new Promise((resolve) => { markFirstLoginStarted = resolve; }); const generatedLinks: string[] = []; const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); const internals = fallback as unknown as { login: (login: string, password: string, signal?: AbortSignal) => Promise; generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>; queues: Map>; }; const login = vi.spyOn(internals, "login").mockImplementation(async () => { markFirstLoginStarted(); await firstLoginGate; return "first-cookie"; }); vi.spyOn(internals, "generate").mockImplementation(async (link) => { generatedLinks.push(link); return { directUrl: "https://mega.direct/first", fileName: "first.bin" }; }); const firstRequest = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "same-user", password: "same-pass" }); await firstLoginStarted; const queuedController = new AbortController(); const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" }); const queueTail = internals.queues.get("same-user"); queuedController.abort("cancel-before-start"); await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i); releaseFirstLogin(); await expect(firstRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/first" }); await queueTail; expect(login).toHaveBeenCalledTimes(1); expect(generatedLinks).toEqual(["https://mega.debrid/first"]); }); it("starts a new request normally after invalidating a queue with an aborted waiter", async () => { let releaseOldLogin: () => void = () => {}; let markOldLoginStarted: () => void = () => {}; const oldLoginGate = new Promise((resolve) => { releaseOldLogin = resolve; }); const oldLoginStarted = new Promise((resolve) => { markOldLoginStarted = resolve; }); const generatedLinks: string[] = []; const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); const internals = fallback as unknown as { login: (login: string, password: string, signal?: AbortSignal) => Promise; generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>; queues: Map>; }; const login = vi.spyOn(internals, "login") .mockImplementationOnce(async () => { markOldLoginStarted(); await oldLoginGate; return "old-cookie"; }) .mockResolvedValueOnce("fresh-cookie"); vi.spyOn(internals, "generate").mockImplementation(async (link) => { generatedLinks.push(link); return { directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old", fileName: link.includes("fresh") ? "fresh.bin" : "old.bin" }; }); const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" }); const oldSettled = oldRequest.catch((error: unknown) => error); await oldLoginStarted; const queuedController = new AbortController(); const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" }); const staleQueueTail = internals.queues.get("same-user"); queuedController.abort("cancel-before-start"); await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i); fallback.invalidateSession(); const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" }); try { await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 }); await expect(freshRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/fresh", fileName: "fresh.bin" }); } finally { releaseOldLogin(); await Promise.allSettled([oldSettled, freshRequest, staleQueueTail]); } expect(generatedLinks).toEqual(["https://mega.debrid/fresh"]); }); it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => { let fetchCallCount = 0; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url); fetchCallCount += 1; if (urlStr.includes("form=login")) { const headers = new Headers(); headers.append("set-cookie", "session=goodcookie; path=/"); return new Response("", { headers, status: 200 }); } if (urlStr.includes("page=debrideur")) { return new Response('
', { status: 200 }); } if (urlStr.includes("form=debrid")) { return new Response(`

Link: https://mega.debrid/link1

Download
`, { status: 200 }); } if (urlStr.includes("ajax=debrid")) { return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 }); } return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); const result = await fallback.unrestrict("https://mega.debrid/link1"); expect(result).not.toBeNull(); expect(result?.directUrl).toBe("https://mega.direct/123"); expect(result?.fileName).toBe("link1"); expect(fetchCallCount).toBe(4); }); it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => { let ajaxCalls = 0; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url); if (urlStr.includes("form=login")) { const headers = new Headers(); headers.append("set-cookie", "session=goodcookie; path=/"); return new Response("", { headers, status: 200 }); } if (urlStr.includes("page=debrideur")) { return new Response('
', { status: 200 }); } if (urlStr.includes("form=debrid")) { return new Response(`

Link: https://mega.debrid/l1

d
`, { status: 200 }); } if (urlStr.includes("ajax=debrid")) { ajaxCalls += 1; return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 }); } return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i); expect(ajaxCalls).toBe(1); }); it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => { let ajaxCalls = 0; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url); if (urlStr.includes("form=login")) { const headers = new Headers(); headers.append("set-cookie", "session=goodcookie; path=/"); return new Response("", { headers, status: 200 }); } if (urlStr.includes("page=debrideur")) { return new Response('
', { status: 200 }); } if (urlStr.includes("form=debrid")) { return new Response('
Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.
', { status: 200 }); } if (urlStr.includes("ajax=debrid")) { ajaxCalls += 1; return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 }); } return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i); expect(ajaxCalls).toBe(0); }); it("logs in with the per-account credentials passed to unrestrict, not the default", async () => { const loginsUsed: string[] = []; globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => { const urlStr = String(url); if (urlStr.includes("form=login")) { const params = new URLSearchParams(String(opts?.body ?? "")); loginsUsed.push(params.get("login") || ""); const headers = new Headers(); headers.append("set-cookie", "session=goodcookie; path=/"); return new Response("", { headers, status: 200 }); } if (urlStr.includes("page=debrideur")) { return new Response('
', { status: 200 }); } if (urlStr.includes("form=debrid")) { return new Response(`

Link: https://mega.debrid/l1

d
`, { status: 200 }); } if (urlStr.includes("ajax=debrid")) { return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); } return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" })); const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" }); expect(result?.directUrl).toBe("https://mega.direct/ok"); expect(loginsUsed).toContain("account2"); expect(loginsUsed).not.toContain("defaultacc"); }); it("throws if login fails to set cookie", async () => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url); if (urlStr.includes("form=login")) { const headers = new Headers(); return new Response("", { headers, status: 200 }); } return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" })); await expect(fallback.unrestrict("http://mega.debrid/file")) .rejects.toThrow("Mega-Web Login liefert kein Session-Cookie"); }); it("throws if login verify check fails (no form found)", async () => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url); if (urlStr.includes("form=login")) { const headers = new Headers(); headers.append("set-cookie", "session=goodcookie; path=/"); return new Response("", { headers, status: 200 }); } if (urlStr.includes("page=debrideur")) { return new Response('Nothing here', { status: 200 }); } return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" })); await expect(fallback.unrestrict("http://mega.debrid/file")) .rejects.toThrow("Mega-Web Login ungültig oder Session blockiert"); }); it("returns null if generation fails to find a code", async () => { let callCount = 0; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url); callCount++; if (urlStr.includes("form=login")) { const headers = new Headers(); headers.append("set-cookie", "session=goodcookie; path=/"); return new Response("", { headers, status: 200 }); } if (urlStr.includes("page=debrideur")) { return new Response('
', { status: 200 }); } if (urlStr.includes("form=debrid")) { return new Response(`
No links here
`, { status: 200 }); } return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" })); const result = await fallback.unrestrict("http://mega.debrid/file"); expect(result).toBeNull(); }); it("serialisiert gleichzeitige Umwandlungen auf DEMSELBEN Account (kein Doppel-Login)", async () => { let loginCount = 0; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const u = String(url); if (u.includes("form=login")) { loginCount += 1; await new Promise((r) => setTimeout(r, 15)); const headers = new Headers(); headers.append("set-cookie", "session=c; path=/"); return new Response("", { headers, status: 200 }); } if (u.includes("page=debrideur")) return new Response('
', { status: 200 }); if (u.includes("form=debrid")) return new Response(`

Link: https://mega.debrid/l

d
`, { status: 200 }); if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" })); const [r1, r2] = await Promise.all([ fallback.unrestrict("https://mega.debrid/a", undefined, { login: "same", password: "pw" }), fallback.unrestrict("https://mega.debrid/b", undefined, { login: "same", password: "pw" }) ]); expect(r1?.directUrl).toBe("https://mega.direct/ok"); expect(r2?.directUrl).toBe("https://mega.direct/ok"); // Serialisiert auf demselben Account → der zweite nutzt die gecachte Session, kein zweiter Login. expect(loginCount).toBe(1); }); it("wandelt auf VERSCHIEDENEN Accounts parallel um (Logins laufen gleichzeitig)", async () => { let activeLogins = 0; let maxActiveLogins = 0; const bothInFlight = new Promise((resolve) => { const check = setInterval(() => { if (activeLogins >= 2) { clearInterval(check); resolve(); } }, 5); }); globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const u = String(url); if (u.includes("form=login")) { activeLogins += 1; maxActiveLogins = Math.max(maxActiveLogins, activeLogins); await bothInFlight; activeLogins -= 1; const headers = new Headers(); headers.append("set-cookie", "session=c; path=/"); return new Response("", { headers, status: 200 }); } if (u.includes("page=debrideur")) return new Response('
', { status: 200 }); if (u.includes("form=debrid")) return new Response(`

Link: https://mega.debrid/l

d
`, { status: 200 }); if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "d", password: "p" })); const [r1, r2] = await Promise.all([ fallback.unrestrict("https://mega.debrid/a", undefined, { login: "acc1", password: "p" }), fallback.unrestrict("https://mega.debrid/b", undefined, { login: "acc2", password: "p" }) ]); expect(r1?.directUrl).toBe("https://mega.direct/ok"); expect(r2?.directUrl).toBe("https://mega.direct/ok"); // Verschiedene Accounts → beide Logins gleichzeitig in-flight (sonst haengt es am bothInFlight-Barrier). expect(maxActiveLogins).toBe(2); }, 10000); it("aborts pending Mega-Web polling when signal is cancelled", async () => { globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise => { const urlStr = String(url); if (urlStr.includes("form=login")) { const headers = new Headers(); headers.append("set-cookie", "session=goodcookie; path=/"); return Promise.resolve(new Response("", { headers, status: 200 })); } if (urlStr.includes("page=debrideur")) { return Promise.resolve(new Response('
', { status: 200 })); } if (urlStr.includes("form=debrid")) { return Promise.resolve(new Response(`

Link: https://mega.debrid/link2

Download
`, { status: 200 })); } if (urlStr.includes("ajax=debrid")) { return new Promise((_resolve, reject) => { const signal = init?.signal; const onAbort = (): void => reject(new Error("aborted:ajax")); if (signal?.aborted) { onAbort(); return; } signal?.addEventListener("abort", onAbort, { once: true }); }); } return Promise.resolve(new Response("Not found", { status: 404 })); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); const controller = new AbortController(); const timer = setTimeout(() => { controller.abort("test"); }, 200); try { await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i); } finally { clearTimeout(timer); } }); it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => { let releaseLogin: () => void = () => {}; const loginGate = new Promise((resolve) => { releaseLogin = resolve; }); globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const u = String(url); if (u.includes("form=login")) { await loginGate; const headers = new Headers(); headers.append("set-cookie", "session=c; path=/"); return new Response("", { headers, status: 200 }); } if (u.includes("page=debrideur")) return new Response('
', { status: 200 }); if (u.includes("form=debrid")) return new Response(`

Link: https://mega.debrid/l

d
`, { status: 200 }); if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); return new Response("Not found", { status: 404 }); }) as unknown as typeof fetch; const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" })); const firstCtrl = new AbortController(); const first = fallback.unrestrict("https://mega.debrid/a", firstCtrl.signal, { login: "same", password: "pw" }); await new Promise((r) => setTimeout(r, 25)); const secondCtrl = new AbortController(); const second = fallback.unrestrict("https://mega.debrid/b", secondCtrl.signal, { login: "same", password: "pw" }); await new Promise((r) => setTimeout(r, 25)); secondCtrl.abort("caller-timeout"); await expect(second).rejects.toThrow(/queue.?timeout/i); releaseLogin(); await first.catch(() => null); await new Promise((r) => setTimeout(r, 20)); }, 10000); }); });