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); }); });