Replace monolithic download-manager.ts (9500 lines) with 7 focused modules: - error-classifier.ts: 25+ typed DownloadErrorKind enum, classifier functions for network/HTTP/debrid/extraction errors — no more string matching - retry-manager.ts: Declarative per-error-kind retry policies, exponential backoff, shelving after 15 failures, state export/import - stream-writer.ts: HTTP stream → file with pre-resume validation, stall detection, NTFS-aligned buffered writing, Range-ignored detection - pipeline.ts: Single download lifecycle (unrestrict → stream → verify), throws typed errors, caller decides retry strategy - post-processor.ts: Extraction state machine with hard caps (3 attempts per archive, 5 rounds per package), no infinite loops - scheduler.ts: Queue management with priority-based slot allocation, heartbeat stall detection, global watchdog, provider cooldowns - download-manager.ts: Drop-in orchestrator (~1500 lines), same public API Fixes: 1. Hanging downloads: heartbeat-based stall detection + global watchdog 2. Wrong error classification: typed enum at point of origin 3. Unreliable resume: file size vs tracker validation, Range-ignored detection 4. Extraction loops: bounded retries with state machine 215 new unit tests for error-classifier and retry-manager (all passing). Build compiles cleanly. Same IPC interface — UI unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
706 lines
24 KiB
TypeScript
706 lines
24 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
DownloadError,
|
|
DownloadErrorKind,
|
|
classifyFetchError,
|
|
classifyHttpStatus,
|
|
classifyUnrestrictError,
|
|
classifyExtractionError,
|
|
classifyRangeIgnored,
|
|
ensureDownloadError,
|
|
errorKindLabel,
|
|
isPermanentKind,
|
|
} from "../src/main/download/error-classifier";
|
|
|
|
// ===========================================================================
|
|
// DownloadError construction and properties
|
|
// ===========================================================================
|
|
|
|
describe("DownloadError", () => {
|
|
it("stores kind, message, and defaults retryable/permanent from isPermanentKind", () => {
|
|
const err = new DownloadError(DownloadErrorKind.NetworkReset, "socket hang up");
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err.name).toBe("DownloadError");
|
|
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
|
|
expect(err.message).toBe("socket hang up");
|
|
expect(err.retryable).toBe(true);
|
|
expect(err.permanent).toBe(false);
|
|
});
|
|
|
|
it("marks permanent kinds as non-retryable by default", () => {
|
|
const err = new DownloadError(DownloadErrorKind.LinkDead, "file deleted");
|
|
expect(err.retryable).toBe(false);
|
|
expect(err.permanent).toBe(true);
|
|
});
|
|
|
|
it("stores httpStatus when provided", () => {
|
|
const err = new DownloadError(DownloadErrorKind.ServerError, "HTTP 500", {
|
|
httpStatus: 500,
|
|
});
|
|
expect(err.httpStatus).toBe(500);
|
|
});
|
|
|
|
it("stores originalError when provided", () => {
|
|
const orig = new Error("root cause");
|
|
const err = new DownloadError(DownloadErrorKind.Unknown, "wrapped", {
|
|
originalError: orig,
|
|
});
|
|
expect(err.originalError).toBe(orig);
|
|
});
|
|
|
|
it("stores arbitrary context", () => {
|
|
const err = new DownloadError(DownloadErrorKind.RangeNotSatisfied, "range", {
|
|
context: { existingBytes: 1024, expectedTotal: 2048 },
|
|
});
|
|
expect(err.context).toEqual({ existingBytes: 1024, expectedTotal: 2048 });
|
|
});
|
|
|
|
it("allows overriding retryable and permanent via opts", () => {
|
|
// Override a normally-permanent kind to be retryable
|
|
const err = new DownloadError(DownloadErrorKind.DiskFull, "disk full", {
|
|
retryable: true,
|
|
permanent: false,
|
|
});
|
|
expect(err.retryable).toBe(true);
|
|
expect(err.permanent).toBe(false);
|
|
});
|
|
|
|
it("httpStatus is undefined when not provided", () => {
|
|
const err = new DownloadError(DownloadErrorKind.Unknown, "x");
|
|
expect(err.httpStatus).toBeUndefined();
|
|
});
|
|
|
|
it("toLogString produces a compact representation", () => {
|
|
const err = new DownloadError(DownloadErrorKind.ServerError, "Internal Server Error", {
|
|
httpStatus: 500,
|
|
});
|
|
const log = err.toLogString();
|
|
expect(log).toContain("[server_error]");
|
|
expect(log).toContain("Internal Server Error");
|
|
expect(log).toContain("(HTTP 500)");
|
|
});
|
|
|
|
it("toLogString omits HTTP status when not set", () => {
|
|
const err = new DownloadError(DownloadErrorKind.Timeout, "stalled");
|
|
const log = err.toLogString();
|
|
expect(log).toBe("[timeout] stalled");
|
|
expect(log).not.toContain("HTTP");
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// classifyFetchError
|
|
// ===========================================================================
|
|
|
|
describe("classifyFetchError", () => {
|
|
// ---- Network Reset ----
|
|
it.each([
|
|
"socket hang up",
|
|
"ECONNRESET",
|
|
"ECONNREFUSED",
|
|
"EPIPE broken pipe",
|
|
"network error on fetch",
|
|
"socket closed unexpectedly",
|
|
"connection reset by peer",
|
|
"fetch failed",
|
|
])("classifies '%s' as NetworkReset", (msg) => {
|
|
const err = classifyFetchError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- Connection Timeout ----
|
|
it.each([
|
|
"ETIMEDOUT",
|
|
"connect_timeout reached",
|
|
"Connection timed out after 30s",
|
|
])("classifies '%s' as ConnectTimeout", (msg) => {
|
|
const err = classifyFetchError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- DNS Failure ----
|
|
it.each([
|
|
"getaddrinfo ENOTFOUND example.com",
|
|
"ENOTFOUND",
|
|
"DNS lookup failed",
|
|
])("classifies '%s' as DnsFailure", (msg) => {
|
|
const err = classifyFetchError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.DnsFailure);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- Stall / Read Timeout ----
|
|
it.each([
|
|
"stall_timeout after 60s",
|
|
"read timeout waiting for data",
|
|
])("classifies '%s' as Timeout", (msg) => {
|
|
const err = classifyFetchError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.Timeout);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- Write Drain Timeout ----
|
|
it("classifies write_drain_timeout as WriteDrainTimeout", () => {
|
|
const err = classifyFetchError(new Error("write_drain_timeout: disk slow"));
|
|
expect(err.kind).toBe(DownloadErrorKind.WriteDrainTimeout);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- Disk Full ----
|
|
it.each([
|
|
"ENOSPC: no space left on device",
|
|
"no space left on device",
|
|
])("classifies '%s' as DiskFull (permanent)", (msg) => {
|
|
const err = classifyFetchError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
|
|
expect(err.permanent).toBe(true);
|
|
expect(err.retryable).toBe(false);
|
|
});
|
|
|
|
// ---- Permission Denied ----
|
|
it.each([
|
|
"EACCES: permission denied '/tmp/f'",
|
|
"EPERM: operation not permitted",
|
|
"Permission denied writing to output",
|
|
])("classifies '%s' as PermissionDenied (permanent)", (msg) => {
|
|
const err = classifyFetchError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.PermissionDenied);
|
|
expect(err.permanent).toBe(true);
|
|
});
|
|
|
|
// ---- File Locked ----
|
|
it.each([
|
|
"EBUSY: resource busy or locked",
|
|
"file is locked by another process",
|
|
"being used by another process",
|
|
])("classifies '%s' as FileLocked", (msg) => {
|
|
const err = classifyFetchError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.FileLocked);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- Resume Underflow ----
|
|
it("classifies resume_download_underflow as ResumeUnderflow", () => {
|
|
const err = classifyFetchError(new Error("resume_download_underflow:512/1024"));
|
|
expect(err.kind).toBe(DownloadErrorKind.ResumeUnderflow);
|
|
});
|
|
|
|
// ---- Range Ignored ----
|
|
it("classifies range_ignored_on_resume as RangeIgnored", () => {
|
|
const err = classifyFetchError(new Error("range_ignored_on_resume:512/2048"));
|
|
expect(err.kind).toBe(DownloadErrorKind.RangeIgnored);
|
|
});
|
|
|
|
// ---- Unknown ----
|
|
it("classifies an unrecognised message as Unknown", () => {
|
|
const err = classifyFetchError(new Error("something completely new"));
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- Abort handling ----
|
|
it("re-throws abort errors instead of classifying", () => {
|
|
expect(() => classifyFetchError(new Error("Aborted: user cancelled"))).toThrow();
|
|
});
|
|
|
|
it("re-throws abort errors for a plain 'abort' message", () => {
|
|
expect(() => classifyFetchError(new Error("abort"))).toThrow();
|
|
});
|
|
|
|
// ---- Non-Error inputs ----
|
|
it("handles a plain string as input", () => {
|
|
const err = classifyFetchError("ECONNRESET");
|
|
expect(err.kind).toBe(DownloadErrorKind.NetworkReset);
|
|
});
|
|
|
|
it("handles null/undefined gracefully", () => {
|
|
const err = classifyFetchError(null);
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
|
|
it("handles undefined gracefully", () => {
|
|
const err = classifyFetchError(undefined);
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
|
|
it("preserves originalError reference", () => {
|
|
const orig = new Error("ECONNRESET");
|
|
const err = classifyFetchError(orig);
|
|
expect(err.originalError).toBe(orig);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// classifyHttpStatus
|
|
// ===========================================================================
|
|
|
|
describe("classifyHttpStatus", () => {
|
|
it("classifies 416 as RangeNotSatisfied", () => {
|
|
const err = classifyHttpStatus({ status: 416 });
|
|
expect(err.kind).toBe(DownloadErrorKind.RangeNotSatisfied);
|
|
expect(err.httpStatus).toBe(416);
|
|
});
|
|
|
|
it("stores existingBytes in context for 416", () => {
|
|
const err = classifyHttpStatus({ status: 416, existingBytes: 4096 });
|
|
expect(err.context).toEqual({ existingBytes: 4096 });
|
|
});
|
|
|
|
it("classifies 429 as RateLimited", () => {
|
|
const err = classifyHttpStatus({ status: 429, statusText: "Too Many Requests" });
|
|
expect(err.kind).toBe(DownloadErrorKind.RateLimited);
|
|
expect(err.httpStatus).toBe(429);
|
|
});
|
|
|
|
it("classifies 403 as Forbidden", () => {
|
|
const err = classifyHttpStatus({ status: 403 });
|
|
expect(err.kind).toBe(DownloadErrorKind.Forbidden);
|
|
expect(err.httpStatus).toBe(403);
|
|
});
|
|
|
|
it("classifies 404 as NotFound", () => {
|
|
const err = classifyHttpStatus({ status: 404, statusText: "Not Found" });
|
|
expect(err.kind).toBe(DownloadErrorKind.NotFound);
|
|
expect(err.httpStatus).toBe(404);
|
|
});
|
|
|
|
it("classifies 500 as ServerError", () => {
|
|
const err = classifyHttpStatus({ status: 500 });
|
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
|
expect(err.httpStatus).toBe(500);
|
|
});
|
|
|
|
it("classifies 502 as ServerError", () => {
|
|
const err = classifyHttpStatus({ status: 502, statusText: "Bad Gateway" });
|
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
|
expect(err.httpStatus).toBe(502);
|
|
});
|
|
|
|
it("classifies 503 as ServerError", () => {
|
|
const err = classifyHttpStatus({ status: 503, statusText: "Service Unavailable" });
|
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
|
expect(err.httpStatus).toBe(503);
|
|
});
|
|
|
|
it("classifies 401 as Unknown (no special branch)", () => {
|
|
const err = classifyHttpStatus({ status: 401 });
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
expect(err.httpStatus).toBe(401);
|
|
});
|
|
|
|
it("includes responseText in the message when provided", () => {
|
|
const err = classifyHttpStatus({ status: 500, responseText: "Internal Server Error" });
|
|
expect(err.message).toContain("500");
|
|
expect(err.message).toContain("Internal Server Error");
|
|
});
|
|
|
|
it("uses statusText as fallback when responseText is absent", () => {
|
|
const err = classifyHttpStatus({ status: 500, statusText: "Server Error" });
|
|
expect(err.message).toContain("Server Error");
|
|
});
|
|
|
|
it("produces message without body when neither responseText nor statusText is given", () => {
|
|
const err = classifyHttpStatus({ status: 500 });
|
|
expect(err.message).toBe("HTTP 500");
|
|
});
|
|
|
|
it("all server errors (5xx) are retryable", () => {
|
|
for (const code of [500, 502, 503, 504]) {
|
|
const err = classifyHttpStatus({ status: code });
|
|
expect(err.retryable).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// classifyRangeIgnored
|
|
// ===========================================================================
|
|
|
|
describe("classifyRangeIgnored", () => {
|
|
it("returns RangeIgnored kind", () => {
|
|
const err = classifyRangeIgnored(1024, 4096);
|
|
expect(err.kind).toBe(DownloadErrorKind.RangeIgnored);
|
|
});
|
|
|
|
it("includes existingBytes and contentLength in the message", () => {
|
|
const err = classifyRangeIgnored(512, 2048);
|
|
expect(err.message).toContain("512");
|
|
expect(err.message).toContain("2048");
|
|
});
|
|
|
|
it("stores existingBytes and contentLength in context", () => {
|
|
const err = classifyRangeIgnored(1024, 8192);
|
|
expect(err.context).toEqual({ existingBytes: 1024, contentLength: 8192 });
|
|
});
|
|
|
|
it("is retryable by default", () => {
|
|
const err = classifyRangeIgnored(0, 100);
|
|
expect(err.retryable).toBe(true);
|
|
expect(err.permanent).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// classifyUnrestrictError
|
|
// ===========================================================================
|
|
|
|
describe("classifyUnrestrictError", () => {
|
|
// ---- LinkDead (permanent) ----
|
|
it.each([
|
|
"File not found",
|
|
"file unavailable",
|
|
"Link is dead",
|
|
"File has been removed",
|
|
"file has been deleted",
|
|
"file is no longer available",
|
|
"file was removed from server",
|
|
"file was deleted by owner",
|
|
"permanent ungültig",
|
|
])("classifies '%s' as LinkDead (permanent)", (msg) => {
|
|
const err = classifyUnrestrictError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.LinkDead);
|
|
expect(err.permanent).toBe(true);
|
|
expect(err.retryable).toBe(false);
|
|
});
|
|
|
|
// ---- ProviderBusy ----
|
|
it.each([
|
|
"too many active downloads",
|
|
"too many concurrent sessions",
|
|
"too many downloads at once",
|
|
"active download limit",
|
|
"concurrent limit exceeded",
|
|
"slot limit reached for this host",
|
|
"limit reached try later",
|
|
"zu viele aktive Downloads",
|
|
"zu viele gleichzeitige Transfers",
|
|
"zu viele Downloads",
|
|
])("classifies '%s' as ProviderBusy", (msg) => {
|
|
const err = classifyUnrestrictError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.ProviderBusy);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- HosterUnavailable ----
|
|
it("classifies 'hosternotavailable' as HosterUnavailable", () => {
|
|
const err = classifyUnrestrictError(new Error("hosternotavailable"));
|
|
expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- QuotaExceeded ----
|
|
it.each([
|
|
"quota exceeded for today",
|
|
"bandwidth limit exceeded",
|
|
])("classifies '%s' as QuotaExceeded", (msg) => {
|
|
const err = classifyUnrestrictError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.QuotaExceeded);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- ProviderDown ----
|
|
it.each([
|
|
"server error occurred",
|
|
"internal server error",
|
|
"temporarily unavailable",
|
|
"temporary unavailable please wait",
|
|
"temporarily disabled",
|
|
"try again later",
|
|
"service unavailable",
|
|
"host is down",
|
|
"maintenance in progress",
|
|
"bad gateway",
|
|
"gateway timeout",
|
|
"cloudflare challenge detected",
|
|
"worker error at edge",
|
|
])("classifies '%s' as ProviderDown", (msg) => {
|
|
const err = classifyUnrestrictError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.ProviderDown);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- UnrestrictFailed ----
|
|
it.each([
|
|
"unrestrict call failed",
|
|
"mega-web provider error",
|
|
"mega-debrid session lost",
|
|
"bestdebrid API error",
|
|
"alldebrid unrestrict failed",
|
|
"kein debrid-provider verfügbar",
|
|
"session-cookie expired",
|
|
"session cookie invalid",
|
|
"session blockiert",
|
|
"session expired please re-login",
|
|
"invalid session token",
|
|
"login ungültig",
|
|
"login liefert HTTP 401",
|
|
"login required for this host",
|
|
"login failed with credentials",
|
|
])("classifies '%s' as UnrestrictFailed", (msg) => {
|
|
const err = classifyUnrestrictError(new Error(msg));
|
|
expect(err.kind).toBe(DownloadErrorKind.UnrestrictFailed);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
// ---- Unknown ----
|
|
it("classifies unrecognised debrid error as Unknown", () => {
|
|
const err = classifyUnrestrictError(new Error("completely unknown debrid error"));
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
|
|
// ---- Non-Error inputs ----
|
|
it("handles a plain string as input", () => {
|
|
const err = classifyUnrestrictError("hosternotavailable");
|
|
expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable);
|
|
});
|
|
|
|
it("handles null input gracefully", () => {
|
|
const err = classifyUnrestrictError(null);
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// classifyExtractionError
|
|
// ===========================================================================
|
|
|
|
describe("classifyExtractionError", () => {
|
|
// ---- WrongPassword (permanent) ----
|
|
it("classifies 'wrong password' as WrongPassword", () => {
|
|
const err = classifyExtractionError("Wrong password for archive.rar");
|
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
|
expect(err.permanent).toBe(true);
|
|
expect(err.retryable).toBe(false);
|
|
});
|
|
|
|
it("classifies 'falsches Passwort' as WrongPassword", () => {
|
|
const err = classifyExtractionError("Falsches Passwort eingegeben");
|
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
|
expect(err.permanent).toBe(true);
|
|
});
|
|
|
|
it("classifies category 'wrong_password' as WrongPassword even with generic message", () => {
|
|
const err = classifyExtractionError("extraction error", "wrong_password");
|
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
|
expect(err.permanent).toBe(true);
|
|
});
|
|
|
|
// ---- ArchiveCorrupt ----
|
|
it.each([
|
|
"archive is corrupt",
|
|
"unexpected end of archive",
|
|
"broken header in rar",
|
|
"invalid archive format",
|
|
"bad signature in header",
|
|
"Archiv beschädigt",
|
|
])("classifies '%s' as ArchiveCorrupt", (msg) => {
|
|
const err = classifyExtractionError(msg);
|
|
expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
it("classifies category 'archive_corrupt' as ArchiveCorrupt", () => {
|
|
const err = classifyExtractionError("some error", "archive_corrupt");
|
|
expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt);
|
|
});
|
|
|
|
// ---- ExtractorCrash ----
|
|
it.each([
|
|
"process exited with code 1",
|
|
"process crashed unexpectedly",
|
|
"extractor failed to start",
|
|
"Segmentation fault (core dumped)",
|
|
])("classifies '%s' as ExtractorCrash", (msg) => {
|
|
const err = classifyExtractionError(msg);
|
|
expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash);
|
|
expect(err.retryable).toBe(true);
|
|
});
|
|
|
|
it("classifies category 'extractor_crash' as ExtractorCrash", () => {
|
|
const err = classifyExtractionError("unknown", "extractor_crash");
|
|
expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash);
|
|
});
|
|
|
|
// ---- DiskFull ----
|
|
it.each([
|
|
"ENOSPC: write failed",
|
|
"No space left on device",
|
|
])("classifies '%s' as DiskFull (permanent)", (msg) => {
|
|
const err = classifyExtractionError(msg);
|
|
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
|
|
expect(err.permanent).toBe(true);
|
|
expect(err.retryable).toBe(false);
|
|
});
|
|
|
|
// ---- Unknown ----
|
|
it("classifies unrecognised extraction error as Unknown", () => {
|
|
const err = classifyExtractionError("some new error we haven't seen");
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
|
|
it("handles empty string input", () => {
|
|
const err = classifyExtractionError("");
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// ensureDownloadError
|
|
// ===========================================================================
|
|
|
|
describe("ensureDownloadError", () => {
|
|
it("returns existing DownloadError unchanged", () => {
|
|
const orig = new DownloadError(DownloadErrorKind.Timeout, "timed out");
|
|
const result = ensureDownloadError(orig);
|
|
expect(result).toBe(orig);
|
|
});
|
|
|
|
it("wraps a plain Error via classifyFetchError", () => {
|
|
const result = ensureDownloadError(new Error("ECONNRESET"));
|
|
expect(result).toBeInstanceOf(DownloadError);
|
|
expect(result.kind).toBe(DownloadErrorKind.NetworkReset);
|
|
});
|
|
|
|
it("wraps a string via classifyFetchError", () => {
|
|
const result = ensureDownloadError("ETIMEDOUT");
|
|
expect(result).toBeInstanceOf(DownloadError);
|
|
expect(result.kind).toBe(DownloadErrorKind.ConnectTimeout);
|
|
});
|
|
|
|
it("wraps null as Unknown", () => {
|
|
const result = ensureDownloadError(null);
|
|
expect(result).toBeInstanceOf(DownloadError);
|
|
expect(result.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
|
|
it("re-throws abort errors (inherits classifyFetchError behavior)", () => {
|
|
expect(() => ensureDownloadError(new Error("abort"))).toThrow();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// errorKindLabel
|
|
// ===========================================================================
|
|
|
|
describe("errorKindLabel", () => {
|
|
it("returns a non-empty string for every DownloadErrorKind", () => {
|
|
for (const kind of Object.values(DownloadErrorKind)) {
|
|
const label = errorKindLabel(kind);
|
|
expect(label).toBeTruthy();
|
|
expect(typeof label).toBe("string");
|
|
expect(label.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("returns specific labels for known kinds", () => {
|
|
expect(errorKindLabel(DownloadErrorKind.NetworkReset)).toBe("Netzwerkfehler");
|
|
expect(errorKindLabel(DownloadErrorKind.DiskFull)).toBe("Festplatte voll");
|
|
expect(errorKindLabel(DownloadErrorKind.WrongPassword)).toBe("Falsches Archiv-Passwort");
|
|
expect(errorKindLabel(DownloadErrorKind.RateLimited)).toBe("Rate-Limit erreicht");
|
|
expect(errorKindLabel(DownloadErrorKind.Unknown)).toBe("Unbekannter Fehler");
|
|
});
|
|
|
|
it("falls back to 'Unbekannter Fehler' for an unrecognised kind", () => {
|
|
const label = errorKindLabel("made_up_kind" as DownloadErrorKind);
|
|
expect(label).toBe("Unbekannter Fehler");
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// isPermanentKind
|
|
// ===========================================================================
|
|
|
|
describe("isPermanentKind", () => {
|
|
it("returns true for LinkDead", () => {
|
|
expect(isPermanentKind(DownloadErrorKind.LinkDead)).toBe(true);
|
|
});
|
|
|
|
it("returns true for DiskFull", () => {
|
|
expect(isPermanentKind(DownloadErrorKind.DiskFull)).toBe(true);
|
|
});
|
|
|
|
it("returns true for PermissionDenied", () => {
|
|
expect(isPermanentKind(DownloadErrorKind.PermissionDenied)).toBe(true);
|
|
});
|
|
|
|
it("returns true for WrongPassword", () => {
|
|
expect(isPermanentKind(DownloadErrorKind.WrongPassword)).toBe(true);
|
|
});
|
|
|
|
it("returns false for retryable kinds", () => {
|
|
const retryableKinds = [
|
|
DownloadErrorKind.NetworkReset,
|
|
DownloadErrorKind.Timeout,
|
|
DownloadErrorKind.DnsFailure,
|
|
DownloadErrorKind.ConnectTimeout,
|
|
DownloadErrorKind.RangeNotSatisfied,
|
|
DownloadErrorKind.RangeIgnored,
|
|
DownloadErrorKind.ServerError,
|
|
DownloadErrorKind.RateLimited,
|
|
DownloadErrorKind.Forbidden,
|
|
DownloadErrorKind.NotFound,
|
|
DownloadErrorKind.UnrestrictFailed,
|
|
DownloadErrorKind.ProviderBusy,
|
|
DownloadErrorKind.ProviderDown,
|
|
DownloadErrorKind.HosterUnavailable,
|
|
DownloadErrorKind.QuotaExceeded,
|
|
DownloadErrorKind.FileLocked,
|
|
DownloadErrorKind.FileCorrupt,
|
|
DownloadErrorKind.FileTruncated,
|
|
DownloadErrorKind.ResumeUnderflow,
|
|
DownloadErrorKind.ArchiveCorrupt,
|
|
DownloadErrorKind.ExtractorCrash,
|
|
DownloadErrorKind.WriteDrainTimeout,
|
|
DownloadErrorKind.Unknown,
|
|
];
|
|
for (const kind of retryableKinds) {
|
|
expect(isPermanentKind(kind)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Edge cases and priority
|
|
// ===========================================================================
|
|
|
|
describe("classifier priority / edge cases", () => {
|
|
it("classifyFetchError checks abort before other patterns", () => {
|
|
// "abort" appears before network patterns, so abort should win
|
|
expect(() => classifyFetchError(new Error("Aborted: ECONNRESET"))).toThrow();
|
|
});
|
|
|
|
it("classifyFetchError: ETIMEDOUT wins over ECONNRESET when both keywords present", () => {
|
|
// ConnectTimeout is checked before NetworkReset in the code
|
|
const err = classifyFetchError(new Error("ETIMEDOUT ECONNRESET"));
|
|
expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout);
|
|
});
|
|
|
|
it("classifyFetchError: DNS checked before NetworkReset", () => {
|
|
const err = classifyFetchError(new Error("getaddrinfo ENOTFOUND fetch failed"));
|
|
expect(err.kind).toBe(DownloadErrorKind.DnsFailure);
|
|
});
|
|
|
|
it("classifyFetchError: ENOSPC checked before generic unknown", () => {
|
|
const err = classifyFetchError(new Error("write error ENOSPC"));
|
|
expect(err.kind).toBe(DownloadErrorKind.DiskFull);
|
|
});
|
|
|
|
it("classifyExtractionError: wrong_password category overrides message text", () => {
|
|
// Even if message contains 'corrupt', category should take priority
|
|
const err = classifyExtractionError("archive is corrupt", "wrong_password");
|
|
expect(err.kind).toBe(DownloadErrorKind.WrongPassword);
|
|
});
|
|
|
|
it("classifyHttpStatus: treats status 599 as ServerError (>= 500 rule)", () => {
|
|
const err = classifyHttpStatus({ status: 599 });
|
|
expect(err.kind).toBe(DownloadErrorKind.ServerError);
|
|
});
|
|
|
|
it("classifyHttpStatus: treats status 200 as Unknown", () => {
|
|
const err = classifyHttpStatus({ status: 200 });
|
|
expect(err.kind).toBe(DownloadErrorKind.Unknown);
|
|
});
|
|
});
|