Skip to content

Commit b599d7b

Browse files
joewizclaude
andcommitted
test(search): make /api/search e2e tests self-contained
The search.cy.js suite assumed the built-in `doc` app would be findable via /api/search (?q=xquery, &app=doc). Since /api/search now queries the shared `site-content` Lucene field — which the `doc` app does not populate — a bare existdb-openapi install (the CI image) returns no results, so three assertions failed (`expected 0 to be above 0`, then the <mark> and uri assertions cascaded off the empty result set). This is what turned develop red after #52 merged. Seed a self-contained fixture instead: a `before()` hook stores a small collection under /db/apps/cypress-search-test with a `site-content` index (6 <page> docs containing "xquery" across two sections) and reindexes, via admin XQuery through /api/query; `after()` removes it. The suite now searches its own corpus rather than depending on the image's `doc` app, and exercises the real shared-field contract (relevance, <mark> highlighting, dedup, offset/limit paging, and the app facet). Verified end-to-end on a live existdb-openapi instance: the setup runs via /api/query, ?q=xquery returns the 6 fixture docs with <mark> snippets and highlights, and ?app=cypress-search-test restricts to the fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 73fbb80 commit b599d7b

1 file changed

Lines changed: 78 additions & 14 deletions

File tree

src/test/cypress/e2e/search.cy.js

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,73 @@
11
const auth = { username: 'admin', password: '' };
22

