Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/lib/components/ContactPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
24 changes: 21 additions & 3 deletions frontend/src/lib/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <E> List<E> 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
Expand Down Expand Up @@ -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);

Comment thread
WilliamAGH marked this conversation as resolved.
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);
Expand Down Expand Up @@ -760,16 +827,16 @@ private static Document exactListOfOverloadDocument(String documentId, String do
.orElseThrow();
return Document.builder()
.id(documentId)
.text("static <E> List<E> 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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down