@open-mercato/shared 0.6.7-develop.6788.1.c2a1520a30 → 0.6.7-develop.6814.1.0627c7e9f1

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 (54) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/lib/auth/organizationScope.js +34 -0
  4. package/dist/lib/auth/organizationScope.js.map +7 -0
  5. package/dist/lib/di/container.js +33 -3
  6. package/dist/lib/di/container.js.map +2 -2
  7. package/dist/lib/email/send.js +56 -0
  8. package/dist/lib/email/send.js.map +2 -2
  9. package/dist/lib/query/engine.js +25 -42
  10. package/dist/lib/query/engine.js.map +2 -2
  11. package/dist/lib/query/types.js.map +1 -1
  12. package/dist/lib/search/availability.js +107 -0
  13. package/dist/lib/search/availability.js.map +7 -0
  14. package/dist/lib/search/config.js +24 -2
  15. package/dist/lib/search/config.js.map +2 -2
  16. package/dist/lib/search/tokenize.js +23 -11
  17. package/dist/lib/search/tokenize.js.map +2 -2
  18. package/dist/lib/version.js +1 -1
  19. package/dist/lib/version.js.map +1 -1
  20. package/dist/lib/webhooks/body.js +57 -0
  21. package/dist/lib/webhooks/body.js.map +7 -0
  22. package/dist/lib/webhooks/index.js +13 -1
  23. package/dist/lib/webhooks/index.js.map +2 -2
  24. package/dist/lib/webhooks/verify.js +12 -7
  25. package/dist/lib/webhooks/verify.js.map +2 -2
  26. package/dist/modules/payment_gateways/types.js +8 -1
  27. package/dist/modules/payment_gateways/types.js.map +2 -2
  28. package/dist/modules/widgets/extension-points.js.map +1 -1
  29. package/package.json +8 -8
  30. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  31. package/src/lib/auth/organizationScope.ts +65 -0
  32. package/src/lib/di/__tests__/container-app-di-absent.test.ts +139 -0
  33. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  34. package/src/lib/di/container.ts +45 -4
  35. package/src/lib/email/__tests__/send.test.ts +37 -1
  36. package/src/lib/email/send.ts +79 -0
  37. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  38. package/src/lib/query/__tests__/engine.test.ts +65 -13
  39. package/src/lib/query/engine.ts +36 -60
  40. package/src/lib/query/types.ts +4 -5
  41. package/src/lib/search/__tests__/availability.test.ts +211 -0
  42. package/src/lib/search/__tests__/config.test.ts +59 -0
  43. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  44. package/src/lib/search/availability.ts +232 -0
  45. package/src/lib/search/config.ts +28 -0
  46. package/src/lib/search/tokenize.ts +33 -11
  47. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  48. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  49. package/src/lib/webhooks/body.ts +70 -0
  50. package/src/lib/webhooks/index.ts +7 -1
  51. package/src/lib/webhooks/verify.ts +17 -9
  52. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  53. package/src/modules/payment_gateways/types.ts +15 -0
  54. package/src/modules/widgets/extension-points.ts +104 -1
