@asteby/metacore-runtime-react 46.4.0 → 47.0.0

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 (87) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/action-modal-dispatcher.d.ts +10 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +107 -19
  5. package/dist/business/catalog.d.ts.map +1 -1
  6. package/dist/business/catalog.js +24 -0
  7. package/dist/business/index.d.ts +3 -0
  8. package/dist/business/index.d.ts.map +1 -1
  9. package/dist/business/index.js +3 -0
  10. package/dist/business/print-send-dialog.d.ts +27 -0
  11. package/dist/business/print-send-dialog.d.ts.map +1 -0
  12. package/dist/business/print-send-dialog.js +55 -0
  13. package/dist/business/relate-documents.d.ts +41 -0
  14. package/dist/business/relate-documents.d.ts.map +1 -0
  15. package/dist/business/relate-documents.js +42 -0
  16. package/dist/business/vehicle-picker.d.ts +36 -0
  17. package/dist/business/vehicle-picker.d.ts.map +1 -0
  18. package/dist/business/vehicle-picker.js +61 -0
  19. package/dist/cfdi-stamp-panel.d.ts +29 -0
  20. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  21. package/dist/cfdi-stamp-panel.js +53 -0
  22. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  23. package/dist/dialogs/dynamic-record.js +5 -0
  24. package/dist/document-form-dialog.d.ts +23 -0
  25. package/dist/document-form-dialog.d.ts.map +1 -0
  26. package/dist/document-form-dialog.js +196 -0
  27. package/dist/dynamic-crud-page.d.ts +6 -0
  28. package/dist/dynamic-crud-page.d.ts.map +1 -1
  29. package/dist/dynamic-crud-page.js +4 -2
  30. package/dist/dynamic-kanban.d.ts.map +1 -1
  31. package/dist/dynamic-kanban.js +3 -1
  32. package/dist/dynamic-table.d.ts.map +1 -1
  33. package/dist/dynamic-table.js +6 -2
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/nav-permissions.d.ts +19 -0
  38. package/dist/nav-permissions.d.ts.map +1 -0
  39. package/dist/nav-permissions.js +51 -0
  40. package/dist/navigation-builder.d.ts +6 -0
  41. package/dist/navigation-builder.d.ts.map +1 -1
  42. package/dist/navigation-builder.js +26 -1
  43. package/dist/permissions-manager.d.ts +9 -0
  44. package/dist/permissions-manager.d.ts.map +1 -1
  45. package/dist/permissions-manager.js +50 -17
  46. package/dist/print-document-button.d.ts.map +1 -1
  47. package/dist/print-document-button.js +9 -1
  48. package/dist/record-mutation-events.d.ts +14 -0
  49. package/dist/record-mutation-events.d.ts.map +1 -0
  50. package/dist/record-mutation-events.js +37 -0
  51. package/dist/types.d.ts +46 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/use-print-document.d.ts +15 -1
  54. package/dist/use-print-document.d.ts.map +1 -1
  55. package/dist/use-print-document.js +52 -1
  56. package/dist/use-record-mutation-tick.d.ts +6 -0
  57. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  58. package/dist/use-record-mutation-tick.js +11 -0
  59. package/package.json +3 -3
  60. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  61. package/src/__tests__/business-pickers.test.tsx +157 -0
  62. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  63. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  64. package/src/__tests__/nav-permissions.test.tsx +76 -0
  65. package/src/__tests__/permissions-manager.test.tsx +21 -4
  66. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  67. package/src/action-modal-dispatcher.tsx +124 -6
  68. package/src/business/catalog.ts +24 -0
  69. package/src/business/index.ts +3 -0
  70. package/src/business/print-send-dialog.tsx +175 -0
  71. package/src/business/relate-documents.tsx +182 -0
  72. package/src/business/vehicle-picker.tsx +188 -0
  73. package/src/cfdi-stamp-panel.tsx +146 -0
  74. package/src/dialogs/dynamic-record.tsx +5 -0
  75. package/src/document-form-dialog.tsx +357 -0
  76. package/src/dynamic-crud-page.tsx +21 -2
  77. package/src/dynamic-kanban.tsx +3 -1
  78. package/src/dynamic-table.tsx +6 -2
  79. package/src/index.ts +28 -0
  80. package/src/nav-permissions.ts +58 -0
  81. package/src/navigation-builder.tsx +27 -1
  82. package/src/permissions-manager.tsx +39 -26
  83. package/src/print-document-button.tsx +9 -1
  84. package/src/record-mutation-events.ts +50 -0
  85. package/src/types.ts +49 -0
  86. package/src/use-print-document.ts +66 -3
  87. package/src/use-record-mutation-tick.ts +12 -0