3-
// These tests rely on the `doc` app (eXist documentation) being installed and
4-
// full-text indexed — it ships with the existdb/existdb image. "xquery" is a
5-
// term with many hits across the docs.
3+
// /api/search queries the shared `site-content` Lucene field that content apps
4+
// contribute to. A bare existdb-openapi install (CI image) has no producer app
5+
// populating `site-content`, so these tests seed their own self-contained
6+
// fixture — a small collection under /db/apps with a `site-content` index — and
7+
// search that. The term "xquery" appears across the fixture; the app id is
8+
// `cypress-search-test`. Setup/teardown run admin XQuery via /api/query.
9+
const APP = 'cypress-search-test';
10+
const TERM = 'xquery';
11+
const DATA = `/db/apps/${APP}/data`;
12+
const CONF = `/db/system/config/db/apps/${APP}/data`;
13+
14+
const SETUP = `
15+
let $xconf :=
16+
<collection xmlns="http://exist-db.org/collection-config/1.0">
17+
<index><lucene>
18+
<analyzer class="org.apache.lucene.analysis.standard.StandardAnalyzer"/>
19+
<text qname="page">
20+
<field name="site-content" expression="string-join((title, body)//text(), ' ')"/>
21+
<field name="site-title" expression="string(title)"/>
22+
<field name="site-url" expression="string(url)"/>
23+
<facet dimension="site-app" expression="'${APP}'"/>
24+
<facet dimension="site-section" expression="string(@section)"/>
25+
</text>
26+
</lucene></index>
27+
</collection>
28+
let $mk := function($n as xs:string, $sec as xs:string, $title as xs:string, $body as xs:string) {
29+
<page section="{$sec}"><title>{$title}</title><url>/exist/apps/${APP}/{$sec}/{$n}</url><body>{$body}</body></page>
30+
}
31+
let $docs := (
32+
$mk("p1","guide","XQuery basics","An introduction to XQuery, the query language for XML."),
33+
$mk("p2","guide","XQuery functions","XQuery has many built-in functions for sequences."),
34+
$mk("p3","guide","FLWOR in XQuery","The XQuery FLWOR expression iterates and filters."),
35+
$mk("p4","reference","XQuery modules","Library modules organize reusable XQuery code."),
36+
$mk("p5","reference","XQuery serialization","Control how XQuery serializes XML and JSON."),
37+
$mk("p6","reference","XQuery updates","The XQuery update facility modifies stored XML.")
38+
)
39+
return (
40+
xmldb:create-collection("/db/system/config/db", "apps"),
41+
xmldb:create-collection("/db/system/config/db/apps", "${APP}"),
42+
xmldb:create-collection("/db/system/config/db/apps/${APP}", "data"),
43+
xmldb:store("${CONF}", "collection.xconf", $xconf),
44+
xmldb:create-collection("/db/apps", "${APP}"),
45+
xmldb:create-collection("/db/apps/${APP}", "data"),
46+
(for $d at $i in $docs return xmldb:store("${DATA}", "p" || $i || ".xml", $d)),
47+
xmldb:reindex("${DATA}"),
48+
"indexed=" || count(collection("${DATA}")/page)
49+
)[last()]
50+
`;
51+
52+
const TEARDOWN = `
53+
(if (xmldb:collection-available("/db/apps/${APP}")) then xmldb:remove("/db/apps/${APP}") else (),
54+
if (xmldb:collection-available("/db/system/config/db/apps/${APP}")) then xmldb:remove("/db/system/config/db/apps/${APP}") else (),
55+
"cleaned")[last()]
56+
`;
57+
58+
function runAdmin(query) {
59+
return cy.request({ url: '/api/query', method: 'POST', auth, body: { query } }).then(r => {
60+
// The query runs (and its side effects materialize) on POST; release the cursor.
61+
if (r.body && r.body.cursor) {
62+
cy.request({ url: `/api/query/${r.body.cursor}`, method: 'DELETE', auth, failOnStatusCode: false });
63+
}
64+
});
65+
}
66+
667
describe('/api/search', () => {
68+
before(() => runAdmin(SETUP));
69+
after(() => runAdmin(TEARDOWN));
70+
771
describe('GET /api/search', () => {
872
it('requires the q parameter', () => {
973
cy.request({ url: '/api/search', auth, failOnStatusCode: false }).then(response => {
@@ -13,9 +77,9 @@ describe('/api/search', () => {
1377
});
1478

1579
it('returns the documented shape: query, total, offset, limit, results', () => {
16-
cy.request({ url: '/api/search?q=xquery&limit=5', auth }).then(response => {
80+
cy.request({ url: `/api/search?q=${TERM}&limit=5`, auth }).then(response => {
1781
expect(response.status).to.eq(200);
18-
expect(response.body).to.have.property('query', 'xquery');
82+
expect(response.body).to.have.property('query', TERM);
1983
expect(response.body).to.have.property('total').that.is.a('number');
2084
expect(response.body).to.have.property('offset', 0);
2185
expect(response.body).to.have.property('limit', 5);
@@ -25,7 +89,7 @@ describe('/api/search', () => {
2589
});
2690

2791
it('orders results by descending relevance score', () => {
28-
cy.request({ url: '/api/search?q=xquery&limit=10', auth }).then(response => {
92+
cy.request({ url: `/api/search?q=${TERM}&limit=10`, auth }).then(response => {
2993
const scores = response.body.results.map(r => r.score);
3094
scores.forEach(s => expect(s).to.be.a('number').and.to.be.greaterThan(0));
3195
for (let i = 1; i < scores.length; i++) {
@@ -35,7 +99,7 @@ describe('/api/search', () => {
3599
});
36100

37101
it('wraps matched terms in <mark> in snippet and highlights', () => {
38-
cy.request({ url: '/api/search?q=xquery&limit=5', auth }).then(response => {
102+
cy.request({ url: `/api/search?q=${TERM}&limit=5`, auth }).then(response => {
39103
const hit = response.body.results.find(
40104
r => r.snippet && /<mark>/i.test(r.snippet)
41105
);
@@ -47,7 +111,7 @@ describe('/api/search', () => {
47111
});
48112

49113
it('exposes a canonical document identifier (uri / path)', () => {
50-
cy.request({ url: '/api/search?q=xquery&limit=1', auth }).then(response => {
114+
cy.request({ url: `/api/search?q=${TERM}&limit=1`, auth }).then(response => {
51115
const hit = response.body.results[0];
52116
expect(hit).to.have.property('uri').that.matches(/^\/db\//);
53117
expect(hit).to.have.property('path', hit.uri);
@@ -57,7 +121,7 @@ describe('/api/search', () => {
57121
});
58122

59123
it('returns well-formed, single-rooted <span> XML snippets (parse-xml friendly)', () => {
60-
cy.request({ url: '/api/search?q=xquery&limit=5', auth }).then(response => {
124+
cy.request({ url: `/api/search?q=${TERM}&limit=5`, auth }).then(response => {
61125
const isWellFormed = (s) => {
62126
expect(s, 'fragment is wrapped in a single <span> root').to.match(
63127
/^<span>[\s\S]*<\/span>$/
@@ -74,17 +138,17 @@ describe('/api/search', () => {
74138
});
75139

76140
it('returns one result per document (deduplicated)', () => {
77-
cy.request({ url: '/api/search?q=xquery&limit=50', auth }).then(response => {
141+
cy.request({ url: `/api/search?q=${TERM}&limit=50`, auth }).then(response => {
78142
const uris = response.body.results.map(r => r.uri);
79143
expect(uris.length).to.eq(new Set(uris).size);
80144
});
81145
});
82146

83147
it('pages through results with offset/limit', () => {
84-
cy.request({ url: '/api/search?q=xquery&limit=3&offset=0', auth }).then(page1 => {
148+
cy.request({ url: `/api/search?q=${TERM}&limit=3&offset=0`, auth }).then(page1 => {
85149
// Only meaningful when there is more than one page of results.
86150
if (page1.body.total <= 3) return;
87-
cy.request({ url: '/api/search?q=xquery&limit=3&offset=3', auth }).then(page2 => {
151+
cy.request({ url: `/api/search?q=${TERM}&limit=3&offset=3`, auth }).then(page2 => {
88152
expect(page2.body.offset).to.eq(3);
89153
const u1 = new Set(page1.body.results.map(r => r.uri));
90154
page2.body.results.forEach(r => expect(u1.has(r.uri)).to.eq(false));
@@ -93,9 +157,9 @@ describe('/api/search', () => {
93157
});
94158

95159
it('restricts to a single app with the app parameter', () => {
96-
cy.request({ url: '/api/search?q=xquery&app=doc&limit=10', auth }).then(response => {
160+
cy.request({ url: `/api/search?q=${TERM}&app=${APP}&limit=10`, auth }).then(response => {
97161
expect(response.status).to.eq(200);
98-
response.body.results.forEach(r => expect(r.app).to.eq('doc'));
162+
response.body.results.forEach(r => expect(r.app).to.eq(APP));
99163
});
100164
});
101165
});

0 commit comments

Comments
 (0)