@@ -0,0 +1,173 @@
1
+ // Tests for the app-level DI override hook (`src/di.ts` → `@/di`) in
2
+ // `createRequestContainer()`. The hook used to swallow EVERY failure with
3
+ // bare `catch {}` blocks, so a src/di.ts whose register() threw — or a @/di
4
+ // module that failed to load for any reason other than being absent — was
5
+ // skipped without a trace and the documented override point silently did
6
+ // nothing. Absence of the optional module must stay quiet; real failures
7
+ // must be logged.
8
+
9
+ import { asValue } from 'awilix'
10
+
11
+ jest.mock(
12
+ '@open-mercato/shared/lib/db/mikro',
13
+ () => {
14
+ const baseEm: any = {
15
+ fork: () => baseEm,
16
+ getRepository: () => ({ find: async () => [], findOne: async () => null }),
17
+ }
18
+ return {
19
+ __esModule: true,
20
+ getOrm: async () => ({ em: baseEm }),
21
+ getOrmEntities: () => [],
22
+ registerOrmEntities: () => {},
23
+ }
24
+ },
25
+ { virtual: false },
26
+ )
27
+
28
+ jest.mock(
29
+ '@mikro-orm/core',
30
+ () => ({
31
+ __esModule: true,
32
+ RequestContext: { getEntityManager: () => null },
33
+ }),
34
+ { virtual: true },
35
+ )
36
+
37
+ jest.mock(
38
+ '@open-mercato/shared/lib/query/engine',
39
+ () => ({ __esModule: true, BasicQueryEngine: class {} }),
40
+ { virtual: false },
41
+ )
42
+
43
+ jest.mock(
44
+ '@open-mercato/shared/lib/data/engine',
45
+ () => ({ __esModule: true, DefaultDataEngine: class {} }),
46
+ { virtual: false },
47
+ )
48
+
49
+ jest.mock(
50
+ '@open-mercato/shared/lib/commands',
51
+ () => ({
52
+ __esModule: true,
53
+ commandRegistry: {},
54
+ CommandBus: class { constructor() {} },
55
+ }),
56
+ { virtual: false },
57
+ )
58
+
59
+ jest.mock(
60
+ '@open-mercato/shared/modules/overrides',
61
+ () => ({
62
+ __esModule: true,
63
+ applyDiOverridesToContainer: () => {},
64
+ }),
65
+ { virtual: false },
66
+ )
67
+
68
+ // Keep this suite hermetic: without this mock the REAL core bootstrap module
69
+ // loads (and runs its module-level side effects) whenever generated files
70
+ // exist on disk, polluting globalThis state shared with sibling test files.
71
+ jest.mock(
72
+ '@open-mercato/core/bootstrap',
73
+ () => ({
74
+ __esModule: true,
75
+ bootstrap: async () => {},
76
+ }),
77
+ { virtual: true },
78
+ )
79
+
80
+ jest.mock(
81
+ '@open-mercato/shared/lib/encryption/subscriber',
82
+ () => ({
83
+ __esModule: true,
84
+ registerTenantEncryptionSubscriber: () => {},
85
+ }),
86
+ { virtual: true },
87
+ )
88
+
89
+ const mockWarn = jest.fn()
90
+ jest.mock('../../logger', () => ({
91
+ createLogger: () => ({
92
+ debug: jest.fn(),
93
+ info: jest.fn(),
94
+ warn: (...args: unknown[]) => mockWarn(...args),
95
+ error: jest.fn(),
96
+ child() { return this },
97
+ }),
98
+ }))
99
+
100
+ const mockAppDiRegister = jest.fn()
101
+ jest.mock(
102
+ '@/di',
103
+ () => ({
104
+ __esModule: true,
105
+ register: (...args: unknown[]) => mockAppDiRegister(...args),
106
+ }),
107
+ { virtual: true },
108
+ )
109
+
110
+ const {
111
+ createRequestContainer,
112
+ registerAppDiRegistrar,
113
+ registerDiRegistrars,
114
+ resetBootstrapCache,
115
+ } = require('@open-mercato/shared/lib/di/container')
116
+
117
+ describe('app-level DI override hook (@/di)', () => {
118
+ beforeEach(() => {
119
+ resetBootstrapCache()
120
+ registerAppDiRegistrar(null)
121
+ registerDiRegistrars([])
122
+ mockWarn.mockClear()
123
+ mockAppDiRegister.mockReset()
124
+ })
125
+
126
+ it('calls register() with the request container and applies its registrations', async () => {
127
+ mockAppDiRegister.mockImplementation((container: any) => {
128
+ container.register({ appOverrideProbe: asValue('from-app-di') })
129
+ })
130
+ const container = await createRequestContainer()
131
+ expect(mockAppDiRegister).toHaveBeenCalledTimes(1)
132
+ expect(mockAppDiRegister).toHaveBeenCalledWith(container)
133
+ expect(container.resolve('appOverrideProbe')).toBe('from-app-di')
134
+ expect(mockWarn).not.toHaveBeenCalled()
135
+ })
136
+
137
+ it('awaits an async register()', async () => {
138
+ mockAppDiRegister.mockImplementation(async (container: any) => {
139
+ await Promise.resolve()
140
+ container.register({ appOverrideProbe: asValue('async-app-di') })
141
+ })
142
+ const container = await createRequestContainer()
143
+ expect(container.resolve('appOverrideProbe')).toBe('async-app-di')
144
+ expect(mockWarn).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('warns instead of silently swallowing when register() throws', async () => {
148
+ mockAppDiRegister.mockImplementation(() => {
149
+ throw new Error('boom from app di')
150
+ })
151
+ const firstContainer = await createRequestContainer()
152
+ const secondContainer = await createRequestContainer()
153
+ expect(firstContainer).toBeDefined()
154
+ expect(secondContainer).toBeDefined()
155
+ expect(mockAppDiRegister).toHaveBeenCalledTimes(2)
156
+ expect(mockWarn).toHaveBeenCalledTimes(1)
157
+ const [message, fields] = mockWarn.mock.calls[0]
158
+ expect(String(message)).toContain('register()')
159
+ expect((fields as { err: Error }).err.message).toBe('boom from app di')
160
+ })
161
+
162
+ it('warns when an async register() rejects', async () => {
163
+ mockAppDiRegister.mockImplementation(async () => {
164
+ throw new Error('async boom from app di')
165
+ })
166
+ const firstContainer = await createRequestContainer()
167
+ const secondContainer = await createRequestContainer()
168
+ expect(firstContainer).toBeDefined()
169
+ expect(secondContainer).toBeDefined()
170
+ expect(mockAppDiRegister).toHaveBeenCalledTimes(2)
171
+ expect(mockWarn).toHaveBeenCalledTimes(1)
172
+ })
173
+ })
@@ -24,6 +24,8 @@ export type AppDiRegistrar = (container: AppContainer) => void | Promise<void>
24
24
  // file can be loaded as multiple module instances when mixing dynamic and static imports
25
25
  const GLOBAL_KEY = '__openMercatoDiRegistrars__'
26
26
  const APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'
27
+ const APP_DI_LOAD_WARNING_KEY = '__openMercatoAppDiLoadWarningEmitted__'
28
+ const APP_DI_REGISTER_WARNING_KEY = '__openMercatoAppDiRegisterWarningEmitted__'
27
29
  // Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
28
30
  // services bootstrap() creates are inherently process-scoped (they hold
29
31
  // state across requests). Caching them on globalThis after the first
@@ -137,10 +139,35 @@ export function registerAppDiRegistrar(registrar: AppDiRegistrar | null): void {
137
139
  ;(globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY] = registrar
138
140
  }
139
141
 
140
- /** Test-only helper to drop the process-scoped bootstrap cache. */
142
+ /** Test-only helper to drop process-scoped request-container state. */
141
143
  export function resetBootstrapCache(): void {
142
144
  (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
143
145
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
146
+ ;(globalThis as Record<string, unknown>)[APP_DI_LOAD_WARNING_KEY] = undefined
147
+ ;(globalThis as Record<string, unknown>)[APP_DI_REGISTER_WARNING_KEY] = undefined
148
+ }
149
+
150
+ function isAppDiModuleNotFound(error: unknown): boolean {
151
+ if (!error || typeof error !== 'object') return false
152
+ const { code, message } = error as { code?: unknown; message?: unknown }
153
+ const text = typeof message === 'string' ? message : ''
154
+ const moduleNotFound =
155
+ code === 'MODULE_NOT_FOUND'
156
+ || code === 'ERR_MODULE_NOT_FOUND'
157
+ || text.startsWith('Cannot find module')
158
+ || text.startsWith('Cannot find package')
159
+ return moduleNotFound && /(?:module|package) ['"]@\/di['"]/.test(text)
160
+ }
161
+
162
+ function warnAppDiFailureOnce(
163
+ key: typeof APP_DI_LOAD_WARNING_KEY | typeof APP_DI_REGISTER_WARNING_KEY,
164
+ message: string,
165
+ error: unknown,
166
+ ): void {
167
+ const globalScope = globalThis as Record<string, unknown>
168
+ if (globalScope[key] === true) return
169
+ globalScope[key] = true
170
+ logger.warn(message, { err: error })
144
171
  }
145
172
 
146
173
  function isAwilixResolver(value: unknown): value is Resolver<unknown> {
@@ -249,11 +276,25 @@ export async function createRequestContainer(): Promise<AppContainer> {
249
276
  try {
250
277
  const maybe = appDi.register(container)
251
278
  if (maybe && typeof maybe.then === 'function') await maybe
252
- } catch (error) {
253
- logger.error('App-level DI registrar failed', { err: error })
279
+ } catch (err) {
280
+ warnAppDiFailureOnce(
281
+ APP_DI_REGISTER_WARNING_KEY,
282
+ 'App-level DI override (src/di.ts register()) threw; its registrations are skipped',
283
+ err,
284
+ )
254
285
  }
255
286
  }
256
- } catch {}
287
+ } catch (err) {
288
+ if (isAppDiModuleNotFound(err)) {
289
+ logger.debug('App-level DI override module (@/di) not resolvable; skipping', { err })
290
+ } else {
291
+ warnAppDiFailureOnce(
292
+ APP_DI_LOAD_WARNING_KEY,
293
+ 'App-level DI override module (@/di) failed to load; its registrations are skipped',
294
+ err,
295
+ )
296
+ }
297
+ }
257
298
  }
258
299
  applyDiOverridesToContainer({
259
300
  register: (registrations) => container.register(toAwilixRegistrations(registrations)),
@@ -1,4 +1,7 @@
1
1
  import React from 'react'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
2
5
  import { sendEmail } from '../send'
3
6
 
4
7
  var sendMock: jest.Mock
@@ -15,6 +18,7 @@ jest.mock('resend', () => {
15
18
 
16
19
  describe('sendEmail', () => {
17
20
  const originalEnv = process.env
21
+ let tempDir: string | null = null
18
22
 
19
23
  beforeEach(() => {
20
24
  process.env = {
@@ -26,7 +30,11 @@ describe('sendEmail', () => {
26
30
  ResendMock.mockClear()
27
31
  })
28
32
 
29
- afterEach(() => {
33
+ afterEach(async () => {
34
+ if (tempDir) {
35
+ await rm(tempDir, { recursive: true, force: true })
36
+ tempDir = null
37
+ }
30
38
  process.env = originalEnv
31
39
  })
32
40
 
@@ -158,4 +166,32 @@ describe('sendEmail', () => {
158
166
  expect(ResendMock).not.toHaveBeenCalled()
159
167
  expect(sendMock).not.toHaveBeenCalled()
160
168
  })
169
+
170
+ it('captures email links in OM_TEST_MODE without external delivery', async () => {
171
+ tempDir = await mkdtemp(join(tmpdir(), 'om-email-capture-'))
172
+ const capturePath = join(tempDir, 'emails.jsonl')
173
+ process.env.OM_TEST_MODE = '1'
174
+ process.env.OM_TEST_EMAIL_CAPTURE_PATH = capturePath
175
+ delete process.env.RESEND_API_KEY
176
+
177
+ await sendEmail({
178
+ to: 'user@example.com',
179
+ subject: 'Invite',
180
+ react: React.createElement('div', null, [
181
+ React.createElement('p', { key: 'text' }, 'Accept your invite'),
182
+ React.createElement('a', { key: 'link', href: 'https://example.com/portal/invite?token=raw' }, 'Accept'),
183
+ ]),
184
+ })
185
+
186
+ const rows = (await readFile(capturePath, 'utf8')).trim().split('\n')
187
+ expect(rows).toHaveLength(1)
188
+ expect(JSON.parse(rows[0])).toEqual(expect.objectContaining({
189
+ to: 'user@example.com',
190
+ subject: 'Invite',
191
+ links: ['https://example.com/portal/invite?token=raw'],
192
+ text: 'Accept your invite Accept',
193
+ }))
194
+ expect(ResendMock).not.toHaveBeenCalled()
195
+ expect(sendMock).not.toHaveBeenCalled()
196
+ })
161
197
  })
@@ -1,5 +1,8 @@
1
1
  import { Resend } from 'resend'
2
2
  import React from 'react'
3
+ import { appendFile, mkdir } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
3
6
  import { parseBooleanWithDefault } from '../boolean'
4
7
  import { resolveDefaultEmailFromAddress } from './config'
5
8
 
@@ -16,10 +19,86 @@ export type SendEmailOptions = {
16
19
  }>
17
20
  }
18
21
 
22
+ type CapturedEmail = {
23
+ to: string
24
+ subject: string
25
+ from: string | null
26
+ replyTo: string | null
27
+ links: string[]
28
+ text: string
29
+ capturedAt: string
30
+ }
31
+
32
+ type ReactElementProps = {
33
+ href?: unknown
34
+ children?: unknown
35
+ }
36
+
37
+ const DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), 'open-mercato-email-capture.jsonl')
38
+
39
+ function resolveTestEmailCapturePath(): string {
40
+ return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH
41
+ }
42
+
43
+ function readElementProps(node: React.ReactElement): ReactElementProps {
44
+ return node.props as ReactElementProps
45
+ }
46
+
47
+ function collectEmailLinks(node: unknown, links: string[] = []): string[] {
48
+ if (node == null || typeof node === 'boolean') return links
49
+ if (Array.isArray(node)) {
50
+ for (const child of node) collectEmailLinks(child, links)
51
+ return links
52
+ }
53
+ if (React.isValidElement(node)) {
54
+ const props = readElementProps(node)
55
+ if (typeof props.href === 'string' && props.href.length > 0) links.push(props.href)
56
+ collectEmailLinks(props.children, links)
57
+ }
58
+ return links
59
+ }
60
+
61
+ function collectEmailText(node: unknown, parts: string[] = []): string[] {
62
+ if (node == null || typeof node === 'boolean') return parts
63
+ if (typeof node === 'string' || typeof node === 'number') {
64
+ parts.push(String(node))
65
+ return parts
66
+ }
67
+ if (Array.isArray(node)) {
68
+ for (const child of node) collectEmailText(child, parts)
69
+ return parts
70
+ }
71
+ if (React.isValidElement(node)) {
72
+ collectEmailText(readElementProps(node).children, parts)
73
+ }
74
+ return parts
75
+ }
76
+
77
+ async function captureEmailForTests(options: SendEmailOptions): Promise<void> {
78
+ if (!parseBooleanWithDefault(process.env.OM_TEST_MODE, false)) return
79
+
80
+ const capturePath = resolveTestEmailCapturePath()
81
+ const record: CapturedEmail = {
82
+ to: options.to,
83
+ subject: options.subject,
84
+ from: options.from ?? resolveDefaultEmailFromAddress() ?? null,
85
+ replyTo: options.replyTo ?? null,
86
+ links: collectEmailLinks(options.react),
87
+ text: collectEmailText(options.react).join(' ').replace(/\s+/g, ' ').trim(),
88
+ capturedAt: new Date().toISOString(),
89
+ }
90
+
91
+ await mkdir(dirname(capturePath), { recursive: true })
92
+ await appendFile(capturePath, `${JSON.stringify(record)}\n`, 'utf8')
93
+ }
94
+
19
95
  export async function sendEmail({ to, subject, react, from, replyTo, attachments }: SendEmailOptions) {
20
96
  const emailDisabled =
21
97
  parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||
22
98
  parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
99
+
100
+ await captureEmailForTests({ to, subject, react, from, replyTo, attachments })
101
+
23
102
  if (emailDisabled) return
24
103
 
25
104
  const apiKey = process.env.RESEND_API_KEY
@@ -0,0 +1,30 @@
1
+ import { loadDictionary, registerModules } from '../server'
2
+ import type { Module } from '../../../modules/registry'
3
+
4
+ describe('loadDictionary memoization', () => {
5
+ it('returns a cached dictionary for repeated calls with the same locale', async () => {
6
+ registerModules([
7
+ { id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } },
8
+ ] satisfies Module[])
9
+
10
+ const first = await loadDictionary('en')
11
+ const second = await loadDictionary('en')
12
+
13
+ expect(second).toBe(first)
14
+ expect(first['demo.hello']).toBe('Hello')
15
+ })
16
+
17
+ it('busts the cache when the registered module set changes', async () => {
18
+ registerModules([{ id: 'a', translations: { en: { 'a.k': 'A' } } }] satisfies Module[])
19
+ const before = await loadDictionary('en')
20
+
21
+ registerModules([
22
+ { id: 'a', translations: { en: { 'a.k': 'A' } } },
23
+ { id: 'b', translations: { en: { 'b.k': 'B' } } },
24
+ ] satisfies Module[])
25
+ const after = await loadDictionary('en')
26
+
27
+ expect(after).not.toBe(before)
28
+ expect(after['b.k']).toBe('B')
29
+ })
30
+ })
@@ -1,6 +1,13 @@
1
1
  import { BasicQueryEngine } from '../engine'
2
2
  import { SortDir } from '../types'
3
3
  import { registerModules } from '../../i18n/server'
4
+ import { clearSearchTokenPresenceCache } from '../../search/availability'
5
+
6
+ // The token-presence answer is cached process-wide (TTL); without clearing it,
7
+ // probe-count assertions would observe hits from earlier tests in this file.
8
+ beforeEach(() => {
9
+ clearSearchTokenPresenceCache()
10
+ })
4
11
 
5
12
  // Mock modules with one entity extension
6
13
  const mockModules = [
@@ -355,6 +362,8 @@ describe('BasicQueryEngine (Kysely)', () => {
355
362
  const fakeDb = createFakeKysely({
356
363
  customer_entities: [],
357
364
  customer_people: [],
365
+ search_tokens: [{ one: 1 }],
366
+ 'information_schema.tables': [{ table_name: 'search_tokens' }],
358
367
  'information_schema.columns': [
359
368
  { table_name: 'customer_entities', column_name: 'tenant_id' },
360
369
  { table_name: 'customer_people', column_name: 'id' },
@@ -362,8 +371,6 @@ describe('BasicQueryEngine (Kysely)', () => {
362
371
  ],
363
372
  })
364
373
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
365
- jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
366
- jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
367
374
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
368
375
 
369
376
  await engine.query('customers:customer_entity', {
@@ -409,7 +416,6 @@ describe('BasicQueryEngine (Kysely)', () => {
409
416
  ],
410
417
  })
411
418
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
412
- jest.spyOn(engine as any, 'tableExists').mockResolvedValue(false)
413
419
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
414
420
 
415
421
  await engine.query('customers:customer_entity', {
@@ -445,10 +451,10 @@ describe('BasicQueryEngine (Kysely)', () => {
445
451
  test('uses search tokens for index document fields on base entities', async () => {
446
452
  const fakeDb = createFakeKysely({
447
453
  todos: [],
454
+ search_tokens: [{ one: 1 }],
455
+ 'information_schema.tables': [{ table_name: 'search_tokens' }],
448
456
  })
449
457
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
450
- const tableExistsSpy = jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
451
- const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
452
458
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
453
459
 
454
460
  await engine.query('example:todo', {
@@ -461,12 +467,15 @@ describe('BasicQueryEngine (Kysely)', () => {
461
467
  page: { page: 1, pageSize: 10 },
462
468
  })
463
469
 
464
- expect(tableExistsSpy).toHaveBeenCalledWith('search_tokens')
465
- expect(hasSearchTokensSpy).toHaveBeenCalledWith(
466
- 'example:todo',
467
- 't1',
468
- expect.objectContaining({ ids: ['org1'] }),
469
- )
470
+ const calls = fakeDb._calls as Array<{ _ops: { table: string; wheres: unknown[][] } }>
471
+ const tableProbe = calls.find((call) =>
472
+ call._ops.table === 'information_schema.tables' &&
473
+ call._ops.wheres.some((where) => where[0] === 'table_name' && where[2] === 'search_tokens'))
474
+ expect(tableProbe).toBeTruthy()
475
+ const tokenProbe = calls.find((call) =>
476
+ call._ops.table === 'search_tokens' &&
477
+ call._ops.wheres.some((where) => where[0] === 'entity_type' && where[2] === 'example:todo'))
478
+ expect(tokenProbe).toBeTruthy()
470
479
  expect(applySearchTokensSpy).toHaveBeenCalledWith(
471
480
  expect.anything(),
472
481
  expect.objectContaining({
@@ -488,7 +497,6 @@ describe('BasicQueryEngine (Kysely)', () => {
488
497
  ],
489
498
  })
490
499
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
491
- const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
492
500
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
493
501
 
494
502
  await engine.query('example:todo', {
@@ -501,7 +509,8 @@ describe('BasicQueryEngine (Kysely)', () => {
501
509
  page: { page: 1, pageSize: 10 },
502
510
  })
503
511
 
504
- expect(hasSearchTokensSpy).not.toHaveBeenCalled()
512
+ const calls = fakeDb._calls as Array<{ _ops: { table: string } }>
513
+ expect(calls.some((call) => call._ops.table === 'search_tokens')).toBe(false)
505
514
  expect(applySearchTokensSpy).not.toHaveBeenCalled()
506
515
  const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === 'todos')
507
516
  expect(baseCall?._ops.wheres).toContainEqual(['todos.search_text', 'ilike', '%avision%'])
@@ -933,4 +942,47 @@ describe('BasicQueryEngine (Kysely)', () => {
933
942
  expect(baseCall._ops.limits).toBe(10)
934
943
  expect(baseCall._ops.offsets).toBe(10)
935
944
  })
945
+
946
+ describe('search_tokens coverage probe (#4723 parity)', () => {
947
+ type ProbeDbLog = { _calls: Array<{ _ops: { table: string } }> }
948
+
949
+ const countProbes = (fakeDb: ProbeDbLog): number =>
950
+ fakeDb._calls.filter((call) => call._ops.table === 'search_tokens').length
951
+
952
+ const buildEngine = (fakeDb: unknown): BasicQueryEngine => new BasicQueryEngine(
953
+ {} as ConstructorParameters<typeof BasicQueryEngine>[0],
954
+ (() => fakeDb) as unknown as NonNullable<ConstructorParameters<typeof BasicQueryEngine>[1]>,
955
+ )
956
+
957
+ const buildDb = () => createFakeKysely({
958
+ users: [],
959
+ 'information_schema.tables': [{ table_name: 'search_tokens' }],
960
+ })
961
+
962
+ test('is skipped when the query carries no like/ilike filter', async () => {
963
+ const fakeDb = buildDb()
964
+ const engine = buildEngine(fakeDb)
965
+
966
+ await engine.query('auth:user', {
967
+ tenantId: 't1',
968
+ organizationId: 'org1',
969
+ filters: { is_active: { $eq: true } },
970
+ })
971
+
972
+ expect(countProbes(fakeDb)).toBe(0)
973
+ })
974
+
975
+ test('still runs when the query actually searches', async () => {
976
+ const fakeDb = buildDb()
977
+ const engine = buildEngine(fakeDb)
978
+
979
+ await engine.query('auth:user', {
980
+ tenantId: 't1',
981
+ organizationId: 'org1',
982
+ filters: { email: { $ilike: '%abc%' } },
983
+ })
984
+
985
+ expect(countProbes(fakeDb)).toBeGreaterThan(0)
986
+ })
987
+ })
936
988
  })