` by `type()`; `forSource` throws a clear error for unknown types; duplicate types fail fast
+
+**Related behaviors:** website resolves; unknown type fails clearly; new strategy auto-discovered
+
+---
+
+## Step 5: Tests
+
+- [x] `SourceStrategyRegistryTest` — resolve, unknown-type error, auto-discovery, duplicate guard (test-double strategies)
+- [x] `WebsiteSourceStrategyTest` — real `SitemapCrawler`/`PageFetcher`/`ContentExtractor` + `MockRestServiceServer`: type, discover delegation, INDEX/UNCHANGED/DELETE/SKIP(error)/SKIP(empty)
+
+**Acceptance criteria:**
+- [x] All tests pass (`mvn test`); build green
+
+---
+
+## Behavior Coverage
+
+| Scenario | Layer | Covered in Step |
+|----------|-------|-----------------|
+| Website source resolves to website strategy | Backend | Step 5 (`SourceStrategyRegistryTest.websiteSourceResolvesToWebsiteStrategy`) |
+| Unknown type fails clearly | Backend | Step 5 (`SourceStrategyRegistryTest.unknownTypeFailsClearly`) |
+| discover delegates to the sitemap crawler | Backend | Step 5 (`WebsiteSourceStrategyTest.discoverDelegatesToCrawler`) |
+| Successful fetch produces an INDEX outcome | Backend | Step 5 (`WebsiteSourceStrategyTest.successfulFetchProducesIndex`) |
+| 304 produces UNCHANGED | Backend | Step 5 (`WebsiteSourceStrategyTest.notModifiedProducesUnchanged`) |
+| 404 produces DELETE | Backend | Step 5 (`WebsiteSourceStrategyTest.notFoundProducesDelete`) |
+| Extraction failure produces SKIP | Backend | Step 5 (`WebsiteSourceStrategyTest.emptyContentProducesSkip`, `fetchErrorProducesSkip`) |
+| New strategy is auto-discovered | Backend | Step 5 (`SourceStrategyRegistryTest.newStrategyIsAutoDiscovered`) |
+
+All scenarios are backend; there is no frontend in this spec.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index b1d7587..40bf92e 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -12,7 +12,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
| 004 | 004-sitemap-crawler | Sitemap crawler | backend, crawler | `SitemapCrawler` — sitemap discovery of URLs + lastmod | #7 | done |
| 005 | 005-page-fetcher | Page fetcher | backend, crawler | `PageFetcher` — robust HTTP fetch (ETag/IMS, rate limit, retry) | #9 | done |
| 006 | 006-content-extractor | Content extractor | backend, crawler | `ContentExtractor` — jsoup content + metadata extraction | #11 | done |
-| 007 | 007-source-strategy | Source strategy | backend, architecture | `ContentSourceStrategy` interface + `WebsiteSourceStrategy` | — | open |
+| 007 | 007-source-strategy | Source strategy | backend, architecture | `ContentSourceStrategy` interface + `WebsiteSourceStrategy` | #13 | done |
| 008 | 008-content-indexer | Content indexer | backend, search, crawler | `ContentIndexer` — orchestration, diff, upsert/delete | — | open |
| 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | — | open |
| 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler` — `@Scheduled` incremental re-crawl | — | open |
diff --git a/src/main/java/com/openelements/content/ContentSourceStrategy.java b/src/main/java/com/openelements/content/ContentSourceStrategy.java
new file mode 100644
index 0000000..3d3b4bf
--- /dev/null
+++ b/src/main/java/com/openelements/content/ContentSourceStrategy.java
@@ -0,0 +1,33 @@
+package com.openelements.content;
+
+import java.util.List;
+
+/**
+ * Strategy for discovering and fetching the content of one {@link SourceType}.
+ *
+ * The indexer (spec 008) depends only on this interface, so a new source type (e.g. {@code git},
+ * spec 016) is added as a new bean without changing the orchestration or the Meilisearch flow. One
+ * implementation exists per {@link SourceType}; {@link SourceStrategyRegistry} selects the right one.
+ */
+public interface ContentSourceStrategy {
+
+ /** @return the source type this strategy handles */
+ SourceType type();
+
+ /**
+ * Discovers the candidate items of a source.
+ *
+ * @param source the source to discover
+ * @return the discovered items (URL + change marker)
+ */
+ List discover(ContentSource source);
+
+ /**
+ * Fetches a single discovered item and decides what the indexer should do with it.
+ *
+ * @param source the owning source
+ * @param item the item to fetch
+ * @return the outcome (index/unchanged/delete/skip)
+ */
+ FetchOutcome fetch(ContentSource source, DiscoveredItem item);
+}
diff --git a/src/main/java/com/openelements/content/FetchOutcome.java b/src/main/java/com/openelements/content/FetchOutcome.java
new file mode 100644
index 0000000..e952421
--- /dev/null
+++ b/src/main/java/com/openelements/content/FetchOutcome.java
@@ -0,0 +1,42 @@
+package com.openelements.content;
+
+/**
+ * What a {@link ContentSourceStrategy} determined should happen with one discovered item, plus the
+ * document when there is one to index.
+ *
+ * This widens the design's {@code ContentDocument fetch(item)} sketch so the non-happy paths
+ * (unchanged, deleted, skipped) are first-class rather than encoded as {@code null} or exceptions.
+ *
+ * @param result the action the indexer should take
+ * @param document the document to upsert (non-null only for {@link Result#INDEX})
+ */
+public record FetchOutcome(Result result, ContentDocument document) {
+
+ /** The action the indexer should take for a discovered item. */
+ public enum Result {
+ /** A document to upsert into the index. */
+ INDEX,
+ /** Unchanged since last seen (e.g. 304); nothing to do. */
+ UNCHANGED,
+ /** The item no longer exists (e.g. 404/410); remove it from the index. */
+ DELETE,
+ /** Fetch or extraction failed for this item; log and continue with the batch. */
+ SKIP
+ }
+
+ static FetchOutcome index(ContentDocument document) {
+ return new FetchOutcome(Result.INDEX, document);
+ }
+
+ static FetchOutcome unchanged() {
+ return new FetchOutcome(Result.UNCHANGED, null);
+ }
+
+ static FetchOutcome delete() {
+ return new FetchOutcome(Result.DELETE, null);
+ }
+
+ static FetchOutcome skip() {
+ return new FetchOutcome(Result.SKIP, null);
+ }
+}
diff --git a/src/main/java/com/openelements/content/SourceStrategyRegistry.java b/src/main/java/com/openelements/content/SourceStrategyRegistry.java
new file mode 100644
index 0000000..1725dcc
--- /dev/null
+++ b/src/main/java/com/openelements/content/SourceStrategyRegistry.java
@@ -0,0 +1,45 @@
+package com.openelements.content;
+
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.stereotype.Component;
+
+/**
+ * Selects the {@link ContentSourceStrategy} for a {@link ContentSource} by its {@link SourceType}.
+ *
+ *
All strategy beans are injected and indexed by {@link ContentSourceStrategy#type()}, so a new
+ * strategy is auto-discovered simply by being a bean. Two strategies claiming the same type is a
+ * configuration error and fails fast at startup.
+ */
+@Component
+public class SourceStrategyRegistry {
+
+ private final Map byType;
+
+ public SourceStrategyRegistry(List strategies) {
+ Map map = new EnumMap<>(SourceType.class);
+ for (ContentSourceStrategy strategy : strategies) {
+ ContentSourceStrategy previous = map.putIfAbsent(strategy.type(), strategy);
+ if (previous != null) {
+ throw new IllegalStateException(
+ "Multiple content source strategies registered for type " + strategy.type());
+ }
+ }
+ this.byType = map;
+ }
+
+ /**
+ * @param source the source whose strategy is needed
+ * @return the strategy handling the source's type
+ * @throws IllegalArgumentException if no strategy is registered for the source's type
+ */
+ public ContentSourceStrategy forSource(ContentSource source) {
+ ContentSourceStrategy strategy = byType.get(source.type());
+ if (strategy == null) {
+ throw new IllegalArgumentException(
+ "No content source strategy registered for type " + source.type());
+ }
+ return strategy;
+ }
+}
diff --git a/src/main/java/com/openelements/content/WebsiteSourceStrategy.java b/src/main/java/com/openelements/content/WebsiteSourceStrategy.java
new file mode 100644
index 0000000..3c9f4e8
--- /dev/null
+++ b/src/main/java/com/openelements/content/WebsiteSourceStrategy.java
@@ -0,0 +1,80 @@
+package com.openelements.content;
+
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * {@link ContentSourceStrategy} for {@link SourceType#WEBSITE}: discovery via {@link SitemapCrawler},
+ * fetching via {@link PageFetcher}, and extraction via {@link ContentExtractor}.
+ *
+ * The fetcher's classified {@link FetchResult} is mapped to a {@link FetchOutcome}: {@code 304} →
+ * {@code UNCHANGED}, {@code 404}/{@code 410} → {@code DELETE}, transient/other errors → {@code SKIP},
+ * and a successful fetch with extractable content → {@code INDEX} carrying a {@link ContentDocument}.
+ * An empty extraction is treated as {@code SKIP}.
+ */
+@Component
+public class WebsiteSourceStrategy implements ContentSourceStrategy {
+
+ private static final Logger log = LoggerFactory.getLogger(WebsiteSourceStrategy.class);
+
+ private final SitemapCrawler sitemapCrawler;
+ private final PageFetcher pageFetcher;
+ private final ContentExtractor contentExtractor;
+
+ public WebsiteSourceStrategy(SitemapCrawler sitemapCrawler, PageFetcher pageFetcher,
+ ContentExtractor contentExtractor) {
+ this.sitemapCrawler = sitemapCrawler;
+ this.pageFetcher = pageFetcher;
+ this.contentExtractor = contentExtractor;
+ }
+
+ @Override
+ public SourceType type() {
+ return SourceType.WEBSITE;
+ }
+
+ @Override
+ public List discover(ContentSource source) {
+ return sitemapCrawler.discover(source);
+ }
+
+ @Override
+ public FetchOutcome fetch(ContentSource source, DiscoveredItem item) {
+ // Prior ETag/Last-Modified for the conditional GET are supplied by the indexer's diff
+ // (spec 008); until then this passes null and relies on the sitemap lastmod diff.
+ FetchResult result = pageFetcher.fetch(item.url(), null, null);
+ return switch (result.status()) {
+ case NOT_MODIFIED -> FetchOutcome.unchanged();
+ case NOT_FOUND -> FetchOutcome.delete();
+ case ERROR -> {
+ log.warn("Skipping {}: fetch failed (http {})", item.url(), result.httpStatus());
+ yield FetchOutcome.skip();
+ }
+ case OK -> toIndexOutcome(source, item, result);
+ };
+ }
+
+ private FetchOutcome toIndexOutcome(ContentSource source, DiscoveredItem item, FetchResult result) {
+ ExtractedContent extracted = contentExtractor.extract(source, item.url(), result.html());
+ if (extracted.body() == null || extracted.body().isBlank()) {
+ log.warn("Skipping {}: no extractable content", item.url());
+ return FetchOutcome.skip();
+ }
+ ContentDocument document = new ContentDocument(
+ ContentDocument.id(source.id(), item.url()),
+ source.id(),
+ extracted.locale(),
+ item.url(),
+ extracted.title(),
+ extracted.excerpt(),
+ extracted.body(),
+ extracted.author(),
+ extracted.categories(),
+ extracted.publishedDate(),
+ item.lastmod(),
+ extracted.previewImage());
+ return FetchOutcome.index(document);
+ }
+}
diff --git a/src/test/java/com/openelements/content/SourceStrategyRegistryTest.java b/src/test/java/com/openelements/content/SourceStrategyRegistryTest.java
new file mode 100644
index 0000000..262e155
--- /dev/null
+++ b/src/test/java/com/openelements/content/SourceStrategyRegistryTest.java
@@ -0,0 +1,77 @@
+package com.openelements.content;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link SourceStrategyRegistry} using lightweight test-double strategies.
+ */
+@DisplayName("Source strategy registry")
+class SourceStrategyRegistryTest {
+
+ private static ContentSource source(SourceType type) {
+ return new ContentSource(
+ "s", type, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), true);
+ }
+
+ private static ContentSourceStrategy strategyFor(SourceType type) {
+ return new ContentSourceStrategy() {
+ @Override
+ public SourceType type() {
+ return type;
+ }
+
+ @Override
+ public List discover(ContentSource src) {
+ return List.of();
+ }
+
+ @Override
+ public FetchOutcome fetch(ContentSource src, DiscoveredItem item) {
+ return FetchOutcome.skip();
+ }
+ };
+ }
+
+ @Test
+ @DisplayName("a WEBSITE source resolves to the website strategy")
+ void websiteSourceResolvesToWebsiteStrategy() {
+ ContentSourceStrategy website = strategyFor(SourceType.WEBSITE);
+ SourceStrategyRegistry registry = new SourceStrategyRegistry(List.of(website));
+
+ assertThat(registry.forSource(source(SourceType.WEBSITE))).isSameAs(website);
+ }
+
+ @Test
+ @DisplayName("an unregistered type fails with a clear error naming the type")
+ void unknownTypeFailsClearly() {
+ SourceStrategyRegistry registry = new SourceStrategyRegistry(List.of(strategyFor(SourceType.WEBSITE)));
+
+ assertThatThrownBy(() -> registry.forSource(source(SourceType.GIT)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("GIT");
+ }
+
+ @Test
+ @DisplayName("an additional strategy bean is auto-discovered and indexed by its type")
+ void newStrategyIsAutoDiscovered() {
+ ContentSourceStrategy git = strategyFor(SourceType.GIT);
+ SourceStrategyRegistry registry =
+ new SourceStrategyRegistry(List.of(strategyFor(SourceType.WEBSITE), git));
+
+ assertThat(registry.forSource(source(SourceType.GIT))).isSameAs(git);
+ }
+
+ @Test
+ @DisplayName("two strategies for the same type fail fast")
+ void duplicateStrategiesFailFast() {
+ assertThatThrownBy(() -> new SourceStrategyRegistry(
+ List.of(strategyFor(SourceType.WEBSITE), strategyFor(SourceType.WEBSITE))))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("WEBSITE");
+ }
+}
diff --git a/src/test/java/com/openelements/content/WebsiteSourceStrategyTest.java b/src/test/java/com/openelements/content/WebsiteSourceStrategyTest.java
new file mode 100644
index 0000000..879efc6
--- /dev/null
+++ b/src/test/java/com/openelements/content/WebsiteSourceStrategyTest.java
@@ -0,0 +1,145 @@
+package com.openelements.content;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.ExpectedCount.manyTimes;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.time.Duration;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.util.unit.DataSize;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Tests for {@link WebsiteSourceStrategy} using its real collaborators ({@link SitemapCrawler},
+ * {@link PageFetcher}, {@link ContentExtractor}) wired to {@link MockRestServiceServer}, so the
+ * fetch-outcome mapping is exercised end-to-end without mocks or real network.
+ */
+@DisplayName("Website source strategy")
+class WebsiteSourceStrategyTest {
+
+ private static final String PAGE_URL = "https://ex.com/posts/a";
+
+ private MockRestServiceServer fetchServer;
+ private MockRestServiceServer crawlServer;
+ private WebsiteSourceStrategy strategy;
+
+ @BeforeEach
+ void setUp() {
+ ContentSourceProperties properties = new ContentSourceProperties(
+ true, null, "OpenElementsContentBot/1.0", 2.0, Duration.ofSeconds(10), DataSize.ofMegabytes(5), List.of());
+
+ RestClient.Builder fetchBuilder = RestClient.builder();
+ fetchServer = MockRestServiceServer.bindTo(fetchBuilder).ignoreExpectOrder(true).build();
+ PageFetcher fetcher = new PageFetcher(
+ fetchBuilder.build(), properties, new HostRateLimiter(0, () -> 0L, millis -> { }), millis -> { });
+
+ RestClient.Builder crawlBuilder = RestClient.builder();
+ crawlServer = MockRestServiceServer.bindTo(crawlBuilder).ignoreExpectOrder(true).build();
+ SitemapCrawler crawler = new SitemapCrawler(crawlBuilder, new UrlMatcher());
+
+ ContentExtractor extractor = new ContentExtractor(new ContentLocaleResolver(), new ObjectMapper());
+ strategy = new WebsiteSourceStrategy(crawler, fetcher, extractor);
+ }
+
+ private static ContentSource source(List sitemaps) {
+ return new ContentSource(
+ "open-elements", SourceType.WEBSITE, "https://ex.com",
+ sitemaps, List.of("/**"), List.of(), "article", List.of(), true);
+ }
+
+ private static DiscoveredItem item(String lastmod) {
+ return new DiscoveredItem(PAGE_URL, lastmod);
+ }
+
+ @Test
+ @DisplayName("the strategy handles the WEBSITE source type")
+ void handlesWebsiteType() {
+ assertThat(strategy.type()).isEqualTo(SourceType.WEBSITE);
+ }
+
+ @Test
+ @DisplayName("discover delegates to the sitemap crawler")
+ void discoverDelegatesToCrawler() {
+ crawlServer.expect(requestTo("https://ex.com/sitemap.xml")).andRespond(withSuccess(
+ ""
+ + "" + PAGE_URL + "",
+ MediaType.APPLICATION_XML));
+
+ List items = strategy.discover(source(List.of("/sitemap.xml")));
+
+ assertThat(items).extracting(DiscoveredItem::url).containsExactly(PAGE_URL);
+ }
+
+ @Test
+ @DisplayName("a successful fetch with content produces an INDEX outcome with a populated document")
+ void successfulFetchProducesIndex() {
+ fetchServer.expect(requestTo(PAGE_URL)).andRespond(withSuccess(
+ ""
+ + "Article body text.
",
+ MediaType.TEXT_HTML));
+
+ FetchOutcome outcome = strategy.fetch(source(List.of()), item("2026-03-01"));
+
+ assertThat(outcome.result()).isEqualTo(FetchOutcome.Result.INDEX);
+ ContentDocument document = outcome.document();
+ assertThat(document).isNotNull();
+ assertThat(document.id()).isEqualTo(ContentDocument.id("open-elements", PAGE_URL));
+ assertThat(document.source()).isEqualTo("open-elements");
+ assertThat(document.url()).isEqualTo(PAGE_URL);
+ assertThat(document.title()).isEqualTo("The Title");
+ assertThat(document.body()).contains("Article body text.");
+ assertThat(document.locale()).isEqualTo("en");
+ assertThat(document.lastmod()).isEqualTo("2026-03-01");
+ }
+
+ @Test
+ @DisplayName("a 304 produces an UNCHANGED outcome")
+ void notModifiedProducesUnchanged() {
+ fetchServer.expect(requestTo(PAGE_URL)).andRespond(withStatus(HttpStatus.NOT_MODIFIED));
+
+ FetchOutcome outcome = strategy.fetch(source(List.of()), item(null));
+
+ assertThat(outcome.result()).isEqualTo(FetchOutcome.Result.UNCHANGED);
+ assertThat(outcome.document()).isNull();
+ }
+
+ @Test
+ @DisplayName("a 404 produces a DELETE outcome")
+ void notFoundProducesDelete() {
+ fetchServer.expect(requestTo(PAGE_URL)).andRespond(withStatus(HttpStatus.NOT_FOUND));
+
+ FetchOutcome outcome = strategy.fetch(source(List.of()), item(null));
+
+ assertThat(outcome.result()).isEqualTo(FetchOutcome.Result.DELETE);
+ }
+
+ @Test
+ @DisplayName("a persistent fetch error produces a SKIP outcome")
+ void fetchErrorProducesSkip() {
+ fetchServer.expect(manyTimes(), requestTo(PAGE_URL)).andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE));
+
+ FetchOutcome outcome = strategy.fetch(source(List.of()), item(null));
+
+ assertThat(outcome.result()).isEqualTo(FetchOutcome.Result.SKIP);
+ }
+
+ @Test
+ @DisplayName("a page with no extractable content produces a SKIP outcome")
+ void emptyContentProducesSkip() {
+ fetchServer.expect(requestTo(PAGE_URL)).andRespond(withSuccess(
+ "", MediaType.TEXT_HTML));
+
+ FetchOutcome outcome = strategy.fetch(source(List.of()), item(null));
+
+ assertThat(outcome.result()).isEqualTo(FetchOutcome.Result.SKIP);
+ }
+}