diff --git a/frontend/src/lib/components/ContactPage.test.ts b/frontend/src/lib/components/ContactPage.test.ts index 6865f28f..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,6 +93,9 @@ describe("ContactPage submission states", () => { await waitFor(() => expect(renderedPage.container.querySelector("#contact-name-error")).not.toBeNull(), ); + 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 2f3d11f5..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. * @@ -131,9 +137,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: CONTACT_NAME_REQUIRED_MESSAGE }) + .max(CONTACT_NAME_MAX_LENGTH, { + error: CONTACT_NAME_TOO_LONG_MESSAGE, + }), + email: z.email({ error: CONTACT_EMAIL_INVALID_MESSAGE }), + message: z + .string() + .trim() + .min(1, { error: CONTACT_MESSAGE_REQUIRED_MESSAGE }) + .max(CONTACT_MESSAGE_MAX_LENGTH, { + error: CONTACT_MESSAGE_TOO_LONG_MESSAGE, + }), website: z.string(), renderedAt: z.int().positive(), }); 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/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index 429757ba..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 @@ -452,6 +469,56 @@ 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(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(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(EXACT_LIST_OF_QUERY, officialDocumentationConstraint); + + assertEquals( + List.of(JAVA_21_LIST_API_URL, JAVA_24_LIST_API_URL), + citationOutcome.citations().stream() + .map(citation -> citation.getUrl()) + .toList()); + assertEquals( + 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 void exactJavaSyntaxInNonJavaScopeDoesNotDispatchJavaApiCitationRetrieval() { HybridSearchService hybridSearchService = mock(HybridSearchService.class); @@ -760,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 bfea1fd5..df3572a9 100644 --- a/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/ContactControllerTest.java @@ -133,6 +133,42 @@ void missingRenderTimestampIsDroppedSilentlyWithoutSendingMail() throws Exceptio verify(javaMailSender, never()).send(any(MimeMessage.class)); } + @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(senderIp)) + .contentType(MediaType.APPLICATION_JSON) + .content(submissionJson( + "Legitimate Sender", + "legitimate@example.test", + "This request should retain the available rate-limit capacity.", + "", + legitimateRenderedAt()))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("accepted")); + + verify(javaMailSender, times(1)).send(any(MimeMessage.class)); + } + @Test void fourthAcceptedSubmissionFromSameIpIsRateLimited() throws Exception { String senderIp = "198.51.100.4";