@@ -0,0 +1,190 @@
1
+ // @vitest-environment happy-dom
2
+ // FAC-12 (#1022): alta guiada por tipo de documento.
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
7
+ vi.mock('react-i18next', () => {
8
+ // Stable references: a fresh `t` per render would retrigger effects that depend on it.
9
+ const i18n = { language: 'es' }
10
+ const t = (k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? k
11
+ const value = { t, i18n }
12
+ return { useTranslation: () => value }
13
+ })
14
+
15
+ import { DocumentFormDialog, resolveDocumentForms } from '../document-form-dialog'
16
+ import { DynamicCRUDPage } from '../dynamic-crud-page'
17
+ import { useMetadataCache } from '../metadata-cache'
18
+ import { ApiProvider, type ApiClient } from '../api-context'
19
+ import type { DocumentFormsManifest } from '../types'
20
+
21
+ afterEach(cleanup)
22
+
23
+ const forms: DocumentFormsManifest = {
24
+ type_field: 'type',
25
+ types: [
26
+ {
27
+ key: 'invoice',
28
+ label: 'Factura',
29
+ description: 'Ingreso (I)',
30
+ value: 'I',
31
+ fields: [
32
+ { key: 'customer_name', label: 'Cliente', type: 'text', required: true },
33
+ { key: 'payment_method', label: 'Método de pago', type: 'text' },
34
+ ],
35
+ lines: { columns: ['tax'] },
36
+ },
37
+ {
38
+ key: 'credit_note',
39
+ label: 'Nota de crédito',
40
+ value: 'E',
41
+ fields: [
42
+ { key: 'customer_name', label: 'Cliente', type: 'text', required: true },
43
+ { key: 'origin_invoice', label: 'Factura origen', type: 'text', required: true },
44
+ ],
45
+ },
46
+ ],
47
+ }
48
+
49
+ function api(post: ApiClient['post']): ApiClient {
50
+ return { get: vi.fn().mockResolvedValue({ data: {} }), post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
51
+ }
52
+
53
+ function setup(manifest: DocumentFormsManifest, post: ApiClient['post'] = vi.fn().mockResolvedValue({ data: { success: true, data: { id: '1' } } })) {
54
+ const onOpenChange = vi.fn()
55
+ const onSaved = vi.fn()
56
+ render(
57
+ <ApiProvider client={api(post)}>
58
+ <DocumentFormDialog open onOpenChange={onOpenChange} model="fiscal_documents" forms={manifest} onSaved={onSaved} />
59
+ </ApiProvider>,
60
+ )
61
+ return { post: post as ReturnType<typeof vi.fn>, onOpenChange, onSaved }
62
+ }
63
+
64
+ describe('DocumentFormDialog', () => {
65
+ it('con dos tipos muestra tarjetas; elegir «Nota de crédito» muestra sus campos y no los de factura', () => {
66
+ setup(forms)
67
+ expect(screen.getByText('Factura')).toBeTruthy()
68
+ expect(screen.getByText('Nota de crédito')).toBeTruthy()
69
+ expect(screen.queryByText('Factura origen')).toBeNull()
70
+ fireEvent.click(screen.getByText('Nota de crédito'))
71
+ expect(screen.getByText('Factura origen')).toBeTruthy()
72
+ expect(screen.queryByText('Método de pago')).toBeNull()
73
+ })
74
+
75
+ it('un solo tipo se salta el selector', () => {
76
+ setup({ ...forms, types: [forms.types[1]] })
77
+ expect(screen.queryByRole('radiogroup')).toBeNull()
78
+ expect(screen.getByText('Factura origen')).toBeTruthy()
79
+ })
80
+
81
+ it('envía el valor del tipo y los campos al endpoint del modelo', async () => {
82
+ const { post, onSaved, onOpenChange } = setup(forms)
83
+ fireEvent.click(screen.getByText('Nota de crédito'))
84
+ const inputs = document.querySelectorAll('input')
85
+ fireEvent.change(inputs[0], { target: { value: 'ACME' } })
86
+ fireEvent.change(inputs[1], { target: { value: 'FAC-1' } })
87
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
88
+ await waitFor(() => expect(post).toHaveBeenCalled())
89
+ expect(post.mock.calls[0][0]).toBe('/dynamic/fiscal_documents')
90
+ expect(post.mock.calls[0][1]).toMatchObject({ type: 'E', customer_name: 'ACME', origin_invoice: 'FAC-1' })
91
+ await waitFor(() => expect(onSaved).toHaveBeenCalled())
92
+ expect(onOpenChange).toHaveBeenCalledWith(false)
93
+ })
94
+
95
+ it('el paso de renglones usa el editor del SDK y los serializa', async () => {
96
+ const { post } = setup({ ...forms, types: [forms.types[0]] })
97
+ fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
98
+ fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
99
+ // Sin renglones no guarda: banner en el diálogo.
100
+ await screen.findByRole('button', { name: 'Renglón libre' })
101
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
102
+ await screen.findByRole('alert')
103
+ expect(post).not.toHaveBeenCalled()
104
+ // Con un renglón válido, viajan serializados en `lines`.
105
+ fireEvent.click(screen.getByRole('button', { name: 'Renglón libre' }))
106
+ fireEvent.change(screen.getByLabelText('Descripción'), { target: { value: 'Servicio' } })
107
+ fireEvent.change(screen.getByLabelText('Precio'), { target: { value: '100' } })
108
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
109
+ await waitFor(() => expect(post).toHaveBeenCalled())
110
+ const body = post.mock.calls[0][1] as any
111
+ expect(body.type).toBe('I')
112
+ expect(body.lines).toEqual([expect.objectContaining({ description: 'Servicio', quantity: 1, unit_price: 100 })])
113
+ })
114
+
115
+ it('un 422 se muestra dentro del diálogo y no lo cierra', async () => {
116
+ const post = vi.fn().mockRejectedValue({
117
+ response: { status: 422, data: { success: false, message: 'validation failed', errors: { origin_invoice: ['La factura origen no existe'] } } },
118
+ })
119
+ const { onOpenChange, onSaved } = setup({ ...forms, types: [forms.types[1]] }, post)
120
+ const inputs = document.querySelectorAll('input')
121
+ fireEvent.change(inputs[0], { target: { value: 'ACME' } })
122
+ fireEvent.change(inputs[1], { target: { value: 'X' } })
123
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
124
+ const banner = await screen.findByRole('alert')
125
+ expect(banner.textContent).toContain('La factura origen no existe')
126
+ expect(onOpenChange).not.toHaveBeenCalledWith(false)
127
+ expect(onSaved).not.toHaveBeenCalled()
128
+ })
129
+ })
130
+
131
+ describe('resolveDocumentForms', () => {
132
+ it('sin manifest (o sin tipos) devuelve undefined: el alta genérica sigue igual', () => {
133
+ expect(resolveDocumentForms(null)).toBeUndefined()
134
+ expect(resolveDocumentForms({} as any)).toBeUndefined()
135
+ expect(resolveDocumentForms({ document_forms: { types: [] } } as any)).toBeUndefined()
136
+ })
137
+ it('la prop gana sobre el metadata', () => {
138
+ expect(resolveDocumentForms({ document_forms: { types: [forms.types[0]] } } as any, forms)).toBe(forms)
139
+ expect(resolveDocumentForms({ document_forms: forms } as any)).toBe(forms)
140
+ })
141
+ })
142
+
143
+ describe('DynamicCRUDPage + document_forms', () => {
144
+ const meta = (extra: Record<string, unknown>) =>
145
+ ({
146
+ title: 'Notas de crédito',
147
+ endpoint: '/data/x',
148
+ columns: [],
149
+ actions: [],
150
+ perPageOptions: [10],
151
+ defaultPerPage: 10,
152
+ searchPlaceholder: 'Buscar',
153
+ enableCRUDActions: true,
154
+ hasActions: false,
155
+ canExport: false,
156
+ canImport: false,
157
+ ...extra,
158
+ }) as any
159
+
160
+ const mount = (m: any) => {
161
+ useMetadataCache.getState().setMetadata('fiscal_documents', m)
162
+ const client = {
163
+ get: vi.fn(async (url: string) =>
164
+ url.startsWith('/metadata/') ? { data: { success: true, data: m } } : { data: { success: true, data: [], meta: { total: 0 } } },
165
+ ),
166
+ post: vi.fn(),
167
+ put: vi.fn(),
168
+ delete: vi.fn(),
169
+ } as unknown as ApiClient
170
+ render(
171
+ <ApiProvider client={client}>
172
+ <DynamicCRUDPage model="fiscal_documents" />
173
+ </ApiProvider>,
174
+ )
175
+ }
176
+
177
+ it('con document_forms, Crear abre el selector de tipo en vez del formulario genérico', async () => {
178
+ mount(meta({ document_forms: forms }))
179
+ fireEvent.click((await screen.findByText(/New Nota/)).closest('button')!)
180
+ expect(await screen.findByRole('radiogroup')).toBeTruthy()
181
+ expect(screen.getByText('Nota de crédito')).toBeTruthy()
182
+ })
183
+
184
+ it('sin document_forms no aparece el selector (alta genérica)', async () => {
185
+ mount(meta({}))
186
+ fireEvent.click((await screen.findByText(/New Nota/)).closest('button')!)
187
+ await new Promise((r) => setTimeout(r, 50))
188
+ expect(screen.queryByRole('radiogroup')).toBeNull()
189
+ })
190
+ })
@@ -0,0 +1,116 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicTable classic pagination ('pages', the default mode):
4
+ // 1. The pager footer renders; clicking page 2 issues a NEW query with
5
+ // page=2 and REPLACES the visible rows (no accumulation).
6
+ // 2. Changing the filters resets back to page 1.
7
+ // 3. The chosen page size persists per table in localStorage and is adopted
8
+ // on the next mount (over the model's server default).
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
11
+
12
+ vi.mock('@tanstack/react-router', () => ({
13
+ useNavigate: () => () => {},
14
+ }))
15
+ const I18N = {
16
+ t: (_k: string, o?: { defaultValue?: string; count?: number; total?: number }) => {
17
+ if (o?.defaultValue && o.count != null && o.total != null) {
18
+ return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
19
+ }
20
+ return o?.defaultValue ?? _k
21
+ },
22
+ i18n: { language: 'es' },
23
+ }
24
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
25
+
26
+ import { act } from '@testing-library/react'
27
+ import { emitRecordMutation } from '../record-mutation-events'
28
+ import { DynamicTable } from '../dynamic-table'
29
+ import { ApiProvider, type ApiClient } from '../api-context'
30
+ import { useMetadataCache } from '../metadata-cache'
31
+ import type { TableMetadata } from '../types'
32
+
33
+ afterEach(cleanup)
34
+
35
+ function meta(): TableMetadata {
36
+ return {
37
+ title: 'Issues',
38
+ endpoint: '/data/issue',
39
+ group_by: 'stage',
40
+ columns: [
41
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
42
+ ],
43
+ actions: [],
44
+ perPageOptions: [10, 20, 50],
45
+ defaultPerPage: 10,
46
+ searchPlaceholder: 'Buscar...',
47
+ enableCRUDActions: false,
48
+ hasActions: false,
49
+ }
50
+ }
51
+
52
+ function fakeApi(total: number): ApiClient {
53
+ const ok = (data: unknown) => ({ data: { success: true, data, meta: { total } } })
54
+ const page = (n: number, size: number) => {
55
+ const start = (n - 1) * size
56
+ const rows: Array<{ id: number; title: string }> = []
57
+ for (let i = start; i < Math.min(start + size, total); i++) {
58
+ rows.push({ id: i + 1, title: `Issue ${i + 1}` })
59
+ }
60
+ return rows
61
+ }
62
+ return {
63
+ get: vi.fn(async (url: string, cfg?: any) => {
64
+ if (url.startsWith('/metadata/table/')) return ok(meta())
65
+ if (url.endsWith('/facets')) return ok([])
66
+ const p = cfg?.params?.page ?? 1
67
+ const size = cfg?.params?.per_page ?? 10
68
+ return ok(page(p, size))
69
+ }),
70
+ post: vi.fn(async () => ok(null)),
71
+ put: vi.fn(async () => ok(null)),
72
+ delete: vi.fn(async () => ok(null)),
73
+ }
74
+ }
75
+
76
+ function dataCalls(api: ApiClient) {
77
+ return (api.get as any).mock.calls.filter((c: any[]) => c[0] === '/data/issue')
78
+ }
79
+
80
+ describe('DynamicTable reloads on record mutation events (#1020)', () => {
81
+ beforeEach(() => {
82
+ localStorage.clear()
83
+ sessionStorage.clear()
84
+ useMetadataCache.getState().setMetadata('issue', meta())
85
+ })
86
+
87
+ it('repeats the GET when a record of the same model (any case) is mutated', async () => {
88
+ const api = fakeApi(5)
89
+ render(
90
+ <ApiProvider client={api}>
91
+ <DynamicTable model="issue" enableUrlSync={false} />
92
+ </ApiProvider>,
93
+ )
94
+ await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(0))
95
+ await new Promise((r) => setTimeout(r, 450))
96
+ const before = dataCalls(api).length
97
+
98
+ act(() => emitRecordMutation('ISSUE', 'create'))
99
+ await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(before))
100
+ })
101
+
102
+ it('ignores mutations of other models', async () => {
103
+ const api = fakeApi(5)
104
+ render(
105
+ <ApiProvider client={api}>
106
+ <DynamicTable model="issue" enableUrlSync={false} />
107
+ </ApiProvider>,
108
+ )
109
+ await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(0))
110
+ await new Promise((r) => setTimeout(r, 450))
111
+ const before = dataCalls(api).length
112
+ act(() => emitRecordMutation('other_model', 'delete'))
113
+ await new Promise((r) => setTimeout(r, 500))
114
+ expect(dataCalls(api).length).toBe(before)
115
+ })
116
+ })
@@ -0,0 +1,76 @@
1
+ // @vitest-environment happy-dom
2
+ // PIT-044: the sidebar follows the role's effective permissions.
3
+ import { describe, expect, it } from 'vitest'
4
+ import { renderHook } from '@testing-library/react'
5
+ import type { ReactNode } from 'react'
6
+ import { PermissionsProvider, makeCan } from '../permissions-context'
7
+ import {
8
+ capabilityForNavItem,
9
+ isNavItemAllowed,
10
+ modelFromNavUrl,
11
+ useNavItemVisible,
12
+ } from '../nav-permissions'
13
+ import { filterNavigationByCapability, useNavigation, type NavItem } from '../navigation-builder'
14
+
15
+ describe('nav url → capability', () => {
16
+ it('maps /m/<model> to <model>.index and ignores other routes', () => {
17
+ expect(modelFromNavUrl('/m/POS_Orders?view=kanban')).toBe('POS_Orders')
18
+ expect(modelFromNavUrl('/app/m/stock_transfers')).toBe('stock_transfers')
19
+ expect(modelFromNavUrl('/app/activity')).toBeUndefined()
20
+ expect(capabilityForNavItem({ url: '/m/pos_orders' })).toBe('pos_orders.index')
21
+ expect(capabilityForNavItem({ url: '/m/x', requires: 'general.admin' })).toBe('general.admin')
22
+ expect(capabilityForNavItem({ url: '/app/activity' })).toBeUndefined()
23
+ })
24
+
25
+ it('allows non-model routes and gates model routes', () => {
26
+ const can = makeCan(['pos_orders.index'], false)
27
+ expect(isNavItemAllowed({ url: '/m/pos_orders' }, can)).toBe(true)
28
+ expect(isNavItemAllowed({ url: '/m/invoices' }, can)).toBe(false)
29
+ expect(isNavItemAllowed({ url: '/app/activity' }, can)).toBe(true)
30
+ })
31
+ })
32
+
33
+ describe('useNavItemVisible', () => {
34
+ it('is undefined without a provider (nothing filtered)', () => {
35
+ const { result } = renderHook(() => useNavItemVisible())
36
+ expect(result.current).toBeUndefined()
37
+ })
38
+
39
+ it('hides entries the role cannot index; admin sees all', () => {
40
+ const wrap = (perms: string[], isAdmin: boolean) =>
41
+ ({ children }: { children: ReactNode }) => (
42
+ <PermissionsProvider permissions={perms} isAdmin={isAdmin}>
43
+ {children}
44
+ </PermissionsProvider>
45
+ )
46
+ const limited = renderHook(() => useNavItemVisible(), { wrapper: wrap(['pos_orders.index'], false) })
47
+ expect(limited.result.current!({ url: '/m/pos_orders' })).toBe(true)
48
+ expect(limited.result.current!({ url: '/m/invoices' })).toBe(false)
49
+ const admin = renderHook(() => useNavItemVisible(), { wrapper: wrap([], true) })
50
+ expect(admin.result.current!({ url: '/m/invoices' })).toBe(true)
51
+ })
52
+ })
53
+
54
+ describe('navigation builder capability filter', () => {
55
+ const base: NavItem[] = [
56
+ { key: 'sales', label: 'Ventas', children: [
57
+ { key: 'orders', label: 'Pedidos', to: '/m/orders', requires: 'orders.index' },
58
+ { key: 'invoices', label: 'Facturas', to: '/m/invoices', requires: 'invoices.index' },
59
+ ] },
60
+ { key: 'admin', label: 'Admin', children: [
61
+ { key: 'roles', label: 'Roles', to: '/m/roles', requires: 'roles.index' },
62
+ ] },
63
+ { key: 'home', label: 'Inicio', to: '/' },
64
+ ]
65
+
66
+ it('drops denied items and parents left empty', () => {
67
+ const out = filterNavigationByCapability(base, makeCan(['orders.index'], false))
68
+ expect(out.map((i) => i.key)).toEqual(['sales', 'home'])
69
+ expect(out[0].children!.map((c) => c.key)).toEqual(['orders'])
70
+ })
71
+
72
+ it('useNavigation keeps everything without a provider', () => {
73
+ const { result } = renderHook(() => useNavigation(base, []))
74
+ expect(result.current).toHaveLength(3)
75
+ })
76
+ })
@@ -249,9 +249,25 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
249
249
  expect(screen.getByRole('option', { name: /Pedidos POS/ })).toBeTruthy()
