@open-mercato/search 0.6.8-develop.7072.1.19c2a8bbe0 → 0.6.8-develop.7077.1.94f1126c13

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.
@@ -190,4 +190,238 @@ describe('createPresenterEnricher', () => {
190
190
  const [enriched] = await enrich(results, 'tenant-1', null)
191
191
  expect(enriched.presenter?.title).toBe('Stored')
192
192
  })
193
+
194
+ it('merges person and company profile hits into their matching customer entities', async () => {
195
+ mockedDecryptIndexDocForSearch.mockImplementation(async (_entityId, doc) => doc)
196
+
197
+ const rows: IndexRow[] = [
198
+ {
199
+ entity_type: 'customers:customer_entity',
200
+ entity_id: 'person-entity',
201
+ doc: { id: 'person-entity', display_name: 'Ada Lovelace', kind: 'person' },
202
+ },
203
+ {
204
+ entity_type: 'customers:customer_person_profile',
205
+ entity_id: 'person-profile',
206
+ doc: { id: 'person-profile', entity_id: 'person-entity', display_name: 'Ada Lovelace' },
207
+ },
208
+ {
209
+ entity_type: 'customers:customer_entity',
210
+ entity_id: 'company-entity',
211
+ doc: { id: 'company-entity', display_name: 'Analytical Engines', kind: 'company' },
212
+ },
213
+ {
214
+ entity_type: 'customers:customer_company_profile',
215
+ entity_id: 'company-profile',
216
+ doc: { id: 'company-profile', entity_id: 'company-entity', display_name: 'Analytical Engines' },
217
+ },
218
+ ]
219
+ const personConfig = createConfig({
220
+ entityId: 'customers:customer_person_profile' as EntityId,
221
+ formatResult: async (context) => ({ title: String(context.record.display_name) }),
222
+ resolveUrl: async (context) => `/backend/customers/people-v2/${String(context.record.entity_id)}`,
223
+ })
224
+ const companyConfig = createConfig({
225
+ entityId: 'customers:customer_company_profile' as EntityId,
226
+ formatResult: async (context) => ({ title: String(context.record.display_name) }),
227
+ resolveUrl: async (context) => `/backend/customers/companies-v2/${String(context.record.entity_id)}`,
228
+ })
229
+ const enrich = createPresenterEnricher(
230
+ createKyselyMock(rows),
231
+ new Map([
232
+ [personConfig.entityId, personConfig],
233
+ [companyConfig.entityId, companyConfig],
234
+ ]),
235
+ )
236
+
237
+ const enriched = await enrich([
238
+ createResult({
239
+ entityId: 'customers:customer_entity' as EntityId,
240
+ recordId: 'person-entity',
241
+ presenter: undefined,
242
+ score: 0.9,
243
+ }),
244
+ createResult({
245
+ entityId: 'customers:customer_person_profile' as EntityId,
246
+ recordId: 'person-profile',
247
+ presenter: undefined,
248
+ score: 0.8,
249
+ }),
250
+ createResult({
251
+ entityId: 'customers:customer_entity' as EntityId,
252
+ recordId: 'company-entity',
253
+ presenter: undefined,
254
+ score: 0.7,
255
+ }),
256
+ createResult({
257
+ entityId: 'customers:customer_company_profile' as EntityId,
258
+ recordId: 'company-profile',
259
+ presenter: undefined,
260
+ score: 0.6,
261
+ }),
262
+ ], 'tenant-1', null)
263
+
264
+ expect(enriched).toEqual([
265
+ expect.objectContaining({
266
+ entityId: 'customers:customer_entity',
267
+ recordId: 'person-entity',
268
+ presenter: { title: 'Ada Lovelace' },
269
+ url: '/backend/customers/people-v2/person-entity',
270
+ }),
271
+ expect.objectContaining({
272
+ entityId: 'customers:customer_entity',
273
+ recordId: 'company-entity',
274
+ presenter: { title: 'Analytical Engines' },
275
+ url: '/backend/customers/companies-v2/company-entity',
276
+ }),
277
+ ])
278
+ })
279
+
280
+ it('re-sorts merged results when linked profiles outrank their customer entities', async () => {
281
+ mockedDecryptIndexDocForSearch.mockImplementation(async (_entityId, doc) => doc)
282
+
283
+ const rows: IndexRow[] = [
284
+ {
285
+ entity_type: 'customers:customer_entity',
286
+ entity_id: 'person-entity',
287
+ doc: { id: 'person-entity', display_name: 'Ada Lovelace', kind: 'person' },
288
+ },
289
+ {
290
+ entity_type: 'customers:customer_person_profile',
291
+ entity_id: 'person-profile',
292
+ doc: { id: 'person-profile', entity_id: 'person-entity', display_name: 'Ada Lovelace' },
293
+ },
294
+ {
295
+ entity_type: 'customers:customer_entity',
296
+ entity_id: 'company-entity',
297
+ doc: { id: 'company-entity', display_name: 'Analytical Engines', kind: 'company' },
298
+ },
299
+ {
300
+ entity_type: 'customers:customer_company_profile',
301
+ entity_id: 'company-profile',
302
+ doc: { id: 'company-profile', entity_id: 'company-entity', display_name: 'Analytical Engines' },
303
+ },
304
+ ]
305
+ const personConfig = createConfig({
306
+ entityId: 'customers:customer_person_profile' as EntityId,
307
+ formatResult: async (context) => ({ title: String(context.record.display_name) }),
308
+ resolveUrl: async (context) => `/backend/customers/people-v2/${String(context.record.entity_id)}`,
309
+ })
310
+ const companyConfig = createConfig({
311
+ entityId: 'customers:customer_company_profile' as EntityId,
312
+ formatResult: async (context) => ({ title: String(context.record.display_name) }),
313
+ resolveUrl: async (context) => `/backend/customers/companies-v2/${String(context.record.entity_id)}`,
314
+ })
315
+ const enrich = createPresenterEnricher(
316
+ createKyselyMock(rows),
317
+ new Map([
318
+ [personConfig.entityId, personConfig],
319
+ [companyConfig.entityId, companyConfig],
320
+ ]),
321
+ )
322
+
323
+ const enriched = await enrich([
324
+ createResult({
325
+ entityId: 'customers:customer_person_profile' as EntityId,
326
+ recordId: 'person-profile',
327
+ organizationId: 'org-1',
328
+ presenter: undefined,
329
+ score: 0.95,
330
+ source: 'fulltext',
331
+ }),
332
+ createResult({
333
+ entityId: 'customers:customer_company_profile' as EntityId,
334
+ recordId: 'company-profile',
335
+ organizationId: 'org-1',
336
+ presenter: undefined,
337
+ score: 0.85,
338
+ source: 'fulltext',
339
+ }),
340
+ createResult({
341
+ entityId: 'orders:order' as EntityId,
342
+ recordId: 'order-1',
343
+ organizationId: 'org-1',
344
+ presenter: { title: 'Order 1' },
345
+ url: '/backend/sales/orders/order-1',
346
+ score: 0.5,
347
+ }),
348
+ createResult({
349
+ entityId: 'customers:customer_entity' as EntityId,
350
+ recordId: 'person-entity',
351
+ organizationId: 'org-1',
352
+ presenter: undefined,
353
+ score: 0.2,
354
+ }),
355
+ createResult({
356
+ entityId: 'customers:customer_entity' as EntityId,
357
+ recordId: 'company-entity',
358
+ organizationId: 'org-1',
359
+ presenter: undefined,
360
+ score: 0.1,
361
+ }),
362
+ ], 'tenant-1', 'org-1')
363
+
364
+ expect(enriched).toEqual([
365
+ expect.objectContaining({
366
+ entityId: 'customers:customer_entity',
367
+ recordId: 'person-entity',
368
+ score: 0.95,
369
+ url: '/backend/customers/people-v2/person-entity',
370
+ }),
371
+ expect.objectContaining({
372
+ entityId: 'customers:customer_entity',
373
+ recordId: 'company-entity',
374
+ score: 0.85,
375
+ url: '/backend/customers/companies-v2/company-entity',
376
+ }),
377
+ expect.objectContaining({
378
+ entityId: 'orders:order',
379
+ recordId: 'order-1',
380
+ score: 0.5,
381
+ }),
382
+ ])
383
+ })
384
+
385
+ it('keeps linked content hits when their navigation includes a page anchor', async () => {
386
+ mockedDecryptIndexDocForSearch.mockImplementation(async (_entityId, doc) => doc)
387
+
388
+ const entityId = 'person-entity'
389
+ const rows: IndexRow[] = [
390
+ {
391
+ entity_type: 'customers:customer_entity',
392
+ entity_id: entityId,
393
+ doc: { id: entityId, display_name: 'Ada Lovelace' },
394
+ },
395
+ {
396
+ entity_type: 'customers:customer_comment',
397
+ entity_id: 'comment-1',
398
+ doc: { id: 'comment-1', body: 'Ada Lovelace', entity_id: entityId },
399
+ },
400
+ ]
401
+ const commentConfig = createConfig({
402
+ entityId: 'customers:customer_comment' as EntityId,
403
+ formatResult: async () => ({ title: 'Ada Lovelace' }),
404
+ resolveUrl: async () => `/backend/customers/people-v2/${entityId}#notes`,
405
+ })
406
+ const enrich = createPresenterEnricher(
407
+ createKyselyMock(rows),
408
+ new Map([[commentConfig.entityId, commentConfig]]),
409
+ )
410
+
411
+ const enriched = await enrich([
412
+ createResult({
413
+ entityId: 'customers:customer_entity' as EntityId,
414
+ recordId: entityId,
415
+ presenter: undefined,
416
+ }),
417
+ createResult({
418
+ entityId: 'customers:customer_comment' as EntityId,
419
+ recordId: 'comment-1',
420
+ presenter: undefined,
421
+ }),
422
+ ], 'tenant-1', null)
423
+
424
+ expect(enriched).toHaveLength(2)
425
+ expect(enriched[1]?.url).toBe(`/backend/customers/people-v2/${entityId}#notes`)
426
+ })
193
427
  })
