create-mercato-app 0.4.6-develop-af28b566dd → 0.4.6-develop-4d77832982
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/package.json +1 -1
- package/template/src/app/(backend)/backend/[...slug]/page.tsx +6 -2
- package/template/src/app/(backend)/backend/__tests__/backend-require-features.test.tsx +108 -0
- package/template/src/app/(backend)/backend/layout.tsx +30 -27
- package/template/src/bootstrap.ts +4 -0
- package/template/src/components/ClientBootstrap.tsx +2 -5
- package/template/src/components/ComponentOverridesBootstrap.tsx +20 -0
- package/template/src/modules/example/__integration__/TC-UMES-004.spec.ts +337 -0
- package/template/src/modules/example/__integration__/meta.ts +3 -0
- package/template/src/modules/example/api/customer-priorities/route.ts +154 -0
- package/template/src/modules/example/api/interceptors.ts +174 -0
- package/template/src/modules/example/api/todos/route.ts +1 -1
- package/template/src/modules/example/backend/umes-extensions/page.meta.ts +24 -0
- package/template/src/modules/example/backend/umes-extensions/page.tsx +365 -0
- package/template/src/modules/example/backend/umes-handlers/page.tsx +1 -1
- package/template/src/modules/example/data/enrichers.ts +32 -4
- package/template/src/modules/example/data/entities.ts +28 -0
- package/template/src/modules/example/data/validators.ts +22 -0
- package/template/src/modules/example/i18n/de.json +55 -0
- package/template/src/modules/example/i18n/en.json +55 -0
- package/template/src/modules/example/i18n/es.json +55 -0
- package/template/src/modules/example/i18n/pl.json +55 -0
- package/template/src/modules/example/migrations/Migration20260226161000_example.ts +15 -0
- package/template/src/modules/example/widgets/__tests__/injection-table.test.ts +39 -6
- package/template/src/modules/example/widgets/components.ts +23 -0
- package/template/src/modules/example/widgets/injection/crud-validation/widget.client.tsx +13 -13
- package/template/src/modules/example/widgets/injection/customer-priority-bulk-actions/widget.ts +67 -0
- package/template/src/modules/example/widgets/injection/customer-priority-column/widget.ts +23 -0
- package/template/src/modules/example/widgets/injection/customer-priority-detail/widget.client.tsx +136 -0
- package/template/src/modules/example/widgets/injection/customer-priority-detail/widget.ts +13 -0
- package/template/src/modules/example/widgets/injection/customer-priority-field/widget.ts +68 -0
- package/template/src/modules/example/widgets/injection/customer-priority-filter/widget.ts +25 -0
- package/template/src/modules/example/widgets/injection/customer-priority-row-action/widget.ts +26 -0
- package/template/src/modules/example/widgets/injection-table.ts +54 -1
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import { ApplyBreadcrumb } from '@open-mercato/ui/backend/AppShell'
|
|
|
7
7
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
8
|
import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
9
9
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
10
|
+
import { ComponentReplacementHandles, resolveRegisteredComponent } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
10
11
|
|
|
11
12
|
type Awaitable<T> = T | Promise<T>
|
|
12
13
|
|
|
@@ -47,12 +48,15 @@ export default async function BackendCatchAll(props: { params: Awaitable<{ slug?
|
|
|
47
48
|
if (!ok) redirect('/login?requireFeature=' + encodeURIComponent(features.join(',')))
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
|
-
const
|
|
51
|
+
const pageHandle = ComponentReplacementHandles.page(pathname)
|
|
52
|
+
const Component = resolveRegisteredComponent(pageHandle, match.route.Component)
|
|
51
53
|
|
|
52
54
|
return (
|
|
53
55
|
<>
|
|
54
56
|
<ApplyBreadcrumb breadcrumb={match.route.breadcrumb} title={match.route.title} titleKey={match.route.titleKey} />
|
|
55
|
-
<
|
|
57
|
+
<div data-component-handle={pageHandle}>
|
|
58
|
+
<Component params={match.params} />
|
|
59
|
+
</div>
|
|
56
60
|
</>
|
|
57
61
|
)
|
|
58
62
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests the backend catch-all route guarding for requireFeatures.
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
6
|
+
// Avoid loading the full generated modules (which pull example modules and DSL)
|
|
7
|
+
jest.mock('@/generated/modules.generated', () => ({ modules: [] }))
|
|
8
|
+
|
|
9
|
+
import BackendCatchAll from '@/app/(backend)/backend/[...slug]/page'
|
|
10
|
+
|
|
11
|
+
// Mock UI breadcrumb component to avoid UI package dependency
|
|
12
|
+
jest.mock('@open-mercato/ui/backend/AppShell', () => ({
|
|
13
|
+
ApplyBreadcrumb: () => React.createElement('div', null, 'Breadcrumb'),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
// Mock UI CrudForm to avoid importing ESM-only deps like remark-gfm in Jest
|
|
17
|
+
jest.mock('@open-mercato/ui/backend/CrudForm', () => ({
|
|
18
|
+
CrudForm: () => React.createElement('form', null, React.createElement('div', null, 'CrudFormMock')),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const cookieStore = { get: jest.fn() }
|
|
22
|
+
const cookiesMock = jest.fn(() => cookieStore)
|
|
23
|
+
jest.mock('next/headers', () => ({
|
|
24
|
+
cookies: () => cookiesMock(),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
// Mock registry to return a match with requireFeatures
|
|
28
|
+
jest.mock('@open-mercato/shared/modules/registry', () => ({
|
|
29
|
+
findBackendMatch: jest.fn(() => ({
|
|
30
|
+
route: {
|
|
31
|
+
requireAuth: true,
|
|
32
|
+
requireRoles: [],
|
|
33
|
+
requireFeatures: ['entities.records.view'],
|
|
34
|
+
title: 'Test',
|
|
35
|
+
Component: () => React.createElement('div', null, 'OK'),
|
|
36
|
+
},
|
|
37
|
+
params: {},
|
|
38
|
+
})),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
// Mock auth cookie reader
|
|
42
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
43
|
+
getAuthFromCookies: jest.fn(),
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
// Mock DI container
|
|
47
|
+
const mockRbac = {
|
|
48
|
+
userHasAllFeatures: jest.fn<
|
|
49
|
+
ReturnType<RbacService['userHasAllFeatures']>,
|
|
50
|
+
Parameters<RbacService['userHasAllFeatures']>
|
|
51
|
+
>()
|
|
52
|
+
}
|
|
53
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
54
|
+
createRequestContainer: async () => ({
|
|
55
|
+
resolve: (key: string) => (key === 'rbacService' ? mockRbac : null),
|
|
56
|
+
}),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
// Mock next/navigation redirect and notFound
|
|
60
|
+
const redirect = jest.fn((href?: string) => { throw new Error('REDIRECT ' + href) })
|
|
61
|
+
const notFound = jest.fn(() => { throw new Error('NOT_FOUND') })
|
|
62
|
+
jest.mock('next/navigation', () => ({
|
|
63
|
+
redirect: (href?: string) => redirect(href),
|
|
64
|
+
notFound: () => notFound(),
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
type GetAuthFromCookies = typeof import('@open-mercato/shared/lib/auth/server')['getAuthFromCookies']
|
|
68
|
+
|
|
69
|
+
async function setAuthMock(value: Awaited<ReturnType<GetAuthFromCookies>>) {
|
|
70
|
+
const authModule = await import('@open-mercato/shared/lib/auth/server')
|
|
71
|
+
const mocked = authModule.getAuthFromCookies as jest.MockedFunction<GetAuthFromCookies>
|
|
72
|
+
mocked.mockResolvedValue(value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('Backend requireFeatures guard', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
jest.clearAllMocks()
|
|
78
|
+
mockRbac.userHasAllFeatures.mockResolvedValue(true)
|
|
79
|
+
cookieStore.get.mockReset()
|
|
80
|
+
cookieStore.get.mockReturnValue(undefined)
|
|
81
|
+
cookiesMock.mockClear()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders component when features are satisfied', async () => {
|
|
85
|
+
await setAuthMock({ sub: 'u1', tenantId: 't1', orgId: 'o1', roles: [] })
|
|
86
|
+
|
|
87
|
+
const el = await BackendCatchAll({ params: Promise.resolve({ slug: ['entities', 'records'] }) })
|
|
88
|
+
expect(el).toBeTruthy()
|
|
89
|
+
expect(redirect).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('redirects to refresh if not authenticated', async () => {
|
|
93
|
+
await setAuthMock(null)
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
BackendCatchAll({ params: Promise.resolve({ slug: ['entities', 'records'] }) })
|
|
97
|
+
).rejects.toThrow(/REDIRECT \/api\/auth\/session\/refresh/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('redirects to login when RBAC denies required features', async () => {
|
|
101
|
+
await setAuthMock({ sub: 'u1', tenantId: 't1', orgId: 'o1', roles: [] })
|
|
102
|
+
mockRbac.userHasAllFeatures.mockResolvedValueOnce(false)
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
BackendCatchAll({ params: Promise.resolve({ slug: ['entities', 'records'] }) })
|
|
106
|
+
).rejects.toThrow(/REDIRECT \/login\?requireFeature=/)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -38,6 +38,7 @@ import { APP_VERSION } from '@open-mercato/shared/lib/version'
|
|
|
38
38
|
import { PageInjectionBoundary } from '@open-mercato/ui/backend/injection/PageInjectionBoundary'
|
|
39
39
|
import { AiAssistantIntegration, AiChatHeaderButton } from '@open-mercato/ai-assistant/frontend'
|
|
40
40
|
import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
|
|
41
|
+
import { ComponentOverridesBootstrap } from '@/components/ComponentOverridesBootstrap'
|
|
41
42
|
|
|
42
43
|
type NavItem = {
|
|
43
44
|
href: string
|
|
@@ -383,34 +384,36 @@ export default async function BackendLayout({ children, params }: { children: Re
|
|
|
383
384
|
<>
|
|
384
385
|
<Script async src="https://w.appzi.io/w.js?token=TtIV6" strategy="afterInteractive" />
|
|
385
386
|
<I18nProvider locale={locale} dict={dict}>
|
|
386
|
-
<
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
<AppShell
|
|
391
|
-
key={path}
|
|
392
|
-
productName={productName}
|
|
393
|
-
email={auth?.email}
|
|
394
|
-
groups={groups}
|
|
395
|
-
currentTitle={currentTitle}
|
|
396
|
-
breadcrumb={breadcrumb}
|
|
397
|
-
sidebarCollapsedDefault={initialCollapsed}
|
|
398
|
-
rightHeaderSlot={rightHeaderContent}
|
|
399
|
-
mobileSidebarSlot={mobileSidebarContent}
|
|
400
|
-
adminNavApi="/api/auth/admin/nav"
|
|
401
|
-
version={APP_VERSION}
|
|
402
|
-
settingsPathPrefixes={settingsPathPrefixes}
|
|
403
|
-
settingsSections={filteredSettingsSections}
|
|
404
|
-
settingsSectionTitle={translate('backend.nav.settings', 'Settings')}
|
|
405
|
-
profileSections={profileSections}
|
|
406
|
-
profileSectionTitle={translate('profile.page.title', 'Profile')}
|
|
407
|
-
profilePathPrefixes={profilePathPrefixes}
|
|
387
|
+
<ComponentOverridesBootstrap>
|
|
388
|
+
<AiAssistantIntegration
|
|
389
|
+
tenantId={auth?.tenantId ?? null}
|
|
390
|
+
organizationId={auth?.orgId ?? null}
|
|
408
391
|
>
|
|
409
|
-
<
|
|
410
|
-
{
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
392
|
+
<AppShell
|
|
393
|
+
key={path}
|
|
394
|
+
productName={productName}
|
|
395
|
+
email={auth?.email}
|
|
396
|
+
groups={groups}
|
|
397
|
+
currentTitle={currentTitle}
|
|
398
|
+
breadcrumb={breadcrumb}
|
|
399
|
+
sidebarCollapsedDefault={initialCollapsed}
|
|
400
|
+
rightHeaderSlot={rightHeaderContent}
|
|
401
|
+
mobileSidebarSlot={mobileSidebarContent}
|
|
402
|
+
adminNavApi="/api/auth/admin/nav"
|
|
403
|
+
version={APP_VERSION}
|
|
404
|
+
settingsPathPrefixes={settingsPathPrefixes}
|
|
405
|
+
settingsSections={filteredSettingsSections}
|
|
406
|
+
settingsSectionTitle={translate('backend.nav.settings', 'Settings')}
|
|
407
|
+
profileSections={profileSections}
|
|
408
|
+
profileSectionTitle={translate('profile.page.title', 'Profile')}
|
|
409
|
+
profilePathPrefixes={profilePathPrefixes}
|
|
410
|
+
>
|
|
411
|
+
<PageInjectionBoundary path={path} context={injectionContext}>
|
|
412
|
+
{children}
|
|
413
|
+
</PageInjectionBoundary>
|
|
414
|
+
</AppShell>
|
|
415
|
+
</AiAssistantIntegration>
|
|
416
|
+
</ComponentOverridesBootstrap>
|
|
414
417
|
</I18nProvider>
|
|
415
418
|
</>
|
|
416
419
|
)
|
|
@@ -44,6 +44,8 @@ import { eventModuleConfigs, allEvents } from '@/.mercato/generated/events.gener
|
|
|
44
44
|
import { registerEventModuleConfigs } from '@open-mercato/shared/modules/events'
|
|
45
45
|
import { analyticsModuleConfigs } from '@/.mercato/generated/analytics.generated'
|
|
46
46
|
import { enricherEntries } from '@/.mercato/generated/enrichers.generated'
|
|
47
|
+
import { interceptorEntries } from '@/.mercato/generated/interceptors.generated'
|
|
48
|
+
import { componentOverrideEntries } from '@/.mercato/generated/component-overrides.generated'
|
|
47
49
|
import { messageTypes } from '@/.mercato/generated/message-types.generated'
|
|
48
50
|
import { messageObjectTypes } from '@/.mercato/generated/message-objects.generated'
|
|
49
51
|
import { registerMessageTypes } from '@open-mercato/core/modules/messages/lib/message-types-registry'
|
|
@@ -70,6 +72,8 @@ export const bootstrap = createBootstrap({
|
|
|
70
72
|
searchModuleConfigs,
|
|
71
73
|
analyticsModuleConfigs,
|
|
72
74
|
enricherEntries,
|
|
75
|
+
interceptorEntries,
|
|
76
|
+
componentOverrideEntries,
|
|
73
77
|
})
|
|
74
78
|
|
|
75
79
|
export { isBootstrapped }
|
|
@@ -11,8 +11,8 @@ import { dashboardWidgetEntries } from '@/.mercato/generated/dashboard-widgets.g
|
|
|
11
11
|
import { registerDashboardWidgets } from '@open-mercato/ui/backend/dashboard/widgetRegistry'
|
|
12
12
|
// Side-effect: registers translatable fields for client-side TranslationManager
|
|
13
13
|
import '@/.mercato/generated/translations-fields.generated'
|
|
14
|
-
|
|
15
|
-
import
|
|
14
|
+
// Side-effect: configures message UI component and object type registries on the client.
|
|
15
|
+
import '@/.mercato/generated/messages.client.generated'
|
|
16
16
|
|
|
17
17
|
let _clientBootstrapped = false
|
|
18
18
|
|
|
@@ -27,9 +27,6 @@ function clientBootstrap() {
|
|
|
27
27
|
|
|
28
28
|
// Register dashboard widgets
|
|
29
29
|
registerDashboardWidgets(dashboardWidgetEntries)
|
|
30
|
-
|
|
31
|
-
// Configure message UI components from generated client registry.
|
|
32
|
-
configureMessageUiComponentRegistry(getMessageUiComponentRegistry())
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
export function ClientBootstrapProvider({ children }: { children: React.ReactNode }) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { componentOverrideEntries } from '@/.mercato/generated/component-overrides.generated'
|
|
5
|
+
import { ComponentOverrideProvider } from '@open-mercato/ui/backend/injection/ComponentOverrideProvider'
|
|
6
|
+
|
|
7
|
+
export function ComponentOverridesBootstrap({ children }: { children: React.ReactNode }) {
|
|
8
|
+
const overrides = React.useMemo(
|
|
9
|
+
() => componentOverrideEntries.flatMap((entry) => entry.componentOverrides ?? []),
|
|
10
|
+
[],
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<ComponentOverrideProvider overrides={overrides}>
|
|
15
|
+
{children}
|
|
16
|
+
</ComponentOverrideProvider>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default ComponentOverridesBootstrap
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
import { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'
|
|
4
|
+
import { createPersonFixture, deleteEntityIfExists } from '@open-mercato/core/modules/core/__integration__/helpers/crmFixtures'
|
|
5
|
+
|
|
6
|
+
type PriorityListResponse = {
|
|
7
|
+
items?: Array<{ id: string; priority?: string }>
|
|
8
|
+
data?: Array<{ id: string; priority?: string }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test.describe('TC-UMES-004: Phase E-H completion', () => {
|
|
12
|
+
let adminToken = ''
|
|
13
|
+
|
|
14
|
+
test.beforeAll(async ({ request }) => {
|
|
15
|
+
adminToken = await getAuthToken(request, 'admin')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('TC-UMES-I01: interceptor before rejects blocked POST with 422', async ({ request }) => {
|
|
19
|
+
const blocked = await apiRequest(request, 'POST', '/api/example/todos', {
|
|
20
|
+
token: adminToken,
|
|
21
|
+
data: { title: 'BLOCKED todo from interceptor test' },
|
|
22
|
+
})
|
|
23
|
+
expect(blocked.status()).toBe(422)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('TC-UMES-I02: interceptor before allows valid POST', async ({ request }) => {
|
|
27
|
+
let todoId: string | null = null
|
|
28
|
+
try {
|
|
29
|
+
const created = await apiRequest(request, 'POST', '/api/example/todos', {
|
|
30
|
+
token: adminToken,
|
|
31
|
+
data: { title: `VALID-${Date.now()}` },
|
|
32
|
+
})
|
|
33
|
+
expect(created.ok()).toBeTruthy()
|
|
34
|
+
todoId = (await created.json())?.id ?? null
|
|
35
|
+
expect(typeof todoId).toBe('string')
|
|
36
|
+
} finally {
|
|
37
|
+
await deleteEntityIfExists(request, adminToken, '/api/example/todos', todoId)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('TC-UMES-I03/I06: interceptor after merges metadata payload in GET response', async ({ request }) => {
|
|
42
|
+
const enriched = await apiRequest(request, 'GET', '/api/example/todos?page=1&pageSize=1', {
|
|
43
|
+
token: adminToken,
|
|
44
|
+
})
|
|
45
|
+
expect(enriched.ok()).toBeTruthy()
|
|
46
|
+
const enrichedBody = await enriched.json()
|
|
47
|
+
expect(Array.isArray(enrichedBody)).toBe(false)
|
|
48
|
+
expect(Array.isArray(enrichedBody?.items)).toBe(true)
|
|
49
|
+
expect(enrichedBody?._example?.interceptor).toBeDefined()
|
|
50
|
+
expect(typeof enrichedBody?._example?.interceptor?.processingTimeMs).toBe('number')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('TC-UMES-I04: wildcard interceptor matches /example/todos', async ({ request }) => {
|
|
54
|
+
const todosResponse = await apiRequest(
|
|
55
|
+
request,
|
|
56
|
+
'GET',
|
|
57
|
+
'/api/example/todos?page=1&pageSize=1&interceptorProbe=wildcard',
|
|
58
|
+
{ token: adminToken },
|
|
59
|
+
)
|
|
60
|
+
expect(todosResponse.ok()).toBeTruthy()
|
|
61
|
+
const todosPayload = await todosResponse.json()
|
|
62
|
+
expect(Array.isArray(todosPayload)).toBe(false)
|
|
63
|
+
expect(todosPayload?._example?.wildcardProbe).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('TC-UMES-I05: interceptor query rewrite is revalidated by route schema', async ({ request }) => {
|
|
67
|
+
const badQuery = await apiRequest(request, 'GET', '/api/example/todos?interceptorProbe=bad-query', {
|
|
68
|
+
token: adminToken,
|
|
69
|
+
})
|
|
70
|
+
expect(badQuery.status()).toBe(400)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('TC-UMES-I08/I09: interceptor timeout and crash fail closed', async ({ request }) => {
|
|
74
|
+
const timeout = await apiRequest(request, 'GET', '/api/example/todos?interceptorProbe=timeout', {
|
|
75
|
+
token: adminToken,
|
|
76
|
+
})
|
|
77
|
+
expect(timeout.status()).toBe(504)
|
|
78
|
+
const timeoutBody = await timeout.json()
|
|
79
|
+
expect(timeoutBody.error).toBe('Interceptor timeout')
|
|
80
|
+
|
|
81
|
+
const crash = await apiRequest(request, 'GET', '/api/example/todos?interceptorProbe=crash', {
|
|
82
|
+
token: adminToken,
|
|
83
|
+
})
|
|
84
|
+
expect(crash.status()).toBe(500)
|
|
85
|
+
const crashBody = await crash.json()
|
|
86
|
+
expect(crashBody.error).toBe('Internal interceptor error')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('TC-UMES-I10: extension page probe reports interceptor metadata rows as ok', async ({ page }) => {
|
|
90
|
+
await login(page, 'admin')
|
|
91
|
+
await page.goto('/backend/umes-extensions')
|
|
92
|
+
await page.waitForLoadState('domcontentloaded')
|
|
93
|
+
|
|
94
|
+
await page.getByTestId('phase-e-run-probe').click()
|
|
95
|
+
await expect(page.getByTestId('phase-e-status')).toContainText('status=ok', { timeout: 15_000 })
|
|
96
|
+
await expect(page.getByTestId('phase-e-probe-default')).toContainText('status=ok')
|
|
97
|
+
await expect(page.getByTestId('phase-e-probe-wildcard')).toContainText('status=ok')
|
|
98
|
+
await expect(page.getByTestId('phase-e-result')).toContainText('_example')
|
|
99
|
+
await expect(page.getByTestId('phase-e-result')).not.toContainText('response=[]')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('TC-UMES-I07: interceptor query rewrites remain tenant-safe for customer priority filter', async ({ request }) => {
|
|
103
|
+
let personId: string | null = null
|
|
104
|
+
let priorityId: string | null = null
|
|
105
|
+
try {
|
|
106
|
+
personId = await createPersonFixture(request, adminToken, {
|
|
107
|
+
firstName: `QA-UMES-EH-${Date.now()}`,
|
|
108
|
+
lastName: 'Priority',
|
|
109
|
+
displayName: `QA UMES EH ${Date.now()}`,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const createPriority = await apiRequest(request, 'POST', '/api/example/customer-priorities', {
|
|
113
|
+
token: adminToken,
|
|
114
|
+
data: { customerId: personId, priority: 'high' },
|
|
115
|
+
})
|
|
116
|
+
expect(createPriority.ok()).toBeTruthy()
|
|
117
|
+
priorityId = (await createPriority.json())?.id ?? null
|
|
118
|
+
|
|
119
|
+
const filtered = await apiRequest(
|
|
120
|
+
request,
|
|
121
|
+
'GET',
|
|
122
|
+
`/api/customers/people?id=${encodeURIComponent(personId)}&examplePriority=high`,
|
|
123
|
+
{ token: adminToken },
|
|
124
|
+
)
|
|
125
|
+
expect(filtered.ok()).toBeTruthy()
|
|
126
|
+
const filteredBody = await filtered.json()
|
|
127
|
+
const filteredItems = filteredBody?.items ?? filteredBody?.data ?? []
|
|
128
|
+
expect(filteredItems.some((item: Record<string, unknown>) => item.id === personId)).toBe(true)
|
|
129
|
+
} finally {
|
|
130
|
+
await deleteEntityIfExists(request, adminToken, '/api/example/customer-priorities', priorityId)
|
|
131
|
+
await deleteEntityIfExists(request, adminToken, '/api/customers/people', personId)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('TC-UMES-D01/D02/D03/D04: DataTable extensions render column, row action, filters, and bulk action button', async ({ page, request }) => {
|
|
136
|
+
let personId: string | null = null
|
|
137
|
+
let priorityId: string | null = null
|
|
138
|
+
const displayName = `QA UMES D ${Date.now()}`
|
|
139
|
+
try {
|
|
140
|
+
personId = await createPersonFixture(request, adminToken, {
|
|
141
|
+
firstName: `QA-UMES-D-${Date.now()}`,
|
|
142
|
+
lastName: 'Table',
|
|
143
|
+
displayName,
|
|
144
|
+
})
|
|
145
|
+
const priorityResponse = await apiRequest(
|
|
146
|
+
request,
|
|
147
|
+
'POST',
|
|
148
|
+
'/api/example/customer-priorities',
|
|
149
|
+
{ token: adminToken, data: { customerId: personId, priority: 'high' } },
|
|
150
|
+
)
|
|
151
|
+
expect(priorityResponse.ok()).toBeTruthy()
|
|
152
|
+
priorityId = (await priorityResponse.json())?.id ?? null
|
|
153
|
+
|
|
154
|
+
await login(page, 'admin')
|
|
155
|
+
await page.goto('/backend/customers/people')
|
|
156
|
+
await page.waitForLoadState('domcontentloaded')
|
|
157
|
+
await expect(page.getByRole('button', { name: 'Set normal priority' })).toBeVisible()
|
|
158
|
+
|
|
159
|
+
await page.getByPlaceholder(/search/i).first().fill(displayName)
|
|
160
|
+
const targetRow = page.locator('tbody tr', { hasText: displayName }).first()
|
|
161
|
+
await expect(targetRow).toBeVisible({ timeout: 10_000 })
|
|
162
|
+
await expect(targetRow.getByText('high')).toBeVisible({ timeout: 10_000 })
|
|
163
|
+
|
|
164
|
+
await page.getByRole('button', { name: 'Filters' }).first().click()
|
|
165
|
+
await expect(page.getByText('Priority').first()).toBeVisible()
|
|
166
|
+
await page.keyboard.press('Escape')
|
|
167
|
+
|
|
168
|
+
await expect(page.getByText('Open actions').first()).toBeVisible()
|
|
169
|
+
} finally {
|
|
170
|
+
await deleteEntityIfExists(request, adminToken, '/api/example/customer-priorities', priorityId)
|
|
171
|
+
await deleteEntityIfExists(request, adminToken, '/api/customers/people', personId)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('TC-UMES-D06: injected bulk action executes against selected rows', async ({ page, request }) => {
|
|
176
|
+
let personId: string | null = null
|
|
177
|
+
let priorityId: string | null = null
|
|
178
|
+
const displayName = `QA UMES BULK ${Date.now()}`
|
|
179
|
+
try {
|
|
180
|
+
personId = await createPersonFixture(request, adminToken, {
|
|
181
|
+
firstName: `QA-UMES-BULK-${Date.now()}`,
|
|
182
|
+
lastName: 'Bulk',
|
|
183
|
+
displayName,
|
|
184
|
+
})
|
|
185
|
+
const priorityResponse = await apiRequest(
|
|
186
|
+
request,
|
|
187
|
+
'POST',
|
|
188
|
+
'/api/example/customer-priorities',
|
|
189
|
+
{ token: adminToken, data: { customerId: personId, priority: 'critical' } },
|
|
190
|
+
)
|
|
191
|
+
expect(priorityResponse.ok()).toBeTruthy()
|
|
192
|
+
priorityId = (await priorityResponse.json())?.id ?? null
|
|
193
|
+
|
|
194
|
+
await login(page, 'admin')
|
|
195
|
+
await page.goto('/backend/customers/people')
|
|
196
|
+
await page.waitForLoadState('domcontentloaded')
|
|
197
|
+
|
|
198
|
+
await expect(page.getByRole('button', { name: 'Set normal priority' })).toBeVisible({ timeout: 10_000 })
|
|
199
|
+
await page.getByPlaceholder(/search/i).first().fill(displayName)
|
|
200
|
+
const targetRow = page.locator('tbody tr', { hasText: displayName }).first()
|
|
201
|
+
await expect(targetRow).toBeVisible({ timeout: 10_000 })
|
|
202
|
+
await targetRow.getByRole('checkbox', { name: 'Select row' }).check()
|
|
203
|
+
await page.getByRole('button', { name: 'Set normal priority' }).click()
|
|
204
|
+
|
|
205
|
+
await expect
|
|
206
|
+
.poll(async () => {
|
|
207
|
+
const response = await apiRequest(
|
|
208
|
+
request,
|
|
209
|
+
'GET',
|
|
210
|
+
`/api/example/customer-priorities?customerId=${encodeURIComponent(personId!)}&page=1&pageSize=1`,
|
|
211
|
+
{ token: adminToken },
|
|
212
|
+
)
|
|
213
|
+
const payload = await response.json() as PriorityListResponse
|
|
214
|
+
const items = Array.isArray(payload.items) ? payload.items : (Array.isArray(payload.data) ? payload.data : [])
|
|
215
|
+
return items[0]?.priority ?? null
|
|
216
|
+
}, { timeout: 8_000 })
|
|
217
|
+
.toBe('normal')
|
|
218
|
+
} finally {
|
|
219
|
+
await deleteEntityIfExists(request, adminToken, '/api/example/customer-priorities', priorityId)
|
|
220
|
+
await deleteEntityIfExists(request, adminToken, '/api/customers/people', personId)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('TC-UMES-D05: injected DataTable extensions are available for authorized employee role', async ({ page }) => {
|
|
225
|
+
await login(page, 'employee')
|
|
226
|
+
await page.goto('/backend/customers/people')
|
|
227
|
+
await page.waitForLoadState('domcontentloaded')
|
|
228
|
+
|
|
229
|
+
await expect(page.getByRole('button', { name: 'Set normal priority' })).toBeVisible()
|
|
230
|
+
await page.getByRole('button', { name: 'Customize columns' }).click()
|
|
231
|
+
await expect(page.getByText(/Example priority|example\.priority\.column/).first()).toBeVisible({ timeout: 10_000 })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('TC-UMES-CF01/CF02/CF03/CF04/CF05: detail injection priority field save path and payload boundaries', async ({ page, request }) => {
|
|
235
|
+
let personId: string | null = null
|
|
236
|
+
let createdPriorityId: string | null = null
|
|
237
|
+
try {
|
|
238
|
+
personId = await createPersonFixture(request, adminToken, {
|
|
239
|
+
firstName: `QA-UMES-CF-${Date.now()}`,
|
|
240
|
+
lastName: 'Form',
|
|
241
|
+
displayName: `QA UMES CF ${Date.now()}`,
|
|
242
|
+
})
|
|
243
|
+
const seededPriority = await apiRequest(request, 'POST', '/api/example/customer-priorities', {
|
|
244
|
+
token: adminToken,
|
|
245
|
+
data: { customerId: personId, priority: 'high' },
|
|
246
|
+
})
|
|
247
|
+
expect(seededPriority.ok()).toBeTruthy()
|
|
248
|
+
createdPriorityId = (await seededPriority.json())?.id ?? null
|
|
249
|
+
|
|
250
|
+
await login(page, 'admin')
|
|
251
|
+
await page.goto(`/backend/customers/people/${encodeURIComponent(personId)}`)
|
|
252
|
+
await page.waitForLoadState('domcontentloaded')
|
|
253
|
+
|
|
254
|
+
const priorityWidget = page.locator('div.rounded-md.border', { hasText: 'Customer priority' }).first()
|
|
255
|
+
const hasPriorityWidget = await priorityWidget.isVisible({ timeout: 1500 }).catch(() => false)
|
|
256
|
+
test.skip(!hasPriorityWidget, 'Example customer detail injections are not active in this runtime')
|
|
257
|
+
await expect(priorityWidget).toBeVisible()
|
|
258
|
+
const priorityField = priorityWidget.locator('select').first()
|
|
259
|
+
await expect(priorityField).toBeVisible()
|
|
260
|
+
await expect(priorityField).toHaveValue('high')
|
|
261
|
+
await priorityField.selectOption('critical')
|
|
262
|
+
|
|
263
|
+
await expect
|
|
264
|
+
.poll(async () => {
|
|
265
|
+
const response = await apiRequest(
|
|
266
|
+
request,
|
|
267
|
+
'GET',
|
|
268
|
+
`/api/example/customer-priorities?customerId=${encodeURIComponent(personId!)}&page=1&pageSize=1`,
|
|
269
|
+
{ token: adminToken },
|
|
270
|
+
)
|
|
271
|
+
if (!response.ok()) return null
|
|
272
|
+
const payload = await response.json() as PriorityListResponse
|
|
273
|
+
const items = Array.isArray(payload.items) ? payload.items : (Array.isArray(payload.data) ? payload.data : [])
|
|
274
|
+
return items[0]?.priority ?? null
|
|
275
|
+
}, { timeout: 8_000 })
|
|
276
|
+
.toBe('critical')
|
|
277
|
+
|
|
278
|
+
const priorityList = await apiRequest(
|
|
279
|
+
request,
|
|
280
|
+
'GET',
|
|
281
|
+
`/api/example/customer-priorities?customerId=${encodeURIComponent(personId)}&page=1&pageSize=1`,
|
|
282
|
+
{ token: adminToken },
|
|
283
|
+
)
|
|
284
|
+
const priorityPayload = await priorityList.json() as PriorityListResponse
|
|
285
|
+
const priorityItems = Array.isArray(priorityPayload.items) ? priorityPayload.items : (Array.isArray(priorityPayload.data) ? priorityPayload.data : [])
|
|
286
|
+
createdPriorityId = priorityItems[0]?.id ?? null
|
|
287
|
+
|
|
288
|
+
const personResponse = await apiRequest(
|
|
289
|
+
request,
|
|
290
|
+
'GET',
|
|
291
|
+
`/api/customers/people?id=${encodeURIComponent(personId)}`,
|
|
292
|
+
{ token: adminToken },
|
|
293
|
+
)
|
|
294
|
+
expect(personResponse.ok()).toBeTruthy()
|
|
295
|
+
const personPayload = await personResponse.json()
|
|
296
|
+
const personItems = personPayload?.items ?? personPayload?.data ?? []
|
|
297
|
+
const person = personItems.find((entry: Record<string, unknown>) => entry.id === personId) as Record<string, unknown> | undefined
|
|
298
|
+
expect(person).toBeDefined()
|
|
299
|
+
expect(person?.priority).toBeUndefined()
|
|
300
|
+
expect(person?.['_example.priority']).toBeUndefined()
|
|
301
|
+
} finally {
|
|
302
|
+
await deleteEntityIfExists(request, adminToken, '/api/example/customer-priorities', createdPriorityId)
|
|
303
|
+
await deleteEntityIfExists(request, adminToken, '/api/customers/people', personId)
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('TC-UMES-CR01/CR02/CR03: replacement handles and wrapper render', async ({ page, request }) => {
|
|
308
|
+
let personId: string | null = null
|
|
309
|
+
try {
|
|
310
|
+
personId = await createPersonFixture(request, adminToken, {
|
|
311
|
+
firstName: `QA-UMES-CR-${Date.now()}`,
|
|
312
|
+
lastName: 'Wrap',
|
|
313
|
+
displayName: `QA UMES CR ${Date.now()}`,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
await login(page, 'admin')
|
|
317
|
+
await page.goto('/backend/umes-extensions')
|
|
318
|
+
await page.waitForLoadState('domcontentloaded')
|
|
319
|
+
const interceptorHint = page.getByText(
|
|
320
|
+
'Note: red network entries for probes 3-5 are expected and indicate correct fail-closed behavior.',
|
|
321
|
+
)
|
|
322
|
+
await expect(interceptorHint).toBeVisible()
|
|
323
|
+
await expect(interceptorHint.locator('xpath=ancestor::div[1]')).toHaveClass(/border-amber-500\/40/)
|
|
324
|
+
|
|
325
|
+
await expect(page.locator('[data-component-handle="page:/backend/umes-extensions"]')).toHaveCount(1)
|
|
326
|
+
await expect(page.locator('[data-component-handle="data-table:example.umes.extensions"]')).toHaveCount(1)
|
|
327
|
+
await expect(page.locator('[data-component-handle="crud-form:example.todo"]')).toHaveCount(1)
|
|
328
|
+
|
|
329
|
+
await page.goto(`/backend/customers/people/${encodeURIComponent(personId)}`)
|
|
330
|
+
await page.waitForLoadState('domcontentloaded')
|
|
331
|
+
await expect(page.locator('[data-component-handle="section:ui.detail.NotesSection"]')).toHaveCount(1)
|
|
332
|
+
await expect(page.locator('div.border-dashed').first()).toBeVisible()
|
|
333
|
+
} finally {
|
|
334
|
+
await deleteEntityIfExists(request, adminToken, '/api/customers/people', personId)
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
})
|