@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.
- package/AGENTS.md +1 -0
- package/dist/indexer/search-indexer.js +66 -0
- package/dist/indexer/search-indexer.js.map +2 -2
- package/dist/lib/presenter-enricher.js +71 -1
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +143 -0
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
- package/dist/modules/search/lib/entity-access.js +1 -43
- package/dist/modules/search/lib/entity-access.js.map +2 -2
- package/dist/modules/search/workers/fulltext-index.worker.js +7 -24
- package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
- package/dist/service.js +18 -1
- package/dist/service.js.map +2 -2
- package/dist/strategies/token.strategy.js +8 -2
- package/dist/strategies/token.strategy.js.map +2 -2
- package/package.json +6 -5
- package/src/__tests__/presenter-enricher.test.ts +234 -0
- package/src/__tests__/search-indexer-batch.test.ts +214 -0
- package/src/__tests__/service.test.ts +24 -0
- package/src/__tests__/token-strategy-entity-exclusion.test.ts +99 -0
- package/src/__tests__/workers.test.ts +46 -17
- package/src/indexer/search-indexer.ts +84 -0
- package/src/lib/presenter-enricher.ts +92 -1
- package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +190 -2
- package/src/modules/search/api/__tests__/global-search.routes.test.ts +107 -0
- package/src/modules/search/lib/entity-access.ts +4 -130
- package/src/modules/search/workers/fulltext-index.worker.ts +13 -29
- package/src/service.ts +37 -2
- 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
|
|
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
|
|
505
|
-
|
|
506
|
-
expect(mockSearchIndexer.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
})
|