@open-mercato/shared 0.4.2-canary-c02407ff85
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/build.mjs +101 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/api/crud.js +47 -0
- package/dist/lib/api/crud.js.map +7 -0
- package/dist/lib/api/scoped.js +140 -0
- package/dist/lib/api/scoped.js.map +7 -0
- package/dist/lib/auth/jwt.js +34 -0
- package/dist/lib/auth/jwt.js.map +7 -0
- package/dist/lib/auth/server.js +157 -0
- package/dist/lib/auth/server.js.map +7 -0
- package/dist/lib/boolean.js +22 -0
- package/dist/lib/boolean.js.map +7 -0
- package/dist/lib/bootstrap/appResolver.js +43 -0
- package/dist/lib/bootstrap/appResolver.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +108 -0
- package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
- package/dist/lib/bootstrap/factory.js +59 -0
- package/dist/lib/bootstrap/factory.js.map +7 -0
- package/dist/lib/bootstrap/index.js +11 -0
- package/dist/lib/bootstrap/index.js.map +7 -0
- package/dist/lib/bootstrap/types.js +1 -0
- package/dist/lib/bootstrap/types.js.map +7 -0
- package/dist/lib/cache/segments.js +36 -0
- package/dist/lib/cache/segments.js.map +7 -0
- package/dist/lib/cli/progress.js +46 -0
- package/dist/lib/cli/progress.js.map +7 -0
- package/dist/lib/commands/command-bus.js +285 -0
- package/dist/lib/commands/command-bus.js.map +7 -0
- package/dist/lib/commands/customFieldSnapshots.js +66 -0
- package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
- package/dist/lib/commands/helpers.js +98 -0
- package/dist/lib/commands/helpers.js.map +7 -0
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +7 -0
- package/dist/lib/commands/operationMetadata.js +32 -0
- package/dist/lib/commands/operationMetadata.js.map +7 -0
- package/dist/lib/commands/registry.js +43 -0
- package/dist/lib/commands/registry.js.map +7 -0
- package/dist/lib/commands/scope.js +44 -0
- package/dist/lib/commands/scope.js.map +7 -0
- package/dist/lib/commands/types.js +8 -0
- package/dist/lib/commands/types.js.map +7 -0
- package/dist/lib/crud/cache-stats.js +98 -0
- package/dist/lib/crud/cache-stats.js.map +7 -0
- package/dist/lib/crud/cache.js +175 -0
- package/dist/lib/crud/cache.js.map +7 -0
- package/dist/lib/crud/custom-fields-client.js +52 -0
- package/dist/lib/crud/custom-fields-client.js.map +7 -0
- package/dist/lib/crud/custom-fields.js +467 -0
- package/dist/lib/crud/custom-fields.js.map +7 -0
- package/dist/lib/crud/errors.js +24 -0
- package/dist/lib/crud/errors.js.map +7 -0
- package/dist/lib/crud/exporters.js +154 -0
- package/dist/lib/crud/exporters.js.map +7 -0
- package/dist/lib/crud/factory.js +1311 -0
- package/dist/lib/crud/factory.js.map +7 -0
- package/dist/lib/crud/types.js +1 -0
- package/dist/lib/crud/types.js.map +7 -0
- package/dist/lib/custom-fields/normalize.js +36 -0
- package/dist/lib/custom-fields/normalize.js.map +7 -0
- package/dist/lib/data/engine.js +396 -0
- package/dist/lib/data/engine.js.map +7 -0
- package/dist/lib/db/escapeLikePattern.js +5 -0
- package/dist/lib/db/escapeLikePattern.js.map +7 -0
- package/dist/lib/db/mikro.js +82 -0
- package/dist/lib/db/mikro.js.map +7 -0
- package/dist/lib/di/container.js +94 -0
- package/dist/lib/di/container.js.map +7 -0
- package/dist/lib/email/send.js +12 -0
- package/dist/lib/email/send.js.map +7 -0
- package/dist/lib/encryption/aes.js +58 -0
- package/dist/lib/encryption/aes.js.map +7 -0
- package/dist/lib/encryption/customFieldValues.js +49 -0
- package/dist/lib/encryption/customFieldValues.js.map +7 -0
- package/dist/lib/encryption/entityFields.js +26 -0
- package/dist/lib/encryption/entityFields.js.map +7 -0
- package/dist/lib/encryption/entityIds.js +80 -0
- package/dist/lib/encryption/entityIds.js.map +7 -0
- package/dist/lib/encryption/find.js +45 -0
- package/dist/lib/encryption/find.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +69 -0
- package/dist/lib/encryption/indexDoc.js.map +7 -0
- package/dist/lib/encryption/kms.js +282 -0
- package/dist/lib/encryption/kms.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +330 -0
- package/dist/lib/encryption/subscriber.js.map +7 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
- package/dist/lib/encryption/toggles.js +18 -0
- package/dist/lib/encryption/toggles.js.map +7 -0
- package/dist/lib/entities/naming.js +9 -0
- package/dist/lib/entities/naming.js.map +7 -0
- package/dist/lib/entities/system-entities.js +43 -0
- package/dist/lib/entities/system-entities.js.map +7 -0
- package/dist/lib/frontend/organizationEvents.js +41 -0
- package/dist/lib/frontend/organizationEvents.js.map +7 -0
- package/dist/lib/frontend/useOrganizationScope.js +32 -0
- package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
- package/dist/lib/hotkeys/index.js +128 -0
- package/dist/lib/hotkeys/index.js.map +7 -0
- package/dist/lib/i18n/app-dictionaries.js +17 -0
- package/dist/lib/i18n/app-dictionaries.js.map +7 -0
- package/dist/lib/i18n/config.js +7 -0
- package/dist/lib/i18n/config.js.map +7 -0
- package/dist/lib/i18n/context.js +50 -0
- package/dist/lib/i18n/context.js.map +7 -0
- package/dist/lib/i18n/server.js +68 -0
- package/dist/lib/i18n/server.js.map +7 -0
- package/dist/lib/i18n/translate.js +45 -0
- package/dist/lib/i18n/translate.js.map +7 -0
- package/dist/lib/indexers/error-log.js +82 -0
- package/dist/lib/indexers/error-log.js.map +7 -0
- package/dist/lib/indexers/status-log.js +80 -0
- package/dist/lib/indexers/status-log.js.map +7 -0
- package/dist/lib/lib/auth/jwt.js +34 -0
- package/dist/lib/lib/auth/jwt.js.map +7 -0
- package/dist/lib/lib/auth/server.js +77 -0
- package/dist/lib/lib/auth/server.js.map +7 -0
- package/dist/lib/lib/email/send.js +12 -0
- package/dist/lib/lib/email/send.js.map +7 -0
- package/dist/lib/lib/i18n/config.js +7 -0
- package/dist/lib/lib/i18n/config.js.map +7 -0
- package/dist/lib/lib/i18n/context.js +31 -0
- package/dist/lib/lib/i18n/context.js.map +7 -0
- package/dist/lib/lib/utils.js +9 -0
- package/dist/lib/lib/utils.js.map +7 -0
- package/dist/lib/location/countries.js +68 -0
- package/dist/lib/location/countries.js.map +7 -0
- package/dist/lib/modules/index.js +6 -0
- package/dist/lib/modules/index.js.map +7 -0
- package/dist/lib/modules/registry.js +18 -0
- package/dist/lib/modules/registry.js.map +7 -0
- package/dist/lib/openapi/crud.js +137 -0
- package/dist/lib/openapi/crud.js.map +7 -0
- package/dist/lib/openapi/generator.js +1131 -0
- package/dist/lib/openapi/generator.js.map +7 -0
- package/dist/lib/openapi/index.js +10 -0
- package/dist/lib/openapi/index.js.map +7 -0
- package/dist/lib/openapi/sanitize.js +110 -0
- package/dist/lib/openapi/sanitize.js.map +7 -0
- package/dist/lib/openapi/types.js +1 -0
- package/dist/lib/openapi/types.js.map +7 -0
- package/dist/lib/profiler/index.js +258 -0
- package/dist/lib/profiler/index.js.map +7 -0
- package/dist/lib/query/engine.js +729 -0
- package/dist/lib/query/engine.js.map +7 -0
- package/dist/lib/query/join-utils.js +195 -0
- package/dist/lib/query/join-utils.js.map +7 -0
- package/dist/lib/query/types.js +9 -0
- package/dist/lib/query/types.js.map +7 -0
- package/dist/lib/search/config.js +32 -0
- package/dist/lib/search/config.js.map +7 -0
- package/dist/lib/search/tokenize.js +34 -0
- package/dist/lib/search/tokenize.js.map +7 -0
- package/dist/lib/slugify.js +24 -0
- package/dist/lib/slugify.js.map +7 -0
- package/dist/lib/testing/bootstrap.js +51 -0
- package/dist/lib/testing/bootstrap.js.map +7 -0
- package/dist/lib/testing/index.js +17 -0
- package/dist/lib/testing/index.js.map +7 -0
- package/dist/lib/testing/renderWithProviders.js +15 -0
- package/dist/lib/testing/renderWithProviders.js.map +7 -0
- package/dist/lib/url.js +12 -0
- package/dist/lib/url.js.map +7 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +7 -0
- package/dist/lib/version.js +7 -0
- package/dist/lib/version.js.map +7 -0
- package/dist/modules/dashboard/widgets.js +1 -0
- package/dist/modules/dashboard/widgets.js.map +7 -0
- package/dist/modules/dsl.js +30 -0
- package/dist/modules/dsl.js.map +7 -0
- package/dist/modules/entities/kinds.js +22 -0
- package/dist/modules/entities/kinds.js.map +7 -0
- package/dist/modules/entities/options.js +26 -0
- package/dist/modules/entities/options.js.map +7 -0
- package/dist/modules/entities/validation.js +102 -0
- package/dist/modules/entities/validation.js.map +7 -0
- package/dist/modules/entities/validators.js +88 -0
- package/dist/modules/entities/validators.js.map +7 -0
- package/dist/modules/entities.js +1 -0
- package/dist/modules/entities.js.map +7 -0
- package/dist/modules/navigation/sidebarPreferences.js +50 -0
- package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
- package/dist/modules/perspectives/types.js +1 -0
- package/dist/modules/perspectives/types.js.map +7 -0
- package/dist/modules/registry.js +96 -0
- package/dist/modules/registry.js.map +7 -0
- package/dist/modules/search.js +15 -0
- package/dist/modules/search.js.map +7 -0
- package/dist/modules/vector.js +1 -0
- package/dist/modules/vector.js.map +7 -0
- package/dist/modules/widgets/injection-loader.js +180 -0
- package/dist/modules/widgets/injection-loader.js.map +7 -0
- package/dist/modules/widgets/injection.js +1 -0
- package/dist/modules/widgets/injection.js.map +7 -0
- package/dist/security/features.js +23 -0
- package/dist/security/features.js.map +7 -0
- package/dist/types/pg.d.js +1 -0
- package/dist/types/pg.d.js.map +7 -0
- package/dist/types/react-email.d.js +1 -0
- package/dist/types/react-email.d.js.map +7 -0
- package/dist/types/resend.d.js +1 -0
- package/dist/types/resend.d.js.map +7 -0
- package/jest.config.cjs +22 -0
- package/package.json +88 -0
- package/src/index.ts +0 -0
- package/src/lib/api/__tests__/scoped.test.ts +38 -0
- package/src/lib/api/crud.ts +59 -0
- package/src/lib/api/scoped.ts +239 -0
- package/src/lib/auth/jwt.ts +39 -0
- package/src/lib/auth/server.ts +199 -0
- package/src/lib/boolean.ts +17 -0
- package/src/lib/bootstrap/appResolver.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +177 -0
- package/src/lib/bootstrap/factory.ts +108 -0
- package/src/lib/bootstrap/index.ts +23 -0
- package/src/lib/bootstrap/types.ts +31 -0
- package/src/lib/cache/segments.ts +56 -0
- package/src/lib/cli/progress.ts +55 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
- package/src/lib/commands/__tests__/helpers.test.ts +42 -0
- package/src/lib/commands/command-bus.ts +349 -0
- package/src/lib/commands/customFieldSnapshots.ts +86 -0
- package/src/lib/commands/helpers.ts +143 -0
- package/src/lib/commands/index.ts +4 -0
- package/src/lib/commands/operationMetadata.ts +40 -0
- package/src/lib/commands/registry.ts +46 -0
- package/src/lib/commands/scope.ts +59 -0
- package/src/lib/commands/types.ts +63 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
- package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
- package/src/lib/crud/cache-stats.ts +127 -0
- package/src/lib/crud/cache.ts +205 -0
- package/src/lib/crud/custom-fields-client.ts +54 -0
- package/src/lib/crud/custom-fields.ts +607 -0
- package/src/lib/crud/errors.ts +23 -0
- package/src/lib/crud/exporters.ts +188 -0
- package/src/lib/crud/factory.ts +1622 -0
- package/src/lib/crud/types.ts +29 -0
- package/src/lib/custom-fields/normalize.ts +45 -0
- package/src/lib/data/engine.ts +562 -0
- package/src/lib/db/escapeLikePattern.ts +2 -0
- package/src/lib/db/mikro.ts +100 -0
- package/src/lib/di/container.ts +105 -0
- package/src/lib/email/send.ts +18 -0
- package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
- package/src/lib/encryption/aes.ts +64 -0
- package/src/lib/encryption/customFieldValues.ts +67 -0
- package/src/lib/encryption/entityFields.ts +39 -0
- package/src/lib/encryption/entityIds.ts +107 -0
- package/src/lib/encryption/find.ts +81 -0
- package/src/lib/encryption/indexDoc.ts +104 -0
- package/src/lib/encryption/kms.ts +337 -0
- package/src/lib/encryption/subscriber.ts +416 -0
- package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
- package/src/lib/encryption/toggles.ts +15 -0
- package/src/lib/entities/naming.ts +6 -0
- package/src/lib/entities/system-entities.ts +43 -0
- package/src/lib/frontend/organizationEvents.ts +55 -0
- package/src/lib/frontend/useOrganizationScope.ts +30 -0
- package/src/lib/hotkeys/index.ts +168 -0
- package/src/lib/i18n/app-dictionaries.ts +18 -0
- package/src/lib/i18n/config.ts +4 -0
- package/src/lib/i18n/context.tsx +66 -0
- package/src/lib/i18n/server.ts +74 -0
- package/src/lib/i18n/translate.ts +54 -0
- package/src/lib/indexers/error-log.ts +106 -0
- package/src/lib/indexers/status-log.ts +119 -0
- package/src/lib/lib/auth/jwt.ts +39 -0
- package/src/lib/lib/auth/server.ts +94 -0
- package/src/lib/lib/email/send.ts +18 -0
- package/src/lib/lib/i18n/config.ts +4 -0
- package/src/lib/lib/i18n/context.tsx +38 -0
- package/src/lib/lib/utils.ts +6 -0
- package/src/lib/location/countries.ts +97 -0
- package/src/lib/modules/index.ts +1 -0
- package/src/lib/modules/registry.ts +18 -0
- package/src/lib/openapi/crud.ts +218 -0
- package/src/lib/openapi/generator.ts +1311 -0
- package/src/lib/openapi/index.ts +4 -0
- package/src/lib/openapi/sanitize.ts +137 -0
- package/src/lib/openapi/types.ts +79 -0
- package/src/lib/profiler/index.ts +371 -0
- package/src/lib/query/__tests__/engine.test.ts +274 -0
- package/src/lib/query/engine.ts +837 -0
- package/src/lib/query/join-utils.ts +238 -0
- package/src/lib/query/types.ts +121 -0
- package/src/lib/search/config.ts +49 -0
- package/src/lib/search/tokenize.ts +45 -0
- package/src/lib/slugify.ts +28 -0
- package/src/lib/testing/bootstrap.ts +124 -0
- package/src/lib/testing/index.ts +15 -0
- package/src/lib/testing/renderWithProviders.tsx +31 -0
- package/src/lib/url.ts +12 -0
- package/src/lib/utils.ts +17 -0
- package/src/lib/version.ts +5 -0
- package/src/modules/__tests__/dsl.test.ts +35 -0
- package/src/modules/__tests__/registry.test.ts +300 -0
- package/src/modules/dashboard/widgets.ts +57 -0
- package/src/modules/dsl.ts +32 -0
- package/src/modules/entities/__tests__/validation.test.ts +52 -0
- package/src/modules/entities/kinds.ts +20 -0
- package/src/modules/entities/options.ts +36 -0
- package/src/modules/entities/validation.ts +118 -0
- package/src/modules/entities/validators.ts +93 -0
- package/src/modules/entities.ts +102 -0
- package/src/modules/navigation/sidebarPreferences.ts +62 -0
- package/src/modules/perspectives/types.ts +40 -0
- package/src/modules/registry.ts +249 -0
- package/src/modules/search.ts +325 -0
- package/src/modules/vector.ts +122 -0
- package/src/modules/widgets/__tests__/injection.test.ts +48 -0
- package/src/modules/widgets/injection-loader.ts +235 -0
- package/src/modules/widgets/injection.ts +120 -0
- package/src/security/features.ts +22 -0
- package/src/types/pg.d.ts +2 -0
- package/src/types/react-email.d.ts +2 -0
- package/src/types/resend.d.ts +2 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
|
+
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
3
|
+
import { env } from 'process'
|
|
4
|
+
|
|
5
|
+
function logScopeViolation(
|
|
6
|
+
ctx: CommandRuntimeContext,
|
|
7
|
+
expected: string,
|
|
8
|
+
actual: string | null
|
|
9
|
+
): void {
|
|
10
|
+
try {
|
|
11
|
+
const requestInfo =
|
|
12
|
+
ctx.request && typeof ctx.request === 'object'
|
|
13
|
+
? {
|
|
14
|
+
method: (ctx.request as Request).method ?? undefined,
|
|
15
|
+
url: (ctx.request as Request).url ?? undefined,
|
|
16
|
+
}
|
|
17
|
+
: null
|
|
18
|
+
const scope = ctx.organizationScope
|
|
19
|
+
? {
|
|
20
|
+
selectedId: ctx.organizationScope.selectedId ?? null,
|
|
21
|
+
tenantId: ctx.organizationScope.tenantId ?? null,
|
|
22
|
+
allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
|
|
23
|
+
? ctx.organizationScope.allowedIds.length
|
|
24
|
+
: null,
|
|
25
|
+
filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
|
|
26
|
+
? ctx.organizationScope.filterIds.length
|
|
27
|
+
: null,
|
|
28
|
+
}
|
|
29
|
+
: null
|
|
30
|
+
if (env.NODE_ENV !== 'test') {
|
|
31
|
+
console.warn('[scope] Forbidden organization scope mismatch detected', {
|
|
32
|
+
expectedId: expected,
|
|
33
|
+
actualId: actual,
|
|
34
|
+
userId: ctx.auth?.sub ?? null,
|
|
35
|
+
actorTenantId: ctx.auth?.tenantId ?? null,
|
|
36
|
+
actorOrganizationId: ctx.auth?.orgId ?? null,
|
|
37
|
+
selectedOrganizationId: ctx.selectedOrganizationId ?? null,
|
|
38
|
+
organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
|
|
39
|
+
scope,
|
|
40
|
+
request: requestInfo,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// best-effort logging
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {
|
|
49
|
+
// Superadmins with global org access can operate on any organization's records
|
|
50
|
+
if (ctx.auth?.isSuperAdmin === true || ctx.organizationScope?.allowedIds === null) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
55
|
+
if (currentOrg && currentOrg !== organizationId) {
|
|
56
|
+
logScopeViolation(ctx, organizationId, currentOrg)
|
|
57
|
+
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
3
|
+
import type { AuthContext } from '../auth/server'
|
|
4
|
+
import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
5
|
+
|
|
6
|
+
export type CommandRuntimeContext = {
|
|
7
|
+
container: AwilixContainer
|
|
8
|
+
auth: AuthContext | null
|
|
9
|
+
organizationScope: OrganizationScope | null
|
|
10
|
+
selectedOrganizationId: string | null
|
|
11
|
+
organizationIds: string[] | null
|
|
12
|
+
request?: Request
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type CommandLogMetadata = {
|
|
16
|
+
tenantId?: string | null
|
|
17
|
+
organizationId?: string | null
|
|
18
|
+
actorUserId?: string | null
|
|
19
|
+
actionLabel?: string | null
|
|
20
|
+
resourceKind?: string | null
|
|
21
|
+
resourceId?: string | null
|
|
22
|
+
undoToken?: string | null
|
|
23
|
+
payload?: unknown
|
|
24
|
+
snapshotBefore?: unknown
|
|
25
|
+
snapshotAfter?: unknown
|
|
26
|
+
changes?: Record<string, unknown> | null
|
|
27
|
+
context?: Record<string, unknown> | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CommandExecuteResult<TResult> = {
|
|
31
|
+
result: TResult
|
|
32
|
+
logEntry: any | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CommandLogBuilderArgs<TInput, TResult> = {
|
|
36
|
+
input: TInput
|
|
37
|
+
result: TResult
|
|
38
|
+
ctx: CommandRuntimeContext
|
|
39
|
+
snapshots: {
|
|
40
|
+
before?: unknown
|
|
41
|
+
after?: unknown
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CommandHandler<TInput = unknown, TResult = unknown> {
|
|
46
|
+
readonly id: string
|
|
47
|
+
readonly isUndoable?: boolean
|
|
48
|
+
prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null
|
|
49
|
+
execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
|
|
50
|
+
buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
|
|
51
|
+
captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
|
|
52
|
+
undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: any }): Promise<void> | void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CommandExecutionOptions<TInput> = {
|
|
56
|
+
input: TInput
|
|
57
|
+
ctx: CommandRuntimeContext
|
|
58
|
+
metadata?: CommandLogMetadata | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function defaultUndoToken(): string {
|
|
62
|
+
return randomUUID()
|
|
63
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
// ---- Mocks ----
|
|
5
|
+
const mockEventBus = { emitEvent: jest.fn() }
|
|
6
|
+
|
|
7
|
+
type Rec = { id: string; organizationId: string; tenantId: string; title?: string; isDone?: boolean; deletedAt?: Date | null }
|
|
8
|
+
let db: Record<string, Rec>
|
|
9
|
+
let idSeq = 1
|
|
10
|
+
let commandBus: { execute: jest.Mock }
|
|
11
|
+
|
|
12
|
+
const em = {
|
|
13
|
+
create: (_cls: any, data: any) => ({ ...data, id: `id-${idSeq++}` }),
|
|
14
|
+
persistAndFlush: async (entity: Rec) => { db[entity.id] = { ...(db[entity.id] || {} as any), ...entity } },
|
|
15
|
+
findOne: async (_entity: any, where: any) => (em.getRepository(_entity).findOne(where) as any),
|
|
16
|
+
getRepository: (_cls: any) => ({
|
|
17
|
+
find: async (where: any) => Object.values(db).filter((r) => {
|
|
18
|
+
const orgClause = where.organizationId
|
|
19
|
+
const matchesOrg = !orgClause
|
|
20
|
+
? true
|
|
21
|
+
: (typeof orgClause === 'object' && Array.isArray(orgClause.$in))
|
|
22
|
+
? orgClause.$in.includes(r.organizationId)
|
|
23
|
+
: r.organizationId === orgClause
|
|
24
|
+
const matchesTenant = !where.tenantId || r.tenantId === where.tenantId
|
|
25
|
+
const matchesDeleted = where.deletedAt === null ? !r.deletedAt : true
|
|
26
|
+
return matchesOrg && matchesTenant && matchesDeleted
|
|
27
|
+
}),
|
|
28
|
+
findOne: async (where: any) => Object.values(db).find((r) => {
|
|
29
|
+
if (r.id !== where.id) return false
|
|
30
|
+
const orgClause = where.organizationId
|
|
31
|
+
const matchesOrg = !orgClause
|
|
32
|
+
? true
|
|
33
|
+
: (typeof orgClause === 'object' && Array.isArray(orgClause.$in))
|
|
34
|
+
? orgClause.$in.includes(r.organizationId)
|
|
35
|
+
: r.organizationId === orgClause
|
|
36
|
+
return matchesOrg && r.tenantId === where.tenantId
|
|
37
|
+
}) || null,
|
|
38
|
+
removeAndFlush: async (entity: Rec) => { delete db[entity.id] },
|
|
39
|
+
}),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const queryEngine = {
|
|
43
|
+
query: jest.fn(async (_entityId: any, _q: any) => ({ items: [{ id: 'id-1', title: 'A', is_done: false, organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenant_id: '123e4567-e89b-12d3-a456-426614174000' }], total: 1 })),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const mockDataEngine = {
|
|
47
|
+
__pendingSideEffects: [] as any[],
|
|
48
|
+
createOrmEntity: jest.fn(async ({ entity, data }: any) => {
|
|
49
|
+
const created = em.create(entity, data)
|
|
50
|
+
await em.persistAndFlush(created as any)
|
|
51
|
+
return created
|
|
52
|
+
}),
|
|
53
|
+
updateOrmEntity: jest.fn(async ({ entity, where, apply }: any) => {
|
|
54
|
+
const current = await (em.getRepository(entity).findOne(where) as any)
|
|
55
|
+
if (!current) return null
|
|
56
|
+
await apply(current)
|
|
57
|
+
await em.persistAndFlush(current)
|
|
58
|
+
return current
|
|
59
|
+
}),
|
|
60
|
+
deleteOrmEntity: jest.fn(async ({ entity, where, soft, softDeleteField }: any) => {
|
|
61
|
+
const repo = em.getRepository(entity)
|
|
62
|
+
const current = await (repo.findOne(where) as any)
|
|
63
|
+
if (!current) return null
|
|
64
|
+
if (soft !== false) { (current as any)[softDeleteField || 'deletedAt'] = new Date(); await em.persistAndFlush(current) }
|
|
65
|
+
else await repo.removeAndFlush(current)
|
|
66
|
+
return current
|
|
67
|
+
}),
|
|
68
|
+
setCustomFields: jest.fn(async (args: any) => {
|
|
69
|
+
await (setRecordCustomFields as any)(em, args)
|
|
70
|
+
}),
|
|
71
|
+
emitOrmEntityEvent: jest.fn(async (_entry: any) => {}),
|
|
72
|
+
markOrmEntityChange: jest.fn(function (this: any, entry: any) {
|
|
73
|
+
if (!entry || !entry.entity) return
|
|
74
|
+
this.__pendingSideEffects.push(entry)
|
|
75
|
+
}),
|
|
76
|
+
flushOrmEntityChanges: jest.fn(async function (this: any) {
|
|
77
|
+
while (this.__pendingSideEffects.length > 0) {
|
|
78
|
+
const next = this.__pendingSideEffects.shift()
|
|
79
|
+
await this.emitOrmEntityEvent(next)
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const accessLogService = {
|
|
85
|
+
log: jest.fn(async () => {}),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
89
|
+
createRequestContainer: async () => ({
|
|
90
|
+
resolve: (name: string) => ({
|
|
91
|
+
em,
|
|
92
|
+
queryEngine,
|
|
93
|
+
eventBus: mockEventBus,
|
|
94
|
+
dataEngine: mockDataEngine,
|
|
95
|
+
accessLogService,
|
|
96
|
+
commandBus,
|
|
97
|
+
} as any)[name],
|
|
98
|
+
})
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => {
|
|
102
|
+
const auth = { sub: 'u1', orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000', roles: ['admin'] }
|
|
103
|
+
return {
|
|
104
|
+
getAuthFromCookies: async () => auth,
|
|
105
|
+
getAuthFromRequest: async () => auth,
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const setRecordCustomFields = jest.fn(async () => {})
|
|
110
|
+
jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
|
|
111
|
+
setRecordCustomFields: (...args: any[]) => (setRecordCustomFields as any)(...args)
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
// Fake entity class
|
|
115
|
+
class Todo {}
|
|
116
|
+
|
|
117
|
+
describe('CRUD Factory', () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
db = {}
|
|
120
|
+
idSeq = 1
|
|
121
|
+
jest.clearAllMocks()
|
|
122
|
+
accessLogService.log.mockClear()
|
|
123
|
+
mockDataEngine.__pendingSideEffects = []
|
|
124
|
+
commandBus = {
|
|
125
|
+
execute: jest.fn(async () => ({ result: {}, logEntry: { id: 'log-1' } })),
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const querySchema = z.object({
|
|
130
|
+
page: z.coerce.number().default(1),
|
|
131
|
+
pageSize: z.coerce.number().default(50),
|
|
132
|
+
sortField: z.string().default('id'),
|
|
133
|
+
sortDir: z.enum(['asc','desc']).default('asc'),
|
|
134
|
+
format: z.enum(['csv', 'json', 'xml', 'markdown']).optional(),
|
|
135
|
+
})
|
|
136
|
+
const createSchema = z.object({ title: z.string().min(1), is_done: z.boolean().optional().default(false), cf_priority: z.number().optional() })
|
|
137
|
+
const updateSchema = z.object({ id: z.string(), title: z.string().optional(), is_done: z.boolean().optional(), cf_priority: z.number().optional() })
|
|
138
|
+
|
|
139
|
+
const route = makeCrudRoute({
|
|
140
|
+
metadata: { GET: { requireAuth: true }, POST: { requireAuth: true }, PUT: { requireAuth: true }, DELETE: { requireAuth: true } },
|
|
141
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
142
|
+
events: { module: 'example', entity: 'todo', persistent: true },
|
|
143
|
+
indexer: { entityType: 'example.todo' },
|
|
144
|
+
list: {
|
|
145
|
+
schema: querySchema,
|
|
146
|
+
entityId: 'example.todo',
|
|
147
|
+
fields: ['id','title','is_done'],
|
|
148
|
+
sortFieldMap: { id: 'id', title: 'title' },
|
|
149
|
+
buildFilters: () => ({} as any),
|
|
150
|
+
transformItem: (i: any) => ({ id: i.id, title: i.title, is_done: i.is_done }),
|
|
151
|
+
allowCsv: true,
|
|
152
|
+
csv: { headers: ['id','title','is_done'], row: (t) => [t.id, t.title, t.is_done ? '1' : '0'], filename: 'todos.csv' }
|
|
153
|
+
},
|
|
154
|
+
create: {
|
|
155
|
+
schema: createSchema,
|
|
156
|
+
mapToEntity: (input) => ({ title: (input as any).title, isDone: !!(input as any).is_done }),
|
|
157
|
+
customFields: { enabled: true, entityId: 'example.todo', pickPrefixed: true },
|
|
158
|
+
},
|
|
159
|
+
update: {
|
|
160
|
+
schema: updateSchema,
|
|
161
|
+
applyToEntity: (e, input) => { if ((input as any).title !== undefined) (e as any).title = (input as any).title; if ((input as any).is_done !== undefined) (e as any).isDone = !!(input as any).is_done },
|
|
162
|
+
customFields: { enabled: true, entityId: 'example.todo', pickPrefixed: true },
|
|
163
|
+
},
|
|
164
|
+
del: { idFrom: 'query', softDelete: true },
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('GET returns JSON list via QueryEngine', async () => {
|
|
168
|
+
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
|
|
169
|
+
expect(res.status).toBe(200)
|
|
170
|
+
const body = await res.json()
|
|
171
|
+
expect(body.items.length).toBe(1)
|
|
172
|
+
expect(body.total).toBe(1)
|
|
173
|
+
expect(body.items[0]).toEqual({ id: 'id-1', title: 'A', is_done: false })
|
|
174
|
+
expect(accessLogService.log).toHaveBeenCalledTimes(1)
|
|
175
|
+
expect(accessLogService.log).toHaveBeenCalledWith(expect.objectContaining({
|
|
176
|
+
resourceKind: 'example.todo',
|
|
177
|
+
resourceId: 'id-1',
|
|
178
|
+
accessType: 'read',
|
|
179
|
+
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
180
|
+
organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
181
|
+
actorUserId: 'u1',
|
|
182
|
+
fields: expect.arrayContaining(['id', 'title', 'is_done']),
|
|
183
|
+
context: expect.objectContaining({
|
|
184
|
+
resultCount: 1,
|
|
185
|
+
accessType: 'read',
|
|
186
|
+
queryKeys: expect.arrayContaining(['page', 'pageSize', 'sortField', 'sortDir']),
|
|
187
|
+
}),
|
|
188
|
+
}))
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('GET returns CSV when format=csv', async () => {
|
|
192
|
+
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc&format=csv'))
|
|
193
|
+
expect(res.headers.get('content-type')).toContain('text/csv')
|
|
194
|
+
expect(res.headers.get('content-disposition')).toContain('todos.csv')
|
|
195
|
+
const text = await res.text()
|
|
196
|
+
expect(text.split('\n')[0]).toBe('id,title,is_done')
|
|
197
|
+
expect(accessLogService.log).toHaveBeenCalledTimes(1)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('GET returns JSON export when format=json', async () => {
|
|
201
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
|
|
202
|
+
expect(res.headers.get('content-type')).toContain('application/json')
|
|
203
|
+
expect(res.headers.get('content-disposition')).toContain('todo.json')
|
|
204
|
+
const text = await res.text()
|
|
205
|
+
const parsed = JSON.parse(text)
|
|
206
|
+
expect(Array.isArray(parsed)).toBe(true)
|
|
207
|
+
expect(parsed[0]).toEqual({ id: 'id-1', title: 'A', is_done: '0' })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('GET returns XML export when format=xml', async () => {
|
|
211
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=xml'))
|
|
212
|
+
expect(res.headers.get('content-type')).toContain('application/xml')
|
|
213
|
+
expect(res.headers.get('content-disposition')).toContain('todo.xml')
|
|
214
|
+
const text = await res.text()
|
|
215
|
+
expect(text).toContain('<records>')
|
|
216
|
+
expect(text).toContain('<id>id-1</id>')
|
|
217
|
+
expect(text).toContain('<title>A</title>')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('GET returns Markdown export when format=markdown', async () => {
|
|
221
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=markdown'))
|
|
222
|
+
expect(res.headers.get('content-type')).toContain('text/markdown')
|
|
223
|
+
expect(res.headers.get('content-disposition')).toContain('todo.md')
|
|
224
|
+
const text = await res.text()
|
|
225
|
+
const lines = text.split('\n')
|
|
226
|
+
expect(lines[0]).toBe('| id | title | is_done |')
|
|
227
|
+
expect(lines[2]).toContain('id-1')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('GET returns full export when exportScope=full', async () => {
|
|
231
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
|
|
232
|
+
expect(res.headers.get('content-type')).toContain('application/json')
|
|
233
|
+
expect(res.headers.get('content-disposition')).toContain('todo_full.json')
|
|
234
|
+
const text = await res.text()
|
|
235
|
+
const parsed = JSON.parse(text)
|
|
236
|
+
expect(Array.isArray(parsed)).toBe(true)
|
|
237
|
+
const row = parsed[0]
|
|
238
|
+
expect(row).toMatchObject({
|
|
239
|
+
Id: 'id-1',
|
|
240
|
+
Title: 'A',
|
|
241
|
+
'Is Done': false,
|
|
242
|
+
'Organization Id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
243
|
+
'Tenant Id': '123e4567-e89b-12d3-a456-426614174000',
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('POST creates entity, saves custom fields, emits created event', async () => {
|
|
248
|
+
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'B', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
249
|
+
expect(res.status).toBe(201)
|
|
250
|
+
const data = await res.json()
|
|
251
|
+
expect(data.id).toBeDefined()
|
|
252
|
+
// CF saved
|
|
253
|
+
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
|
|
254
|
+
// Event + indexer delegated to data engine
|
|
255
|
+
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
256
|
+
const createdCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
|
|
257
|
+
expect(createdCall).toBeDefined()
|
|
258
|
+
const [createdArgs] = createdCall!
|
|
259
|
+
expect(createdArgs.action).toBe('created')
|
|
260
|
+
expect(createdArgs.identifiers.id).toBe(data.id)
|
|
261
|
+
expect(createdArgs.events?.module).toBe('example')
|
|
262
|
+
expect(createdArgs.events?.entity).toBe('todo')
|
|
263
|
+
expect(createdArgs.indexer?.entityType).toBe('example.todo')
|
|
264
|
+
// Entity in db
|
|
265
|
+
const rec = db[data.id]
|
|
266
|
+
expect(rec).toBeTruthy()
|
|
267
|
+
expect(rec.title).toBe('B')
|
|
268
|
+
expect(rec.isDone).toBe(true)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('PUT updates entity, saves custom fields, emits updated event', async () => {
|
|
272
|
+
// Seed
|
|
273
|
+
const created = em.create(Todo, { title: 'X', organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000' }) as Rec
|
|
274
|
+
// Force UUID id to satisfy validation
|
|
275
|
+
created.id = '123e4567-e89b-12d3-a456-426614174001'
|
|
276
|
+
await em.persistAndFlush(created)
|
|
277
|
+
const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
|
|
278
|
+
expect(res.status).toBe(200)
|
|
279
|
+
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
|
|
280
|
+
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
281
|
+
const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
|
|
282
|
+
expect(updatedCall).toBeDefined()
|
|
283
|
+
const [updatedArgs] = updatedCall!
|
|
284
|
+
expect(updatedArgs.action).toBe('updated')
|
|
285
|
+
expect(updatedArgs.identifiers.id).toBe(created.id)
|
|
286
|
+
expect(updatedArgs.indexer?.entityType).toBe('example.todo')
|
|
287
|
+
expect(db[created.id].title).toBe('X2')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('DELETE soft-deletes entity and emits deleted event', async () => {
|
|
291
|
+
const created = em.create(Todo, { title: 'Y', organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000' }) as Rec
|
|
292
|
+
created.id = '123e4567-e89b-12d3-a456-426614174002'
|
|
293
|
+
await em.persistAndFlush(created)
|
|
294
|
+
const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}`, { method: 'DELETE' }))
|
|
295
|
+
expect(res.status).toBe(200)
|
|
296
|
+
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
297
|
+
const deletedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
|
|
298
|
+
expect(deletedCall).toBeDefined()
|
|
299
|
+
const [deletedArgs] = deletedCall!
|
|
300
|
+
expect(deletedArgs.action).toBe('deleted')
|
|
301
|
+
expect(deletedArgs.identifiers.id).toBe(created.id)
|
|
302
|
+
expect(deletedArgs.indexer?.entityType).toBe('example.todo')
|
|
303
|
+
expect(db[created.id].deletedAt).toBeInstanceOf(Date)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('DELETE command route uses domain-specific ids from result when emitting events', async () => {
|
|
307
|
+
const indexedId = 'line-999'
|
|
308
|
+
commandBus.execute.mockResolvedValue({
|
|
309
|
+
result: { lineId: indexedId, orderId: 'order-1' },
|
|
310
|
+
logEntry: { id: 'log-1' },
|
|
311
|
+
})
|
|
312
|
+
const commandRoute = makeCrudRoute({
|
|
313
|
+
metadata: { DELETE: { requireAuth: true } },
|
|
314
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
315
|
+
indexer: { entityType: 'example.todo' },
|
|
316
|
+
actions: {
|
|
317
|
+
delete: {
|
|
318
|
+
commandId: 'example.todo.delete',
|
|
319
|
+
schema: z.any(),
|
|
320
|
+
response: () => ({ ok: true }),
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', { method: 'DELETE', body: JSON.stringify({}), headers: { 'content-type': 'application/json' } }))
|
|
325
|
+
expect(res.status).toBe(200)
|
|
326
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
|
|
327
|
+
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
328
|
+
const [deletedArgs] = mockDataEngine.emitOrmEntityEvent.mock.calls[0]!
|
|
329
|
+
expect(deletedArgs.action).toBe('deleted')
|
|
330
|
+
expect(deletedArgs.identifiers.id).toBe(indexedId)
|
|
331
|
+
expect(deletedArgs.indexer?.entityType).toBe('example.todo')
|
|
332
|
+
})
|
|
333
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries, splitCustomFieldPayload, loadCustomFieldValues } from '../custom-fields'
|
|
2
|
+
import { encryptWithAesGcm } from '../../encryption/aes'
|
|
3
|
+
|
|
4
|
+
const mockEntityManager = (defs: any[]) => ({
|
|
5
|
+
find: jest.fn().mockResolvedValue(defs),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
describe('buildCustomFieldFiltersFromQuery', () => {
|
|
9
|
+
const definitions = [
|
|
10
|
+
{
|
|
11
|
+
id: 'def-fashion-color',
|
|
12
|
+
key: 'color',
|
|
13
|
+
kind: 'text',
|
|
14
|
+
entityId: 'catalog:product',
|
|
15
|
+
configJson: { fieldset: 'fashion' },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'def-shared-material',
|
|
19
|
+
key: 'material',
|
|
20
|
+
kind: 'text',
|
|
21
|
+
entityId: 'catalog:product',
|
|
22
|
+
configJson: {},
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
it('generates filters for matching definitions regardless of fieldset when none specified', async () => {
|
|
27
|
+
const em = mockEntityManager(definitions)
|
|
28
|
+
const filters = await buildCustomFieldFiltersFromQuery({
|
|
29
|
+
entityIds: ['catalog:product'],
|
|
30
|
+
query: { cf_color: 'blue' },
|
|
31
|
+
em: em as any,
|
|
32
|
+
tenantId: 'tenant-1',
|
|
33
|
+
})
|
|
34
|
+
expect(em.find).toHaveBeenCalled()
|
|
35
|
+
expect(filters).toEqual({ 'cf:color': 'blue' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('restricts filters to the requested fieldset code', async () => {
|
|
39
|
+
const em = mockEntityManager(definitions)
|
|
40
|
+
const filters = await buildCustomFieldFiltersFromQuery({
|
|
41
|
+
entityIds: ['catalog:product'],
|
|
42
|
+
query: { cf_color: 'blue' },
|
|
43
|
+
em: em as any,
|
|
44
|
+
tenantId: 'tenant-1',
|
|
45
|
+
fieldset: 'fashion',
|
|
46
|
+
})
|
|
47
|
+
expect(filters).toEqual({ 'cf:color': 'blue' })
|
|
48
|
+
const emptyFilters = await buildCustomFieldFiltersFromQuery({
|
|
49
|
+
entityIds: ['catalog:product'],
|
|
50
|
+
query: { cf_color: 'blue', cf_material: 'cotton' },
|
|
51
|
+
em: em as any,
|
|
52
|
+
tenantId: 'tenant-1',
|
|
53
|
+
fieldset: 'tech',
|
|
54
|
+
})
|
|
55
|
+
expect(emptyFilters).toEqual({})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('splitCustomFieldPayload', () => {
|
|
60
|
+
it('pulls values from customValues map', () => {
|
|
61
|
+
const raw = {
|
|
62
|
+
name: 'Channel',
|
|
63
|
+
customValues: {
|
|
64
|
+
api_url: 'https://example.dev',
|
|
65
|
+
priority: 5,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
expect(splitCustomFieldPayload(raw)).toEqual({
|
|
69
|
+
base: { name: 'Channel' },
|
|
70
|
+
custom: { api_url: 'https://example.dev', priority: 5 },
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('maps array based customFields entries', () => {
|
|
75
|
+
const raw = {
|
|
76
|
+
customFields: [
|
|
77
|
+
{ key: 'api_url', value: 'https://example.dev' },
|
|
78
|
+
{ key: '', value: 'ignored' },
|
|
79
|
+
{ key: 'notes', value: null },
|
|
80
|
+
],
|
|
81
|
+
code: 'demo',
|
|
82
|
+
}
|
|
83
|
+
expect(splitCustomFieldPayload(raw)).toEqual({
|
|
84
|
+
base: { code: 'demo' },
|
|
85
|
+
custom: {
|
|
86
|
+
api_url: 'https://example.dev',
|
|
87
|
+
notes: null,
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('extractAllCustomFieldEntries', () => {
|
|
94
|
+
it('merges entries from customValues maps and customFields objects', () => {
|
|
95
|
+
const item = {
|
|
96
|
+
customValues: { api_url: 'https://fws1.api', priority: 5 },
|
|
97
|
+
customFields: { notes: 'memo' },
|
|
98
|
+
other: 'value',
|
|
99
|
+
}
|
|
100
|
+
expect(extractAllCustomFieldEntries(item)).toEqual({
|
|
101
|
+
cf_api_url: 'https://fws1.api',
|
|
102
|
+
cf_priority: 5,
|
|
103
|
+
cf_notes: 'memo',
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('reads entries from customFields arrays and keeps existing cf_* keys', () => {
|
|
108
|
+
const item = {
|
|
109
|
+
customFields: [
|
|
110
|
+
{ key: 'api_url', value: 'https://onet.pl' },
|
|
111
|
+
{ key: '', value: 'skip-me' },
|
|
112
|
+
{ key: 'notes' },
|
|
113
|
+
],
|
|
114
|
+
cf_existing: 'foo',
|
|
115
|
+
}
|
|
116
|
+
expect(extractAllCustomFieldEntries(item)).toEqual({
|
|
117
|
+
cf_api_url: 'https://onet.pl',
|
|
118
|
+
cf_notes: undefined,
|
|
119
|
+
cf_existing: 'foo',
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('loadCustomFieldValues (encryption)', () => {
|
|
125
|
+
it('decrypts encrypted custom field payloads when definitions mark them encrypted', async () => {
|
|
126
|
+
const dek = Buffer.alloc(32, 2).toString('base64')
|
|
127
|
+
const encrypted = encryptWithAesGcm(JSON.stringify('secret-note'), dek).value
|
|
128
|
+
const em = {
|
|
129
|
+
find: jest.fn().mockImplementation((_, where) => {
|
|
130
|
+
if ((where as any).recordId) {
|
|
131
|
+
return Promise.resolve([
|
|
132
|
+
{ recordId: 'rec-1', fieldKey: 'note', organizationId: null, tenantId: 'tenant-1', valueText: encrypted, valueMultiline: null, valueInt: null, valueFloat: null, valueBool: null, deletedAt: null },
|
|
133
|
+
])
|
|
134
|
+
}
|
|
135
|
+
return Promise.resolve([
|
|
136
|
+
{ key: 'note', entityId: 'demo:entity', organizationId: null, tenantId: 'tenant-1', kind: 'text', configJson: { encrypted: true }, isActive: true },
|
|
137
|
+
])
|
|
138
|
+
}),
|
|
139
|
+
}
|
|
140
|
+
const mockService = { isEnabled: () => true, getDek: async () => ({ key: dek }) }
|
|
141
|
+
const values = await loadCustomFieldValues({
|
|
142
|
+
em: em as any,
|
|
143
|
+
entityId: 'demo:entity',
|
|
144
|
+
recordIds: ['rec-1'],
|
|
145
|
+
tenantIdByRecord: { 'rec-1': 'tenant-1' },
|
|
146
|
+
encryptionService: mockService as any,
|
|
147
|
+
})
|
|
148
|
+
expect(values['rec-1'].cf_note).toBe('secret-note')
|
|
149
|
+
})
|
|
150
|
+
})
|