@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.
- package/AGENTS.md +1 -0
- 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/strategies/token.strategy.js +8 -2
- package/dist/strategies/token.strategy.js.map +2 -2
- package/package.json +4 -4
- package/src/__tests__/presenter-enricher.test.ts +234 -0
- package/src/__tests__/token-strategy-entity-exclusion.test.ts +99 -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/strategies/token.strategy.ts +15 -2
|
@@ -198,6 +198,113 @@ describe('GET /api/search/search/global presenter localization', () => {
|
|
|
198
198
|
expect(result.links?.[0]?.label).toBe('Otwórz osobę')
|
|
199
199
|
}
|
|
200
200
|
})
|
|
201
|
+
|
|
202
|
+
// Covers OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=true, where base customer rows are tokenized and
|
|
203
|
+
// the route can therefore receive both halves of a pair. Under the default the token strategy
|
|
204
|
+
// never returns the base rows at all (see token-strategy-entity-exclusion.test.ts); the strategy
|
|
205
|
+
// here is a stub that yields them regardless, so this pins the merge itself.
|
|
206
|
+
it('merges a customer entity and profile pair into one navigable result when both are indexed', async () => {
|
|
207
|
+
const rows = [
|
|
208
|
+
{
|
|
209
|
+
entity_type: 'customers:customer_entity',
|
|
210
|
+
entity_id: 'person-entity',
|
|
211
|
+
doc: { id: 'person-entity', display_name: 'Ada Lovelace', kind: 'person' },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
entity_type: 'customers:customer_person_profile',
|
|
215
|
+
entity_id: 'person-profile',
|
|
216
|
+
doc: { id: 'person-profile', entity_id: 'person-entity', display_name: 'Ada Lovelace' },
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
entity_type: 'customers:customer_entity',
|
|
220
|
+
entity_id: 'company-entity',
|
|
221
|
+
doc: { id: 'company-entity', display_name: 'Analytical Engines', kind: 'company' },
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
entity_type: 'customers:customer_company_profile',
|
|
225
|
+
entity_id: 'company-profile',
|
|
226
|
+
doc: { id: 'company-profile', entity_id: 'company-entity', display_name: 'Analytical Engines' },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
entity_type: 'orders:order',
|
|
230
|
+
entity_id: 'order-1',
|
|
231
|
+
doc: { id: 'order-1', title: 'Order 1' },
|
|
232
|
+
},
|
|
233
|
+
]
|
|
234
|
+
const personEntityId = 'customers:customer_person_profile' as EntityId
|
|
235
|
+
const companyEntityId = 'customers:customer_company_profile' as EntityId
|
|
236
|
+
const configMap = new Map<EntityId, SearchEntityConfig>([
|
|
237
|
+
[personEntityId, {
|
|
238
|
+
entityId: personEntityId,
|
|
239
|
+
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
240
|
+
resolveUrl: async (context) => `/backend/customers/people-v2/${String(context.record.entity_id)}`,
|
|
241
|
+
}],
|
|
242
|
+
[companyEntityId, {
|
|
243
|
+
entityId: companyEntityId,
|
|
244
|
+
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
245
|
+
resolveUrl: async (context) => `/backend/customers/companies-v2/${String(context.record.entity_id)}`,
|
|
246
|
+
}],
|
|
247
|
+
])
|
|
248
|
+
const scoresByRecordId: Record<string, number> = {
|
|
249
|
+
'person-profile': 0.95,
|
|
250
|
+
'company-profile': 0.85,
|
|
251
|
+
'order-1': 0.5,
|
|
252
|
+
'person-entity': 0.2,
|
|
253
|
+
'company-entity': 0.1,
|
|
254
|
+
}
|
|
255
|
+
const results: SearchResult[] = rows
|
|
256
|
+
.map((row) => ({
|
|
257
|
+
entityId: row.entity_type as EntityId,
|
|
258
|
+
recordId: row.entity_id,
|
|
259
|
+
organizationId: 'org-1',
|
|
260
|
+
score: scoresByRecordId[row.entity_id] ?? 0,
|
|
261
|
+
source: 'tokens' as const,
|
|
262
|
+
}))
|
|
263
|
+
.sort((left, right) => right.score - left.score)
|
|
264
|
+
const strategy: SearchStrategy = {
|
|
265
|
+
id: 'tokens',
|
|
266
|
+
name: 'tokens',
|
|
267
|
+
priority: 10,
|
|
268
|
+
isAvailable: async () => true,
|
|
269
|
+
ensureReady: async () => undefined,
|
|
270
|
+
search: async () => results,
|
|
271
|
+
index: async () => undefined,
|
|
272
|
+
delete: async () => undefined,
|
|
273
|
+
}
|
|
274
|
+
const searchService = new SearchService({
|
|
275
|
+
strategies: [strategy],
|
|
276
|
+
defaultStrategies: ['tokens'],
|
|
277
|
+
presenterEnricher: createPresenterEnricher(createDatabase(rows), configMap),
|
|
278
|
+
})
|
|
279
|
+
mockCreateRequestContainer.mockResolvedValue(
|
|
280
|
+
createContainer(searchService, configMap, { features: ['search.global'], isSuperAdmin: true }),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const response = await GET(new Request('http://localhost/api/search/search/global?q=customer'))
|
|
284
|
+
const body = await response.json() as { results: SearchResult[] }
|
|
285
|
+
|
|
286
|
+
expect(response.status).toBe(200)
|
|
287
|
+
expect(body.results).toEqual([
|
|
288
|
+
expect.objectContaining({
|
|
289
|
+
entityId: 'customers:customer_entity',
|
|
290
|
+
recordId: 'person-entity',
|
|
291
|
+
presenter: expect.objectContaining({ title: 'Ada Lovelace' }),
|
|
292
|
+
url: '/backend/customers/people-v2/person-entity',
|
|
293
|
+
}),
|
|
294
|
+
expect.objectContaining({
|
|
295
|
+
entityId: 'customers:customer_entity',
|
|
296
|
+
recordId: 'company-entity',
|
|
297
|
+
presenter: expect.objectContaining({ title: 'Analytical Engines' }),
|
|
298
|
+
url: '/backend/customers/companies-v2/company-entity',
|
|
299
|
+
}),
|
|
300
|
+
expect.objectContaining({
|
|
301
|
+
entityId: 'orders:order',
|
|
302
|
+
recordId: 'order-1',
|
|
303
|
+
}),
|
|
304
|
+
])
|
|
305
|
+
expect(body.results[0]?.score).toBeGreaterThan(body.results[1]?.score ?? 0)
|
|
306
|
+
expect(body.results[1]?.score).toBeGreaterThan(body.results[2]?.score ?? 0)
|
|
307
|
+
})
|
|
201
308
|
})
|
|
202
309
|
|
|
203
310
|
describe('GET /api/search/search/global per-entity access control', () => {
|
|
@@ -67,10 +67,21 @@ export class TokenSearchStrategy implements SearchStrategy {
|
|
|
67
67
|
// Dynamically import tokenization to avoid circular dependencies
|
|
68
68
|
const { tokenizeText } = await import('@open-mercato/shared/lib/search/tokenize')
|
|
69
69
|
const { resolveSearchConfig } = await import('@open-mercato/shared/lib/search/config')
|
|
70
|
+
const { listSearchTokenExcludedEntityTypes } = await import(
|
|
71
|
+
'@open-mercato/core/modules/query_index/lib/search-entity-policy'
|
|
72
|
+
)
|
|
70
73
|
|
|
71
74
|
const config = resolveSearchConfig()
|
|
72
75
|
if (!config.enabled) return []
|
|
73
76
|
|
|
77
|
+
// The rows themselves stay in `search_tokens` — list routes and the query engines' encrypted
|
|
78
|
+
// like/ilike rewrite depend on them — so the exclusion is enforced here, at read time.
|
|
79
|
+
const excludedEntityTypes = listSearchTokenExcludedEntityTypes()
|
|
80
|
+
const requestedEntityTypes = options.entityTypes?.length
|
|
81
|
+
? options.entityTypes.filter((entityType) => !excludedEntityTypes.includes(entityType))
|
|
82
|
+
: undefined
|
|
83
|
+
if (options.entityTypes?.length && !requestedEntityTypes?.length) return []
|
|
84
|
+
|
|
74
85
|
const { hashes } = tokenizeText(query, config)
|
|
75
86
|
if (hashes.length === 0) return []
|
|
76
87
|
|
|
@@ -96,8 +107,10 @@ export class TokenSearchStrategy implements SearchStrategy {
|
|
|
96
107
|
queryBuilder = queryBuilder.where('organization_id' as any, 'in', organizationIds)
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
if (
|
|
100
|
-
queryBuilder = queryBuilder.where('entity_type' as any, 'in',
|
|
110
|
+
if (requestedEntityTypes?.length) {
|
|
111
|
+
queryBuilder = queryBuilder.where('entity_type' as any, 'in', requestedEntityTypes)
|
|
112
|
+
} else if (excludedEntityTypes.length) {
|
|
113
|
+
queryBuilder = queryBuilder.where('entity_type' as any, 'not in', excludedEntityTypes)
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
const rows = await queryBuilder.execute() as Array<{
|