@open-mercato/search 0.6.6-develop.5594.1.30cd738303 → 0.6.6-develop.5612.1.d382eb2f33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,14 +20,23 @@ test.describe("TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock
20
20
  const settings = await apiRequest(request, "GET", "/api/search/settings", { token });
21
21
  expect(settings.ok(), "search settings should be readable after starting reindex").toBeTruthy();
22
22
  const settingsBody = await readJsonSafe(settings) ?? {};
23
- expect(settingsBody.settings?.fulltextReindexLock?.type, "first reindex should leave an observable fulltext lock").toBe("fulltext");
24
- const second = await apiRequest(request, "POST", "/api/search/reindex", { token, data: { useQueue: true } });
25
- expect(second.status(), "a concurrent reindex must be rejected with 409").toBe(409);
26
- const body = await readJsonSafe(second) ?? {};
27
- expect(body.lock?.type, "the 409 lock descriptor identifies the fulltext lock").toBe("fulltext");
28
- expect(typeof body.lock?.action, "the lock reports its action").toBe("string");
29
- expect(typeof body.lock?.startedAt, "the lock reports when it started").toBe("string");
30
- expect(typeof body.lock?.elapsedMinutes, "the lock reports elapsed minutes").toBe("number");
23
+ const heldLock = settingsBody.settings?.fulltextReindexLock ?? null;
24
+ if (heldLock) {
25
+ expect(heldLock.type, "a held reindex lock identifies the fulltext lock").toBe("fulltext");
26
+ const second = await apiRequest(request, "POST", "/api/search/reindex", { token, data: { useQueue: true } });
27
+ expect(second.status(), "a concurrent reindex must be rejected with 409").toBe(409);
28
+ const body = await readJsonSafe(second) ?? {};
29
+ expect(body.lock?.type, "the 409 lock descriptor identifies the fulltext lock").toBe("fulltext");
30
+ expect(typeof body.lock?.action, "the lock reports its action").toBe("string");
31
+ expect(typeof body.lock?.startedAt, "the lock reports when it started").toBe("string");
32
+ expect(typeof body.lock?.elapsedMinutes, "the lock reports elapsed minutes").toBe("number");
33
+ } else {
34
+ const second = await apiRequest(request, "POST", "/api/search/reindex", { token, data: { useQueue: true } });
35
+ expect(
36
+ [200, 503],
37
+ "with no held lock a concurrent reindex is not rejected with 409"
38
+ ).toContain(second.status());
39
+ }
31
40
  } finally {
32
41
  if (token) {
33
42
  await apiRequest(request, "POST", "/api/search/reindex/cancel", { token }).catch(() => void 0);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-007.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\ntype ReindexLock = {\n type?: string\n action?: string\n startedAt?: string\n elapsedMinutes?: number\n processedCount?: number | null\n totalCount?: number | null\n}\ntype ConflictBody = { error?: string; lock?: ReindexLock }\ntype SearchSettingsResponse = { settings?: { fulltextReindexLock?: ReindexLock | null } }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\n\n/**\n * TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info.\n * Source: issue #2483.\n *\n * Route: POST /api/search/reindex (requireFeatures ['search.reindex']). The route\n * acquires a per-tenant DB lock BEFORE any indexing work and, in queue mode\n * (useQueue:true \u2014 the default), does NOT release it in `finally` (workers and\n * the heartbeat own it). So a first call holds the lock and a second call returns\n * 409 with the lock descriptor \u2014 independent of whether a fulltext backend is\n * configured, because the lock is taken before the strategy-availability check.\n * Integration specs run serially (workers:1), so no other test contends for the\n * shared lock; it is released in `finally` via the cancel endpoint.\n */\ntest.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info', () => {\n test('a second reindex while one is active is rejected with 409', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n let token: string | null = null\n\n try {\n token = await getAuthToken(request, 'admin')\n\n // Start from a clean lock state.\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: DEFAULT_STRATEGIES },\n }).catch(() => undefined)\n\n // First reindex acquires (and, in queue mode, holds) the fulltext lock.\n const first = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(first.status(), 'first reindex must not hit a pre-existing lock').not.toBe(409)\n expect([200, 503], 'first reindex starts (200) or reports backend unavailable (503)').toContain(first.status())\n\n const settings = await apiRequest(request, 'GET', '/api/search/settings', { token })\n expect(settings.ok(), 'search settings should be readable after starting reindex').toBeTruthy()\n const settingsBody = (await readJsonSafe<SearchSettingsResponse>(settings)) ?? {}\n expect(settingsBody.settings?.fulltextReindexLock?.type, 'first reindex should leave an observable fulltext lock').toBe('fulltext')\n\n // Second reindex is rejected while the first holds the lock.\n const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(second.status(), 'a concurrent reindex must be rejected with 409').toBe(409)\n const body = (await readJsonSafe<ConflictBody>(second)) ?? {}\n expect(body.lock?.type, 'the 409 lock descriptor identifies the fulltext lock').toBe('fulltext')\n expect(typeof body.lock?.action, 'the lock reports its action').toBe('string')\n expect(typeof body.lock?.startedAt, 'the lock reports when it started').toBe('string')\n expect(typeof body.lock?.elapsedMinutes, 'the lock reports elapsed minutes').toBe('number')\n } finally {\n if (token) {\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)\n }\n }\n })\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAa7B,MAAM,qBAAqB,CAAC,YAAY,UAAU,QAAQ;AAe1D,KAAK,SAAS,yEAAyE,MAAM;AAC3F,OAAK,6DAA6D,OAAO,EAAE,QAAQ,MAAM;AACvF,SAAK,KAAK;AACV,SAAK,WAAW,IAAO;AAEvB,QAAI,QAAuB;AAE3B,QAAI;AACF,cAAQ,MAAM,aAAa,SAAS,OAAO;AAG3C,YAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,MAAM,CAAC,EAAE,MAAM,MAAM,MAAS;AAChG,YAAM,WAAW,SAAS,QAAQ,sCAAsC;AAAA,QACtE;AAAA,QACA,MAAM,EAAE,mBAAmB,mBAAmB;AAAA,MAChD,CAAC,EAAE,MAAM,MAAM,MAAS;AAGxB,YAAM,QAAQ,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC1G,aAAO,MAAM,OAAO,GAAG,gDAAgD,EAAE,IAAI,KAAK,GAAG;AACrF,aAAO,CAAC,KAAK,GAAG,GAAG,iEAAiE,EAAE,UAAU,MAAM,OAAO,CAAC;AAE9G,YAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,aAAO,SAAS,GAAG,GAAG,2DAA2D,EAAE,WAAW;AAC9F,YAAM,eAAgB,MAAM,aAAqC,QAAQ,KAAM,CAAC;AAChF,aAAO,aAAa,UAAU,qBAAqB,MAAM,wDAAwD,EAAE,KAAK,UAAU;AAGlI,YAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC3G,aAAO,OAAO,OAAO,GAAG,gDAAgD,EAAE,KAAK,GAAG;AAClF,YAAM,OAAQ,MAAM,aAA2B,MAAM,KAAM,CAAC;AAC5D,aAAO,KAAK,MAAM,MAAM,sDAAsD,EAAE,KAAK,UAAU;AAC/F,aAAO,OAAO,KAAK,MAAM,QAAQ,6BAA6B,EAAE,KAAK,QAAQ;AAC7E,aAAO,OAAO,KAAK,MAAM,WAAW,kCAAkC,EAAE,KAAK,QAAQ;AACrF,aAAO,OAAO,KAAK,MAAM,gBAAgB,kCAAkC,EAAE,KAAK,QAAQ;AAAA,IAC5F,UAAE;AACA,UAAI,OAAO;AACT,cAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,MAAM,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,MAClG;AAAA,IACF;AAAA,EACF,CAAC;AACH,CAAC;",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\ntype ReindexLock = {\n type?: string\n action?: string\n startedAt?: string\n elapsedMinutes?: number\n processedCount?: number | null\n totalCount?: number | null\n}\ntype ConflictBody = { error?: string; lock?: ReindexLock }\ntype SearchSettingsResponse = { settings?: { fulltextReindexLock?: ReindexLock | null } }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\n\n/**\n * TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info.\n * Source: issue #2483.\n *\n * Route: POST /api/search/reindex (requireFeatures ['search.reindex']). The route\n * acquires a per-tenant DB lock before starting the operation. In queue mode\n * (useQueue:true \u2014 the default) it only KEEPS the lock when there is queued work\n * for workers to own \u2014 i.e. the fulltext backend is available AND at least one\n * indexing job was enqueued. On the no-op paths (no indexable strategy / backend\n * unavailable \u2192 503, or zero jobs enqueued) the route deliberately releases the\n * lock in `finally` so it cannot get stuck with no worker to clear it (the\n * unit-level contract is pinned by `reindex.routes.test.ts`).\n *\n * Consequently the 409 \"already in progress\" guarantee is only observable while a\n * real reindex is actively holding the lock. This integration environment has no\n * Meilisearch backend, so the first reindex returns 503 and holds no lock; the\n * test asserts the held-lock \u2192 409 contract only when a lock is genuinely present,\n * and otherwise asserts that a backend-unavailable reindex neither leaves a lock\n * nor spuriously rejects a concurrent call. Integration specs run serially\n * (workers:1), so no other test contends for the shared lock; it is released in\n * `finally` via the cancel endpoint.\n */\ntest.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info', () => {\n test('a second reindex while one is active is rejected with 409', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n let token: string | null = null\n\n try {\n token = await getAuthToken(request, 'admin')\n\n // Start from a clean lock state.\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: DEFAULT_STRATEGIES },\n }).catch(() => undefined)\n\n // First reindex acquires the lock. It keeps the lock only when there is\n // queued work (backend available + jobs enqueued); otherwise it returns\n // 503/200 and releases the lock.\n const first = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(first.status(), 'first reindex must not hit a pre-existing lock').not.toBe(409)\n expect([200, 503], 'first reindex starts (200) or reports backend unavailable (503)').toContain(first.status())\n\n const settings = await apiRequest(request, 'GET', '/api/search/settings', { token })\n expect(settings.ok(), 'search settings should be readable after starting reindex').toBeTruthy()\n const settingsBody = (await readJsonSafe<SearchSettingsResponse>(settings)) ?? {}\n const heldLock = settingsBody.settings?.fulltextReindexLock ?? null\n\n if (heldLock) {\n // A real reindex is holding the lock: the concurrency contract applies.\n expect(heldLock.type, 'a held reindex lock identifies the fulltext lock').toBe('fulltext')\n\n const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(second.status(), 'a concurrent reindex must be rejected with 409').toBe(409)\n const body = (await readJsonSafe<ConflictBody>(second)) ?? {}\n expect(body.lock?.type, 'the 409 lock descriptor identifies the fulltext lock').toBe('fulltext')\n expect(typeof body.lock?.action, 'the lock reports its action').toBe('string')\n expect(typeof body.lock?.startedAt, 'the lock reports when it started').toBe('string')\n expect(typeof body.lock?.elapsedMinutes, 'the lock reports elapsed minutes').toBe('number')\n } else {\n // No backend / no queued work: the route released the lock, so a\n // concurrent reindex is NOT rejected with 409 \u2014 it behaves like the first.\n const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(\n [200, 503],\n 'with no held lock a concurrent reindex is not rejected with 409',\n ).toContain(second.status())\n }\n } finally {\n if (token) {\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)\n }\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAa7B,MAAM,qBAAqB,CAAC,YAAY,UAAU,QAAQ;AAwB1D,KAAK,SAAS,yEAAyE,MAAM;AAC3F,OAAK,6DAA6D,OAAO,EAAE,QAAQ,MAAM;AACvF,SAAK,KAAK;AACV,SAAK,WAAW,IAAO;AAEvB,QAAI,QAAuB;AAE3B,QAAI;AACF,cAAQ,MAAM,aAAa,SAAS,OAAO;AAG3C,YAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,MAAM,CAAC,EAAE,MAAM,MAAM,MAAS;AAChG,YAAM,WAAW,SAAS,QAAQ,sCAAsC;AAAA,QACtE;AAAA,QACA,MAAM,EAAE,mBAAmB,mBAAmB;AAAA,MAChD,CAAC,EAAE,MAAM,MAAM,MAAS;AAKxB,YAAM,QAAQ,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC1G,aAAO,MAAM,OAAO,GAAG,gDAAgD,EAAE,IAAI,KAAK,GAAG;AACrF,aAAO,CAAC,KAAK,GAAG,GAAG,iEAAiE,EAAE,UAAU,MAAM,OAAO,CAAC;AAE9G,YAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,aAAO,SAAS,GAAG,GAAG,2DAA2D,EAAE,WAAW;AAC9F,YAAM,eAAgB,MAAM,aAAqC,QAAQ,KAAM,CAAC;AAChF,YAAM,WAAW,aAAa,UAAU,uBAAuB;AAE/D,UAAI,UAAU;AAEZ,eAAO,SAAS,MAAM,kDAAkD,EAAE,KAAK,UAAU;AAEzF,cAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC3G,eAAO,OAAO,OAAO,GAAG,gDAAgD,EAAE,KAAK,GAAG;AAClF,cAAM,OAAQ,MAAM,aAA2B,MAAM,KAAM,CAAC;AAC5D,eAAO,KAAK,MAAM,MAAM,sDAAsD,EAAE,KAAK,UAAU;AAC/F,eAAO,OAAO,KAAK,MAAM,QAAQ,6BAA6B,EAAE,KAAK,QAAQ;AAC7E,eAAO,OAAO,KAAK,MAAM,WAAW,kCAAkC,EAAE,KAAK,QAAQ;AACrF,eAAO,OAAO,KAAK,MAAM,gBAAgB,kCAAkC,EAAE,KAAK,QAAQ;AAAA,MAC5F,OAAO;AAGL,cAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC3G;AAAA,UACE,CAAC,KAAK,GAAG;AAAA,UACT;AAAA,QACF,EAAE,UAAU,OAAO,OAAO,CAAC;AAAA,MAC7B;AAAA,IACF,UAAE;AACA,UAAI,OAAO;AACT,cAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,MAAM,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,MAClG;AAAA,IACF;AAAA,EACF,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.6-develop.5594.1.30cd738303",
3
+ "version": "0.6.6-develop.5612.1.d382eb2f33",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -126,9 +126,9 @@
126
126
  "zod": "^4.4.3"
127
127
  },
128
128
  "peerDependencies": {
129
- "@open-mercato/core": "0.6.6-develop.5594.1.30cd738303",
130
- "@open-mercato/queue": "0.6.6-develop.5594.1.30cd738303",
131
- "@open-mercato/shared": "0.6.6-develop.5594.1.30cd738303"
129
+ "@open-mercato/core": "0.6.6-develop.5612.1.d382eb2f33",
130
+ "@open-mercato/queue": "0.6.6-develop.5612.1.d382eb2f33",
131
+ "@open-mercato/shared": "0.6.6-develop.5612.1.d382eb2f33"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/jest": "^30.0.0",
@@ -20,13 +20,22 @@ const DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']
20
20
  * Source: issue #2483.
21
21
  *
22
22
  * Route: POST /api/search/reindex (requireFeatures ['search.reindex']). The route
23
- * acquires a per-tenant DB lock BEFORE any indexing work and, in queue mode
24
- * (useQueue:true — the default), does NOT release it in `finally` (workers and
25
- * the heartbeat own it). So a first call holds the lock and a second call returns
26
- * 409 with the lock descriptor independent of whether a fulltext backend is
27
- * configured, because the lock is taken before the strategy-availability check.
28
- * Integration specs run serially (workers:1), so no other test contends for the
29
- * shared lock; it is released in `finally` via the cancel endpoint.
23
+ * acquires a per-tenant DB lock before starting the operation. In queue mode
24
+ * (useQueue:true — the default) it only KEEPS the lock when there is queued work
25
+ * for workers to own — i.e. the fulltext backend is available AND at least one
26
+ * indexing job was enqueued. On the no-op paths (no indexable strategy / backend
27
+ * unavailable 503, or zero jobs enqueued) the route deliberately releases the
28
+ * lock in `finally` so it cannot get stuck with no worker to clear it (the
29
+ * unit-level contract is pinned by `reindex.routes.test.ts`).
30
+ *
31
+ * Consequently the 409 "already in progress" guarantee is only observable while a
32
+ * real reindex is actively holding the lock. This integration environment has no
33
+ * Meilisearch backend, so the first reindex returns 503 and holds no lock; the
34
+ * test asserts the held-lock → 409 contract only when a lock is genuinely present,
35
+ * and otherwise asserts that a backend-unavailable reindex neither leaves a lock
36
+ * nor spuriously rejects a concurrent call. Integration specs run serially
37
+ * (workers:1), so no other test contends for the shared lock; it is released in
38
+ * `finally` via the cancel endpoint.
30
39
  */
31
40
  test.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info', () => {
32
41
  test('a second reindex while one is active is rejected with 409', async ({ request }) => {
@@ -45,7 +54,9 @@ test.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock
45
54
  data: { enabledStrategies: DEFAULT_STRATEGIES },
46
55
  }).catch(() => undefined)
47
56
 
48
- // First reindex acquires (and, in queue mode, holds) the fulltext lock.
57
+ // First reindex acquires the lock. It keeps the lock only when there is
58
+ // queued work (backend available + jobs enqueued); otherwise it returns
59
+ // 503/200 and releases the lock.
49
60
  const first = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })
50
61
  expect(first.status(), 'first reindex must not hit a pre-existing lock').not.toBe(409)
51
62
  expect([200, 503], 'first reindex starts (200) or reports backend unavailable (503)').toContain(first.status())
@@ -53,16 +64,28 @@ test.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock
53
64
  const settings = await apiRequest(request, 'GET', '/api/search/settings', { token })
54
65
  expect(settings.ok(), 'search settings should be readable after starting reindex').toBeTruthy()
55
66
  const settingsBody = (await readJsonSafe<SearchSettingsResponse>(settings)) ?? {}
56
- expect(settingsBody.settings?.fulltextReindexLock?.type, 'first reindex should leave an observable fulltext lock').toBe('fulltext')
67
+ const heldLock = settingsBody.settings?.fulltextReindexLock ?? null
68
+
69
+ if (heldLock) {
70
+ // A real reindex is holding the lock: the concurrency contract applies.
71
+ expect(heldLock.type, 'a held reindex lock identifies the fulltext lock').toBe('fulltext')
57
72
 
58
- // Second reindex is rejected while the first holds the lock.
59
- const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })
60
- expect(second.status(), 'a concurrent reindex must be rejected with 409').toBe(409)
61
- const body = (await readJsonSafe<ConflictBody>(second)) ?? {}
62
- expect(body.lock?.type, 'the 409 lock descriptor identifies the fulltext lock').toBe('fulltext')
63
- expect(typeof body.lock?.action, 'the lock reports its action').toBe('string')
64
- expect(typeof body.lock?.startedAt, 'the lock reports when it started').toBe('string')
65
- expect(typeof body.lock?.elapsedMinutes, 'the lock reports elapsed minutes').toBe('number')
73
+ const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })
74
+ expect(second.status(), 'a concurrent reindex must be rejected with 409').toBe(409)
75
+ const body = (await readJsonSafe<ConflictBody>(second)) ?? {}
76
+ expect(body.lock?.type, 'the 409 lock descriptor identifies the fulltext lock').toBe('fulltext')
77
+ expect(typeof body.lock?.action, 'the lock reports its action').toBe('string')
78
+ expect(typeof body.lock?.startedAt, 'the lock reports when it started').toBe('string')
79
+ expect(typeof body.lock?.elapsedMinutes, 'the lock reports elapsed minutes').toBe('number')
80
+ } else {
81
+ // No backend / no queued work: the route released the lock, so a
82
+ // concurrent reindex is NOT rejected with 409 — it behaves like the first.
83
+ const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })
84
+ expect(
85
+ [200, 503],
86
+ 'with no held lock a concurrent reindex is not rejected with 409',
87
+ ).toContain(second.status())
88
+ }
66
89
  } finally {
67
90
  if (token) {
68
91
  await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)