diff --git a/src/common/utils/with-error-logging/with-error-logging.injectable.ts b/src/common/utils/with-error-logging/with-error-logging.injectable.ts new file mode 100644 index 0000000000..fcdf86e047 --- /dev/null +++ b/src/common/utils/with-error-logging/with-error-logging.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../../logger.injectable"; + +const withErrorLoggingInjectable = getInjectable({ + id: "with-error-logging", + + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + + return (getErrorMessage: (error: Error) => string) => + any>(toBeDecorated: T) => + (...args: Parameters): ReturnType => { + try { + const returnValue = toBeDecorated(...args); + + if (isPromise(returnValue)) { + returnValue.catch((e: Error) => { + logger.error(getErrorMessage(e as Error), e); + }); + } + + return returnValue; + } catch (e) { + logger.error(getErrorMessage(e as Error), e); + throw e; + } + }; + }, +}); + +export default withErrorLoggingInjectable; + +function isPromise(reference: any): reference is Promise { + return !!reference?.then; +} diff --git a/src/common/utils/with-error-logging/with-error-logging.test.ts b/src/common/utils/with-error-logging/with-error-logging.test.ts new file mode 100644 index 0000000000..0319dadf7f --- /dev/null +++ b/src/common/utils/with-error-logging/with-error-logging.test.ts @@ -0,0 +1,215 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import loggerInjectable from "../../logger.injectable"; +import type { Logger } from "../../logger"; +import withErrorLoggingInjectable from "./with-error-logging.injectable"; +import { pipeline } from "@ogre-tools/fp"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; + +describe("with-error-logging", () => { + describe("given decorated sync function", () => { + let loggerStub: Logger; + let toBeDecorated: jest.Mock; + let decorated: (a: string, b: string) => number | undefined; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + loggerStub = { + error: jest.fn(), + } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = jest.fn(); + + decorated = pipeline( + toBeDecorated, + withErrorLoggingFor((error: Error) => `some-error-message-for-${error.message}`), + ); + }); + + describe("when function does not throw and returns value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => 42); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("returns the value", () => { + expect(returnValue).toBe(42); + }); + }); + + describe("when function does not throw and returns no value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => undefined); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + + describe("when function throws", () => { + let error: Error; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => { + throw new Error("some-error"); + }); + + try { + decorated("some-parameter", "some-other-parameter"); + } catch (e: any) { + error = e; + } + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("logs the error", () => { + expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("throws", () => { + expect(error.message).toBe("some-error"); + }); + }); + }); + + describe("given decorated async function", () => { + let loggerStub: Logger; + let decorated: (a: string, b: string) => Promise; + let toBeDecorated: AsyncFnMock; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + loggerStub = { + error: jest.fn(), + } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = asyncFn(); + + decorated = pipeline( + toBeDecorated, + withErrorLoggingFor((error: Error) => `some-error-message-for-${error.message}`), + ); + }); + + describe("when called", () => { + let returnValuePromise: Promise; + + beforeEach(() => { + returnValuePromise = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error yet", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(returnValuePromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when call rejects", () => { + let error: Error; + + beforeEach(async () => { + try { + await toBeDecorated.reject(new Error("some-error")); + await returnValuePromise; + } catch (e) { + error = e as Error; + } + }); + + it("logs the error", () => { + expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("rejects", () => { + return expect(() => returnValuePromise).rejects.toThrow("some-error"); + }); + }); + + describe("when call resolves with value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(42); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("resolves with the value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBe(42); + }); + }); + + describe("when call resolves without value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(undefined); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("resolves with the value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBeUndefined(); + }); + }); + }); + }); +});