@open-mercato/search 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

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.
Files changed (29) hide show
  1. package/AGENTS.md +1 -0
  2. package/dist/indexer/search-indexer.js +66 -0
  3. package/dist/indexer/search-indexer.js.map +2 -2
  4. package/dist/lib/presenter-enricher.js +71 -1
  5. package/dist/lib/presenter-enricher.js.map +2 -2
  6. package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +143 -0
  7. package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
  8. package/dist/modules/search/lib/entity-access.js +1 -43
  9. package/dist/modules/search/lib/entity-access.js.map +2 -2
  10. package/dist/modules/search/workers/fulltext-index.worker.js +7 -24
  11. package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
  12. package/dist/service.js +18 -1
  13. package/dist/service.js.map +2 -2
  14. package/dist/strategies/token.strategy.js +8 -2
  15. package/dist/strategies/token.strategy.js.map +2 -2
  16. package/package.json +6 -5
  17. package/src/__tests__/presenter-enricher.test.ts +234 -0
  18. package/src/__tests__/search-indexer-batch.test.ts +214 -0
  19. package/src/__tests__/service.test.ts +24 -0
  20. package/src/__tests__/token-strategy-entity-exclusion.test.ts +99 -0
  21. package/src/__tests__/workers.test.ts +46 -17
  22. package/src/indexer/search-indexer.ts +84 -0
  23. package/src/lib/presenter-enricher.ts +92 -1
  24. package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +190 -2
  25. package/src/modules/search/api/__tests__/global-search.routes.test.ts +107 -0
  26. package/src/modules/search/lib/entity-access.ts +4 -130
  27. package/src/modules/search/workers/fulltext-index.worker.ts +13 -29
  28. package/src/service.ts +37 -2
  29. package/src/strategies/token.strategy.ts +15 -2
