-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathContentRefreshSchedulerTest.java
More file actions
209 lines (169 loc) · 8.02 KB
/
Copy pathContentRefreshSchedulerTest.java
File metadata and controls
209 lines (169 loc) · 8.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
package com.openelements.content;
import static org.assertj.core.api.Assertions.assertThat;
import com.openelements.spring.base.services.search.SearchReadinessState;
import java.lang.reflect.Method;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.util.unit.DataSize;
/**
* Unit tests for {@link ContentRefreshScheduler} using a real {@link ContentIndexer} driven by a
* test-double strategy and an in-memory store, plus a real {@link SearchReadinessState}. No mocks or
* live Meilisearch; non-overlap is verified deterministically via re-entrancy rather than threads.
*/
@DisplayName("Content refresh scheduler")
class ContentRefreshSchedulerTest {
private final StubStrategy strategy = new StubStrategy();
private final InMemoryContentIndexStore store = new InMemoryContentIndexStore();
private final ContentIndexer indexer =
new ContentIndexer(new SourceStrategyRegistry(List.of(strategy)), store);
private final SearchReadinessState readiness = new SearchReadinessState();
@BeforeEach
void markBootstrapComplete() {
// A fresh SearchReadinessState reports isBootstrapping()==true until bootstrap finishes;
// simulate a completed startup bootstrap so scheduled refreshes are allowed to run.
readiness.markBootstrappingFinished();
}
private static ContentSource source(String id, boolean enabled) {
return new ContentSource(
id, SourceType.WEBSITE, "https://ex.com", List.of(), List.of("/**"), List.of(), "article", List.of(), enabled);
}
private static ContentSourceProperties properties(boolean enabled, ContentSource... sources) {
return new ContentSourceProperties(
enabled, null, "UA", 2.0, Duration.ofSeconds(10), DataSize.ofMegabytes(5), List.of(sources));
}
private ContentRefreshScheduler scheduler(ContentSourceProperties properties) {
return new ContentRefreshScheduler(properties, indexer, readiness);
}
private static ContentDocument document(String source, String url, String lastmod) {
return new ContentDocument(
ContentDocument.id(source, url), source, "en", url, "T", "E", "body", "a",
List.of(), "2026-01-01", lastmod, null);
}
@Test
@DisplayName("each enabled source is indexed on a tick")
void runsEachEnabledSource() {
scheduler(properties(true, source("a", true), source("b", true))).refresh();
assertThat(strategy.discoveredSources).containsExactly("a", "b");
}
@Test
@DisplayName("a disabled source is skipped")
void disabledSourceIsSkipped() {
scheduler(properties(true, source("a", true), source("b", false))).refresh();
assertThat(strategy.discoveredSources).containsExactly("a");
}
@Test
@DisplayName("refresh is skipped while bootstrapping")
void skipsWhileBootstrapping() {
readiness.markBootstrappingStarted();
scheduler(properties(true, source("a", true))).refresh();
assertThat(strategy.discoveredSources).isEmpty();
}
@Test
@DisplayName("refresh does nothing when the pipeline is globally disabled")
void skipsWhenGloballyDisabled() {
scheduler(properties(false, source("a", true))).refresh();
assertThat(strategy.discoveredSources).isEmpty();
}
@Test
@DisplayName("a re-entrant tick does not overlap a running refresh")
void nonOverlappingRuns() {
ContentRefreshScheduler scheduler = scheduler(properties(true, source("a", true), source("b", true)));
strategy.reentrantOnce = scheduler::refresh; // fired during the first discover of the outer run
scheduler.refresh();
// The re-entrant call is skipped, so each source is discovered exactly once.
assertThat(strategy.discoveredSources).containsExactly("a", "b");
}
@Test
@DisplayName("one source failing does not stop the others")
void oneSourceFailingDoesNotStopOthers() {
strategy.throwOnDiscover.add("a");
scheduler(properties(true, source("a", true), source("b", true))).refresh();
assertThat(strategy.discoveredSources).containsExactly("a", "b");
}
@Test
@DisplayName("the cron expression is externalized with an hourly default")
void cronIsExternallyConfigurable() throws Exception {
Method refresh = ContentRefreshScheduler.class.getMethod("refresh");
Scheduled scheduled = refresh.getAnnotation(Scheduled.class);
assertThat(scheduled).isNotNull();
assertThat(scheduled.cron()).isEqualTo("${open-elements.content.refresh-cron:0 0 * * * *}");
}
@Test
@DisplayName("a new page is added to the index on refresh")
void newPageIsAdded() {
strategy.itemsBySource.put("a", List.of(new DiscoveredItem("https://ex.com/a", "L")));
scheduler(properties(true, source("a", true))).refresh();
assertThat(store.loadState("a")).containsKey(ContentDocument.id("a", "https://ex.com/a"));
}
@Test
@DisplayName("a vanished page is deleted from the index on refresh")
void deletedPageIsRemoved() {
store.upsert(List.of(document("a", "https://ex.com/gone", "L").toMap()));
strategy.itemsBySource.put("a", List.of()); // no longer discovered
scheduler(properties(true, source("a", true))).refresh();
assertThat(store.loadState("a")).isEmpty();
}
/** Test-double strategy: records discovered sources, can throw or fire a one-shot re-entrant action. */
private static final class StubStrategy implements ContentSourceStrategy {
private final List<String> discoveredSources = new ArrayList<>();
private final Set<String> throwOnDiscover = new HashSet<>();
private final Map<String, List<DiscoveredItem>> itemsBySource = new HashMap<>();
private BiFunction<ContentSource, DiscoveredItem, FetchOutcome> fetchFn =
(source, item) -> FetchOutcome.index(document(source.id(), item.url(), item.lastmod()));
private Runnable reentrantOnce;
private boolean reentrantFired;
@Override
public SourceType type() {
return SourceType.WEBSITE;
}
@Override
public List<DiscoveredItem> discover(ContentSource source) {
discoveredSources.add(source.id());
if (reentrantOnce != null && !reentrantFired) {
reentrantFired = true;
reentrantOnce.run();
}
if (throwOnDiscover.contains(source.id())) {
throw new IllegalStateException("boom " + source.id());
}
return itemsBySource.getOrDefault(source.id(), List.of());
}
@Override
public FetchOutcome fetch(ContentSource source, DiscoveredItem item) {
return fetchFn.apply(source, item);
}
}
/** In-memory {@link ContentIndexStore}: documents keyed by id. */
private static final class InMemoryContentIndexStore implements ContentIndexStore {
private final Map<String, Map<String, Object>> documents = new HashMap<>();
@Override
public Map<String, StoredDocument> loadState(String source) {
Map<String, StoredDocument> state = new HashMap<>();
documents.forEach((id, doc) -> {
if (source.equals(doc.get("source"))) {
state.put(id, new StoredDocument((String) doc.get("url"), (String) doc.get("lastmod")));
}
});
return state;
}
@Override
public int upsert(List<Map<String, Object>> docs) {
docs.forEach(doc -> documents.put((String) doc.get("id"), doc));
return docs.size();
}
@Override
public void delete(String id) {
documents.remove(id);
}
}
}