Skip to content

Commit 8fa12ca

Browse files
committed
Add integration tests
1 parent f57660d commit 8fa12ca

File tree

1 file changed

+311
-0
lines changed

1 file changed

+311
-0
lines changed
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
/*
2+
* Copyright 2025-present the original author or authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.couchbase.core;
17+
18+
import static org.junit.jupiter.api.Assertions.*;
19+
20+
import java.time.Duration;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
import org.junit.jupiter.api.AfterAll;
25+
import org.junit.jupiter.api.BeforeAll;
26+
import org.junit.jupiter.api.Test;
27+
import org.springframework.data.annotation.Id;
28+
import org.springframework.data.couchbase.CouchbaseClientFactory;
29+
import org.springframework.data.couchbase.SimpleCouchbaseClientFactory;
30+
import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter;
31+
import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService;
32+
import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext;
33+
import org.springframework.data.couchbase.repository.Collection;
34+
import org.springframework.data.couchbase.repository.Scope;
35+
import org.springframework.data.couchbase.util.ClusterType;
36+
import org.springframework.data.couchbase.util.IgnoreWhen;
37+
import org.springframework.data.couchbase.util.JavaIntegrationTests;
38+
39+
import com.couchbase.client.core.deps.com.fasterxml.jackson.databind.DeserializationFeature;
40+
import com.couchbase.client.java.Cluster;
41+
import com.couchbase.client.java.json.JacksonTransformers;
42+
import com.couchbase.client.java.manager.search.SearchIndex;
43+
import com.couchbase.client.java.search.SearchQuery;
44+
import com.couchbase.client.java.search.SearchRequest;
45+
import com.couchbase.client.java.search.facet.SearchFacet;
46+
import com.couchbase.client.java.search.result.SearchRow;
47+
import com.couchbase.client.java.search.sort.SearchSort;
48+
49+
/**
50+
* FTS integration tests against travel-sample.inventory.airport.
51+
* <p>
52+
* Requires a Couchbase cluster with the travel-sample bucket loaded and FTS enabled.
53+
* <p>
54+
* A scope-level FTS index is created automatically on first run and reused on subsequent runs.
55+
* Since the scope-level FTS index covers all collections in the inventory scope, queries use a
56+
* conjunction with {@code type:airport} to restrict results to airport documents only.
57+
*
58+
* @author Emilien Bevierre
59+
* @since 6.2
60+
*/
61+
@IgnoreWhen(clusterTypes = ClusterType.MOCKED)
62+
class CouchbaseTemplateFtsIntegrationTests extends JavaIntegrationTests {
63+
64+
private static final String INDEX_NAME = "sd-fts-airport-idx";
65+
private static final String TS_BUCKET = "travel-sample";
66+
67+
private static CouchbaseClientFactory travelSampleClientFactory;
68+
private static CouchbaseTemplate template;
69+
private static ReactiveCouchbaseTemplate reactiveTemplate;
70+
71+
private static final SearchQuery AIRPORT_TYPE_FILTER = SearchQuery.term("airport").field("type");
72+
73+
@BeforeAll
74+
static void setupFtsTest() {
75+
callSuperBeforeAll(new Object() {});
76+
77+
JacksonTransformers.MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
78+
79+
travelSampleClientFactory = new SimpleCouchbaseClientFactory(connectionString(), authenticator(), TS_BUCKET);
80+
Cluster cluster = travelSampleClientFactory.getCluster();
81+
cluster.bucket(TS_BUCKET).waitUntilReady(Duration.ofSeconds(30));
82+
83+
CouchbaseMappingContext mappingContext = new CouchbaseMappingContext();
84+
mappingContext.setAutoIndexCreation(false);
85+
mappingContext.afterPropertiesSet();
86+
MappingCouchbaseConverter converter = new MappingCouchbaseConverter(mappingContext, "t");
87+
JacksonTranslationService translationService = new JacksonTranslationService();
88+
translationService.afterPropertiesSet();
89+
90+
template = new CouchbaseTemplate(travelSampleClientFactory, converter, translationService);
91+
reactiveTemplate = new ReactiveCouchbaseTemplate(travelSampleClientFactory, converter, translationService);
92+
93+
ensureFtsIndex(cluster);
94+
}
95+
96+
@AfterAll
97+
public static void tearDownFtsTest() {
98+
// Index is intentionally NOT dropped: re-indexing 30k+ docs on each run is too slow.
99+
if (travelSampleClientFactory != null) {
100+
try {
101+
travelSampleClientFactory.close();
102+
} catch (Exception e) {
103+
LOGGER.warn("Failed to close travel-sample client factory: {}", e.getMessage());
104+
}
105+
}
106+
callSuperAfterAll(new Object() {});
107+
}
108+
109+
private static SearchRequest airportSearch(SearchQuery query) {
110+
return SearchRequest.create(SearchQuery.conjuncts(query, AIRPORT_TYPE_FILTER));
111+
}
112+
113+
@Test
114+
void searchByMatchQueryReturnsHydratedAirports() {
115+
List<TsAirport> results = template.findBySearch(TsAirport.class)
116+
.withIndex(INDEX_NAME)
117+
.matching(airportSearch(SearchQuery.match("France").field("country")))
118+
.all();
119+
120+
assertFalse(results.isEmpty(), "Expected airports in France");
121+
for (TsAirport airport : results) {
122+
assertEquals("France", airport.country);
123+
}
124+
}
125+
126+
@Test
127+
void searchByQueryStringReturnsMatchingAirports() {
128+
List<TsAirport> results = template.findBySearch(TsAirport.class)
129+
.withIndex(INDEX_NAME)
130+
.matching(airportSearch(SearchQuery.queryString("+country:\"United States\" +faa:SFO")))
131+
.all();
132+
133+
assertFalse(results.isEmpty(), "Expected SFO airport");
134+
assertTrue(results.stream().anyMatch(a -> "SFO".equals(a.faa)));
135+
}
136+
137+
@Test
138+
void searchCountForKnownCountry() {
139+
long count = template.findBySearch(TsAirport.class)
140+
.withIndex(INDEX_NAME)
141+
.matching(airportSearch(SearchQuery.match("United States").field("country")))
142+
.count();
143+
144+
assertTrue(count > 100, "Expected many US airports in travel-sample");
145+
}
146+
147+
@Test
148+
void searchExistsForKnownAirport() {
149+
boolean exists = template.findBySearch(TsAirport.class)
150+
.withIndex(INDEX_NAME)
151+
.matching(airportSearch(SearchQuery.match("SFO").field("faa")))
152+
.exists();
153+
154+
assertTrue(exists, "SFO should exist in travel-sample");
155+
}
156+
157+
@Test
158+
void searchFirstReturnsSingleEntity() {
159+
TsAirport airport = template.findBySearch(TsAirport.class)
160+
.withIndex(INDEX_NAME)
161+
.matching(airportSearch(SearchQuery.match("United Kingdom").field("country")))
162+
.firstValue();
163+
164+
assertNotNull(airport, "Expected at least one UK airport");
165+
assertEquals("United Kingdom", airport.country);
166+
}
167+
168+
@Test
169+
void searchWithLimitAndSkipForPagination() {
170+
SearchRequest request = airportSearch(SearchQuery.match("France").field("country"));
171+
172+
List<TsAirport> page1 = template.findBySearch(TsAirport.class)
173+
.withIndex(INDEX_NAME)
174+
.withSkip(0)
175+
.withLimit(5)
176+
.matching(request)
177+
.all();
178+
179+
List<TsAirport> page2 = template.findBySearch(TsAirport.class)
180+
.withIndex(INDEX_NAME)
181+
.withSkip(5)
182+
.withLimit(5)
183+
.matching(request)
184+
.all();
185+
186+
assertEquals(5, page1.size(), "First page should have 5 results");
187+
assertFalse(page2.isEmpty(), "Second page should have results");
188+
page2.forEach(a2 -> assertFalse(page1.stream().anyMatch(a1 -> a1.key.equals(a2.key)),
189+
"Pages should not overlap"));
190+
}
191+
192+
@Test
193+
void searchRawRowsProvideScoresAndIds() {
194+
List<SearchRow> rows = template.findBySearch(TsAirport.class)
195+
.withIndex(INDEX_NAME)
196+
.matching(airportSearch(SearchQuery.match("international").field("airportname")))
197+
.rows();
198+
199+
assertFalse(rows.isEmpty(), "Expected raw rows for 'international' airports");
200+
for (SearchRow row : rows) {
201+
assertNotNull(row.id(), "Row should have a document ID");
202+
assertTrue(row.score() > 0, "Row should have a positive score");
203+
}
204+
}
205+
206+
@Test
207+
void searchResultCombinesEntitiesAndMetadata() {
208+
Map<String, SearchFacet> facets = Map.of("countries", SearchFacet.term("country", 5));
209+
SearchResult<TsAirport> result = template.findBySearch(TsAirport.class)
210+
.withIndex(INDEX_NAME)
211+
.withFacets(facets)
212+
.withLimit(3)
213+
.matching(airportSearch(SearchQuery.matchAll()))
214+
.result();
215+
216+
assertNotNull(result);
217+
assertEquals(3, result.entities().size(), "Should have 3 hydrated entities");
218+
assertTrue(result.totalRows() > 3, "Total rows should exceed the limit");
219+
assertNotNull(result.metaData(), "Metadata should be present");
220+
assertFalse(result.facets().isEmpty(), "Facet results should be present");
221+
assertTrue(result.facets().containsKey("countries"));
222+
}
223+
224+
@Test
225+
void searchWithSortByScoreDescending() {
226+
List<SearchRow> rows = template.findBySearch(TsAirport.class)
227+
.withIndex(INDEX_NAME)
228+
.withSort(SearchSort.byScore().desc(true))
229+
.withLimit(10)
230+
.matching(airportSearch(SearchQuery.match("international").field("airportname")))
231+
.rows();
232+
233+
assertFalse(rows.isEmpty());
234+
for (int i = 1; i < rows.size(); i++) {
235+
assertTrue(rows.get(i - 1).score() >= rows.get(i).score(),
236+
"Results should be sorted by score descending");
237+
}
238+
}
239+
240+
@Test
241+
void reactiveSearchReturnsHydratedEntities() {
242+
List<TsAirport> results = reactiveTemplate.findBySearch(TsAirport.class)
243+
.withIndex(INDEX_NAME)
244+
.withLimit(5)
245+
.matching(airportSearch(SearchQuery.match("United Kingdom").field("country")))
246+
.all()
247+
.collectList()
248+
.block();
249+
250+
assertNotNull(results);
251+
assertFalse(results.isEmpty(), "Expected UK airports");
252+
for (TsAirport airport : results) {
253+
assertEquals("United Kingdom", airport.country);
254+
}
255+
}
256+
257+
// --- Domain entity for travel-sample airports ---
258+
259+
@Scope("inventory")
260+
@Collection("airport")
261+
static class TsAirport {
262+
@Id String key;
263+
String airportname;
264+
String city;
265+
String country;
266+
String faa;
267+
String icao;
268+
String tz;
269+
}
270+
271+
// --- Helpers ---
272+
273+
private static void ensureFtsIndex(Cluster cluster) {
274+
com.couchbase.client.java.Scope scope = cluster.bucket(TS_BUCKET).scope("inventory");
275+
try {
276+
scope.searchIndexes().getIndex(INDEX_NAME);
277+
LOGGER.info("FTS index '{}' already exists, reusing.", INDEX_NAME);
278+
} catch (com.couchbase.client.core.error.IndexNotFoundException ex) {
279+
SearchIndex index = new SearchIndex(INDEX_NAME, TS_BUCKET);
280+
scope.searchIndexes().upsertIndex(index);
281+
LOGGER.info("Created FTS index '{}'", INDEX_NAME);
282+
}
283+
waitForFtsIndex(scope);
284+
}
285+
286+
private static void waitForFtsIndex(com.couchbase.client.java.Scope scope) {
287+
// Phase 1: wait for the index to be queryable
288+
for (int i = 0; i < 30; i++) {
289+
try {
290+
scope.search(INDEX_NAME, SearchRequest.create(SearchQuery.matchAll()));
291+
break;
292+
} catch (Exception ex) {
293+
if (i == 29)
294+
throw new RuntimeException("FTS index did not become queryable in time", ex);
295+
sleepMs(2000);
296+
}
297+
}
298+
// Phase 2: wait for documents to be indexed
299+
for (int i = 0; i < 60; i++) {
300+
try {
301+
long docCount = scope.searchIndexes().getIndexedDocumentsCount(INDEX_NAME);
302+
if (docCount > 0) {
303+
LOGGER.info("FTS index '{}' ready with {} indexed documents", INDEX_NAME, docCount);
304+
return;
305+
}
306+
} catch (Exception ignored) {}
307+
sleepMs(2000);
308+
}
309+
LOGGER.warn("FTS index '{}' may not have finished indexing", INDEX_NAME);
310+
}
311+
}

0 commit comments

Comments
 (0)