@@ -0,0 +1,99 @@
1
+ import { TokenSearchStrategy } from '../strategies/token.strategy'
2
+
3
+ /**
4
+ * Coverage for `OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY` on the read path (#5046).
5
+ *
6
+ * The exclusion is read-side by design and the writer deliberately ignores the flag:
7
+ * `search_tokens` doubles as the encrypted-column lookup index, so the People and Companies list
8
+ * search resolves ids through the very `customers:customer_entity` rows this flag hides from
9
+ * search results. Dropping them write-side would turn that list search into a silent empty page.
10
+ * These tests therefore pin the SQL predicate the strategy issues, never the writer's behavior —
11
+ * `packages/core/src/modules/query_index/__tests__/search-entity-policy.test.ts` is the other half
12
+ * of the pair and asserts the writer emits byte-identical rows in both flag states.
13
+ */
14
+
15
+ const CUSTOMER_ENTITY = 'customers:customer_entity'
16
+ const PERSON_PROFILE = 'customers:customer_person_profile'
17
+
18
+ type RecordedWhere = [string, string, unknown]
19
+
20
+ function createMockDb() {
21
+ const wheres: RecordedWhere[] = []
22
+ const builder: Record<string, unknown> = {
23
+ select: jest.fn(() => builder),
24
+ where: jest.fn((column: unknown, op?: unknown, value?: unknown) => {
25
+ if (typeof column === 'string' && typeof op === 'string') wheres.push([column, op, value])
26
+ return builder
27
+ }),
28
+ groupBy: jest.fn(() => builder),
29
+ having: jest.fn(() => builder),
30
+ orderBy: jest.fn(() => builder),
31
+ limit: jest.fn(() => builder),
32
+ execute: jest.fn().mockResolvedValue([]),
33
+ }
34
+ const db = { selectFrom: jest.fn(() => builder) }
35
+ return { db, wheres, builder }
36
+ }
37
+
38
+ const entityTypePredicates = (wheres: RecordedWhere[]) => wheres.filter(([column]) => column === 'entity_type')
39
+
40
+ const originalFlag = process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
41
+
42
+ afterEach(() => {
43
+ if (originalFlag === undefined) delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
44
+ else process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY = originalFlag
45
+ })
46
+
47
+ describe('TokenSearchStrategy excludes base customer entities by default', () => {
48
+ it('adds a NOT IN predicate when the caller requests no specific entity types', async () => {
49
+ delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
50
+ const { db, wheres } = createMockDb()
51
+ const strategy = new TokenSearchStrategy(db as never)
52
+
53
+ await strategy.search('ada lovelace', { tenantId: 'tenant-1' })
54
+
55
+ expect(entityTypePredicates(wheres)).toEqual([['entity_type', 'not in', [CUSTOMER_ENTITY]]])
56
+ })
57
+
58
+ it('drops the excluded type from an explicit entityTypes filter and keeps the rest', async () => {
59
+ delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
60
+ const { db, wheres } = createMockDb()
61
+ const strategy = new TokenSearchStrategy(db as never)
62
+
63
+ await strategy.search('ada lovelace', {
64
+ tenantId: 'tenant-1',
65
+ entityTypes: [CUSTOMER_ENTITY, PERSON_PROFILE],
66
+ })
67
+
68
+ expect(entityTypePredicates(wheres)).toEqual([['entity_type', 'in', [PERSON_PROFILE]]])
69
+ })
70
+
71
+ it('returns no results — and issues no query — when only excluded types were requested', async () => {
72
+ delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
73
+ const { db } = createMockDb()
74
+ const strategy = new TokenSearchStrategy(db as never)
75
+
76
+ const results = await strategy.search('ada lovelace', {
77
+ tenantId: 'tenant-1',
78
+ entityTypes: [CUSTOMER_ENTITY],
79
+ })
80
+
81
+ expect(results).toEqual([])
82
+ expect(db.selectFrom).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('leaves the query untouched when the flag re-enables base customer entities', async () => {
86
+ process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY = 'true'
87
+ const { db, wheres } = createMockDb()
88
+ const strategy = new TokenSearchStrategy(db as never)
89
+
90
+ await strategy.search('ada lovelace', { tenantId: 'tenant-1' })
91
+ expect(entityTypePredicates(wheres)).toEqual([])
92
+
93
+ await strategy.search('ada lovelace', {
94
+ tenantId: 'tenant-1',
95
+ entityTypes: [CUSTOMER_ENTITY, PERSON_PROFILE],
96
+ })
97
+ expect(entityTypePredicates(wheres)).toEqual([['entity_type', 'in', [CUSTOMER_ENTITY, PERSON_PROFILE]]])
98
+ })
99
+ })
@@ -104,6 +104,95 @@ type EnrichmentResult = {
104
104
  links?: SearchResultLink[]
105
105
  }
106
106
 
107
+ function primaryNavigationHref(result: SearchResult): string | null {
108
+ if (typeof result.url === 'string' && result.url.trim().length > 0) {
109
+ return result.url.trim()
110
+ }
111
+ const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)
112
+ return primaryLink?.href.trim() ?? null
113
+ }
114
+
115
+ function directNavigationRecordId(href: string): string | null {
116
+ try {
117
+ const url = new URL(href, 'http://search.local')
118
+ if (url.search || url.hash) return null
119
+ const segments = url.pathname.split('/').filter(Boolean)
120
+ const lastSegment = segments.at(-1)
121
+ return lastSegment ? decodeURIComponent(lastSegment) : null
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ function presenterTitle(result: SearchResult): string | null {
128
+ const title = result.presenter?.title?.trim()
129
+ return title?.length ? title : null
130
+ }
131
+
132
+ function resultScopeKey(result: SearchResult, recordId: string): string {
133
+ return `${result.organizationId ?? ''}:${recordId}`
134
+ }
135
+
136
+ function mergeResultMetadata(
137
+ targetMetadata: SearchResult['metadata'],
138
+ linkedMetadata: SearchResult['metadata'],
139
+ ): SearchResult['metadata'] {
140
+ if (!targetMetadata && !linkedMetadata) return undefined
141
+ return {
142
+ ...targetMetadata,
143
+ ...linkedMetadata,
144
+ }
145
+ }
146
+
147
+ function mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {
148
+ const indexesByRecord = new Map<string, number[]>()
149
+ for (let index = 0; index < results.length; index += 1) {
150
+ const result = results[index]
151
+ const key = resultScopeKey(result, result.recordId)
152
+ const indexes = indexesByRecord.get(key) ?? []
153
+ indexes.push(index)
154
+ indexesByRecord.set(key, indexes)
155
+ }
156
+
157
+ const replacements = new Map<number, SearchResult>()
158
+ const removedIndexes = new Set<number>()
159
+
160
+ for (let index = 0; index < results.length; index += 1) {
161
+ const linkedResult = results[index]
162
+ const href = primaryNavigationHref(linkedResult)
163
+ const targetRecordId = href ? directNavigationRecordId(href) : null
164
+ if (!targetRecordId || targetRecordId === linkedResult.recordId) continue
165
+
166
+ const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])
167
+ .filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))
168
+ if (targetIndexes.length !== 1) continue
169
+
170
+ const targetIndex = targetIndexes[0]
171
+ const targetResult = replacements.get(targetIndex) ?? results[targetIndex]
172
+ if (primaryNavigationHref(targetResult)) continue
173
+
174
+ const linkedTitle = presenterTitle(linkedResult)
175
+ const targetTitle = presenterTitle(targetResult)
176
+ if (!linkedTitle || linkedTitle !== targetTitle) continue
177
+
178
+ replacements.set(targetIndex, {
179
+ ...targetResult,
180
+ score: Math.max(targetResult.score, linkedResult.score),
181
+ source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,
182
+ presenter: linkedResult.presenter ?? targetResult.presenter,
183
+ url: linkedResult.url ?? targetResult.url,
184
+ links: linkedResult.links ?? targetResult.links,
185
+ metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),
186
+ })
187
+ removedIndexes.add(index)
188
+ }
189
+
190
+ return results
191
+ .map((result, index) => replacements.get(index) ?? result)
192
+ .filter((_, index) => !removedIndexes.has(index))
193
+ .sort((left, right) => right.score - left.score)
194
+ }
195
+
107
196
  /**
108
197
  * Compute presenter, URL, and links for a single doc using config or fallback.
109
198
  * Returns presenter (null if cannot be computed), and optionally URL/links from config.
@@ -285,7 +374,7 @@ export function createPresenterEnricher(
285
374
  }
286
375
 
287
376
  // Enrich results with computed presenter, URL, and links
288
- return results.map((result) => {
377
+ const enrichedResults = results.map((result) => {
289
378
  if (!shouldEnrich(result)) return result
290
379
  const key = `${result.entityId}:${result.recordId}`
291
380
  const enriched = enrichmentMap.get(key)
@@ -297,5 +386,7 @@ export function createPresenterEnricher(
297
386
  links: enriched.links ?? result.links,
298
387
  }
299
388
  })
389
+
390
+ return mergeLinkedDuplicateResults(enrichedResults)
300
391
  }
301
392
  }
@@ -1,12 +1,75 @@
1
- import { expect, test } from '@playwright/test'
1
+ import { expect, test, type APIRequestContext } from '@playwright/test'
2
2
  import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
3
  import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import {
5
+ createCompanyFixture,
6
+ createPersonFixture,
7
+ deleteEntityIfExists,
8
+ } from '@open-mercato/core/helpers/integration/crmFixtures'
4
9
 
5
10
  type GlobalSearchSettings = { enabledStrategies?: string[] }
6
11
  type GlobalSearchUpdate = { ok?: boolean; enabledStrategies?: string[] }
7
- type GlobalSearchResponse = { strategiesEnabled?: string[] }
12
+ type SearchResultItem = {
13
+ entityId?: string
14
+ recordId?: string
15
+ presenter?: { title?: string } | null
16
+ url?: string | null
17
+ }
18
+ type GlobalSearchResponse = { strategiesEnabled?: string[]; results?: SearchResultItem[] }
19
+ type SearchQueryResult = { ok: boolean; status: number; results: SearchResultItem[] }
8
20
 
9
21
  const DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']
22
+ const CUSTOMER_ENTITY = 'customers:customer_entity'
23
+ const PERSON_PROFILE = 'customers:customer_person_profile'
24
+ const COMPANY_PROFILE = 'customers:customer_company_profile'
25
+
26
+ function presenterTitle(result: SearchResultItem): string | null {
27
+ const title = result.presenter?.title
28
+ return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null
29
+ }
30
+
31
+ async function searchResults(
32
+ request: APIRequestContext,
33
+ token: string,
34
+ path: string,
35
+ ): Promise<SearchQueryResult> {
36
+ const response = await apiRequest(request, 'GET', path, { token })
37
+ if (!response.ok()) return { ok: false, status: response.status(), results: [] }
38
+ const body = (await readJsonSafe<GlobalSearchResponse>(response)) ?? {}
39
+ return {
40
+ ok: true,
41
+ status: response.status(),
42
+ results: Array.isArray(body.results) ? body.results : [],
43
+ }
44
+ }
45
+
46
+ function customerSearchPath(query: string, entityType: string): string {
47
+ const params = new URLSearchParams({
48
+ q: query,
49
+ limit: '20',
50
+ strategies: 'tokens',
51
+ entityTypes: entityType,
52
+ })
53
+ return `/api/search/search?${params.toString()}`
54
+ }
55
+
56
+ function globalSearchPath(query: string): string {
57
+ const params = new URLSearchParams({ q: query, limit: '20' })
58
+ return `/api/search/search/global?${params.toString()}`
59
+ }
60
+
61
+ /**
62
+ * A profile result navigates to the customer's v2 detail page, whose path segment is the base
63
+ * customer entity id — not the profile's own `recordId`. So this asserts the shape of a direct
64
+ * detail link (prefix + one non-empty id segment, no query string or anchor) rather than
65
+ * equality with `recordId`.
66
+ */
67
+ function hasCanonicalNavigation(result: SearchResultItem, expectedPrefix: string): boolean {
68
+ if (typeof result.url !== 'string') return false
69
+ if (!result.url.startsWith(`${expectedPrefix}/`)) return false
70
+ const target = result.url.slice(expectedPrefix.length + 1)
71
+ return target.length > 0 && !/[/?#]/.test(target)
72
+ }
10
73
 
11
74
  /**
12
75
  * TC-SEARCH-006: global (Cmd+K) search honors the saved strategy config over a
@@ -71,4 +134,129 @@ test.describe('TC-SEARCH-006: global search honors saved strategy config over UR
71
134
  }
72
135
  }
73
136
  })
137
+
138
+ test('returns one navigable profile result per customer and no base-entity duplicate', async ({ request }) => {
139
+ test.slow()
140
+ test.setTimeout(120_000)
141
+
142
+ const stamp = Date.now()
143
+ const personName = `QASRCH006P${stamp}`
144
+ const companyName = `QASRCH006C${stamp}`
145
+ let token: string | null = null
146
+ let originalStrategies: string[] | null = DEFAULT_STRATEGIES
147
+ let personId: string | null = null
148
+ let companyId: string | null = null
149
+ let personGlobalResults: SearchResultItem[] = []
150
+ let companyGlobalResults: SearchResultItem[] = []
151
+
152
+ try {
153
+ token = await getAuthToken(request, 'admin')
154
+
155
+ const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })
156
+ expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()
157
+ const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}
158
+ originalStrategies =
159
+ Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0
160
+ ? current.enabledStrategies
161
+ : DEFAULT_STRATEGIES
162
+
163
+ const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {
164
+ token,
165
+ data: { enabledStrategies: ['tokens'] },
166
+ })
167
+ expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)
168
+
169
+ personId = await createPersonFixture(request, token, {
170
+ firstName: 'QA',
171
+ lastName: `Search 006 ${stamp}`,
172
+ displayName: personName,
173
+ })
174
+ companyId = await createCompanyFixture(request, token, companyName)
175
+
176
+ await expect
177
+ .poll(
178
+ async () => {
179
+ const [personEntity, personProfile, companyEntity, companyProfile, personGlobal, companyGlobal] =
180
+ await Promise.all([
181
+ searchResults(request, token!, customerSearchPath(personName, CUSTOMER_ENTITY)),
182
+ searchResults(request, token!, customerSearchPath(personName, PERSON_PROFILE)),
183
+ searchResults(request, token!, customerSearchPath(companyName, CUSTOMER_ENTITY)),
184
+ searchResults(request, token!, customerSearchPath(companyName, COMPANY_PROFILE)),
185
+ searchResults(request, token!, globalSearchPath(personName)),
186
+ searchResults(request, token!, globalSearchPath(companyName)),
187
+ ])
188
+
189
+ const queries = [
190
+ ['person-entity', personEntity],
191
+ ['person-profile', personProfile],
192
+ ['company-entity', companyEntity],
193
+ ['company-profile', companyProfile],
194
+ ['person-global', personGlobal],
195
+ ['company-global', companyGlobal],
196
+ ] as const
197
+ const failedQuery = queries.find(([, result]) => !result.ok)
198
+ if (failedQuery) return `${failedQuery[0]}:status:${failedQuery[1].status}`
199
+
200
+ const indexedQueries = [
201
+ ['person-profile', personProfile.results, personName, PERSON_PROFILE],
202
+ ['company-profile', companyProfile.results, companyName, COMPANY_PROFILE],
203
+ ] as const
204
+ for (const [label, results, expectedTitle, expectedEntityId] of indexedQueries) {
205
+ const matches = results.filter(
206
+ (result) => presenterTitle(result) === expectedTitle && result.entityId === expectedEntityId,
207
+ )
208
+ if (matches.length === 0) return `${label}:matches:0`
209
+ }
210
+
211
+ // Under the default OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=false the token strategy
212
+ // refuses to return base customer rows, so an explicit query for that entity type is
213
+ // empty even though the same customers' profiles are already indexed above. (The rows
214
+ // themselves stay in search_tokens — the list-search id lookup still needs them.)
215
+ const baseEntityQueries = [
216
+ ['person-entity', personEntity.results, personName],
217
+ ['company-entity', companyEntity.results, companyName],
218
+ ] as const
219
+ for (const [label, results, expectedTitle] of baseEntityQueries) {
220
+ const matches = results.filter((result) => presenterTitle(result) === expectedTitle)
221
+ if (matches.length !== 0) return `${label}:matches:${matches.length}`
222
+ }
223
+
224
+ personGlobalResults = personGlobal.results.filter((result) => presenterTitle(result) === personName)
225
+ companyGlobalResults = companyGlobal.results.filter((result) => presenterTitle(result) === companyName)
226
+ if (personGlobalResults.length !== 1) return `person-global:matches:${personGlobalResults.length}`
227
+ if (companyGlobalResults.length !== 1) return `company-global:matches:${companyGlobalResults.length}`
228
+ if (personGlobalResults[0]?.entityId !== PERSON_PROFILE) {
229
+ return `person-global:entity:${personGlobalResults[0]?.entityId ?? 'missing'}`
230
+ }
231
+ if (companyGlobalResults[0]?.entityId !== COMPANY_PROFILE) {
232
+ return `company-global:entity:${companyGlobalResults[0]?.entityId ?? 'missing'}`
233
+ }
234
+ if (!hasCanonicalNavigation(personGlobalResults[0], '/backend/customers/people-v2')) {
235
+ return `person-global:navigation:${personGlobalResults[0]?.url ?? 'missing'}`
236
+ }
237
+ if (!hasCanonicalNavigation(companyGlobalResults[0], '/backend/customers/companies-v2')) {
238
+ return `company-global:navigation:${companyGlobalResults[0]?.url ?? 'missing'}`
239
+ }
240
+
241
+ return 'ready'
242
+ },
243
+ { timeout: 10_000 },
244
+ )
245
+ .toBe('ready')
246
+
247
+ expect(personGlobalResults).toHaveLength(1)
248
+ expect(personGlobalResults[0]?.entityId).toBe(PERSON_PROFILE)
249
+ expect(companyGlobalResults).toHaveLength(1)
250
+ expect(companyGlobalResults[0]?.entityId).toBe(COMPANY_PROFILE)
251
+ } finally {
252
+ await deleteEntityIfExists(request, token, '/api/customers/people', personId)
253
+ await deleteEntityIfExists(request, token, '/api/customers/companies', companyId)
254
+ if (token && originalStrategies) {
255
+ await apiRequest(request, 'POST', '/api/search/settings/global-search', {
256
+ token,
257
+ data: { enabledStrategies: originalStrategies },
258
+ }).catch(() => undefined)
259
+ }
260
+ }
261
+ })
74
262
  })