From 9f60df468c438d2336880658b798b9842d41d2b6 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 4 Aug 2026 20:30:34 -0700 Subject: [PATCH 1/5] test(citations): prove exact multi-release discovery Cover the static citation path with exact List.of overload evidence from Java 21 and 24, preserving strict anchor fidelity. --- .../service/RetrievalServiceTest.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index 429757ba..b0d07386 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java @@ -452,6 +452,43 @@ void exactMultiVersionOverloadUsesAuthoritativeDocumentsFromEveryRequestedReleas verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } + @Test + void multiVersionExactCitationDiscoveryReturnsAuthoritativeOverloadFromEveryRequestedRelease() { + HybridSearchService hybridSearchService = mock(HybridSearchService.class); + RerankerService rerankerService = mock(RerankerService.class); + RetrievalService retrievalService = new RetrievalService( + hybridSearchService, new AppProperties(), rerankerService, mock(DocumentFactory.class)); + RetrievalConstraint officialDocumentationConstraint = + RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); + RetrievalConstraint java21Constraint = officialDocumentationConstraint.withDocVersions(List.of("21")); + RetrievalConstraint java24Constraint = officialDocumentationConstraint.withDocVersions(List.of("24")); + String exactComparisonQuery = "Compare Java 21 and Java 24 for java.util.List.of(E, E)."; + Document java21ExactOverload = exactListOfOverloadDocument("java-21-exact", "21", "exact-hash-21"); + Document java24ExactOverload = exactListOfOverloadDocument("java-24-exact", "24", "exact-hash-24"); + when(hybridSearchService.searchDocumentationCitationsOutcomes( + eq(exactComparisonQuery), eq(10), eq(List.of(java21Constraint, java24Constraint)), anyLong())) + .thenReturn(List.of( + new HybridSearchService.SearchOutcome(List.of(java21ExactOverload), List.of()), + new HybridSearchService.SearchOutcome(List.of(java24ExactOverload), List.of()))); + + RetrievalService.CitationOutcome citationOutcome = + retrievalService.discoverCitations(exactComparisonQuery, officialDocumentationConstraint); + + assertEquals( + List.of( + "https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html", + "https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/List.html"), + citationOutcome.citations().stream() + .map(citation -> citation.getUrl()) + .toList()); + assertEquals( + List.of("of(E,E)", "of(E,E)"), + citationOutcome.citations().stream() + .map(citation -> citation.getAnchor()) + .toList()); + assertEquals(0, citationOutcome.failedConversionCount()); + } + @Test void exactJavaSyntaxInNonJavaScopeDoesNotDispatchJavaApiCitationRetrieval() { HybridSearchService hybridSearchService = mock(HybridSearchService.class); From 0ef6b25514794e2e358465eb3198e52ad90ea2b7 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 4 Aug 2026 20:31:12 -0700 Subject: [PATCH 2/5] fix(contact): reject non-positive render timestamps Treat non-positive client render times as spam before reserving rate-limit capacity or attempting SMTP delivery, while preserving the 202 response. --- .../contact/ContactSubmissionUseCase.java | 2 +- .../javachat/web/ContactControllerTest.java | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/application/contact/ContactSubmissionUseCase.java b/src/main/java/com/williamcallahan/javachat/application/contact/ContactSubmissionUseCase.java index 2663a44d..93e54ffc 100644 --- a/src/main/java/com/williamcallahan/javachat/application/contact/ContactSubmissionUseCase.java +++ b/src/main/java/com/williamcallahan/javachat/application/contact/ContactSubmissionUseCase.java @@ -96,7 +96,7 @@ private static boolean isSpamSubmission(ContactSubmission contactSubmission) { return true; } Long renderedAt = contactSubmission.renderedAt(); - if (renderedAt == null) { + if (renderedAt == null || renderedAt <= 0) { return true; } long submissionAgeMillis = contactSubmission.receivedAt().toEpochMilli() - renderedAt.longValue(); diff --git a/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java b/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java index bfea1fd5..c1e26ad7 100644 --- a/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java @@ -17,6 +17,8 @@ import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -133,6 +135,25 @@ void missingRenderTimestampIsDroppedSilentlyWithoutSendingMail() throws Exceptio verify(javaMailSender, never()).send(any(MimeMessage.class)); } + @ParameterizedTest + @ValueSource(longs = {0L, -1L, Long.MIN_VALUE}) + void nonPositiveRenderTimestampIsDroppedSilentlyWithoutSendingMail(long renderedAtEpochMillis) throws Exception { + mockMvc.perform(post(CONTACT_ENDPOINT) + .with(csrf()) + .with(remoteAddress("198.51.100.8")) + .contentType(MediaType.APPLICATION_JSON) + .content(submissionJson( + "Invalid Timestamp", + "invalid-timestamp@example.test", + "This request must not reach email delivery.", + "", + renderedAtEpochMillis))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("accepted")); + + verify(javaMailSender, never()).send(any(MimeMessage.class)); + } + @Test void fourthAcceptedSubmissionFromSameIpIsRateLimited() throws Exception { String senderIp = "198.51.100.4"; From 8e94373666216ab0f2f6522570ba460f6c84eca5 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 5 Aug 2026 12:42:56 -0700 Subject: [PATCH 3/5] fix(contact): explain field validation failures Give each visible contact field user-facing Zod messages so required and malformed input no longer collapses to generic dependency copy. Test exact inline guidance and preserve client-side rejection. --- .../src/lib/components/ContactPage.test.ts | 3 +++ frontend/src/lib/validation/schemas.ts | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/ContactPage.test.ts b/frontend/src/lib/components/ContactPage.test.ts index 6865f28f..9e5f02df 100644 --- a/frontend/src/lib/components/ContactPage.test.ts +++ b/frontend/src/lib/components/ContactPage.test.ts @@ -88,6 +88,9 @@ describe("ContactPage submission states", () => { await waitFor(() => expect(renderedPage.container.querySelector("#contact-name-error")).not.toBeNull(), ); + expect(renderedPage.getByText("Enter your name")).toBeInTheDocument(); + expect(renderedPage.getByText("Enter a valid email address")).toBeInTheDocument(); + expect(renderedPage.getByText("Enter a message")).toBeInTheDocument(); expect(renderedPage.container.querySelector("#contact-message-error")).not.toBeNull(); expect(renderedPage.getByLabelText("Name")).toHaveAttribute("aria-invalid", "true"); expect(renderedPage.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true"); diff --git a/frontend/src/lib/validation/schemas.ts b/frontend/src/lib/validation/schemas.ts index 2f3d11f5..02e18978 100644 --- a/frontend/src/lib/validation/schemas.ts +++ b/frontend/src/lib/validation/schemas.ts @@ -131,9 +131,21 @@ export const CONTACT_MESSAGE_MAX_LENGTH = 5000; * reject submissions that arrive faster than a human can type. */ export const ContactSubmissionSchema = z.object({ - name: z.string().trim().min(1).max(CONTACT_NAME_MAX_LENGTH), - email: z.email(), - message: z.string().trim().min(1).max(CONTACT_MESSAGE_MAX_LENGTH), + name: z + .string() + .trim() + .min(1, { error: "Enter your name" }) + .max(CONTACT_NAME_MAX_LENGTH, { + error: `Name must be ${CONTACT_NAME_MAX_LENGTH} characters or fewer`, + }), + email: z.email({ error: "Enter a valid email address" }), + message: z + .string() + .trim() + .min(1, { error: "Enter a message" }) + .max(CONTACT_MESSAGE_MAX_LENGTH, { + error: `Message must be ${CONTACT_MESSAGE_MAX_LENGTH} characters or fewer`, + }), website: z.string(), renderedAt: z.int().positive(), }); From acb1a452c5933cd02b73b5f5cdd88e3bfcaa21c6 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 5 Aug 2026 13:17:11 -0700 Subject: [PATCH 4/5] refactor(contact): name validation guidance Keep field-specific error copy with the contact schema while preserving independent rendered-copy expectations at the UI test boundary. --- frontend/src/lib/components/ContactPage.test.ts | 11 ++++++++--- frontend/src/lib/validation/schemas.ts | 16 +++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/ContactPage.test.ts b/frontend/src/lib/components/ContactPage.test.ts index 9e5f02df..8f69447e 100644 --- a/frontend/src/lib/components/ContactPage.test.ts +++ b/frontend/src/lib/components/ContactPage.test.ts @@ -11,6 +11,11 @@ vi.mock("../services/contact", () => { import ContactPage from "./ContactPage.svelte"; +// Independent expectations keep accidental user-facing copy changes visible. +const EXPECTED_CONTACT_NAME_REQUIRED_GUIDANCE = "Enter your name"; +const EXPECTED_CONTACT_EMAIL_INVALID_GUIDANCE = "Enter a valid email address"; +const EXPECTED_CONTACT_MESSAGE_REQUIRED_GUIDANCE = "Enter a message"; + function renderContactPage() { const renderedPage = render(ContactPage); const contactForm = renderedPage.container.querySelector("form"); @@ -88,9 +93,9 @@ describe("ContactPage submission states", () => { await waitFor(() => expect(renderedPage.container.querySelector("#contact-name-error")).not.toBeNull(), ); - expect(renderedPage.getByText("Enter your name")).toBeInTheDocument(); - expect(renderedPage.getByText("Enter a valid email address")).toBeInTheDocument(); - expect(renderedPage.getByText("Enter a message")).toBeInTheDocument(); + expect(renderedPage.getByText(EXPECTED_CONTACT_NAME_REQUIRED_GUIDANCE)).toBeInTheDocument(); + expect(renderedPage.getByText(EXPECTED_CONTACT_EMAIL_INVALID_GUIDANCE)).toBeInTheDocument(); + expect(renderedPage.getByText(EXPECTED_CONTACT_MESSAGE_REQUIRED_GUIDANCE)).toBeInTheDocument(); expect(renderedPage.container.querySelector("#contact-message-error")).not.toBeNull(); expect(renderedPage.getByLabelText("Name")).toHaveAttribute("aria-invalid", "true"); expect(renderedPage.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true"); diff --git a/frontend/src/lib/validation/schemas.ts b/frontend/src/lib/validation/schemas.ts index 02e18978..dbf50f64 100644 --- a/frontend/src/lib/validation/schemas.ts +++ b/frontend/src/lib/validation/schemas.ts @@ -122,6 +122,12 @@ export const CONTACT_NAME_MAX_LENGTH = 100; /** Longest message body accepted by POST /api/contact; mirrored in the form's maxlength. */ export const CONTACT_MESSAGE_MAX_LENGTH = 5000; +const CONTACT_NAME_REQUIRED_MESSAGE = "Enter your name"; +const CONTACT_NAME_TOO_LONG_MESSAGE = `Name must be ${CONTACT_NAME_MAX_LENGTH} characters or fewer`; +const CONTACT_EMAIL_INVALID_MESSAGE = "Enter a valid email address"; +const CONTACT_MESSAGE_REQUIRED_MESSAGE = "Enter a message"; +const CONTACT_MESSAGE_TOO_LONG_MESSAGE = `Message must be ${CONTACT_MESSAGE_MAX_LENGTH} characters or fewer`; + /** * Submission contract for POST /api/contact. * @@ -134,17 +140,17 @@ export const ContactSubmissionSchema = z.object({ name: z .string() .trim() - .min(1, { error: "Enter your name" }) + .min(1, { error: CONTACT_NAME_REQUIRED_MESSAGE }) .max(CONTACT_NAME_MAX_LENGTH, { - error: `Name must be ${CONTACT_NAME_MAX_LENGTH} characters or fewer`, + error: CONTACT_NAME_TOO_LONG_MESSAGE, }), - email: z.email({ error: "Enter a valid email address" }), + email: z.email({ error: CONTACT_EMAIL_INVALID_MESSAGE }), message: z .string() .trim() - .min(1, { error: "Enter a message" }) + .min(1, { error: CONTACT_MESSAGE_REQUIRED_MESSAGE }) .max(CONTACT_MESSAGE_MAX_LENGTH, { - error: `Message must be ${CONTACT_MESSAGE_MAX_LENGTH} characters or fewer`, + error: CONTACT_MESSAGE_TOO_LONG_MESSAGE, }), website: z.string(), renderedAt: z.int().positive(), From b368011c2785bd5d85bada4f9552c90ff6393625 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 5 Aug 2026 13:17:38 -0700 Subject: [PATCH 5/5] test(regressions): tighten contact and citation guarantees Prove spam timestamps leave same-IP quota untouched and lock exact multi-release citation discovery to its dedicated dispatch path. --- .../service/RetrievalServiceTest.java | 62 ++++++++++++++----- .../javachat/web/ContactControllerTest.java | 37 +++++++---- 2 files changed, 72 insertions(+), 27 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index b0d07386..60ac9321 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java @@ -47,6 +47,23 @@ class RetrievalServiceTest { .max(Comparator.comparingInt(source -> Integer.parseInt(source.javaRelease()))) .map(DocsSourceRegistry.JavaApiDocumentationSource::relativeMirrorPath) .orElseThrow(); + private static final int DOCUMENTATION_CITATION_CANDIDATE_LIMIT = 10; + private static final String JAVA_21_RELEASE = "21"; + private static final String JAVA_24_RELEASE = "24"; + private static final String EXACT_LIST_OF_QUERY = "Compare Java 21 and Java 24 for java.util.List.of(E, E)."; + private static final String JAVA_21_EXACT_LIST_DOCUMENT_ID = "java-21-exact"; + private static final String JAVA_24_EXACT_LIST_DOCUMENT_ID = "java-24-exact"; + private static final String JAVA_21_EXACT_LIST_CONTENT_HASH = "exact-hash-21"; + private static final String JAVA_24_EXACT_LIST_CONTENT_HASH = "exact-hash-24"; + private static final String EXACT_LIST_OVERLOAD_TEXT = + "static List of(E e1, E e2) Returns an unmodifiable list containing two elements"; + private static final String EXACT_LIST_OVERLOAD_ANCHOR = "of(E,E)"; + private static final String JAVA_UTIL_PACKAGE = "java.util"; + private static final String JAVA_LIST_API_PAGE = "List.html"; + private static final String JAVA_21_LIST_API_URL = + "https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html"; + private static final String JAVA_24_LIST_API_URL = + "https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/List.html"; private static final Duration STAGE_DEADLINE_ASSERTION_TOLERANCE = Duration.ofSeconds(1); @Test @@ -460,33 +477,46 @@ void multiVersionExactCitationDiscoveryReturnsAuthoritativeOverloadFromEveryRequ hybridSearchService, new AppProperties(), rerankerService, mock(DocumentFactory.class)); RetrievalConstraint officialDocumentationConstraint = RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); - RetrievalConstraint java21Constraint = officialDocumentationConstraint.withDocVersions(List.of("21")); - RetrievalConstraint java24Constraint = officialDocumentationConstraint.withDocVersions(List.of("24")); - String exactComparisonQuery = "Compare Java 21 and Java 24 for java.util.List.of(E, E)."; - Document java21ExactOverload = exactListOfOverloadDocument("java-21-exact", "21", "exact-hash-21"); - Document java24ExactOverload = exactListOfOverloadDocument("java-24-exact", "24", "exact-hash-24"); + RetrievalConstraint java21Constraint = + officialDocumentationConstraint.withDocVersions(List.of(JAVA_21_RELEASE)); + RetrievalConstraint java24Constraint = + officialDocumentationConstraint.withDocVersions(List.of(JAVA_24_RELEASE)); + Document java21ExactOverload = exactListOfOverloadDocument( + JAVA_21_EXACT_LIST_DOCUMENT_ID, JAVA_21_RELEASE, JAVA_21_EXACT_LIST_CONTENT_HASH); + Document java24ExactOverload = exactListOfOverloadDocument( + JAVA_24_EXACT_LIST_DOCUMENT_ID, JAVA_24_RELEASE, JAVA_24_EXACT_LIST_CONTENT_HASH); when(hybridSearchService.searchDocumentationCitationsOutcomes( - eq(exactComparisonQuery), eq(10), eq(List.of(java21Constraint, java24Constraint)), anyLong())) + eq(EXACT_LIST_OF_QUERY), + eq(DOCUMENTATION_CITATION_CANDIDATE_LIMIT), + eq(List.of(java21Constraint, java24Constraint)), + anyLong())) .thenReturn(List.of( new HybridSearchService.SearchOutcome(List.of(java21ExactOverload), List.of()), new HybridSearchService.SearchOutcome(List.of(java24ExactOverload), List.of()))); RetrievalService.CitationOutcome citationOutcome = - retrievalService.discoverCitations(exactComparisonQuery, officialDocumentationConstraint); + retrievalService.discoverCitations(EXACT_LIST_OF_QUERY, officialDocumentationConstraint); assertEquals( - List.of( - "https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html", - "https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/List.html"), + List.of(JAVA_21_LIST_API_URL, JAVA_24_LIST_API_URL), citationOutcome.citations().stream() .map(citation -> citation.getUrl()) .toList()); assertEquals( - List.of("of(E,E)", "of(E,E)"), + List.of(EXACT_LIST_OVERLOAD_ANCHOR, EXACT_LIST_OVERLOAD_ANCHOR), citationOutcome.citations().stream() .map(citation -> citation.getAnchor()) .toList()); assertEquals(0, citationOutcome.failedConversionCount()); + verify(hybridSearchService) + .searchDocumentationCitationsOutcomes( + eq(EXACT_LIST_OF_QUERY), + eq(DOCUMENTATION_CITATION_CANDIDATE_LIMIT), + eq(List.of(java21Constraint, java24Constraint)), + anyLong()); + verify(hybridSearchService, never()) + .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @Test @@ -797,16 +827,16 @@ private static Document exactListOfOverloadDocument(String documentId, String do .orElseThrow(); return Document.builder() .id(documentId) - .text("static List of(E e1, E e2) Returns an unmodifiable list containing two elements") + .text(EXACT_LIST_OVERLOAD_TEXT) .metadata(QdrantPayloadFieldSchema.DOC_VERSION_FIELD, documentVersion) .metadata(QdrantPayloadFieldSchema.HASH_FIELD, contentHash) .metadata( QdrantPayloadFieldSchema.URL_FIELD, - documentationSource.remoteBaseUrl() + "java.base/java/util/List.html") + documentationSource.remoteBaseUrl() + "java.base/java/util/" + JAVA_LIST_API_PAGE) .metadata(QdrantPayloadFieldSchema.DOC_TYPE_FIELD, DocsSourceRegistry.JAVA_API_DOCUMENT_TYPE) - .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, "java.util") - .metadata(QdrantPayloadFieldSchema.JAVA_API_TYPE_PAGE_FIELD, "List.html") - .metadata(QdrantPayloadFieldSchema.ANCHOR_FIELD, "of(E,E)") + .metadata(QdrantPayloadFieldSchema.PACKAGE_FIELD, JAVA_UTIL_PACKAGE) + .metadata(QdrantPayloadFieldSchema.JAVA_API_TYPE_PAGE_FIELD, JAVA_LIST_API_PAGE) + .metadata(QdrantPayloadFieldSchema.ANCHOR_FIELD, EXACT_LIST_OVERLOAD_ANCHOR) .build(); } diff --git a/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java b/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java index c1e26ad7..df3572a9 100644 --- a/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java @@ -17,8 +17,6 @@ import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -135,23 +133,40 @@ void missingRenderTimestampIsDroppedSilentlyWithoutSendingMail() throws Exceptio verify(javaMailSender, never()).send(any(MimeMessage.class)); } - @ParameterizedTest - @ValueSource(longs = {0L, -1L, Long.MIN_VALUE}) - void nonPositiveRenderTimestampIsDroppedSilentlyWithoutSendingMail(long renderedAtEpochMillis) throws Exception { + @Test + void nonPositiveRenderTimestampsDoNotConsumeRateLimitCapacity() throws Exception { + String senderIp = "198.51.100.8"; + long[] spamRenderTimestamps = {0L, -1L, Long.MIN_VALUE}; + for (long renderedAtEpochMillis : spamRenderTimestamps) { + mockMvc.perform(post(CONTACT_ENDPOINT) + .with(csrf()) + .with(remoteAddress(senderIp)) + .contentType(MediaType.APPLICATION_JSON) + .content(submissionJson( + "Invalid Timestamp", + "invalid-timestamp@example.test", + "This request must not consume rate-limit capacity.", + "", + renderedAtEpochMillis))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("accepted")); + } + verify(javaMailSender, never()).send(any(MimeMessage.class)); + mockMvc.perform(post(CONTACT_ENDPOINT) .with(csrf()) - .with(remoteAddress("198.51.100.8")) + .with(remoteAddress(senderIp)) .contentType(MediaType.APPLICATION_JSON) .content(submissionJson( - "Invalid Timestamp", - "invalid-timestamp@example.test", - "This request must not reach email delivery.", + "Legitimate Sender", + "legitimate@example.test", + "This request should retain the available rate-limit capacity.", "", - renderedAtEpochMillis))) + legitimateRenderedAt()))) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.status").value("accepted")); - verify(javaMailSender, never()).send(any(MimeMessage.class)); + verify(javaMailSender, times(1)).send(any(MimeMessage.class)); } @Test