250
250
  expect(screen.getByRole('option', { name: /Terminal/ })).toBeTruthy()
251
251
 
252
- // Generales presentes con descripción.
253
- expect(screen.getByText('Permisos Generales')).toBeTruthy()
254
- expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
252
+ // Los generales son un grupo sintético del combobox (#1021), no cards
253
+ // sueltas junto a la tarjeta del rol.
254
+ expect(screen.getByRole('option', { name: /Permisos generales/ })).toBeTruthy()
255
+ expect(screen.queryByText('Trabajar fuera de horario')).toBeNull()
256
+ })
257
+
258
+ it('«Permisos generales» se edita en la misma grilla de dos columnas y cuenta en el total', async () => {
259
+ const props = makeProps()
260
+ render(<PermissionsManager {...props} />)
261
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
262
+
263
+ await selectModule(/Permisos generales/)
264
+ expect(await screen.findByText('Trabajar fuera de horario')).toBeTruthy()
265
+ expect(screen.getByText('0/1')).toBeTruthy()
266
+ const box = screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ })
267
+ // Misma grilla que las acciones de un módulo.
268
+ expect(box.closest('div.grid')?.className).toContain('sm:grid-cols-2')
269
+ fireEvent.click(box)
270
+ expect(screen.getByText('1/1')).toBeTruthy()
255
271
  })
