fix(accounts): serialize rapid toggle intents
This commit is contained in:
+143
-24
@@ -1,17 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createRendererSettings } from "../src/main/renderer-state";
|
||||
import {
|
||||
buildAccountTogglePatch,
|
||||
buildConfiguredProviderOrder,
|
||||
createAccountToggleQueue,
|
||||
enqueueAccountToggleIntent,
|
||||
filterAccountDialogOptions,
|
||||
getAvailableAccountOptions,
|
||||
getAccountDialogSelectableOptions,
|
||||
isAccountRowSelectionKey,
|
||||
matchesAccountModeFilter,
|
||||
mergeAccountToggleSettings,
|
||||
pruneAccountRowSelection,
|
||||
resolveAccountUsername,
|
||||
resolveVisibleAccountKind,
|
||||
runAccountEnableRefresh,
|
||||
sortAccountServices
|
||||
} from "../src/renderer/account-ui";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import * as accountUi from "../src/renderer/account-ui";
|
||||
|
||||
describe("account mode filter", () => {
|
||||
@@ -136,37 +142,150 @@ describe("account usernames", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("account activation refresh", () => {
|
||||
it("checks an account before enabling it", async () => {
|
||||
describe("account toggle bursts", () => {
|
||||
it("serializes different account intents without dropping the second task", async () => {
|
||||
const queue = createAccountToggleQueue();
|
||||
const events: string[] = [];
|
||||
let releaseFirst: () => void = () => {};
|
||||
const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
||||
const first = queue.enqueue("account-a", async () => {
|
||||
events.push("a:start");
|
||||
await firstGate;
|
||||
events.push("a:end");
|
||||
return "a";
|
||||
});
|
||||
const second = queue.enqueue("account-b", async () => {
|
||||
events.push("b:start");
|
||||
events.push("b:end");
|
||||
return "b";
|
||||
});
|
||||
|
||||
await runAccountEnableRefresh(
|
||||
async () => { events.push("check"); return { valid: true, message: "Premium aktiv" }; },
|
||||
async () => { events.push("persist"); }
|
||||
);
|
||||
|
||||
expect(events).toEqual(["check", "persist"]);
|
||||
await Promise.resolve();
|
||||
expect(events).toEqual(["a:start"]);
|
||||
releaseFirst();
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ status: "applied", value: "a" },
|
||||
{ status: "applied", value: "b" }
|
||||
]);
|
||||
expect(events).toEqual(["a:start", "a:end", "b:start", "b:end"]);
|
||||
});
|
||||
|
||||
it("does not check an account while disabling it", async () => {
|
||||
const events: string[] = [];
|
||||
it("lets only the latest queued intent for the same account persist", async () => {
|
||||
const queue = createAccountToggleQueue();
|
||||
const persisted: string[] = [];
|
||||
let releaseBlocker: () => void = () => {};
|
||||
const blocker = queue.enqueue("blocker", async () => new Promise<string>((resolve) => {
|
||||
releaseBlocker = () => resolve("released");
|
||||
}));
|
||||
const stale = queue.enqueue("account-a", async () => {
|
||||
persisted.push("stale");
|
||||
return "stale";
|
||||
});
|
||||
const latest = queue.enqueue("account-a", async () => {
|
||||
persisted.push("latest");
|
||||
return "latest";
|
||||
});
|
||||
|
||||
await runAccountEnableRefresh(
|
||||
undefined,
|
||||
async () => { events.push("persist"); }
|
||||
);
|
||||
|
||||
expect(events).toEqual(["persist"]);
|
||||
await Promise.resolve();
|
||||
releaseBlocker();
|
||||
await blocker;
|
||||
await expect(stale).resolves.toEqual({ status: "superseded" });
|
||||
await expect(latest).resolves.toEqual({ status: "applied", value: "latest" });
|
||||
expect(persisted).toEqual(["latest"]);
|
||||
});
|
||||
|
||||
it("does not enable an account whose silent refresh is invalid", async () => {
|
||||
const events: string[] = [];
|
||||
it("rebases consecutive account patches on the latest persisted settings", () => {
|
||||
const source = defaultSettings();
|
||||
source.debridLinkApiKeys = "first-key\nsecond-key";
|
||||
const settings = createRendererSettings(source);
|
||||
const [first, second] = parseDebridLinkApiKeys(source.debridLinkApiKeys);
|
||||
const afterFirst = { ...settings, ...buildAccountTogglePatch(settings, { type: "debridlink", accountId: first.id }, false) };
|
||||
const afterSecond = { ...afterFirst, ...buildAccountTogglePatch(afterFirst, { type: "debridlink", accountId: second.id }, false) };
|
||||
|
||||
await expect(runAccountEnableRefresh(
|
||||
async () => { events.push("check"); return { valid: false, message: "Sitzung abgelaufen" }; },
|
||||
async () => { events.push("persist"); }
|
||||
)).rejects.toThrow("Sitzung abgelaufen");
|
||||
expect(afterSecond.debridLinkDisabledKeyIds).toEqual([first.id, second.id]);
|
||||
});
|
||||
|
||||
expect(events).toEqual(["check"]);
|
||||
it("does not let a stale enable check overwrite a newer disable intent", async () => {
|
||||
const queue = createAccountToggleQueue();
|
||||
let settings = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" });
|
||||
let releaseCheck: () => void = () => {};
|
||||
const checkGate = new Promise<void>((resolve) => { releaseCheck = resolve; });
|
||||
const persistedPatches: unknown[] = [];
|
||||
const dependencies = {
|
||||
getSettings: () => settings,
|
||||
persist: async (patch: ReturnType<typeof buildAccountTogglePatch>) => {
|
||||
persistedPatches.push(patch);
|
||||
settings = { ...settings, ...patch };
|
||||
return settings;
|
||||
}
|
||||
};
|
||||
const target = { type: "provider", provider: "deepbrid" } as const;
|
||||
const enable = enqueueAccountToggleIntent(queue, {
|
||||
key: "svc-deepbrid",
|
||||
target,
|
||||
enabled: true,
|
||||
check: async () => {
|
||||
await checkGate;
|
||||
return { valid: true };
|
||||
}
|
||||
}, dependencies);
|
||||
|
||||
await Promise.resolve();
|
||||
const disable = enqueueAccountToggleIntent(queue, { key: "svc-deepbrid", target, enabled: false }, dependencies);
|
||||
releaseCheck();
|
||||
|
||||
await expect(enable).resolves.toEqual({ status: "superseded" });
|
||||
await expect(disable).resolves.toEqual(expect.objectContaining({ status: "applied" }));
|
||||
expect(persistedPatches).toHaveLength(1);
|
||||
expect(settings.disabledProviders).toContain("deepbrid");
|
||||
});
|
||||
|
||||
it("continues with the next account after an earlier toggle fails", async () => {
|
||||
const queue = createAccountToggleQueue();
|
||||
const first = queue.enqueue("account-a", async () => { throw new Error("invalid account"); });
|
||||
const second = queue.enqueue("account-b", async () => "saved-b");
|
||||
|
||||
await expect(first).resolves.toEqual(expect.objectContaining({ status: "failed" }));
|
||||
await expect(second).resolves.toEqual({ status: "applied", value: "saved-b" });
|
||||
});
|
||||
|
||||
it("rebases a full settings save onto the latest persisted account state", () => {
|
||||
const base = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" });
|
||||
const draft = { ...base, theme: "light" as const, disabledProviders: [] };
|
||||
const persisted = { ...base, theme: "dark" as const, disabledProviders: ["deepbrid" as const] };
|
||||
|
||||
expect(mergeAccountToggleSettings(draft, persisted)).toEqual(expect.objectContaining({
|
||||
theme: "light",
|
||||
disabledProviders: ["deepbrid"]
|
||||
}));
|
||||
});
|
||||
|
||||
it("preserves a queued account toggle when a full settings save follows immediately", async () => {
|
||||
const queue = createAccountToggleQueue();
|
||||
let settings = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" });
|
||||
const draft = { ...settings, theme: "light" as const };
|
||||
const dependencies = {
|
||||
getSettings: () => settings,
|
||||
persist: async (patch: ReturnType<typeof buildAccountTogglePatch>) => {
|
||||
settings = { ...settings, ...patch };
|
||||
return settings;
|
||||
}
|
||||
};
|
||||
const toggle = enqueueAccountToggleIntent(queue, {
|
||||
key: "svc-deepbrid",
|
||||
target: { type: "provider", provider: "deepbrid" },
|
||||
enabled: false
|
||||
}, dependencies);
|
||||
const fullSave = queue.enqueue("settings-save-1", async () => {
|
||||
settings = mergeAccountToggleSettings(draft, settings);
|
||||
return settings;
|
||||
});
|
||||
|
||||
await expect(toggle).resolves.toEqual(expect.objectContaining({ status: "applied" }));
|
||||
await expect(fullSave).resolves.toEqual(expect.objectContaining({ status: "applied" }));
|
||||
expect(settings).toEqual(expect.objectContaining({
|
||||
theme: "light",
|
||||
disabledProviders: ["deepbrid"]
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,10 @@ import { createRendererSettings, createRendererState } from "../src/main/rendere
|
||||
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
|
||||
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
|
||||
import {
|
||||
buildAccountTogglePatch,
|
||||
buildScopedAccountEnabledState,
|
||||
buildConfiguredProviderOrder,
|
||||
resolveAccountStatusState,
|
||||
runOptimisticAccountUpdate
|
||||
resolveAccountStatusState
|
||||
} from "../src/renderer/account-ui";
|
||||
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
@@ -1048,7 +1048,7 @@ describe("account workspace", () => {
|
||||
model: workspaceModel(),
|
||||
actions: workspaceActions({
|
||||
onSelect: (id, additive) => calls.push(`select:${id}:${additive}`),
|
||||
onToggleEnabled: (id) => calls.push(`toggle:${id}`),
|
||||
onToggleEnabled: (id, enabled) => calls.push(`toggle:${id}:${enabled}`),
|
||||
onEdit: (id) => calls.push(`edit:${id}`),
|
||||
onContextMenu: (id) => calls.push(`context:${id}`)
|
||||
})
|
||||
@@ -1062,7 +1062,7 @@ describe("account workspace", () => {
|
||||
row.props.onClick({ target: { role: "cell" }, currentTarget: row, ctrlKey: true, metaKey: false });
|
||||
row.props.onKeyDown({ key: "Enter", target: row, currentTarget: row, preventDefault: () => {} });
|
||||
row.props.onKeyDown({ key: " ", target: checkbox, currentTarget: row, preventDefault: () => {} });
|
||||
checkbox.props.onChange();
|
||||
checkbox.props.onChange({ target: { checked: false } });
|
||||
row.props.onDoubleClick();
|
||||
actionButton.props.onClick({ stopPropagation: () => {}, currentTarget: { getBoundingClientRect: () => ({ right: 20, bottom: 30 }) } });
|
||||
actionButton.props.onDoubleClick({ stopPropagation: () => calls.push("action-double-click-stopped") });
|
||||
@@ -1071,7 +1071,7 @@ describe("account workspace", () => {
|
||||
`select:${rowId}:false`,
|
||||
`select:${rowId}:true`,
|
||||
`select:${rowId}:false`,
|
||||
`toggle:${rowId}`,
|
||||
`toggle:${rowId}:false`,
|
||||
`edit:${rowId}`,
|
||||
`context:${rowId}`,
|
||||
"action-double-click-stopped"
|
||||
@@ -1432,10 +1432,10 @@ describe("settings App integration", () => {
|
||||
const deleteKeyBlock = sourceBlock(appSource, "const onRemoveDebridLinkKey", "const onToggleAccountEnabled");
|
||||
const deleteRowsBlock = sourceBlock(appSource, "const removeAccountTableRows", "const checkAccountsActive");
|
||||
|
||||
expect(editBlock.indexOf("await persistDraftSettings()")).toBeLessThan(editBlock.indexOf("window.rd.replaceAccount"));
|
||||
expect(createBlock.indexOf("await persistDraftSettings()")).toBeLessThan(createBlock.indexOf("window.rd.createAccount"));
|
||||
expect(deleteKeyBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteKeyBlock.indexOf("window.rd.deleteAccount"));
|
||||
expect(deleteRowsBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteRowsBlock.indexOf("window.rd.deleteAccount"));
|
||||
expect(editBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(editBlock.indexOf("window.rd.replaceAccount"));
|
||||
expect(createBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(createBlock.indexOf("window.rd.createAccount"));
|
||||
expect(deleteKeyBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(deleteKeyBlock.indexOf("window.rd.deleteAccount"));
|
||||
expect(deleteRowsBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(deleteRowsBlock.indexOf("window.rd.deleteAccount"));
|
||||
});
|
||||
|
||||
it("invalidates an archive password reveal before applying imported settings", () => {
|
||||
@@ -1462,12 +1462,18 @@ describe("settings App integration", () => {
|
||||
disabledAccountIds: ["key-active"]
|
||||
});
|
||||
|
||||
const debridLinkBlock = sourceBlock(appSource, "const onToggleDebridLinkApiKeyEnabled", "const onAccountRowQuickAction");
|
||||
const megaBlock = sourceBlock(appSource, "const onToggleMegaAccountEnabled", "const onRemoveDebridLinkKey");
|
||||
expect(debridLinkBlock).toContain("buildScopedAccountEnabledState");
|
||||
expect(megaBlock).toContain("buildScopedAccountEnabledState");
|
||||
expect(megaBlock).toContain('megaDebridApiEnabled: mode === "api" && enabled ? true');
|
||||
expect(megaBlock).toContain('megaDebridWebEnabled: mode === "web" && enabled ? true');
|
||||
const rendererSettings = createRendererSettings(defaultSettings());
|
||||
const megaPatch = buildAccountTogglePatch({
|
||||
...rendererSettings,
|
||||
disabledProviders: ["megadebrid-web"],
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridWebDisabledAccountIds: ["mega-web-account"]
|
||||
}, { type: "megadebrid", provider: "megadebrid-web", accountId: "mega-web-account" }, true);
|
||||
expect(megaPatch).toEqual(expect.objectContaining({
|
||||
disabledProviders: [],
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridWebDisabledAccountIds: []
|
||||
}));
|
||||
});
|
||||
|
||||
it("shows a failed all-account check even when the account is disabled", () => {
|
||||
@@ -1476,28 +1482,6 @@ describe("settings App integration", () => {
|
||||
expect(resolveAccountStatusState(false, undefined)).toBe("unchecked");
|
||||
});
|
||||
|
||||
it("applies account switches before persistence settles and rolls back failed saves", async () => {
|
||||
const events: string[] = [];
|
||||
let resolvePersist: (value: string) => void = () => { throw new Error("persist resolver missing"); };
|
||||
const pending = runOptimisticAccountUpdate(
|
||||
() => events.push("apply"),
|
||||
() => new Promise<string>((resolve) => { resolvePersist = resolve; }),
|
||||
() => events.push("rollback")
|
||||
);
|
||||
|
||||
expect(events).toEqual(["apply"]);
|
||||
resolvePersist("saved");
|
||||
await expect(pending).resolves.toBe("saved");
|
||||
expect(events).toEqual(["apply"]);
|
||||
|
||||
await expect(runOptimisticAccountUpdate(
|
||||
() => events.push("apply-failed"),
|
||||
async () => { throw new Error("save failed"); },
|
||||
() => events.push("rollback")
|
||||
)).rejects.toThrow("save failed");
|
||||
expect(events.slice(-2)).toEqual(["apply-failed", "rollback"]);
|
||||
});
|
||||
|
||||
it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -1517,24 +1501,6 @@ describe("settings App integration", () => {
|
||||
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");
|
||||
const toggleBlock = sourceBlock(appSource, "const persistAccountToggle", "const onToggleDebridLinkApiKeyEnabled");
|
||||
expect(block).toContain("revisionAtStart");
|
||||
expect(block).toContain("mergeConcurrentSpecificSettings");
|
||||
expect(block).toContain('setSettingsSaveState("dirty")');
|
||||
expect(block).toContain("persistedSettingsRef.current = result");
|
||||
expect(block.indexOf("persistedSettingsRef.current = result")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)"));
|
||||
expect(block).toContain("themeChoiceAtStart: settingsThemeChoiceRef.current");
|
||||
expect(block).toContain("const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext");
|
||||
expect(block).toContain("persistedThemeChoiceRef.current = themeChoiceAtStart");
|
||||
expect(block.indexOf("persistedThemeChoiceRef.current = themeChoiceAtStart")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)"));
|
||||
expect(toggleBlock).toContain("const themeChoiceAtStart = settingsThemeChoiceRef.current");
|
||||
expect(toggleBlock).toContain("const persistenceContext = {");
|
||||
expect(toggleBlock.indexOf("const persistenceContext = {")).toBeLessThan(toggleBlock.indexOf("runAccountEnableRefresh("));
|
||||
expect(toggleBlock).toContain("persistSpecificSettings(nextDraft, persistenceContext)");
|
||||
});
|
||||
|
||||
it("keeps unchecked single accounts honest without a positive status", () => {
|
||||
const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId");
|
||||
expect(block).toContain("resolveAccountStatusState(row.disabled, checkedStatus, runtimeNow)");
|
||||
|
||||
Reference in New Issue
Block a user