Align provider selection and abort observation

Share one pure provider selection plan between real unrestrict routing and cooldown projection, including direct 1Fichier and DDownload paths, disabled Mega aliases, and the secondary-provider exception used when Real-Debrid is cooling down. Attach terminal observers to raw serialized web jobs before evaluating already-aborted signals so late rejections stay handled while the queue remains available. Add RED-to-GREEN regressions for each provider-selection counterexample and pre-aborted Real-Debrid, AllDebrid, and BestDebrid jobs.
This commit is contained in:
Sucukdeluxe
2026-08-22 11:05:42 +02:00
parent ab31d04410
commit f57b513625
10 changed files with 336 additions and 77 deletions
+27
View File
@@ -181,6 +181,33 @@ describe("alldebrid-web", () => {
await Promise.resolve();
});
it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({
link: "https://alldebrid.direct/next.bin",
filename: "next.bin",
filesize: 666
}), { status: 200 }));
const fallback = new AllDebridWebFallback(() => true);
const controller = new AbortController();
controller.abort("before-queue");
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal))
.rejects.toThrow("aborted:alldebrid-web");
await new Promise((resolve) => setImmediate(resolve));
await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({
directUrl: "https://alldebrid.direct/next.bin",
fileName: "next.bin"
});
expect(unhandled).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("opens the login window after login_required and retries generation with the same session partition", async () => {
mockFetch
.mockResolvedValueOnce(new Response("login", { status: 200 }))
+34
View File
@@ -210,4 +210,38 @@ describe("bestdebrid-web", () => {
rejectRequest(new Error("late bestdebrid rejection"));
await Promise.resolve();
});
it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
await fallback.importCookiesFromFile(filePath);
mockFetch.mockResolvedValue(new Response(JSON.stringify({
error: 0,
link: "https://bestdebrid.direct/next.bin",
filename: "next.bin",
size: "777 B"
}), { status: 200 }));
const controller = new AbortController();
controller.abort("before-queue");
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
await expect(fallback.unrestrict("https://1fichier.com/?pre-aborted", controller.signal))
.rejects.toThrow("aborted:bestdebrid-web");
await new Promise((resolve) => setImmediate(resolve));
await expect(fallback.unrestrict("https://1fichier.com/?next")).resolves.toMatchObject({
directUrl: "https://bestdebrid.direct/next.bin",
fileName: "next.bin"
});
expect(unhandled).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
});
+87
View File
@@ -998,6 +998,93 @@ describe("deterministic stop and restart lifecycle", () => {
});
});
it("uses an available secondary provider for cooldown projection even when automatic failure fallback is disabled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-fallback-provider-context-"));
tempDirs.push(root);
const accountId = "rda_disabled_fallback_secondary";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
allDebridToken: "all-debrid-token",
providerOrder: ["realdebrid", "alldebrid"],
autoProviderFallback: false,
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "secondary", links: ["https://rapidgator.net/file/secondary"] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
});
it.each([
{
name: "1Fichier",
link: "https://1fichier.com/?direct123",
settings: { oneFichierApiKey: "onefichier-key" }
},
{
name: "DDownload",
link: "https://ddownload.com/abc12345/direct.bin",
settings: { ddownloadLogin: "user", ddownloadPassword: "password" }
}
])("does not project a Real-Debrid cooldown over the direct $name path", ({ link, settings: directSettings }) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-provider-context-"));
tempDirs.push(root);
const accountId = `rda_direct_${path.basename(root)}`;
const manager = new DownloadManager(
{
...defaultSettings(),
...directSettings,
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
providerOrder: ["realdebrid"],
autoProviderFallback: false,
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "direct", links: [link] }]);
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("does not project a Mega-Debrid retry when the shared Mega provider alias is disabled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-mega-alias-context-"));
tempDirs.push(root);
const login = "disabled-mega@example.test";
const accountId = getMegaDebridAccountId(login);
const manager = new DownloadManager(
{
...defaultSettings(),
megaDebridApiCredentials: `${login}:password`,
megaDebridApiEnabled: true,
disabledProviders: ["megadebrid"],
providerOrder: ["megadebrid"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "mega-disabled", links: ["https://rapidgator.net/file/mega-disabled"] }]);
primeMegaDebridRuntimeCooldownForTests(`${accountId}:api`, 60_000);
expect(manager.getSnapshot()).toMatchObject({
canStart: false,
lifecycle: { phase: "idle", retryAt: null }
});
});
it("ignores cooldowns for another provider or hoster and for pure post-processing", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-irrelevant-cooldown-context-"));
tempDirs.push(root);
+28
View File
@@ -231,6 +231,34 @@ describe("realdebrid-web", () => {
await Promise.resolve();
});
it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => {
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"next-token\">", { status: 200 }));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
download: "https://cdn.real-debrid.example/next.bin",
filename: "next.bin",
filesize: 555
}), { status: 200 })));
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_pre_aborted", () => true);
const controller = new AbortController();
controller.abort("before-queue");
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal))
.rejects.toThrow("aborted:realdebrid-web");
await new Promise((resolve) => setImmediate(resolve));
await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({
directUrl: "https://cdn.real-debrid.example/next.bin",
fileName: "next.bin"
});
expect(unhandled).toEqual([]);
expect(mockSessionFetch).toHaveBeenCalledTimes(1);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("does not open a login window for an authenticated account with a fair-use error", async () => {
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({