Files
Multi-Debrid-Downloader/tests/account-check.test.ts
Sucukdeluxe 1b7caba2eb feat: add Deepbrid, daily scheduling, and notification center
Add encrypted Deepbrid API accounts with account validation, provider routing, fallback, usage tracking, safe error handling, and verified 1Fichier downloads. Restore persistent recurring daily starts with local-calendar deduplication and legacy schedule compatibility. Add durable Discord package, run, remaining-volume, stall, and recovery notifications with privacy-safe telemetry and disk-failure recovery.
2026-08-24 07:37:55 +02:00

450 lines
18 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkDeepbridAccount, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getRealDebridAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
}
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
}
function mockFetchOnce(status: number, body: unknown): void {
const text = typeof body === "string" ? body : JSON.stringify(body);
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => text
})) as unknown as typeof fetch);
}
function mockDeepbridResponse(status: number, body: unknown, contentType = "application/json"): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async () => new Response(
typeof body === "string" ? body : JSON.stringify(body),
{ status, headers: { "content-type": contentType } }
));
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
return fetchMock;
}
const NOW = 1_700_000_000_000;
afterEach(() => {
vi.unstubAllGlobals();
});
describe("checkMegaDebridAccount", () => {
it("reports valid + premium from vip_end (future Unix ts)", async () => {
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(futureSec * 1000);
expect(st.email).toBe("a@b.de");
expect(st.message).toMatch(/Premium noch/);
});
it("reports valid but NOT premium when vip_end is in the past", async () => {
const pastSec = Math.floor(NOW / 1000) - 1000;
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
});
it("reports valid but no premium when vip_end is 0/missing", async () => {
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.premiumUntilMs).toBe(0);
expect(st.message).toMatch(/Kein Premium/);
});
it("reports invalid login when response_code != ok", async () => {
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Ungueltiger Login/);
});
it("reports invalid on HTTP error", async () => {
mockFetchOnce(500, "server error");
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
});
it("never throws on network error — returns a failed status", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
});
});
describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60;
mockFetchOnce(200, { success: true, value: { username: "u", email: "u@example.test", accountType: 1, premiumLeft } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
expect(st.username).toBe("u");
expect(st.email).toBe("u@example.test");
});
it("does not present a Debrid-Link username as an email address", async () => {
mockFetchOnce(200, { success: true, value: { username: "xsucukde5", accountType: 0, premiumLeft: 0 } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.username).toBe("xsucukde5");
expect(st.email).toBeUndefined();
});
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Free/);
});
it("reports invalid key on HTTP 401", async () => {
mockFetchOnce(401, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Ungueltiger API-Key/);
});
it("reports invalid key when success=false", async () => {
mockFetchOnce(200, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
});
});
describe("checkRealDebridAccount", () => {
it("keys every API account status by its concrete pool identity", async () => {
const token = "rd-api-pool-token";
mockFetchOnce(200, { username: "api-user", type: "premium", expiration: new Date(NOW + 100_000).toISOString() });
const account = getRealDebridAccounts({ realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_checkOpaque", token }]) })[0];
const status = await checkRealDebridAccount(account, undefined, NOW);
expect(status.accountId).toBe("rda_checkOpaque");
});
it("keeps browser-session username and email in separate status fields", async () => {
const premiumUntilMs = NOW + 30 * 24 * 60 * 60 * 1000;
const probe = vi.fn(async () => ({
valid: true,
isPremium: true,
premiumUntilMs,
username: "web-user",
email: "web-user@example.test"
}));
const status = await checkRealDebridAccount({
token: "",
realDebridUseWebLogin: true
} as AppSettings, undefined, NOW, probe);
expect(probe).toHaveBeenCalledTimes(1);
expect(status).toMatchObject({
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
valid: true,
isPremium: true,
premiumUntilMs,
username: "web-user",
email: "web-user@example.test"
});
});
it("keeps API username and email in separate status fields", async () => {
mockFetchOnce(200, {
username: "api-user",
email: "api-user@example.test",
type: "premium",
expiration: new Date(NOW + 30 * 24 * 60 * 60 * 1000).toISOString()
});
const status = await checkRealDebridAccount({
token: "rd-api-token",
realDebridUseWebLogin: false
} as AppSettings, undefined, NOW);
expect(status).toMatchObject({
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
valid: true,
username: "api-user",
email: "api-user@example.test"
});
});
});
describe("checkDeepbridAccount", () => {
const key = "fixture-deepbrid-check-key-4fG7";
it("reports a current premium account with safe identity metadata", async () => {
const expiration = new Date(NOW + 3 * 24 * 60 * 60 * 1000).toISOString();
mockDeepbridResponse(200, {
username: "deep-user",
email: "deep-user@example.test",
type: "premium",
expiration,
maxDownloads: 10,
maxConnections: 4
});
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status).toMatchObject({
accountId: "svc-deepbrid",
provider: "deepbrid",
label: "Deepbrid",
valid: true,
isPremium: true,
premiumUntilMs: Date.parse(expiration),
username: "deep-user",
email: "deep-user@example.test"
});
expect(status.message).toMatch(/Premium noch/);
expect(status.maskedLogin).not.toBe(key);
expect(JSON.stringify(status)).not.toContain(key);
});
it("keeps a free account valid without marking it as premium", async () => {
mockDeepbridResponse(200, {
username: "free-user",
email: "free-user@example.test",
type: "free",
expiration: "",
maxDownloads: 2,
maxConnections: 1
});
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status).toMatchObject({ valid: true, isPremium: false, premiumUntilMs: null });
expect(status.message).toMatch(/Free/);
});
it.each([
[401, /ungültig/i],
[403, /gesperrt/i]
])("reports HTTP %i as an honest invalid key status", async (httpStatus, message) => {
mockDeepbridResponse(httpStatus, { error: httpStatus });
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status.valid).toBe(false);
expect(status.message).toMatch(message);
expect(JSON.stringify(status)).not.toContain(key);
});
it("reports HTML instead of account JSON as a failed check", async () => {
mockDeepbridResponse(200, "<html>gateway</html>", "text/html");
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status.valid).toBe(false);
expect(status.message).toMatch(/Prüfung fehlgeschlagen/);
expect(status.message).not.toContain("gateway");
});
it.each([
new Error("ECONNRESET fixture-deepbrid-check-key-4fG7"),
new DOMException("Timeout fixture-deepbrid-check-key-4fG7", "TimeoutError")
])("reports transport failures without reflecting raw errors", async (error) => {
vi.stubGlobal("fetch", vi.fn(async () => { throw error; }) as unknown as typeof fetch);
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status.valid).toBe(false);
expect(status.message).toBe("Prüfung fehlgeschlagen");
expect(JSON.stringify(status)).not.toContain(key);
});
it("distinguishes a caller abort from a failed check", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const controller = new AbortController();
controller.abort(new Error(`cancel ${key}`));
const status = await checkDeepbridAccount(key, controller.signal, NOW);
expect(status.message).toBe("Prüfung abgebrochen");
expect(fetchMock).not.toHaveBeenCalled();
expect(JSON.stringify(status)).not.toContain(key);
});
});
describe("checkAllDebridAccounts", () => {
it("checks one configured Deepbrid account in all scope and only an enabled one in active scope", async () => {
const key = "fixture-deepbrid-bulk-key-7hJ2";
const fetchMock = mockDeepbridResponse(200, {
username: "bulk-user",
email: "bulk-user@example.test",
type: "free",
expiration: "",
maxDownloads: 2,
maxConnections: 1
});
const settings = {
...defaultSettings(),
deepbridApiKey: key,
disabledProviders: ["deepbrid" as const]
};
const active = await checkAllDebridAccounts(settings, undefined, undefined, "active");
const all = await checkAllDebridAccounts(settings, undefined, undefined, "all");
expect(active).toEqual([]);
expect(all).toEqual([expect.objectContaining({ accountId: "svc-deepbrid", provider: "deepbrid", valid: true })]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("discards a late Real-Debrid result after its account was removed", () => {
const removedId = "rda_removedAfterCheck";
const lateStatus = { accountId: removedId, provider: "realdebrid" as const, label: "API-Token 1", maskedLogin: "Geschützt", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: NOW };
expect(retainConfiguredRealDebridStatuses(defaultSettings(), [lateStatus])).toEqual([]);
});
it("checks only enabled Real-Debrid pool entries in active scope and all entries in all scope", async () => {
const firstToken = "rd-pool-active";
const secondToken = "rd-pool-disabled";
const activeId = "rda_poolActive";
const disabledId = "rda_poolDisabled";
const settings = {
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: activeId, token: firstToken }, { id: disabledId, token: secondToken }]),
realDebridWebAccountIds: ["rdw_first", "rdw_second"],
realDebridDisabledAccountIds: [disabledId, "rdw_second"]
};
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ username: "api", type: "premium" }) })) as unknown as typeof fetch);
const probedAccountIds: string[] = [];
const probe = vi.fn(async (accountId: string) => {
probedAccountIds.push(accountId);
return { valid: true, isPremium: true, username: "web" };
});
const active = await checkAllDebridAccounts(settings, undefined, probe, "active");
const all = await checkAllDebridAccounts(settings, undefined, probe, "all");
expect(active.map((status) => status.accountId)).toEqual([activeId, "rdw_first"]);
expect(all.map((status) => status.accountId)).toEqual([activeId, disabledId, "rdw_first", "rdw_second"]);
expect(probedAccountIds).toEqual(["rdw_first", "rdw_first", "rdw_second"]);
});
it("returns empty array when nothing configured", async () => {
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toEqual([]);
});
it("includes Real-Debrid web login in the bulk account check", async () => {
const settings = {
token: "",
realDebridUseWebLogin: true,
megaCredentials: "",
megaPassword: "",
debridLinkApiKeys: ""
} as AppSettings;
const probe = vi.fn(async () => ({ valid: true, isPremium: true, username: "web-user" }));
const result = await checkAllDebridAccounts(settings, undefined, probe);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
valid: true
});
});
it("checks every configured mega account + debrid-link key", async () => {
const futureSec = Math.floor(Date.now() / 1000) + 1000;
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
if (String(url).includes("mega-debrid")) {
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
}
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const settings = {
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
megaPassword: "",
debridLinkApiKeys: "key1\nkey2\nkey3"
} as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(5);
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
expect(result.every((r) => r.valid)).toBe(true);
});
it("checks only enabled accounts in active scope and every account in all scope", async () => {
const megaCredentials = [
"one@example.test:pw1",
"two@example.test:pw2",
"three@example.test:pw3",
"four@example.test:pw4"
].join("\n");
const settings: AppSettings = {
...defaultSettings(),
realDebridUseWebLogin: true,
megaCredentials,
megaDebridWebCredentials: megaCredentials,
megaDebridWebEnabled: true,
debridLinkApiKeys: "disabled-debrid-link-key",
debridLinkDisabledKeyIds: [getDebridLinkApiKeyId("disabled-debrid-link-key")],
megaDebridWebDisabledAccountIds: [
getMegaDebridAccountId("one@example.test"),
getMegaDebridAccountId("two@example.test"),
getMegaDebridAccountId("three@example.test"),
getMegaDebridAccountId("four@example.test")
]
};
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: "4102444800" })
}));
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const probe = vi.fn(async () => ({ valid: true, isPremium: true, username: "rd-user" }));
const active = await checkAllDebridAccounts(settings, undefined, probe, "active");
const all = await checkAllDebridAccounts(settings, undefined, probe, "all");
expect(active.map((status) => status.accountId)).toEqual([REAL_DEBRID_STATUS_ID]);
expect(all).toHaveLength(6);
expect(fetchMock).toHaveBeenCalledTimes(5);
});
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
let inFlight = 0;
let maxInFlight = 0;
vi.stubGlobal("fetch", vi.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(9);
expect(maxInFlight).toBeLessThanOrEqual(4);
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
});
});