@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/di/container.js +33 -3
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/query/engine.js +25 -42
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +24 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/widgets/extension-points.js.map +1 -1
- package/package.json +8 -8
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +139 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/container.ts +45 -4
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/engine.ts +36 -60
- package/src/lib/query/types.ts +4 -5
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +59 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +28 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/index.ts +7 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +15 -0
- 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
|
+
})
|
package/src/lib/di/container.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
253
|
-
|
|
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
|
})
|
package/src/lib/email/send.ts
CHANGED
|
@@ -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
|
-
|
|
465
|
-
|
|
466
|
-
'
|
|
467
|
-
'
|
|
468
|
-
|
|
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
|
-
|
|
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
|
})
|