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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/template/src/app/(backend)/backend/[...slug]/page.tsx +6 -2
  3. package/template/src/app/(backend)/backend/__tests__/backend-require-features.test.tsx +108 -0
  4. package/template/src/app/(backend)/backend/layout.tsx +30 -27
  5. package/template/src/bootstrap.ts +4 -0
  6. package/template/src/components/ClientBootstrap.tsx +2 -5
  7. package/template/src/components/ComponentOverridesBootstrap.tsx +20 -0
  8. package/template/src/modules/example/__integration__/TC-UMES-004.spec.ts +337 -0
  9. package/template/src/modules/example/__integration__/meta.ts +3 -0
  10. package/template/src/modules/example/api/customer-priorities/route.ts +154 -0
  11. package/template/src/modules/example/api/interceptors.ts +174 -0
  12. package/template/src/modules/example/api/todos/route.ts +1 -1
  13. package/template/src/modules/example/backend/umes-extensions/page.meta.ts +24 -0
  14. package/template/src/modules/example/backend/umes-extensions/page.tsx +365 -0
  15. package/template/src/modules/example/backend/umes-handlers/page.tsx +1 -1
  16. package/template/src/modules/example/data/enrichers.ts +32 -4
  17. package/template/src/modules/example/data/entities.ts +28 -0
  18. package/template/src/modules/example/data/validators.ts +22 -0
  19. package/template/src/modules/example/i18n/de.json +55 -0
  20. package/template/src/modules/example/i18n/en.json +55 -0
  21. package/template/src/modules/example/i18n/es.json +55 -0
  22. package/template/src/modules/example/i18n/pl.json +55 -0
  23. package/template/src/modules/example/migrations/Migration20260226161000_example.ts +15 -0
  24. package/template/src/modules/example/widgets/__tests__/injection-table.test.ts +39 -6
  25. package/template/src/modules/example/widgets/components.ts +23 -0
  26. package/template/src/modules/example/widgets/injection/crud-validation/widget.client.tsx +13 -13
  27. package/template/src/modules/example/widgets/injection/customer-priority-bulk-actions/widget.ts +67 -0
  28. package/template/src/modules/example/widgets/injection/customer-priority-column/widget.ts +23 -0
  29. package/template/src/modules/example/widgets/injection/customer-priority-detail/widget.client.tsx +136 -0
  30. package/template/src/modules/example/widgets/injection/customer-priority-detail/widget.ts +13 -0
  31. package/template/src/modules/example/widgets/injection/customer-priority-field/widget.ts +68 -0
  32. package/template/src/modules/example/widgets/injection/customer-priority-filter/widget.ts +25 -0
  33. package/template/src/modules/example/widgets/injection/customer-priority-row-action/widget.ts +26 -0
  34. package/template/src/modules/example/widgets/injection-table.ts +54 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.4.6-develop-af28b566dd",
3
+ "version": "0.4.6-develop-4d77832982",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -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 Component = match.route.Component
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
- <Component params={match.params} />
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
- <AiAssistantIntegration
387
- tenantId={auth?.tenantId ?? null}
388
- organizationId={auth?.orgId ?? null}
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
- <PageInjectionBoundary path={path} context={injectionContext}>
410
- {children}
411
- </PageInjectionBoundary>
412
- </AppShell>
413
- </AiAssistantIntegration>
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
- import { getMessageUiComponentRegistry } from '@/.mercato/generated/messages.client.generated'
15
- import { configureMessageUiComponentRegistry } from '@open-mercato/core/modules/messages/components/utils/typeUiRegistry'
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
+ })
@@ -0,0 +1,3 @@
1
+ export const integrationMeta = {
2
+ dependsOnModules: ['example'],
3
+ };