diff --git a/src/utils/errors/error-builder.ts b/src/utils/errors/error-builder.ts new file mode 100644 index 0000000..203e059 --- /dev/null +++ b/src/utils/errors/error-builder.ts @@ -0,0 +1,77 @@ +import { Logger, NULL_LOGGER } from "@/utils/logging"; +import { FailMode } from "./fail-mode"; + +/** + * A class for building and handling errors based on a given mode. + */ +export class ErrorBuilder { + /** + * The logger to use for logging errors. + */ + private readonly _logger: Logger; + + /** + * The accumulated errors. + */ + private readonly _errors: Error[]; + + /** + * Constructs a new {@link ErrorBuilder} instance. + * + * @param logger - The logger to use for logging errors. + */ + constructor(logger?: Logger) { + this._logger = logger || NULL_LOGGER; + this._errors = []; + } + + /** + * Checks if any errors have been appended. + * + * @returns `true` if there are errors; otherwise, `false`. + */ + get hasErrors(): boolean { + return this._errors.length > 0; + } + + /** + * Appends an error to the builder, handling it according to the provided mode. + * + * @param error - The error to append. + * @param mode - The mode to use when handling the error. Defaults to `SKIP` if not provided. + */ + append(error: Error, mode?: FailMode): void { + switch (mode ?? FailMode.SKIP) { + case FailMode.WARN: + this._logger.error(error); + break; + case FailMode.SKIP: + this._logger.error(error); + this._errors.push(error); + break; + default: + throw error; + } + } + + /** + * Builds an `AggregateError` from the errors appended so far. + * + * @returns The built error, or `undefined` if no errors have been appended. + */ + build(): Error | undefined { + return this.hasErrors ? new AggregateError(this._errors) : undefined; + } + + /** + * Builds an `AggregateError` from the errors appended so far, and throw it. + * + * @throws The built error, if any errors have been appended. + */ + throwIfHasErrors(): void | never { + const error = this.build(); + if (error) { + throw error; + } + } +} diff --git a/src/utils/errors/index.ts b/src/utils/errors/index.ts index aed9e77..a9dc94d 100644 --- a/src/utils/errors/index.ts +++ b/src/utils/errors/index.ts @@ -1,6 +1,7 @@ export { isError } from "./error"; export { ArgumentError } from "./argument-error"; export { ArgumentNullError } from "./argument-null-error"; +export { ErrorBuilder } from "./error-builder"; export { FailMode } from "./fail-mode"; export { SoftError, isSoftError } from "./soft-error"; export { FileNotFoundError } from "./file-not-found-error"; diff --git a/tests/unit/utils/errors/error-builder.spec.ts b/tests/unit/utils/errors/error-builder.spec.ts new file mode 100644 index 0000000..4e2d04e --- /dev/null +++ b/tests/unit/utils/errors/error-builder.spec.ts @@ -0,0 +1,93 @@ +import { FailMode } from "@/utils/errors/fail-mode"; +import { Logger } from "@/utils/logging/logger"; +import { ErrorBuilder } from "@/utils/errors/error-builder"; + +describe("ErrorBuilder", () => { + describe("hasErrors", () => { + test("returns false if no errors were appended to the builder", () => { + const builder = new ErrorBuilder(); + + expect(builder.hasErrors).toBe(false); + }); + + test("returns true if errors were appended to the builder", () => { + const builder = new ErrorBuilder(); + builder.append(new Error()); + + expect(builder.hasErrors).toBe(true); + }); + }); + + describe("append", () => { + it("appends an error and logs it when mode is SKIP", () => { + const logger = { error: jest.fn() } as unknown as Logger; + const builder = new ErrorBuilder(logger); + const error = new Error("Test error"); + + builder.append(error, FailMode.SKIP); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(error); + expect(builder.hasErrors).toBe(true); + }); + + it("processes an error and logs it when mode is WARN", () => { + const logger = { error: jest.fn() } as unknown as Logger; + const builder = new ErrorBuilder(logger); + const error = new Error("Test error"); + + builder.append(error, FailMode.WARN); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(error); + expect(builder.hasErrors).toBe(false); + }); + + it("throws error immediately when mode is FAIL", () => { + const logger = { error: jest.fn() } as unknown as Logger; + const builder = new ErrorBuilder(logger); + const error = new Error("Test error"); + + expect(() => builder.append(error, FailMode.FAIL)).toThrow(error); + + expect(logger.error).not.toHaveBeenCalled(); + expect(builder.hasErrors).toBe(false); + }); + }); + + describe("build", () => { + it("returns undefined when there are no errors", () => { + const builder = new ErrorBuilder(); + + expect(builder.build()).toBeUndefined(); + }); + + it("returns an AggregateError when there are errors", () => { + const error = new Error("Test error"); + const builder = new ErrorBuilder(); + + builder.append(error); + const builtError = builder.build(); + + expect(builtError).toBeInstanceOf(AggregateError); + expect((builtError as AggregateError).errors).toEqual([error]); + }); + }); + + describe("throwIfHasErrors", () => { + it("does not throw when there are no errors", () => { + const builder = new ErrorBuilder(); + + expect(() => builder.throwIfHasErrors()).not.toThrow(); + }); + + it("throws an AggregateError when there are errors", () => { + const error = new Error("Test error"); + const builder = new ErrorBuilder(); + + builder.append(error); + + expect(() => builder.throwIfHasErrors()).toThrow(new AggregateError([error])); + }); + }); +});