diff --git a/CLAUDE.md b/CLAUDE.md index b61a4a3..d90d62e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,8 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools │ ├── ContentIndexStore.java # index read/write seam (StoredDocument) │ ├── MeilisearchContentIndexStore.java # ContentIndexStore backed by MeilisearchClient │ ├── ContentBootstrapStep.java # SearchIndexBootstrapStep: startup full reindex -│ └── ContentRefreshScheduler.java # @Scheduled incremental re-crawl (cron, guarded) +│ ├── ContentRefreshScheduler.java # @Scheduled incremental re-crawl (cron, guarded) +│ └── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records) ├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config ├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup) └── docs/ @@ -113,7 +114,11 @@ stream (over all enabled sources, via `ContentIndexer.streamAllDocuments`) in ba and toggles `SearchReadinessState`. `ContentRefreshScheduler` is a `@Scheduled` bean (cron `open-elements.content.refresh-cron`, default hourly, from `@EnableScheduling` in spec 001) that re-runs `ContentIndexer.indexSource` over enabled sources — guarded to skip while disabled or -bootstrapping and to never overlap, with per-source fault isolation. +bootstrapping and to never overlap, with per-source fault isolation. On the read side, +`ContentSearchService` is the facade the MCP tools (spec 012) call: it builds Meilisearch +`multi-search` bodies (AND-combined `source`/`locale`/`categories`/`since` filters, `publishedDate:desc` +tie-breaker, `Highlighter` boundary markers) and returns `SearchHit`s with HTML-safe snippets, plus +`listPosts`, `getByUrlOrId`, and `categoryFacets`. Read-only; the scoped key comes in spec 013. > **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA > datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See diff --git a/docs/specs/011-content-search-service/steps.md b/docs/specs/011-content-search-service/steps.md new file mode 100644 index 0000000..d9f854c --- /dev/null +++ b/docs/specs/011-content-search-service/steps.md @@ -0,0 +1,52 @@ +# Implementation Steps: Content Search Service + +## Step 1: Result/filter records + +- [x] `ContentFilters(source, locale, category, since)` (+ `none()`) +- [x] `SearchHit(title, url, publishedDate, snippet, score)`, `SearchHits(hits, estimatedTotal)`, `CategoryCount(category, count)` + +**Related behaviors:** all (return/argument shapes) + +--- + +## Step 2: `ContentSearchService` + +- [x] `@Component` (gated on `meilisearch.enabled`) over `MeilisearchClient` + `MeilisearchProperties` +- [x] `search`: builds a `multi-search` body — filter, `publishedDate:desc` sort, highlight tags = `Highlighter.PRE_MARK`/`POST_MARK`, crop `body`, `showRankingScore`, paging — and parses hits with `Highlighter.safeHighlight` +- [x] `listPosts`: empty query, `publishedDate:desc`, paged +- [x] `getByUrlOrId`: filter by `id` (preferred) or `url`; empty when both blank / not found +- [x] `categoryFacets`: `facets:["categories"]` with source/locale filter, `limit 0` +- [x] `buildFilter` AND-combines non-null filters (quote-escaped) + +**Related behaviors:** all search/filter/list/get/facet scenarios + +--- + +## Step 3: Tests + +- [x] `ContentSearchServiceTest`: pure `buildFilter`/`parseHits`/`parseFacets` + public methods via a capturing `MeilisearchClient` subclass (request bodies + parsing + get + empty) + +**Acceptance criteria:** +- [x] All tests pass (`mvn test`); build green + +--- + +## Behavior Coverage + +| Scenario | Layer | Covered in Step | +|----------|-------|-----------------| +| Query returns relevant hits with snippet | Backend | Step 3 (`ResponseParsing.parsesHits` + `RequestBuilding.searchBuildsRequest`) | +| Title matches rank above body matches | Backend | Governed by `IndexSettings` searchable order (spec 003) + Meilisearch; the service adds only the date tie-breaker (verified in `searchBuildsRequest`). Live ranking needs Meilisearch. | +| Highlight markers become safe em tags | Backend | Step 3 (`highlightMarkersBecomeSafeEmTags`) | +| Locale/Source/Category filter restricts results | Backend | Step 3 (`FilterBuilding.*` + `searchBuildsRequest`) | +| since filters by published date | Backend | Step 3 (`FilterBuilding.since`) | +| Combined filters are AND-combined | Backend | Step 3 (`FilterBuilding.combined`) | +| listPosts sorts by published date descending | Backend | Step 3 (`listPostsBuildsRequest`) | +| Empty query returns all (filtered) posts | Backend | Step 3 (`listPostsBuildsRequest`) | +| Get by url / id returns full document | Backend | Step 3 (`GetByUrlOrId.returnsFullDocument`, `getById/UrlBuildsFilter`) | +| Get for unknown url/id is empty | Backend | Step 3 (`emptyWhenNotFound`, `emptyWhenNoArguments`) | +| Category facets return counts / respect filter | Backend | Step 3 (`parsesFacets` + `facetsBuildRequest`) | +| No results / paging beyond the end | Backend | Step 3 (`parsesEmpty`; paging via `offset` in `searchBuildsRequest`) | + +All scenarios are backend; there is no frontend in this spec. Live ranking/filtering is Meilisearch's +responsibility (the service builds the correct request); tests verify the request and the parsing. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 9e9bb9a..cafadfd 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -16,7 +16,7 @@ roadmap step, to be implemented sequentially (each builds on the previous). | 008 | 008-content-indexer | Content indexer | backend, search, crawler | `ContentIndexer` — orchestration, diff, upsert/delete | #15 | done | | 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | #17 | done | | 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler` — `@Scheduled` incremental re-crawl | #19 | done | -| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService` — `multiSearch` + highlighting facade | — | open | +| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService` — `multiSearch` + highlighting facade | #21 | done | | 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | — | open | | 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | — | open | | 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | — | open | diff --git a/src/main/java/com/openelements/content/CategoryCount.java b/src/main/java/com/openelements/content/CategoryCount.java new file mode 100644 index 0000000..d1ef3e0 --- /dev/null +++ b/src/main/java/com/openelements/content/CategoryCount.java @@ -0,0 +1,10 @@ +package com.openelements.content; + +/** + * A category facet with its document count. + * + * @param category the category value + * @param count the number of matching documents + */ +public record CategoryCount(String category, long count) { +} diff --git a/src/main/java/com/openelements/content/ContentFilters.java b/src/main/java/com/openelements/content/ContentFilters.java new file mode 100644 index 0000000..7bb61cc --- /dev/null +++ b/src/main/java/com/openelements/content/ContentFilters.java @@ -0,0 +1,18 @@ +package com.openelements.content; + +/** + * Optional filters for a content search or listing. Any {@code null} field is not applied; non-null + * fields are AND-combined. + * + * @param source restrict to a source id + * @param locale restrict to a locale (e.g. {@code en}, {@code de}) + * @param category restrict to a category tag + * @param since restrict to documents with {@code publishedDate >=} this ISO date + */ +public record ContentFilters(String source, String locale, String category, String since) { + + /** @return filters with no restrictions */ + public static ContentFilters none() { + return new ContentFilters(null, null, null, null); + } +} diff --git a/src/main/java/com/openelements/content/ContentSearchService.java b/src/main/java/com/openelements/content/ContentSearchService.java new file mode 100644 index 0000000..6512e14 --- /dev/null +++ b/src/main/java/com/openelements/content/ContentSearchService.java @@ -0,0 +1,245 @@ +package com.openelements.content; + +import com.fasterxml.jackson.databind.JsonNode; +import com.openelements.spring.base.services.search.Highlighter; +import com.openelements.spring.base.services.search.MeilisearchClient; +import com.openelements.spring.base.services.search.MeilisearchProperties; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Read-only facade over the Meilisearch content index, backing the MCP tools (spec 012). + * + *