@@ -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
+ })
@@ -451,6 +451,7 @@ describe('Fulltext Index Worker', () => {
451
451
  const mockSearchIndexer = {
452
452
  getEntityConfig: jest.fn().mockReturnValue(null),
453
453
  indexRecordById: jest.fn().mockResolvedValue({ action: 'indexed', created: true }),
454
+ indexRecordsById: jest.fn().mockResolvedValue({ indexed: 0, skipped: 0 }),
454
455
  }
455
456
 
456
457
  const mockEm = {
@@ -471,6 +472,7 @@ describe('Fulltext Index Worker', () => {
471
472
  ;(hasActiveReindexProgress as jest.Mock).mockResolvedValue(true)
472
473
  mockFulltextStrategy.isAvailable.mockResolvedValue(true)
473
474
  mockSearchIndexer.indexRecordById.mockResolvedValue({ action: 'indexed', created: true })
475
+ mockSearchIndexer.indexRecordsById.mockResolvedValue({ indexed: 0, skipped: 0 })
474
476
  })
475
477
 
476
478
  it('should skip job with missing tenantId', async () => {
@@ -486,12 +488,13 @@ describe('Fulltext Index Worker', () => {
486
488
  expect(mockFulltextStrategy.bulkIndex).not.toHaveBeenCalled()
487
489
  })
488
490
 
489
- it('should index records via searchIndexer when jobType is batch-index', async () => {
491
+ it('should index the whole batch in a single indexRecordsById call when jobType is batch-index', async () => {
490
492
  // Use minimal record format (just entityId + recordId)
491
493
  const records = [
492
494
  { entityId: 'test:entity', recordId: 'rec-1' },
493
495
  { entityId: 'test:entity', recordId: 'rec-2' },
494
496
  ]
497
+ mockSearchIndexer.indexRecordsById.mockResolvedValueOnce({ indexed: 2, skipped: 0 })
495
498
  const job = createMockJob<FulltextIndexJobPayload>({
496
499
  jobType: 'batch-index',
497
500
  tenantId: 'tenant-123',
@@ -501,26 +504,22 @@ describe('Fulltext Index Worker', () => {
501
504
 
502
505
  await handleFulltextIndexJob(job, ctx, mockContainer)
503
506
 
504
- // Verify indexRecordById was called for each record
505
- expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledTimes(2)
506
- expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledWith({
507
- entityId: 'test:entity',
508
- recordId: 'rec-1',
509
- tenantId: 'tenant-123',
510
- organizationId: undefined,
511
- })
512
- expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledWith({
513
- entityId: 'test:entity',
514
- recordId: 'rec-2',
507
+ // Verify the whole batch is written through exactly one indexRecordsById
508
+ // call, not one indexRecordById call per record.
509
+ expect(mockSearchIndexer.indexRecordsById).toHaveBeenCalledTimes(1)
510
+ expect(mockSearchIndexer.indexRecordsById).toHaveBeenCalledWith({
511
+ items: [
512
+ { entityId: 'test:entity', recordId: 'rec-1' },
513
+ { entityId: 'test:entity', recordId: 'rec-2' },
514
+ ],
515
515
  tenantId: 'tenant-123',
516
516
  organizationId: undefined,
517
517
  })
518
+ expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
518
519
  })
519
520
 
520
521
  it('counts handled fulltext batch records as processed so progress can complete', async () => {
521
- mockSearchIndexer.indexRecordById
522
- .mockResolvedValueOnce({ action: 'skipped' })
523
- .mockResolvedValueOnce({ action: 'skipped' })
522
+ mockSearchIndexer.indexRecordsById.mockResolvedValueOnce({ indexed: 0, skipped: 2 })
524
523
  const records = [
525
524
  { entityId: 'test:entity', recordId: 'rec-1' },
526
525
  { entityId: 'test:entity', recordId: 'rec-2' },
@@ -543,7 +542,7 @@ describe('Fulltext Index Worker', () => {
543
542
 
544
543
  await handleFulltextIndexJob(job, createMockJobContext(), containerWithProgress)
545
544
 
546
- expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledTimes(2)
545
+ expect(mockSearchIndexer.indexRecordsById).toHaveBeenCalledTimes(1)
547
546
  expect(updateReindexProgress).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 2, 'org-456')
548
547
  expect(incrementReindexProgress).toHaveBeenCalledWith(
549
548
  expect.objectContaining({ type: 'fulltext', tenantId: 'tenant-123', delta: 2 }),
@@ -551,6 +550,36 @@ describe('Fulltext Index Worker', () => {
551
550
  expect(clearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 'org-456')
552
551
  })
553
552
 
553
+ it('re-throws a failed fulltext batch write without advancing reindex progress so the queue retries it', async () => {
554
+ mockSearchIndexer.indexRecordsById.mockRejectedValueOnce(new Error('meilisearch unavailable'))
555
+ const records = [
556
+ { entityId: 'test:entity', recordId: 'rec-1' },
557
+ { entityId: 'test:entity', recordId: 'rec-2' },
558
+ ]
559
+ const containerWithProgress: HandlerContext = {
560
+ resolve: jest.fn((name: string) => {
561
+ if (name === 'searchStrategies') return [mockFulltextStrategy]
562
+ if (name === 'em') return mockEm
563
+ if (name === 'searchIndexer') return mockSearchIndexer
564
+ if (name === 'progressService') return { id: 'progress' }
565
+ throw new Error(`Unknown service: ${name}`)
566
+ }) as HandlerContext['resolve'],
567
+ }
568
+ const job = createMockJob<FulltextIndexJobPayload>({
569
+ jobType: 'batch-index',
570
+ tenantId: 'tenant-123',
571
+ organizationId: 'org-456',
572
+ records,
573
+ })
574
+
575
+ await expect(
576
+ handleFulltextIndexJob(job, createMockJobContext(), containerWithProgress),
577
+ ).rejects.toThrow('meilisearch unavailable')
578
+
579
+ expect(updateReindexProgress).not.toHaveBeenCalled()
580
+ expect(incrementReindexProgress).not.toHaveBeenCalled()
581
+ })
582
+
554
583
  it('clears an orphaned fulltext reindex lock instead of recreating it when no progress job is active', async () => {
555
584
  ;(hasActiveReindexProgress as jest.Mock).mockResolvedValueOnce(false)
556
585
  const records = [{ entityId: 'test:entity', recordId: 'rec-1' }]
@@ -572,7 +601,7 @@ describe('Fulltext Index Worker', () => {
572
601
 
573
602
  await handleFulltextIndexJob(job, createMockJobContext(), containerWithProgress)
574
603
 
575
- expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledTimes(1)
604
+ expect(mockSearchIndexer.indexRecordsById).toHaveBeenCalledTimes(1)
576
605
  expect(updateReindexProgress).not.toHaveBeenCalled()
577
606
  expect(incrementReindexProgress).not.toHaveBeenCalled()
578
607
  expect(clearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 'org-456')
@@ -33,6 +33,15 @@ export type IndexRecordParams = {
33
33
  customFields?: Record<string, unknown>
34
34
  }
35
35
 
36
+ /**
37
+ * Parameters for indexing a batch of records by id in a single bulk write.
38
+ */
39
+ export type IndexRecordsByIdParams = {
40
+ items: Array<{ entityId: EntityId; recordId: string }>
41
+ tenantId: string
42
+ organizationId?: string | null
43
+ }
44
+
36
45
  /**
37
46
  * Parameters for deleting a record from the search index.
38
47
  */
@@ -342,6 +351,81 @@ export class SearchIndexer {
342
351
  }
343
352
  }
344
353
 
354
+ /**
355
+ * Index a batch of records by id in a single bulk write.
356
+ * Unlike calling indexRecordById() in a loop, this loads each record fresh
357
+ * (same as indexRecordById) but flushes the whole batch through a single
358
+ * searchService.bulkIndex() call. Strategies that implement bulkIndex then
359
+ * collapse the batch into one write; strategies without it still write per
360
+ * record, at the bounded concurrency SearchService applies.
361
+ */
362
+ async indexRecordsById(params: IndexRecordsByIdParams): Promise<{ indexed: number; skipped: number }> {
363
+ const { items, tenantId, organizationId } = params
364
+ if (!this.queryEngine || items.length === 0) {
365
+ return { indexed: 0, skipped: items.length }
366
+ }
367
+
368
+ const recordIdsByEntity = new Map<EntityId, string[]>()
369
+ for (const item of items) {
370
+ const list = recordIdsByEntity.get(item.entityId) ?? []
371
+ list.push(item.recordId)
372
+ recordIdsByEntity.set(item.entityId, list)
373
+ }
374
+
375
+ const allRecords: IndexableRecord[] = []
376
+ let skipped = 0
377
+
378
+ for (const [entityId, recordIds] of recordIdsByEntity) {
379
+ const config = this.entityConfigMap.get(entityId)
380
+ if (!config || config.enabled === false) {
381
+ skipped += recordIds.length
382
+ continue
383
+ }
384
+
385
+ for (const recordId of recordIds) {
386
+ try {
387
+ const result = await this.queryEngine.query(entityId, {
388
+ tenantId,
389
+ organizationId: organizationId ?? undefined,
390
+ filters: { id: recordId },
391
+ includeCustomFields: true,
392
+ page: { page: 1, pageSize: 1 },
393
+ skipAutoReindex: true,
394
+ })
395
+
396
+ const record = result.items[0] as Record<string, unknown> | undefined
397
+ if (!record) {
398
+ skipped++
399
+ continue
400
+ }
401
+
402
+ const { records: built, dropped } = await this.buildIndexableRecords(
403
+ entityId,
404
+ tenantId,
405
+ organizationId ?? null,
406
+ [record],
407
+ config,
408
+ )
409
+ skipped += dropped
410
+ allRecords.push(...built)
411
+ } catch (error) {
412
+ skipped++
413
+ searchError('SearchIndexer', 'Failed to load record for batch indexing', {
414
+ entityId,
415
+ recordId,
416
+ error: error instanceof Error ? error.message : error,
417
+ })
418
+ }
419
+ }
420
+ }
421
+
422
+ if (allRecords.length > 0) {
423
+ await this.searchService.bulkIndex(allRecords)
424
+ }
425
+
426
+ return { indexed: allRecords.length, skipped }
427
+ }
428
+
345
429
  /**
346
430
  * Delete a record from the search index.
347
431
  */
@@ -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
  })