From c4da1ad958f914b95427c79b7547fc4a4ac51aed Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 10 Sep 2026 15:26:14 -0400 Subject: [PATCH] Capture expected error logs in negative-path tests --- .../EligibilityCheckResourceTest.java | 57 +++++++++++++++++-- builder-frontend/src/api/check.test.ts | 42 +++++++++++++- .../eligibilityCheckResource.test.ts | 11 +++- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/builder-api/src/test/java/org/acme/controller/EligibilityCheckResourceTest.java b/builder-api/src/test/java/org/acme/controller/EligibilityCheckResourceTest.java index 8adc1d6a..bd5748b5 100644 --- a/builder-api/src/test/java/org/acme/controller/EligibilityCheckResourceTest.java +++ b/builder-api/src/test/java/org/acme/controller/EligibilityCheckResourceTest.java @@ -15,8 +15,14 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.function.Supplier; +import java.util.logging.Filter; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -118,10 +124,14 @@ void createCustomCheckRemovesTheCheckWhenItsDmnCannotBeStored() throws Exception .thenReturn(""); when(repository.saveNewWorkingCustomCheck(any(EligibilityCheck.class))).thenReturn(checkId); when(storageService.getCheckDmnModelPath(checkId)).thenReturn("check/" + checkId + ".dmn"); - doThrow(new RuntimeException("storage unavailable")) + RuntimeException storageFailure = new RuntimeException("storage unavailable"); + doThrow(storageFailure) .when(storageService).writeStringToStorage(anyString(), anyString(), anyString()); - Response response = resource.createCustomCheck(identity, request); + Response response = captureExpectedErrorLog( + "Could not save the DMN model of check " + checkId + ", removing the check", + storageFailure, + () -> resource.createCustomCheck(identity, request)); assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); verify(repository).deleteWorkingCustomCheck(checkId); @@ -204,13 +214,17 @@ void createCustomCheckReportsArchivedStateAfterAWriteCollision() throws Exceptio void createCustomCheckStillConflictsWhenTheCollidingCheckCannotBeRead() throws Exception { CreateCheckRequest request = createCheckRequest(); String checkId = "W-owner-1-income-incomeCheck"; + IllegalArgumentException readFailure = new IllegalArgumentException("unmappable document"); when(repository.getWorkingCustomCheckMetadata(USER_ID, checkId)) .thenReturn(Optional.empty()) - .thenThrow(new IllegalArgumentException("unmappable document")); + .thenThrow(readFailure); when(repository.saveNewWorkingCustomCheck(any())) .thenThrow(new DocumentAlreadyExistsException(checkId, new RuntimeException())); - Response response = resource.createCustomCheck(identity, request); + Response response = captureExpectedErrorLog( + "Could not read the check " + checkId + " that collided with the new check", + readFailure, + () -> resource.createCustomCheck(identity, request)); assertEquals(Response.Status.CONFLICT.getStatusCode(), response.getStatus()); assertEquals( @@ -414,15 +428,46 @@ void firstPublishOfACorruptWorkingVersionStartsAtTheInitialVersion() throws Exce @Test void publishFailsWhenPublishedVersionsCannotBeRead() throws Exception { workingCheck.setVersion("2.0.0"); - when(repository.getPublishedCheckVersions(workingCheck)).thenThrow(new RuntimeException("firestore unavailable")); + RuntimeException readFailure = new RuntimeException("firestore unavailable"); + when(repository.getPublishedCheckVersions(workingCheck)).thenThrow(readFailure); - Response response = resource.publishCustomCheck(identity, CHECK_ID); + Response response = captureExpectedErrorLog( + "Could not read published versions of check " + CHECK_ID, + readFailure, + () -> resource.publishCustomCheck(identity, CHECK_ID)); assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); verify(repository, never()).saveNewPublishedCustomCheck(any()); verify(repository, never()).updateWorkingCustomCheck(any()); } + private T captureExpectedErrorLog(String expectedMessage, Throwable expectedCause, Supplier action) { + Logger resourceLogger = Logger.getLogger(EligibilityCheckResource.class.getName()); + Filter previousFilter = resourceLogger.getFilter(); + List capturedLogs = new ArrayList<>(); + resourceLogger.setFilter(record -> { + if (expectedMessage.equals(record.getMessage()) && record.getThrown() == expectedCause) { + capturedLogs.add(record); + return false; + } + return previousFilter == null || previousFilter.isLoggable(record); + }); + + T result; + try { + result = action.get(); + } finally { + resourceLogger.setFilter(previousFilter); + } + + assertEquals(1, capturedLogs.size(), "expected exactly one matching log event"); + LogRecord capturedLog = capturedLogs.get(0); + assertEquals(expectedMessage, capturedLog.getMessage()); + assertEquals(Level.SEVERE.intValue(), capturedLog.getLevel().intValue()); + assertSame(expectedCause, capturedLog.getThrown()); + return result; + } + private EligibilityCheck publishedVersion(String version) { return publishedVersion(version, PUBLISHED_PREFIX + "-" + version); } diff --git a/builder-frontend/src/api/check.test.ts b/builder-frontend/src/api/check.test.ts index 9d92bc26..5136219b 100644 --- a/builder-frontend/src/api/check.test.ts +++ b/builder-frontend/src/api/check.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/api/auth", () => ({ authGet: vi.fn(), @@ -19,8 +19,12 @@ const request = { describe("addCheck", () => { beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.restoreAllMocks()); it("reports the API error message when check creation fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); vi.mocked(authPost).mockResolvedValue( new Response( JSON.stringify({ @@ -34,9 +38,21 @@ describe("addCheck", () => { await expect(addCheck(request)).rejects.toThrow( 'You already have a check named "incomeCheck" in module "income".', ); + expect(consoleError).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + "Error creating new check:", + expect.objectContaining({ + message: + 'You already have a check named "incomeCheck" in module "income".', + status: 409, + }), + ); }); it("carries the response status so callers can tell failures apart", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); vi.mocked(authPost).mockResolvedValue( new Response(JSON.stringify({ error: "Could not save Check" }), { status: 500, @@ -47,9 +63,17 @@ describe("addCheck", () => { await expect(addCheck(request)).rejects.toMatchObject({ status: 500, }); + expect(consoleError).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + "Error creating new check:", + expect.objectContaining({ message: "Could not save Check", status: 500 }), + ); }); it("falls back to the status when the error response is not JSON", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); vi.mocked(authPost).mockResolvedValue( new Response("unavailable", { status: 503 }), ); @@ -57,13 +81,29 @@ describe("addCheck", () => { await expect(addCheck(request)).rejects.toThrow( "Post failed with status: 503", ); + expect(consoleError).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + "Error creating new check:", + expect.objectContaining({ + message: "Post failed with status: 503", + status: 503, + }), + ); }); it("rejects with an ApiError", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); vi.mocked(authPost).mockResolvedValue( new Response("unavailable", { status: 503 }), ); await expect(addCheck(request)).rejects.toBeInstanceOf(ApiError); + expect(consoleError).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + "Error creating new check:", + expect.any(ApiError), + ); }); }); diff --git a/builder-frontend/src/components/homeScreen/eligibilityCheckList/eligibilityCheckResource.test.ts b/builder-frontend/src/components/homeScreen/eligibilityCheckList/eligibilityCheckResource.test.ts index 1bb90312..0fd13079 100644 --- a/builder-frontend/src/components/homeScreen/eligibilityCheckList/eligibilityCheckResource.test.ts +++ b/builder-frontend/src/components/homeScreen/eligibilityCheckList/eligibilityCheckResource.test.ts @@ -1,5 +1,5 @@ import { createRoot } from "solid-js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/api/check", () => ({ addCheck: vi.fn(), @@ -18,9 +18,13 @@ import type { EligibilityCheck } from "@/types"; describe("eligibilityCheckResource", () => { beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.restoreAllMocks()); it("propagates create failures to the modal", async () => { const failure = new Error("That check name is already in use."); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); vi.mocked(addCheck).mockRejectedValue(failure); await new Promise((resolve, reject) => { @@ -47,6 +51,11 @@ describe("eligibilityCheckResource", () => { }); }); }); + expect(consoleError).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + "Failed to add new check", + failure, + ); }); it("splits one response into the active and archived lists", async () => {