256
272
 
257
273
  it('CERO acordeones: el módulo es un combobox, no un folder colapsable', async () => {
@@ -304,7 +320,8 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
304
320
  await selectModule(/Pedidos POS/)
305
321
  await screen.findByText('Pagar')
306
322
  fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
307
- fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
323
+ await selectModule(/Permisos generales/)
324
+ fireEvent.click(await screen.findByRole('checkbox', { name: /Trabajar fuera de horario/ }))
308
325
 
309
326
  expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
310
327
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
@@ -0,0 +1,58 @@
1
+ // @vitest-environment happy-dom
2
+ // «Descargar Factura CFDI» no daba señal alguna: ahora hay progreso, éxito y
3
+ // el motivo del servidor si falla (la promesa sigue rechazando).
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { cleanup, renderHook } from '@testing-library/react'
6
+ import type { ReactNode } from 'react'
7
+
8
+ const toast = vi.hoisted(() => ({
9
+ loading: vi.fn(() => 'tid'),
10
+ success: vi.fn(),
11
+ error: vi.fn(),
12
+ dismiss: vi.fn(),
13
+ }))
14
+ vi.mock('sonner', () => ({ toast }))
15
+
16
+ import { usePrintDocument, documentErrorMessage } from '../use-print-document'
17
+ import { ApiProvider, type ApiClient } from '../api-context'
18
+
19
+ afterEach(() => {
20
+ cleanup()
21
+ vi.clearAllMocks()
22
+ })
23
+
24
+ const wrap = (client: ApiClient) => ({ children }: { children: ReactNode }) => <ApiProvider client={client}>{children}</ApiProvider>
25
+
26
+ describe('usePrintDocument feedback', () => {
27
+ it('descarga: toast de progreso y de éxito', async () => {
28
+ URL.createObjectURL = vi.fn(() => 'blob:x')
29
+ const client = { get: vi.fn().mockResolvedValue({ data: new Blob(['x']), headers: {} }) } as unknown as ApiClient
30
+ const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
31
+ await result.current({ model: 'Invoice', id: '1', key: 'cfdi', mode: 'download' })
32
+ expect(toast.loading).toHaveBeenCalled()
33
+ expect(toast.success).toHaveBeenCalledWith('Documento descargado')
34
+ expect(toast.dismiss).toHaveBeenCalled()
35
+ expect(toast.error).not.toHaveBeenCalled()
36
+ })
37
+
38
+ it('fallo: muestra el motivo del servidor (cuerpo Blob) y rechaza', async () => {
39
+ const body = new Blob([JSON.stringify({ message: 'La factura aún no está timbrada' })], { type: 'application/json' })
40
+ const client = { get: vi.fn().mockRejectedValue({ response: { status: 409, data: body } }) } as unknown as ApiClient
41
+ const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
42
+ await expect(result.current({ model: 'Invoice', id: '1', key: 'cfdi', mode: 'download' })).rejects.toBeTruthy()
43
+ expect(toast.error).toHaveBeenCalledWith('La factura aún no está timbrada')
44
+ })
45
+
46
+ it('feedback:false no muestra nada; error:false deja el error al llamador', async () => {
47
+ const client = { get: vi.fn().mockRejectedValue({ response: { status: 500, data: null } }) } as unknown as ApiClient
48
+ const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
49
+ await expect(result.current({ model: 'I', id: '1', key: 'k', mode: 'open', feedback: false })).rejects.toBeTruthy()
50
+ await expect(result.current({ model: 'I', id: '1', key: 'k', mode: 'open', feedback: { progress: true, error: false } })).rejects.toBeTruthy()
51
+ expect(toast.error).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('documentErrorMessage cae a un mensaje genérico', async () => {
55
+ expect(await documentErrorMessage(new Error('x'))).toMatch(/No se pudo generar/)
56
+ expect(await documentErrorMessage({ response: { status: 404 } })).toMatch(/no está disponible/)
57
+ })
58
+ })