release: ship v2.0.23 queue and account reliability fixes

Normalize RapidGator aliases, stabilize active queue ordering, preserve user-controlled package expansion, and align compact queue status presentation.

Separate Mega-Debrid API and Web credential pools, migrate legacy credentials and disabled states safely, refresh live account availability without restart, honor disabled credential persistence, and invalidate Web sessions without allowing stale in-flight, retry, or queued logins to restore old cookies.

Expand regression coverage for account isolation, legacy migration, session races, live scheduler refresh, update notes, and responsive queue behavior.
This commit is contained in:
Sucukdeluxe
2026-08-11 18:31:01 +02:00
parent b5b1ff88e5
commit a6e6d2d439
36 changed files with 2343 additions and 1376 deletions
+51 -6
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
import { defaultSettings } from "../src/main/constants";
import { applyAccountDialogToSettings, createAccountDialogState, AccountDialogState } from "../src/renderer/App";
import { defaultSettings } from "../src/main/constants";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { isMegaDebridAccountDisabled } from "../src/shared/provider-daily-limits";
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
return {
@@ -27,10 +29,53 @@ describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choic
expect(next.megaDebridPreferApi).toBe(false);
});
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(true);
});
});
expect(next.megaDebridPreferApi).toBe(true);
});
it("adds a Web account without copying it into the API account pool", () => {
const settings = {
...defaultSettings(),
megaCredentials: "api@example.test:api-pass",
megaDebridApiCredentials: "api@example.test:api-pass",
megaDebridWebCredentials: "",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false
};
const dialog = createAccountDialogState("create", "megadebrid-web", settings);
expect(dialog.megaAccounts).toEqual([]);
const next = applyAccountDialogToSettings(settings, {
...dialog,
megaAccounts: [{ login: "web@example.test", password: "web-pass" }]
});
expect(next.megaDebridApiCredentials).toBe("api@example.test:api-pass");
expect(next.megaDebridWebCredentials).toBe("web@example.test:web-pass");
expect(next.megaDebridApiEnabled).toBe(true);
expect(next.megaDebridWebEnabled).toBe(true);
});
it("disables an API account without disabling the matching Web account", () => {
const accountId = getMegaDebridAccountId("shared@example.test");
const settings = {
...defaultSettings(),
megaCredentials: "shared@example.test:api-pass",
megaDebridApiCredentials: "shared@example.test:api-pass",
megaDebridWebCredentials: "shared@example.test:web-pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true
};
const next = applyAccountDialogToSettings(settings, {
...createAccountDialogState("edit", "megadebrid-api", settings),
megaDisabledIds: [accountId]
});
expect(isMegaDebridAccountDisabled(next, accountId, "api")).toBe(true);
expect(isMegaDebridAccountDisabled(next, accountId, "web")).toBe(false);
});
});
+301 -294
View File
@@ -1,298 +1,305 @@
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import {
applyAccountEdit,
buildAccountEditCheckSettings,
createAccountEditState,
removeAccountTarget,
validateAccountEdit,
validateAccountEditStatuses,
type AccountEditTarget
} from "../src/renderer/account-edit";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
const GIB = 1024 * 1024 * 1024;
function megaTarget(login: string): AccountEditTarget {
return {
type: "mega",
rowKey: `mega-${getMegaDebridAccountId(login)}`,
kind: "megadebrid-api",
service: "megadebrid-api",
accountId: getMegaDebridAccountId(login)
};
}
function debridLinkTarget(token: string): AccountEditTarget {
return {
type: "debridlink",
rowKey: `dl-${getDebridLinkApiKeyId(token)}`,
kind: "debridlink-api",
service: "debridlink",
keyId: getDebridLinkApiKeyId(token)
};
}
describe("account-specific editing", () => {
it("changes only the selected Mega-Debrid account and preserves sibling order and mode settings", () => {
const oldId = getMegaDebridAccountId("second@example.com");
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import {
applyAccountEdit,
buildAccountEditCheckSettings,
createAccountEditState,
removeAccountTarget,
validateAccountEdit,
validateAccountEditStatuses,
type AccountEditTarget
} from "../src/renderer/account-edit";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
const GIB = 1024 * 1024 * 1024;
function megaTarget(login: string): AccountEditTarget {
return {
type: "mega",
rowKey: `mega-${getMegaDebridAccountId(login)}`,
kind: "megadebrid-api",
service: "megadebrid-api",
accountId: getMegaDebridAccountId(login)
};
}
function debridLinkTarget(token: string): AccountEditTarget {
return {
type: "debridlink",
rowKey: `dl-${getDebridLinkApiKeyId(token)}`,
kind: "debridlink-api",
service: "debridlink",
keyId: getDebridLinkApiKeyId(token)
};
}
describe("account-specific editing", () => {
it("changes only the selected Mega-Debrid account and preserves sibling order and mode settings", () => {
const oldId = getMegaDebridAccountId("second@example.com");
const newId = getMegaDebridAccountId("renamed@example.com");
const firstId = getMegaDebridAccountId("first@example.com");
const webId = getMegaDebridAccountId("web@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
megaDebridPreferApi: false,
megaDebridDisabledAccountIds: [oldId, firstId],
megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB },
megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB },
megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB },
debridAccountStatuses: {
[oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const state = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "renamed@example.com",
password: "new-pass",
dailyLimitGb: "25,5"
};
expect(validateAccountEdit(state, settings)).toBeNull();
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass");
expect(next.megaLogin).toBe("first@example.com");
expect(next.megaPassword).toBe("first-pass");
expect(next.megaDebridApiEnabled).toBe(true);
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(false);
expect(next.megaDebridDisabledAccountIds).toEqual([firstId, newId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB });
expect(next.debridAccountStatuses).toEqual({
[firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
it("keeps Mega-Debrid usage when only the password changes", () => {
const id = getMegaDebridAccountId("user@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "user@example.com:old-pass",
megaLogin: "user@example.com",
megaPassword: "old-pass",
megaDebridAccountDailyLimitBytes: { [id]: 8 * GIB },
megaDebridAccountDailyUsageBytes: { [id]: 3 * GIB },
megaDebridAccountTotalUsageBytes: { [id]: 33 * GIB }
};
const state = { ...createAccountEditState(megaTarget("user@example.com"), settings), password: "new-pass" };
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("user@example.com:new-pass");
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [id]: 3 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [id]: 33 * GIB });
});
it("preserves exact account limits when the rounded display value is left unchanged", () => {
const megaLogin = "user@example.com";
const megaId = getMegaDebridAccountId(megaLogin);
const key = "debrid-link-token";
const keyId = getDebridLinkApiKeyId(key);
const exactLimit = Math.floor(10.05 * GIB);
const settings = {
...defaultSettings(),
megaCredentials: `${megaLogin}:pass`,
megaLogin,
megaPassword: "pass",
megaDebridAccountDailyLimitBytes: { [megaId]: exactLimit },
debridLinkApiKeys: key,
debridLinkApiKeyDailyLimitBytes: { [keyId]: exactLimit }
};
const megaNext = applyAccountEdit(settings, createAccountEditState(megaTarget(megaLogin), settings));
const debridLinkNext = applyAccountEdit(settings, createAccountEditState(debridLinkTarget(key), settings));
expect(megaNext.megaDebridAccountDailyLimitBytes[megaId]).toBe(exactLimit);
expect(debridLinkNext.debridLinkApiKeyDailyLimitBytes[keyId]).toBe(exactLimit);
});
it("rejects whitespace-only Mega-Debrid passwords and empty or mismatched check results", () => {
const login = "user@example.com";
const settings = {
...defaultSettings(),
megaCredentials: `${login}:pass`,
megaLogin: login,
megaPassword: "pass"
};
const state = { ...createAccountEditState(megaTarget(login), settings), password: " " };
expect(validateAccountEdit(state, settings)).toMatch(/Passwort/i);
expect(validateAccountEditStatuses(state, [])).toMatch(/keinen Account/i);
expect(validateAccountEditStatuses(state, [{
accountId: "mda_other",
provider: "megadebrid",
label: "Account 1",
maskedLogin: "ot***er",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
}])).toMatch(/falschen Account/i);
});
it("rejects duplicate Mega-Debrid logins and missing row targets", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass"
};
const duplicate = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "first@example.com"
};
expect(validateAccountEdit(duplicate, settings)).toMatch(/bereits vorhanden/i);
expect(() => createAccountEditState(megaTarget("missing@example.com"), settings)).toThrow(/nicht gefunden/i);
});
it("replaces only the selected Debrid-Link key and migrates its own metadata", () => {
const keyA = "token-a";
const keyB = "token-b";
const keyC = "token-c";
const newKey = "token-b-new";
const idA = getDebridLinkApiKeyId(keyA);
const idB = getDebridLinkApiKeyId(keyB);
const idNew = getDebridLinkApiKeyId(newKey);
const settings = {
...defaultSettings(),
debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`,
debridLinkDisabledKeyIds: [idB, idA],
debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB },
debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB },
debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB }
};
const state = {
...createAccountEditState(debridLinkTarget(keyB), settings),
token: newKey,
dailyLimitGb: "12"
};
const next = applyAccountEdit(settings, state);
expect(next.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`);
expect(next.debridLinkDisabledKeyIds).toEqual([idA, idNew]);
expect(next.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB });
expect(next.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB });
expect(next.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB });
});
it("edits a single login without changing unrelated credentials", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "old@example.com",
ddownloadPassword: "old-pass",
linkSnappyLogin: "keep@example.com",
linkSnappyPassword: "keep-pass"
};
const target: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const state = {
...createAccountEditState(target, settings),
login: "new@example.com",
password: "new-pass",
dailyLimitGb: "7"
};
const next = applyAccountEdit(settings, state);
expect(next.ddownloadLogin).toBe("new@example.com");
expect(next.ddownloadPassword).toBe("new-pass");
expect(next.linkSnappyLogin).toBe("keep@example.com");
expect(next.linkSnappyPassword).toBe("keep-pass");
expect(next.providerDailyLimitBytes.ddownload).toBe(7 * GIB);
});
it("rejects whitespace-only passwords for direct login accounts", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "user@example.com",
ddownloadPassword: "password",
linkSnappyLogin: "member@example.com",
linkSnappyPassword: "secret"
};
const ddownloadTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const linkSnappyTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-linksnappy",
kind: "linksnappy-login",
service: "linksnappy",
provider: "linksnappy"
};
expect(validateAccountEdit({ ...createAccountEditState(ddownloadTarget, settings), password: " " }, settings)).toMatch(/Passwort/i);
expect(validateAccountEdit({ ...createAccountEditState(linkSnappyTarget, settings), password: "\t" }, settings)).toMatch(/Passwort/i);
});
it("builds a targeted check snapshot without invalid sibling accounts", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
debridLinkApiKeys: "one\ntwo"
};
const target = megaTarget("second@example.com");
const state = createAccountEditState(target, settings);
const checkSettings = buildAccountEditCheckSettings(settings, state);
expect(checkSettings.megaCredentials).toBe("second@example.com:second-pass");
expect(checkSettings.megaLogin).toBe("second@example.com");
expect(checkSettings.megaPassword).toBe("second-pass");
expect(checkSettings.debridLinkApiKeys).toBe("");
});
it("removes only the selected Mega-Debrid account and all metadata belonging to it", () => {
const removeId = getMegaDebridAccountId("second@example.com");
const keepId = getMegaDebridAccountId("first@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridDisabledAccountIds: [removeId, keepId],
megaDebridAccountDailyLimitBytes: { [removeId]: 2, [keepId]: 1 },
megaDebridAccountDailyUsageBytes: { [removeId]: 4, [keepId]: 3 },
megaDebridAccountTotalUsageBytes: { [removeId]: 6, [keepId]: 5 },
debridAccountStatuses: {
[removeId]: { accountId: removeId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
[keepId]: { accountId: keepId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const next = removeAccountTarget(settings, megaTarget("second@example.com"));
expect(next.megaCredentials).toBe("first@example.com:first-pass");
expect(next.megaDebridDisabledAccountIds).toEqual([keepId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [keepId]: 1 });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [keepId]: 3 });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [keepId]: 5 });
expect(next.debridAccountStatuses).toEqual({
[keepId]: { accountId: keepId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
});
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass\nweb@example.com:web-pass",
megaDebridApiCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass",
megaDebridWebCredentials: "web@example.com:web-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
megaDebridPreferApi: false,
megaDebridDisabledAccountIds: [oldId, firstId, webId],
megaDebridApiDisabledAccountIds: [oldId, firstId],
megaDebridWebDisabledAccountIds: [webId],
megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB },
megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB },
megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB },
debridAccountStatuses: {
[oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
[firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const state = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "renamed@example.com",
password: "new-pass",
dailyLimitGb: "25,5"
};
expect(validateAccountEdit(state, settings)).toBeNull();
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass\nweb@example.com:web-pass");
expect(next.megaDebridApiCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass");
expect(next.megaDebridWebCredentials).toBe("web@example.com:web-pass");
expect(next.megaLogin).toBe("first@example.com");
expect(next.megaPassword).toBe("first-pass");
expect(next.megaDebridApiEnabled).toBe(true);
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(false);
expect(next.megaDebridDisabledAccountIds).toEqual([firstId, newId, webId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB });
expect(next.debridAccountStatuses).toEqual({
[firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
it("keeps Mega-Debrid usage when only the password changes", () => {
const id = getMegaDebridAccountId("user@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "user@example.com:old-pass",
megaLogin: "user@example.com",
megaPassword: "old-pass",
megaDebridAccountDailyLimitBytes: { [id]: 8 * GIB },
megaDebridAccountDailyUsageBytes: { [id]: 3 * GIB },
megaDebridAccountTotalUsageBytes: { [id]: 33 * GIB }
};
const state = { ...createAccountEditState(megaTarget("user@example.com"), settings), password: "new-pass" };
const next = applyAccountEdit(settings, state);
expect(next.megaCredentials).toBe("user@example.com:new-pass");
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [id]: 3 * GIB });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [id]: 33 * GIB });
});
it("preserves exact account limits when the rounded display value is left unchanged", () => {
const megaLogin = "user@example.com";
const megaId = getMegaDebridAccountId(megaLogin);
const key = "debrid-link-token";
const keyId = getDebridLinkApiKeyId(key);
const exactLimit = Math.floor(10.05 * GIB);
const settings = {
...defaultSettings(),
megaCredentials: `${megaLogin}:pass`,
megaLogin,
megaPassword: "pass",
megaDebridAccountDailyLimitBytes: { [megaId]: exactLimit },
debridLinkApiKeys: key,
debridLinkApiKeyDailyLimitBytes: { [keyId]: exactLimit }
};
const megaNext = applyAccountEdit(settings, createAccountEditState(megaTarget(megaLogin), settings));
const debridLinkNext = applyAccountEdit(settings, createAccountEditState(debridLinkTarget(key), settings));
expect(megaNext.megaDebridAccountDailyLimitBytes[megaId]).toBe(exactLimit);
expect(debridLinkNext.debridLinkApiKeyDailyLimitBytes[keyId]).toBe(exactLimit);
});
it("rejects whitespace-only Mega-Debrid passwords and empty or mismatched check results", () => {
const login = "user@example.com";
const settings = {
...defaultSettings(),
megaCredentials: `${login}:pass`,
megaLogin: login,
megaPassword: "pass"
};
const state = { ...createAccountEditState(megaTarget(login), settings), password: " " };
expect(validateAccountEdit(state, settings)).toMatch(/Passwort/i);
expect(validateAccountEditStatuses(state, [])).toMatch(/keinen Account/i);
expect(validateAccountEditStatuses(state, [{
accountId: "mda_other",
provider: "megadebrid",
label: "Account 1",
maskedLogin: "ot***er",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
}])).toMatch(/falschen Account/i);
});
it("rejects duplicate Mega-Debrid logins and missing row targets", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass"
};
const duplicate = {
...createAccountEditState(megaTarget("second@example.com"), settings),
login: "first@example.com"
};
expect(validateAccountEdit(duplicate, settings)).toMatch(/bereits vorhanden/i);
expect(() => createAccountEditState(megaTarget("missing@example.com"), settings)).toThrow(/nicht gefunden/i);
});
it("replaces only the selected Debrid-Link key and migrates its own metadata", () => {
const keyA = "token-a";
const keyB = "token-b";
const keyC = "token-c";
const newKey = "token-b-new";
const idA = getDebridLinkApiKeyId(keyA);
const idB = getDebridLinkApiKeyId(keyB);
const idNew = getDebridLinkApiKeyId(newKey);
const settings = {
...defaultSettings(),
debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`,
debridLinkDisabledKeyIds: [idB, idA],
debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB },
debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB },
debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB }
};
const state = {
...createAccountEditState(debridLinkTarget(keyB), settings),
token: newKey,
dailyLimitGb: "12"
};
const next = applyAccountEdit(settings, state);
expect(next.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`);
expect(next.debridLinkDisabledKeyIds).toEqual([idA, idNew]);
expect(next.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB });
expect(next.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB });
expect(next.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB });
});
it("edits a single login without changing unrelated credentials", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "old@example.com",
ddownloadPassword: "old-pass",
linkSnappyLogin: "keep@example.com",
linkSnappyPassword: "keep-pass"
};
const target: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const state = {
...createAccountEditState(target, settings),
login: "new@example.com",
password: "new-pass",
dailyLimitGb: "7"
};
const next = applyAccountEdit(settings, state);
expect(next.ddownloadLogin).toBe("new@example.com");
expect(next.ddownloadPassword).toBe("new-pass");
expect(next.linkSnappyLogin).toBe("keep@example.com");
expect(next.linkSnappyPassword).toBe("keep-pass");
expect(next.providerDailyLimitBytes.ddownload).toBe(7 * GIB);
});
it("rejects whitespace-only passwords for direct login accounts", () => {
const settings = {
...defaultSettings(),
ddownloadLogin: "user@example.com",
ddownloadPassword: "password",
linkSnappyLogin: "member@example.com",
linkSnappyPassword: "secret"
};
const ddownloadTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-ddownload",
kind: "ddownload-login",
service: "ddownload",
provider: "ddownload"
};
const linkSnappyTarget: AccountEditTarget = {
type: "single",
rowKey: "svc-linksnappy",
kind: "linksnappy-login",
service: "linksnappy",
provider: "linksnappy"
};
expect(validateAccountEdit({ ...createAccountEditState(ddownloadTarget, settings), password: " " }, settings)).toMatch(/Passwort/i);
expect(validateAccountEdit({ ...createAccountEditState(linkSnappyTarget, settings), password: "\t" }, settings)).toMatch(/Passwort/i);
});
it("builds a targeted check snapshot without invalid sibling accounts", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
debridLinkApiKeys: "one\ntwo"
};
const target = megaTarget("second@example.com");
const state = createAccountEditState(target, settings);
const checkSettings = buildAccountEditCheckSettings(settings, state);
expect(checkSettings.megaCredentials).toBe("second@example.com:second-pass");
expect(checkSettings.megaLogin).toBe("second@example.com");
expect(checkSettings.megaPassword).toBe("second-pass");
expect(checkSettings.debridLinkApiKeys).toBe("");
});
it("removes only the selected Mega-Debrid account and all metadata belonging to it", () => {
const removeId = getMegaDebridAccountId("second@example.com");
const keepId = getMegaDebridAccountId("first@example.com");
const settings = {
...defaultSettings(),
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
megaLogin: "first@example.com",
megaPassword: "first-pass",
megaDebridDisabledAccountIds: [removeId, keepId],
megaDebridAccountDailyLimitBytes: { [removeId]: 2, [keepId]: 1 },
megaDebridAccountDailyUsageBytes: { [removeId]: 4, [keepId]: 3 },
megaDebridAccountTotalUsageBytes: { [removeId]: 6, [keepId]: 5 },
debridAccountStatuses: {
[removeId]: { accountId: removeId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
[keepId]: { accountId: keepId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
}
};
const next = removeAccountTarget(settings, megaTarget("second@example.com"));
expect(next.megaCredentials).toBe("first@example.com:first-pass");
expect(next.megaDebridDisabledAccountIds).toEqual([keepId]);
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [keepId]: 1 });
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [keepId]: 3 });
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [keepId]: 5 });
expect(next.debridAccountStatuses).toEqual({
[keepId]: { accountId: keepId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
});
});
});
+11 -9
View File
@@ -36,10 +36,10 @@ describe("debrid service", () => {
const settings = {
...defaultSettings(),
token: "rd-token",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
bestToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
bestToken: "",
providerOrder: [] as const,
providerPrimary: "realdebrid" as const,
providerSecondary: "megadebrid" as const,
@@ -1621,16 +1621,18 @@ describe("debrid service", () => {
expect(megaGetLinkCalled).toBe(false);
});
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
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",
megaDebridApiEnabled: true,
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,
+2 -2
View File
@@ -430,7 +430,7 @@ describe("debug-server", () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
headers: {
"X-Forwarded-For": "159.195.63.46"
"X-Forwarded-For": "203.0.113.46"
}
});
expect(response.ok).toBe(true);
@@ -439,7 +439,7 @@ describe("debug-server", () => {
const traceLogPath = getTraceLogPath();
expect(traceLogPath).toBeTruthy();
const traceText = fs.readFileSync(traceLogPath!, "utf8");
expect(traceText).toContain("clientIp=159.195.63.46");
expect(traceText).toContain("clientIp=203.0.113.46");
});
it("serves package details and package log by package query", async () => {
+181
View File
@@ -252,6 +252,187 @@ describe("download manager", () => {
expect(failures.has("realdebrid")).toBe(true);
});
it("invalidates only the Mega-Debrid Web session when Web credentials change", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-web-session-refresh-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
megaCredentials: "api-user:api-pass\nweb-user:web-pass",
megaDebridApiCredentials: "api-user:api-pass",
megaDebridWebCredentials: "web-user:web-pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true
};
const invalidateMegaSession = vi.fn();
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
manager.setSettings({ ...settings, megaDebridApiCredentials: "api-user:new-api-pass" });
expect(invalidateMegaSession).not.toHaveBeenCalled();
manager.setSettings({ ...settings, megaDebridApiCredentials: "api-user:new-api-pass", megaDebridWebCredentials: "web-user:new-web-pass" });
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
});
it("releases only Mega-Debrid reset parks when a newly usable account appears", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
const previousSettings = {
...defaultSettings(),
megaCredentials: "old@example.test:old-secret",
megaDebridApiEnabled: true
};
const session = emptySession();
const megaPackageId = "mega-refresh-package";
const otherPackageId = "other-refresh-package";
const megaItemId = "mega-refresh-item";
const otherItemId = "other-refresh-item";
const createdAt = Date.now();
session.packageOrder = [megaPackageId, otherPackageId];
session.packages[megaPackageId] = {
id: megaPackageId,
name: "Mega refresh",
status: "downloading",
itemIds: [megaItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
} as any;
session.packages[otherPackageId] = {
id: otherPackageId,
name: "Other refresh",
status: "queued",
itemIds: [otherItemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
} as any;
session.items[megaItemId] = {
id: megaItemId,
packageId: megaPackageId,
url: "https://rapidgator.net/file/mega-refresh",
provider: "megadebrid-api",
status: "queued",
retries: 1,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "mega-refresh.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "limit",
fullStatus: "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s",
createdAt,
updatedAt: createdAt
} as any;
session.items[otherItemId] = {
...session.items[megaItemId],
id: otherItemId,
packageId: otherPackageId,
url: "https://ddownload.com/file/other-refresh",
provider: "realdebrid",
fileName: "other-refresh.rar",
fullStatus: "Netzwerk-Retry in 60s"
} as any;
session.running = true;
const manager = new DownloadManager(previousSettings, session, storagePaths);
session.running = true;
session.packages[megaPackageId].status = "downloading";
session.items[megaItemId].status = "queued";
session.items[megaItemId].fullStatus = "Mega-Debrid bis Tagesreset gesperrt, Pause 3600s";
session.items[megaItemId].provider = "megadebrid-api";
session.items[otherItemId].status = "queued";
session.items[otherItemId].fullStatus = "Netzwerk-Retry in 60s";
session.items[otherItemId].provider = "realdebrid";
const retryAfter = (manager as any).retryAfterByItem as Map<string, number>;
const retryState = (manager as any).retryStateByItem as Map<string, unknown>;
retryAfter.set(megaItemId, Date.now() + 3_600_000);
retryAfter.set(otherItemId, Date.now() + 60_000);
retryState.set(megaItemId, { unrestrictRetries: 1 });
retryState.set(otherItemId, { genericErrorRetries: 1 });
const scheduler = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupExistingExtractedArchives").mockResolvedValue(0);
manager.setSettings({
...previousSettings,
megaCredentials: "old@example.test:old-secret\nnew@example.test:new-secret",
megaDebridDisabledAccountIds: [getMegaDebridAccountId("old@example.test")]
});
expect(retryAfter.has(megaItemId)).toBe(false);
expect(retryState.has(megaItemId)).toBe(false);
expect(session.items[megaItemId].fullStatus).toBe("Wartet");
expect(session.packages[megaPackageId].status).toBe("queued");
expect(retryAfter.has(otherItemId)).toBe(true);
expect(retryState.has(otherItemId)).toBe(true);
expect(session.items[otherItemId].fullStatus).toBe("Netzwerk-Retry in 60s");
expect(scheduler).toHaveBeenCalledTimes(1);
});
it("updates the package status atomically when an active item is queued for retry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-package-status-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "retry-status-package";
const itemId = "retry-status-item";
const createdAt = Date.now();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Retry status",
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
} as any;
session.items[itemId] = {
id: itemId,
packageId,
url: "https://rapidgator.net/file/retry-status",
provider: "megadebrid-api",
status: "downloading",
retries: 1,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "retry-status.rar",
targetPath: "",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt,
updatedAt: createdAt
} as any;
const manager = new DownloadManager(defaultSettings(), session, storagePaths);
session.packages[packageId].status = "downloading";
const active = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0
};
(manager as any).queueRetry(session.items[itemId], active, 60_000, "Mega-Debrid bis Tagesreset gesperrt, Pause 60s");
expect(session.items[itemId].status).toBe("queued");
expect(session.packages[packageId].status).toBe("queued");
});
it("records history duration from the first actual package start", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
tempDirs.push(root);
+28 -2
View File
@@ -36,7 +36,12 @@ import {
getPackageProgress,
getPackageSizeProgress
} from "../src/renderer/views/downloads/DownloadsTable";
import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format";
import {
compactDownloadServiceLabel,
extractHoster,
formatHosterLabel,
normalizeDownloadServiceLabel
} from "../src/renderer/download-format";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
@@ -71,6 +76,13 @@ describe("Downloadtabellen-Spalten", () => {
expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s);
});
it("changes package collapse state only through user actions", () => {
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/App.tsx"), "utf8");
expect(source).not.toContain("autoExpandedPkgsRef");
expect(source).not.toMatch(/isExtracting[\s\S]{0,800}setCollapsedPackages/);
});
});
describe("rollende Downloadkennzahlen", () => {
@@ -175,12 +187,26 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => {
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%");
expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%");
expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%");
expect(compactDownloadStatus("Passwort knacken: 75% (3/4) · sau-geheim.part1.rar")).toBe("Passwort knacken: 75% (3/4)");
expect(compactDownloadStatus("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden");
expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend");
expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler");
expect(compactDownloadStatus("Extraction error [archive.part1.rar]: Unexpected end of file")).toBe("Extraction error");
});
it("normalizes every supported RapidGator domain to one hoster identity", () => {
const hosters = [
extractHoster("https://rapidgator.net/file/one"),
extractHoster("https://rg.to/file/two"),
extractHoster("https://cdn.rg.to/file/three"),
extractHoster("https://rapidgator.asia/file/four")
];
expect(hosters).toEqual(["rapidgator", "rapidgator", "rapidgator", "rapidgator"]);
expect(new Set(hosters).size).toBe(1);
expect(formatHosterLabel(hosters[1])).toEqual(expect.objectContaining({ compact: "RG", title: "RapidGator", iconSrc: expect.any(String) }));
});
it("removes duplicated access-mode wording from service labels", () => {
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)");
@@ -690,7 +716,7 @@ describe("downloads view", () => {
expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s);
expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s);
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*block;/s);
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*block;/s);
+89 -7
View File
@@ -10,13 +10,95 @@ describe("mega-web-fallback", () => {
});
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("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
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<string>((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<string>;
login: (login: string, password: string) => Promise<string>;
sessions: Map<string, { cookie: string; setAt: number }>;
};
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<string>;
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>;
sessions: Map<string, { cookie: string; setAt: number }>;
};
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" });
const result = await fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" });
expect(result?.directUrl).toBe("https://mega.direct/retry");
expect(internals.sessions.size).toBe(0);
});
it("does not cache old credentials from a queued request after invalidation", async () => {
let releaseFirstLogin: () => void = () => {};
let markFirstLoginStarted: () => void = () => {};
const firstLoginGate = new Promise<void>((resolve) => {
releaseFirstLogin = resolve;
});
const firstLoginStarted = new Promise<void>((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<string>;
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string }>;
sessions: Map<string, { cookie: string; setAt: number }>;
};
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" });
await firstLoginStarted;
const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" });
fallback.invalidateSession();
releaseFirstLogin();
await expect(Promise.all([first, queued])).resolves.toHaveLength(2);
expect(loginCount).toBe(2);
expect(internals.sessions.size).toBe(0);
});
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);
+38 -8
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import { sortPackagesForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[]): PackageEntry {
import { sortPackagesForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
const now = Date.now();
return {
id,
@@ -15,7 +15,8 @@ function createPackage(id: string, itemIds: string[]): PackageEntry {
enabled: true,
priority: "normal",
createdAt: now,
updatedAt: now
updatedAt: now,
downloadStartedAt
};
}
@@ -90,7 +91,7 @@ describe("sortPackagesForDisplay", () => {
expect(orderAfter).toEqual(orderBefore);
});
it("keeps package order untouched when auto sort is disabled", () => {
it("keeps package order untouched when auto sort is disabled", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
@@ -104,6 +105,35 @@ describe("sortPackagesForDisplay", () => {
const sorted = sortPackagesForDisplay(packages, items, true, false);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
});
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("keeps every active package in activation order when a new package starts", () => {
const packages = [
createPackage("pkg-new", ["new-item"], 200),
createPackage("pkg-existing", ["existing-item"], 100)
];
const items: Record<string, DownloadItem> = {
"new-item": createItem("new-item", "pkg-new", "downloading", 100),
"existing-item": createItem("existing-item", "pkg-existing", "downloading", 200)
};
const sorted = sortPackagesForDisplay(packages, items, true, true);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-existing", "pkg-new"]);
});
it("keeps queue order for active packages without a recorded start time", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
createPackage("pkg-c", ["c1"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "completed", 500),
b1: createItem("b1", "pkg-b", "downloading", 200),
c1: createItem("c1", "pkg-c", "downloading", 100)
};
expect(sortPackagesForDisplay(packages, items, true, true).map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-c", "pkg-a"]);
});
});
+19
View File
@@ -3,6 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App";
import {
applyAccountEdit,
createAccountEditState,
@@ -905,6 +906,24 @@ describe("account workspace", () => {
});
describe("settings App integration", () => {
it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => {
const settings = {
...defaultSettings(),
megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret",
debridLinkApiKeys: "existing-debrid-link-key"
};
const megaDialog = createAccountDialogState("create", "megadebrid-api", settings);
const megaFields = buildAccountAddFields(megaDialog);
const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", settings));
expect(megaDialog.megaNewLogin).toBe("");
expect(megaDialog.megaNewPassword).toBe("");
expect(megaDialog.token).toBe("");
expect(megaFields.map((field) => field.id)).toEqual(["megaNewLogin", "megaNewPassword", "dailyLimitGb"]);
expect(megaFields.map((field) => field.label)).not.toContain("Token / API-Key");
expect(debridLinkFields.find((field) => field.id === "token")).toEqual(expect.objectContaining({ value: "" }));
});
it("keeps specific persistence revision-safe when the draft changes in flight", () => {
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
expect(block).toContain("revisionAtStart");
+1 -1
View File
@@ -438,7 +438,7 @@ describe("bandwidth chart palette", () => {
const collector = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
expect(theme).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--ui-focus\);/s);
expect(shell).toContain("color: var(--ui-primary-text);");
expect(shell).toContain("color: var(--ui-update-text);");
expect(collector.match(/color:\s*var\(--ui-primary-text\);/g)).toHaveLength(3);
});
+108 -15
View File
@@ -2,7 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
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 { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
@@ -44,11 +45,32 @@ describe("settings storage", () => {
expect(normalizeSettings({ ...normalized, columnOrder: ["name", "speed"] }).columnOrder).toEqual(["name", "speed"]);
});
it("uses English for new installations and preserves only supported languages", () => {
it("uses English for new installations and preserves only supported languages", () => {
expect(defaultSettings().language).toBe("en");
expect(normalizeSettings({ ...defaultSettings(), language: "de" }).language).toBe("de");
expect(normalizeSettings({ ...defaultSettings(), language: "fr" as "en" }).language).toBe("en");
});
expect(normalizeSettings({ ...defaultSettings(), language: "fr" as "en" }).language).toBe("en");
});
it("migrates a legacy shared Mega-Debrid pool into only the preferred mode", () => {
const legacy = { ...defaultSettings() } as Partial<AppSettings>;
legacy.megaCredentials = "legacy@example.test:legacy-pass";
legacy.megaLogin = "legacy@example.test";
legacy.megaPassword = "legacy-pass";
legacy.megaDebridApiEnabled = true;
legacy.megaDebridWebEnabled = true;
legacy.megaDebridPreferApi = true;
delete legacy.megaDebridApiCredentials;
delete legacy.megaDebridWebCredentials;
delete legacy.megaDebridApiDisabledAccountIds;
delete legacy.megaDebridWebDisabledAccountIds;
const normalized = normalizeSettings(legacy as AppSettings);
expect(normalized.megaDebridApiCredentials).toBe("legacy@example.test:legacy-pass");
expect(normalized.megaDebridWebCredentials).toBe("");
expect(normalized.megaDebridApiEnabled).toBe(true);
expect(normalized.megaDebridWebEnabled).toBe(false);
});
it("keeps German for existing settings files created before language selection existed", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
@@ -109,17 +131,23 @@ describe("settings storage", () => {
saveSettings(paths, {
...defaultSettings(),
rememberToken: false,
token: "rd-token",
megaLogin: "mega-user",
megaPassword: "mega-pass",
bestToken: "best-token",
token: "rd-token",
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaCredentials: "mega-user:mega-pass",
megaDebridApiCredentials: "mega-user:mega-pass",
megaDebridWebCredentials: "web-user:web-pass",
bestToken: "best-token",
allDebridToken: "all-token"
});
const raw = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record<string, unknown>;
expect(raw.token).toBe("");
expect(raw.megaLogin).toBe("");
expect(raw.megaPassword).toBe("");
expect(raw.megaPassword).toBe("");
expect(raw.megaCredentials).toBe("");
expect(raw.megaDebridApiCredentials).toBe("");
expect(raw.megaDebridWebCredentials).toBe("");
expect(raw.bestToken).toBe("");
expect(raw.allDebridToken).toBe("");
@@ -127,7 +155,10 @@ describe("settings storage", () => {
expect(loaded.rememberToken).toBe(false);
expect(loaded.token).toBe("");
expect(loaded.megaLogin).toBe("");
expect(loaded.megaPassword).toBe("");
expect(loaded.megaPassword).toBe("");
expect(loaded.megaCredentials).toBe("");
expect(loaded.megaDebridApiCredentials).toBe("");
expect(loaded.megaDebridWebCredentials).toBe("");
expect(loaded.bestToken).toBe("");
expect(loaded.allDebridToken).toBe("");
});
@@ -275,7 +306,7 @@ describe("settings storage", () => {
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
});
it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => {
it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -304,10 +335,72 @@ describe("settings storage", () => {
fs.writeFileSync(paths2.configFile, JSON.stringify(legacyWeb), "utf8");
const loadedWeb = loadSettings(paths2);
expect(loadedWeb.megaDebridApiEnabled).toBe(false);
expect(loadedWeb.megaDebridWebEnabled).toBe(true);
});
it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => {
expect(loadedWeb.megaDebridWebEnabled).toBe(true);
});
it("migrates an explicitly Web-only legacy account even when API remains preferred", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.configFile, JSON.stringify({
rememberToken: true,
megaCredentials: "web-user:web-pass",
megaDebridPreferApi: true,
megaDebridApiEnabled: false,
megaDebridWebEnabled: true
}), "utf8");
const loaded = loadSettings(paths);
expect(loaded.megaDebridApiCredentials).toBe("");
expect(loaded.megaDebridWebCredentials).toBe("web-user:web-pass");
expect(loaded.megaDebridApiEnabled).toBe(false);
expect(loaded.megaDebridWebEnabled).toBe(true);
});
it("migrates legacy disabled Mega-Debrid accounts into the selected mode", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const accountId = getMegaDebridAccountId("disabled-user");
fs.writeFileSync(paths.configFile, JSON.stringify({
rememberToken: true,
megaCredentials: "disabled-user:disabled-pass",
megaDebridPreferApi: true,
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
megaDebridDisabledAccountIds: [accountId]
}), "utf8");
const loaded = loadSettings(paths);
expect(loaded.megaDebridApiDisabledAccountIds).toEqual([accountId]);
expect(loaded.megaDebridWebDisabledAccountIds).toEqual([]);
});
it("preserves explicit mode-specific disabled Mega-Debrid accounts", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const accountId = getMegaDebridAccountId("shared-user");
fs.writeFileSync(paths.configFile, JSON.stringify({
rememberToken: true,
megaDebridApiCredentials: "shared-user:api-pass",
megaDebridWebCredentials: "shared-user:web-pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
megaDebridDisabledAccountIds: [accountId],
megaDebridApiDisabledAccountIds: [],
megaDebridWebDisabledAccountIds: [accountId]
}), "utf8");
const loaded = loadSettings(paths);
expect(loaded.megaDebridApiDisabledAccountIds).toEqual([]);
expect(loaded.megaDebridWebDisabledAccountIds).toEqual([accountId]);
});
it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
+186 -174
View File
@@ -1,144 +1,144 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App";
import type { UpdateCheckResult } from "../src/shared/types";
import { AppHeader } from "../src/renderer/shell/AppHeader";
import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience";
const callbacks = {
onOpen: () => {},
onClose: () => {},
onInstall: () => {},
onLater: () => {}
};
describe("update experience", () => {
it("renders the available update and prompt as one accessible experience", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={0}
releaseNotes="Changes"
state="prompt"
{...callbacks}
/>
);
expect(html).toContain("aria-label=\"Update verfügbar\"");
expect(html).toContain("role=\"tooltip\"");
expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren.");
expect(html).toContain("role=\"dialog\"");
expect(html).toContain("aria-modal=\"true\"");
expect(html).toContain("Update installieren");
expect(html).toContain("Jetzt aktualisieren");
expect(html).toContain("Später");
expect(html).toContain("Changes");
expect(html).toContain("<details");
});
it("keeps the update affordance but removes the dialog when the prompt is closed", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open={false}
progress={0}
releaseNotes="Changes"
state="prompt"
{...callbacks}
/>
);
expect(html).toContain("aria-label=\"Update verfügbar\"");
expect(html).not.toContain("role=\"dialog\"");
});
it("renders active progress without controls that could close the installation", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={{ percent: 47, text: "Update-Download: 47% (47 MB / 100 MB)" }}
releaseNotes=""
state="downloading"
{...callbacks}
/>
);
expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)");
expect(html).toContain("aria-valuenow=\"47\"");
expect(html).not.toContain("Später");
expect(html).not.toContain("Jetzt aktualisieren");
expect(html).not.toContain("aria-label=\"Schließen\"");
});
it("preserves the original installation error in the reusable dialog", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={{ percent: null, text: "Update-Fehler: Originale Prüfsummenmeldung" }}
releaseNotes=""
state="error"
{...callbacks}
/>
);
expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung");
expect(html).toContain("aria-label=\"Schließen\"");
});
it("renders nothing when no update is available and no dialog is active", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available={false}
currentVersion="v2.0.12"
latestTag=""
open={false}
progress={0}
releaseNotes=""
state="prompt"
{...callbacks}
/>
);
expect(html).toBe("");
});
it("places the update affordance in the accessible global header action group", () => {
const html = renderToStaticMarkup(
<AppHeader
activeView="downloads"
actions={(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open={false}
progress={0}
releaseNotes=""
state="prompt"
{...callbacks}
/>
)}
onViewChange={() => {}}
/>
);
expect(html).toContain("role=\"group\"");
expect(html).toContain("aria-label=\"Globale Aktionen\"");
expect(html).toContain("aria-label=\"Update verfügbar\"");
});
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App";
import type { UpdateCheckResult } from "../src/shared/types";
import { AppHeader } from "../src/renderer/shell/AppHeader";
import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience";
const callbacks = {
onOpen: () => {},
onClose: () => {},
onInstall: () => {},
onLater: () => {}
};
describe("update experience", () => {
it("renders the available update and prompt as one accessible experience", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={0}
releaseNotes="Changes"
state="prompt"
{...callbacks}
/>
);
expect(html).toContain("aria-label=\"Update verfügbar\"");
expect(html).toContain("role=\"tooltip\"");
expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren.");
expect(html).toContain("role=\"dialog\"");
expect(html).toContain("aria-modal=\"true\"");
expect(html).toContain("Update installieren");
expect(html).toContain("Jetzt aktualisieren");
expect(html).toContain("Später");
expect(html).toContain("Changes");
expect(html).toContain("<details");
});
it("keeps the update affordance but removes the dialog when the prompt is closed", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open={false}
progress={0}
releaseNotes="Changes"
state="prompt"
{...callbacks}
/>
);
expect(html).toContain("aria-label=\"Update verfügbar\"");
expect(html).not.toContain("role=\"dialog\"");
});
it("renders active progress without controls that could close the installation", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={{ percent: 47, text: "Update-Download: 47% (47 MB / 100 MB)" }}
releaseNotes=""
state="downloading"
{...callbacks}
/>
);
expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)");
expect(html).toContain("aria-valuenow=\"47\"");
expect(html).not.toContain("Später");
expect(html).not.toContain("Jetzt aktualisieren");
expect(html).not.toContain("aria-label=\"Schließen\"");
});
it("preserves the original installation error in the reusable dialog", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={{ percent: null, text: "Update-Fehler: Originale Prüfsummenmeldung" }}
releaseNotes=""
state="error"
{...callbacks}
/>
);
expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung");
expect(html).toContain("aria-label=\"Schließen\"");
});
it("renders nothing when no update is available and no dialog is active", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available={false}
currentVersion="v2.0.12"
latestTag=""
open={false}
progress={0}
releaseNotes=""
state="prompt"
{...callbacks}
/>
);
expect(html).toBe("");
});
it("places the update affordance in the accessible global header action group", () => {
const html = renderToStaticMarkup(
<AppHeader
activeView="downloads"
actions={(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open={false}
progress={0}
releaseNotes=""
state="prompt"
{...callbacks}
/>
)}
onViewChange={() => {}}
/>
);
expect(html).toContain("role=\"group\"");
expect(html).toContain("aria-label=\"Globale Aktionen\"");
expect(html).toContain("aria-label=\"Update verfügbar\"");
});
it("uses the specified transient and modal elevation tokens", () => {
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
@@ -146,37 +146,49 @@ describe("update experience", () => {
expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s);
});
it("keeps forward and reverse tabbing inside the update dialog", () => {
expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0);
expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3);
expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0);
expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3);
expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull();
expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull();
it("uses a light-blue update affordance and a bounded scrollable changelog", () => {
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
expect(theme).toMatch(/--ui-update:\s*#BAD0FC;/);
expect(theme).toMatch(/--ui-update-hover:\s*#8AA5DC;/);
expect(theme).toMatch(/--ui-update-text:\s*#181A1F;/);
expect(css).toMatch(/\.md-update-trigger\s*\{[^}]*background:\s*var\(--ui-update\);[^}]*color:\s*var\(--ui-update-text\);/s);
expect(css).toMatch(/\.md-update-trigger:hover\s*\{[^}]*background:\s*var\(--ui-update-hover\);/s);
expect(css).toMatch(/\.md-update-release-notes pre\s*\{[^}]*max-height:\s*min\(360px, 45vh\);[^}]*overflow-y:\s*auto;/s);
});
it("rejects stale update-check completions without discarding the latest state", () => {
expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true);
expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false);
expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false);
});
it("applies only the latest result when update checks complete out of order", async () => {
const generation = { current: 0 };
const applied: string[] = [];
let finishStartup: ((result: UpdateCheckResult) => void) | undefined;
let finishManual: ((result: UpdateCheckResult) => void) | undefined;
const startup = new Promise<UpdateCheckResult>((resolve) => { finishStartup = resolve; });
const manual = new Promise<UpdateCheckResult>((resolve) => { finishManual = resolve; });
const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); };
const startupRun = runLatestUpdateCheck(generation, () => startup, apply);
const manualRun = runLatestUpdateCheck(generation, () => manual, apply);
finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" });
await manualRun;
finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" });
await startupRun;
expect(applied).toEqual(["v9.9.9"]);
});
});
it("keeps forward and reverse tabbing inside the update dialog", () => {
expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0);
expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3);
expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0);
expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3);
expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull();
expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull();
});
it("rejects stale update-check completions without discarding the latest state", () => {
expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true);
expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false);
expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false);
});
it("applies only the latest result when update checks complete out of order", async () => {
const generation = { current: 0 };
const applied: string[] = [];
let finishStartup: ((result: UpdateCheckResult) => void) | undefined;
let finishManual: ((result: UpdateCheckResult) => void) | undefined;
const startup = new Promise<UpdateCheckResult>((resolve) => { finishStartup = resolve; });
const manual = new Promise<UpdateCheckResult>((resolve) => { finishManual = resolve; });
const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); };
const startupRun = runLatestUpdateCheck(generation, () => startup, apply);
const manualRun = runLatestUpdateCheck(generation, () => manual, apply);
finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" });
await manualRun;
finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" });
await startupRun;
expect(applied).toEqual(["v9.9.9"]);
});
});
+48 -2
View File
@@ -79,7 +79,7 @@ describe("update", () => {
expect(result.updateAvailable).toBe(false);
});
it("picks setup executable asset from release list", async () => {
it("picks setup executable asset from release list", async () => {
globalThis.fetch = (async (): Promise<Response> => new Response(
JSON.stringify({
tag_name: "v9.9.9",
@@ -106,7 +106,53 @@ describe("update", () => {
expect(result.updateAvailable).toBe(true);
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe");
expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe");
});
});
it("combines every stable release note newer than the installed version", async () => {
const [major = 2, minor = 0, patch = 0] = parseVersionParts(APP_VERSION);
const version = (offset: number): string => `${major}.${minor}.${patch + offset}`;
const requestedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
requestedUrls.push(url);
if (url.endsWith("/releases/latest")) {
return new Response(JSON.stringify({
tag_name: `v${version(3)}`,
html_url: `https://github.com/owner/repo/releases/tag/v${version(3)}`,
body: "Latest changes",
assets: []
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify([
{ tag_name: `v${version(3)}`, body: "Latest changes", draft: false, prerelease: false },
{ tag_name: `v${version(2)}`, body: "Middle changes", draft: false, prerelease: false },
{ tag_name: `v${version(1)}`, body: "First missed changes", draft: false, prerelease: false },
{ tag_name: `v${version(4)}`, body: "Draft changes", draft: true, prerelease: false },
{ tag_name: `v${version(5)}`, body: "Prerelease changes", draft: false, prerelease: true },
{ tag_name: `v${version(0)}`, body: "Installed changes", draft: false, prerelease: false },
{ tag_name: `v${version(-1)}`, body: "Older changes", draft: false, prerelease: false }
]), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
const result = await checkGitHubUpdate("owner/repo");
expect(requestedUrls).toEqual([
"https://api.github.com/repos/owner/repo/releases/latest",
"https://api.github.com/repos/owner/repo/releases?per_page=100&page=1"
]);
expect(result.releaseNotes).toBe([
`v${version(3)}`,
"Latest changes",
"",
`v${version(2)}`,
"Middle changes",
"",
`v${version(1)}`,
"First missed changes"
].join("\n"));
});
it("uses silent NSIS install flags with auto-run after update", () => {
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
+5 -1
View File
@@ -60,6 +60,8 @@ function createSettings(): AppSettings {
megaPassword: "visual-password",
language: "de",
megaCredentials: `${megaLogin}:visual-password`,
megaDebridApiCredentials: `${megaLogin}:visual-password`,
megaDebridWebCredentials: "",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
megaDebridPreferApi: true,
@@ -173,7 +175,9 @@ function createSettings(): AppSettings {
[debridLinkKeys[0].id]: 1099511627776,
[debridLinkKeys[1].id]: 549755813888
},
megaDebridDisabledAccountIds: [],
megaDebridDisabledAccountIds: [],
megaDebridApiDisabledAccountIds: [],
megaDebridWebDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {
[megaAccountId]: 322122547200
},