It builds {@code multi-search} request bodies with AND-combined filters, a + * {@code publishedDate:desc} tie-breaker, and the library {@link Highlighter}'s boundary markers as + * highlight tags, then turns the raw hits into {@link SearchHit}s with HTML-safe snippets. Searchable + * ranking ({@code title > excerpt > body}) is governed by the {@code IndexSettings} (spec 003); this + * service only adds the date tie-breaker and never writes. + * + *

Only created when the Meilisearch stack is enabled. + */ +@Component +@ConditionalOnProperty(prefix = "openelements.meilisearch", name = "enabled", havingValue = "true") +public class ContentSearchService { + + /** Words of {@code body} kept around a match for the snippet preview. */ + static final int SNIPPET_CROP_LENGTH = 50; + + private final MeilisearchClient client; + private final String indexUid; + + public ContentSearchService(MeilisearchClient client, MeilisearchProperties meilisearchProperties) { + this.client = client; + this.indexUid = meilisearchProperties.resolveIndex("content"); + } + + /** + * Full-text search with filters, highlighting, and a date tie-breaker. + * + * @param query the search terms (blank matches everything) + * @param filters optional filters + * @param page zero-based page index + * @param size page size + * @return the matching hits + */ + public SearchHits search(String query, ContentFilters filters, int page, int size) { + Map query0 = baseQuery(filters, page, size); + query0.put("q", query == null ? "" : query); + query0.put("sort", List.of("publishedDate:desc")); + query0.put("attributesToHighlight", List.of("title", "excerpt", "body")); + query0.put("attributesToCrop", List.of("body")); + query0.put("cropLength", SNIPPET_CROP_LENGTH); + query0.put("highlightPreTag", Highlighter.PRE_MARK); + query0.put("highlightPostTag", Highlighter.POST_MARK); + query0.put("showRankingScore", true); + return parseHits(client.multiSearch(wrap(query0))); + } + + /** + * Lists documents (no query) sorted newest-first, with optional filters. + * + * @param filters optional filters + * @param page zero-based page index + * @param size page size + * @return the matching documents as hits + */ + public SearchHits listPosts(ContentFilters filters, int page, int size) { + Map query0 = baseQuery(filters, page, size); + query0.put("q", ""); + query0.put("sort", List.of("publishedDate:desc")); + return parseHits(client.multiSearch(wrap(query0))); + } + + /** + * Fetches a full document by id (preferred) or url. + * + * @param url the document URL (used when {@code id} is blank) + * @param id the document id + * @return the document, or empty if none matches (or both arguments are blank) + */ + public Optional getByUrlOrId(String url, String id) { + String filter; + if (present(id)) { + filter = "id = \"" + escape(id) + "\""; + } else if (present(url)) { + filter = "url = \"" + escape(url) + "\""; + } else { + return Optional.empty(); + } + Map query0 = new LinkedHashMap<>(); + query0.put("indexUid", indexUid); + query0.put("q", ""); + query0.put("filter", filter); + query0.put("limit", 1); + return firstDocument(client.multiSearch(wrap(query0))); + } + + /** + * Category facet counts, optionally restricted by source/locale. + * + * @param source optional source filter + * @param locale optional locale filter + * @return the categories with their document counts + */ + public List categoryFacets(String source, String locale) { + Map query0 = new LinkedHashMap<>(); + query0.put("indexUid", indexUid); + query0.put("q", ""); + String filter = buildFilter(new ContentFilters(source, locale, null, null)); + if (filter != null) { + query0.put("filter", filter); + } + query0.put("facets", List.of("categories")); + query0.put("limit", 0); + return parseFacets(client.multiSearch(wrap(query0))); + } + + // ---- request building ---- + + private Map baseQuery(ContentFilters filters, int page, int size) { + Map query = new LinkedHashMap<>(); + query.put("indexUid", indexUid); + String filter = buildFilter(filters); + if (filter != null) { + query.put("filter", filter); + } + int safeSize = Math.max(0, size); + query.put("limit", safeSize); + query.put("offset", Math.max(0, page) * safeSize); + return query; + } + + private static Map wrap(Map query) { + return Map.of("queries", List.of(query)); + } + + /** Builds the AND-combined Meilisearch filter expression, or {@code null} when no filter applies. */ + static String buildFilter(ContentFilters filters) { + if (filters == null) { + return null; + } + List clauses = new ArrayList<>(); + if (present(filters.source())) { + clauses.add("source = \"" + escape(filters.source()) + "\""); + } + if (present(filters.locale())) { + clauses.add("locale = \"" + escape(filters.locale()) + "\""); + } + if (present(filters.category())) { + clauses.add("categories = \"" + escape(filters.category()) + "\""); + } + if (present(filters.since())) { + clauses.add("publishedDate >= \"" + escape(filters.since()) + "\""); + } + return clauses.isEmpty() ? null : String.join(" AND ", clauses); + } + + // ---- response parsing ---- + + static SearchHits parseHits(JsonNode response) { + JsonNode result = firstResult(response); + if (result == null) { + return SearchHits.empty(); + } + List hits = new ArrayList<>(); + for (JsonNode hit : result.path("hits")) { + hits.add(toHit(hit)); + } + return new SearchHits(hits, result.path("estimatedTotalHits").asLong(0)); + } + + private static SearchHit toHit(JsonNode hit) { + JsonNode formatted = hit.path("_formatted"); + String rawSnippet = firstNonBlank(text(formatted, "body"), text(formatted, "excerpt"), text(hit, "excerpt")); + String snippet = rawSnippet == null ? "" : Highlighter.safeHighlight(rawSnippet); + return new SearchHit( + text(hit, "title"), text(hit, "url"), text(hit, "publishedDate"), + snippet, hit.path("_rankingScore").asDouble(0.0)); + } + + static List parseFacets(JsonNode response) { + JsonNode result = firstResult(response); + if (result == null) { + return List.of(); + } + List counts = new ArrayList<>(); + result.path("facetDistribution").path("categories").fields() + .forEachRemaining(entry -> counts.add(new CategoryCount(entry.getKey(), entry.getValue().asLong(0)))); + return counts; + } + + private static Optional firstDocument(JsonNode response) { + JsonNode result = firstResult(response); + if (result == null) { + return Optional.empty(); + } + JsonNode hits = result.path("hits"); + if (!hits.isArray() || hits.isEmpty()) { + return Optional.empty(); + } + return Optional.of(toDocument(hits.get(0))); + } + + private static ContentDocument toDocument(JsonNode hit) { + List categories = new ArrayList<>(); + hit.path("categories").forEach(category -> categories.add(category.asText())); + return new ContentDocument( + text(hit, "id"), text(hit, "source"), text(hit, "locale"), text(hit, "url"), + text(hit, "title"), text(hit, "excerpt"), text(hit, "body"), text(hit, "author"), + categories, text(hit, "publishedDate"), text(hit, "lastmod"), text(hit, "previewImage")); + } + + // ---- small helpers ---- + + private static JsonNode firstResult(JsonNode response) { + JsonNode results = response.path("results"); + return results.isArray() && !results.isEmpty() ? results.get(0) : null; + } + + private static String text(JsonNode node, String field) { + JsonNode value = node.path(field); + return value.isMissingNode() || value.isNull() ? null : value.asText(); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static boolean present(String value) { + return value != null && !value.isBlank(); + } + + private static String escape(String value) { + return value.replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/openelements/content/SearchHit.java b/src/main/java/com/openelements/content/SearchHit.java new file mode 100644 index 0000000..b86b5a8 --- /dev/null +++ b/src/main/java/com/openelements/content/SearchHit.java @@ -0,0 +1,13 @@ +package com.openelements.content; + +/** + * One search or list result: the display fields plus a safe, highlighted snippet. + * + * @param title the document title + * @param url the canonical URL + * @param publishedDate the published date + * @param snippet an HTML-safe snippet with {@code } around matches (may be empty) + * @param score the ranking score (0 when not available) + */ +public record SearchHit(String title, String url, String publishedDate, String snippet, double score) { +} diff --git a/src/main/java/com/openelements/content/SearchHits.java b/src/main/java/com/openelements/content/SearchHits.java new file mode 100644 index 0000000..4e7d09d --- /dev/null +++ b/src/main/java/com/openelements/content/SearchHits.java @@ -0,0 +1,20 @@ +package com.openelements.content; + +import java.util.List; + +/** + * A page of search/list results. + * + * @param hits the results on this page + * @param estimatedTotal Meilisearch's estimate of the total matching documents + */ +public record SearchHits(List hits, long estimatedTotal) { + + public SearchHits { + hits = hits == null ? List.of() : List.copyOf(hits); + } + + static SearchHits empty() { + return new SearchHits(List.of(), 0); + } +} diff --git a/src/test/java/com/openelements/content/ContentSearchServiceTest.java b/src/test/java/com/openelements/content/ContentSearchServiceTest.java new file mode 100644 index 0000000..7f83c0f --- /dev/null +++ b/src/test/java/com/openelements/content/ContentSearchServiceTest.java @@ -0,0 +1,297 @@ +package com.openelements.content; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.openelements.spring.base.services.search.Highlighter; +import com.openelements.spring.base.services.search.MeilisearchClient; +import com.openelements.spring.base.services.search.MeilisearchProperties; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ContentSearchService}: the pure filter-building and hit/facet parsing helpers, + * and the request bodies + parsing of the public methods via a capturing {@link MeilisearchClient} + * subclass (no mocks, no live Meilisearch). + */ +@DisplayName("Content search service") +class ContentSearchServiceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static MeilisearchProperties properties() { + return new MeilisearchProperties("http://localhost:7700", "", "content_", Duration.ofSeconds(10)); + } + + private static JsonNode emptyResponse() { + return parse("{\"results\":[{\"hits\":[],\"estimatedTotalHits\":0}]}"); + } + + private static JsonNode parse(String json) { + try { + return MAPPER.readTree(json); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings("unchecked") + private static Map firstQuery(Map body) { + return ((List>) body.get("queries")).get(0); + } + + @Nested + @DisplayName("filter building") + class FilterBuilding { + + @Test + @DisplayName("no filters produce no filter expression") + void noneProducesNull() { + assertThat(ContentSearchService.buildFilter(ContentFilters.none())).isNull(); + } + + @Test + @DisplayName("locale filter") + void locale() { + assertThat(ContentSearchService.buildFilter(new ContentFilters(null, "de", null, null))) + .isEqualTo("locale = \"de\""); + } + + @Test + @DisplayName("source filter") + void source() { + assertThat(ContentSearchService.buildFilter(new ContentFilters("hiero", null, null, null))) + .isEqualTo("source = \"hiero\""); + } + + @Test + @DisplayName("category filter matches the array attribute") + void category() { + assertThat(ContentSearchService.buildFilter(new ContentFilters(null, null, "ai", null))) + .isEqualTo("categories = \"ai\""); + } + + @Test + @DisplayName("since filter is a date lower bound") + void since() { + assertThat(ContentSearchService.buildFilter(new ContentFilters(null, null, null, "2026-01-01"))) + .isEqualTo("publishedDate >= \"2026-01-01\""); + } + + @Test + @DisplayName("multiple filters are AND-combined") + void combined() { + assertThat(ContentSearchService.buildFilter(new ContentFilters("open-elements", "en", "ai", null))) + .isEqualTo("source = \"open-elements\" AND locale = \"en\" AND categories = \"ai\""); + } + } + + @Nested + @DisplayName("request building") + class RequestBuilding { + + @Test + @DisplayName("search sets query, filter, date sort, highlight tags, paging and score") + void searchBuildsRequest() { + CapturingClient client = new CapturingClient(emptyResponse()); + ContentSearchService service = new ContentSearchService(client, properties()); + + service.search("agentic wallets", new ContentFilters("open-elements", "en", null, null), 2, 10); + + Map query = firstQuery(client.capturedBody); + assertThat(query).containsEntry("indexUid", "content_content"); + assertThat(query).containsEntry("q", "agentic wallets"); + assertThat(query).containsEntry("filter", "source = \"open-elements\" AND locale = \"en\""); + assertThat(query).containsEntry("sort", List.of("publishedDate:desc")); + assertThat(query).containsEntry("highlightPreTag", Highlighter.PRE_MARK); + assertThat(query).containsEntry("highlightPostTag", Highlighter.POST_MARK); + assertThat(query).containsEntry("showRankingScore", true); + assertThat(query).containsEntry("limit", 10); + assertThat(query).containsEntry("offset", 20); + } + + @Test + @DisplayName("listPosts uses an empty query sorted newest-first") + void listPostsBuildsRequest() { + CapturingClient client = new CapturingClient(emptyResponse()); + ContentSearchService service = new ContentSearchService(client, properties()); + + service.listPosts(new ContentFilters("hiero", null, null, null), 0, 20); + + Map query = firstQuery(client.capturedBody); + assertThat(query).containsEntry("q", ""); + assertThat(query).containsEntry("filter", "source = \"hiero\""); + assertThat(query).containsEntry("sort", List.of("publishedDate:desc")); + assertThat(query).containsEntry("limit", 20); + assertThat(query).containsEntry("offset", 0); + } + + @Test + @DisplayName("getByUrlOrId filters by id when given one") + void getByIdBuildsFilter() { + CapturingClient client = new CapturingClient(emptyResponse()); + ContentSearchService service = new ContentSearchService(client, properties()); + + service.getByUrlOrId(null, "oe_abc"); + + assertThat(firstQuery(client.capturedBody)).containsEntry("filter", "id = \"oe_abc\""); + } + + @Test + @DisplayName("getByUrlOrId filters by url when no id is given") + void getByUrlBuildsFilter() { + CapturingClient client = new CapturingClient(emptyResponse()); + ContentSearchService service = new ContentSearchService(client, properties()); + + service.getByUrlOrId("https://ex.com/a", null); + + assertThat(firstQuery(client.capturedBody)).containsEntry("filter", "url = \"https://ex.com/a\""); + } + + @Test + @DisplayName("categoryFacets requests the categories facet with the source filter") + void facetsBuildRequest() { + CapturingClient client = new CapturingClient(emptyResponse()); + ContentSearchService service = new ContentSearchService(client, properties()); + + service.categoryFacets("open-elements", null); + + Map query = firstQuery(client.capturedBody); + assertThat(query).containsEntry("facets", List.of("categories")); + assertThat(query).containsEntry("filter", "source = \"open-elements\""); + assertThat(query).containsEntry("limit", 0); + } + } + + @Nested + @DisplayName("response parsing") + class ResponseParsing { + + @Test + @DisplayName("hits are parsed with title, url, date, snippet and score") + void parsesHits() { + JsonNode response = parse(""" + {"results":[{"estimatedTotalHits":2,"hits":[ + {"title":"Agentic wallets","url":"https://ex.com/a","publishedDate":"2026-03-12", + "_rankingScore":0.9,"_formatted":{"body":"about wallets"}} + ]}]}"""); + + SearchHits hits = ContentSearchService.parseHits(response); + + assertThat(hits.estimatedTotal()).isEqualTo(2); + assertThat(hits.hits()).hasSize(1); + SearchHit hit = hits.hits().get(0); + assertThat(hit.title()).isEqualTo("Agentic wallets"); + assertThat(hit.url()).isEqualTo("https://ex.com/a"); + assertThat(hit.publishedDate()).isEqualTo("2026-03-12"); + assertThat(hit.score()).isEqualTo(0.9); + assertThat(hit.snippet()).isEqualTo("about wallets"); + } + + @Test + @DisplayName("boundary markers become safe tags and raw HTML is escaped") + void highlightMarkersBecomeSafeEmTags() { + ObjectNode response = MAPPER.createObjectNode(); + ObjectNode result = response.putArray("results").addObject(); + result.put("estimatedTotalHits", 1); + ObjectNode hit = result.putArray("hits").addObject(); + hit.put("title", "T"); + hit.put("url", "u"); + hit.put("publishedDate", "2026-03-12"); + hit.putObject("_formatted") + .put("body", Highlighter.PRE_MARK + "wallets" + Highlighter.POST_MARK + " "); + + SearchHit parsed = ContentSearchService.parseHits(response).hits().get(0); + + assertThat(parsed.snippet()).contains("wallets"); + assertThat(parsed.snippet()).doesNotContain("