@goplusvn/core 0.1.71 → 0.1.72

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 (124) hide show
  1. package/package.json +2 -1
  2. package/src/auth/index.ts +5 -1
  3. package/src/configs/entities/departments.config.ts +1 -0
  4. package/src/configs/entities/material-categories.config.ts +1 -0
  5. package/src/crud/components/crud-export-button.tsx +3 -2
  6. package/src/crud/components/crud-page.tsx +56 -25
  7. package/src/crud/components/crud-row-actions.tsx +16 -4
  8. package/src/crud/crud-route-handlers.test.ts +352 -0
  9. package/src/crud/crud-route-handlers.ts +260 -13
  10. package/src/crud/index.ts +3 -0
  11. package/src/crud/lib/coerce.test.ts +118 -0
  12. package/src/crud/lib/coerce.ts +95 -0
  13. package/src/crud/lib/crud-utils.ts +4 -0
  14. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  15. package/src/crud/lib/entity-endpoints.ts +23 -0
  16. package/src/crud/lib/errors.ts +14 -0
  17. package/src/crud/lib/import-request.ts +99 -0
  18. package/src/crud/lib/mutation-builder.test.ts +114 -0
  19. package/src/crud/lib/mutation-builder.ts +27 -34
  20. package/src/crud/lib/permissions.test.ts +57 -0
  21. package/src/crud/lib/permissions.ts +25 -13
  22. package/src/crud/lib/query-builder.ts +13 -6
  23. package/src/crud/pages/entity-crud-page.tsx +11 -8
  24. package/src/crud/server-service.test.ts +112 -0
  25. package/src/crud/server-service.ts +52 -27
  26. package/src/crud/server.ts +11 -1
  27. package/src/guardrails/rules/design.ts +3 -2
  28. package/src/types/index.ts +75 -10
  29. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  30. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  31. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  32. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  33. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  34. package/src/ui/data-display/index.tsx +1 -0
  35. package/src/ui/data-display/use-client-pagination.ts +48 -0
  36. package/templates/starter-app/AGENTS.md +1 -1
  37. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  38. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  39. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  40. package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
  41. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
  42. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
  43. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
  44. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +20 -9
  45. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
  46. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  47. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  48. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
  49. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
  50. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  51. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  53. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
  54. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
  55. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
  56. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
  57. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  58. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  59. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  60. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  61. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  62. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  63. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  64. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  65. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  66. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  67. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  68. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  69. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  70. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  71. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  72. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  73. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  74. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  75. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  76. package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
  77. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  78. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  79. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  80. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  81. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  82. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  83. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
  84. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
  85. package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
  86. package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
  87. package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
  88. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  89. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  90. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  91. package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
  92. package/templates/starter-app/src/app/api/users/route.ts +11 -9
  93. package/templates/starter-app/src/app/globals.css +0 -1
  94. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  95. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  96. package/templates/starter-app/src/configs/entities/index.ts +1 -1
  97. package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
  98. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
  99. package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
  100. package/templates/starter-app/src/configs/i18n.ts +6 -6
  101. package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
  102. package/templates/starter-app/src/data/dictionary.ts +2 -1
  103. package/templates/starter-app/src/data/navigations.ts +12 -2
  104. package/templates/starter-app/src/lib/action-guard.ts +3 -4
  105. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  106. package/templates/starter-app/src/lib/auth.ts +3 -1
  107. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  108. package/templates/starter-app/src/lib/crud/index.ts +5 -12
  109. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  110. package/templates/starter-app/src/lib/logger.ts +11 -4
  111. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  112. package/templates/starter-app/src/lib/prisma.ts +13 -5
  113. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  114. package/templates/starter-app/src/lib/storage.ts +3 -1
  115. package/templates/starter-app/src/providers/index.tsx +2 -1
  116. package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
  117. package/templates/starter-app/src/server/services/company-service.ts +4 -2
  118. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  119. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  120. package/templates/starter-app/src/server/services/user-service.ts +2 -2
  121. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  122. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  123. package/templates/starter-app/tsconfig.json +8 -0
  124. package/templates/starter-app/vitest.config.ts +17 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.71",
4
+ "version": "0.1.72",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -93,6 +93,7 @@
93
93
  "./rbac/role-service": "./src/rbac/role-service.ts",
94
94
  "./rbac/resource-service": "./src/rbac/resource-service.ts",
95
95
  "./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
96
+ "./crud/server": "./src/crud/server.ts",
96
97
  "./auth/auth-service": "./src/auth/auth-service.ts",
97
98
  "./package.json": "./package.json",
98
99
  "./providers/brand-theme": "./src/providers/brand-theme.ts",
package/src/auth/index.ts CHANGED
@@ -7,7 +7,11 @@ export * from "./auth-service";
7
7
 
8
8
  // `Permission` từng được KHAI BÁO LẠI ở đây, y hệt bản trong ../types — hai
9
9
  // nguồn sự thật cho cùng một shape. Giờ chỉ còn re-export.
10
- export type { Permission, PermissionMap };
10
+ // `Session` PHẢI export từ đây: đây kiểu tham số của checkPermission/hasRole,
11
+ // không export thì mọi app phải viết `session as Parameters<typeof
12
+ // checkPermission>[0]` — Session của app ({ user: SessionUser }) vốn gán thẳng
13
+ // được vào `{ user?: unknown }`, không cần cast nào cả.
14
+ export type { Permission, PermissionMap, Session };
11
15
 
12
16
  // ============================================================================
13
17
  // Types
@@ -17,6 +17,7 @@ export const departmentsConfig: EntityConfig = {
17
17
  icon: Building2,
18
18
  description: "Quản lý cơ cấu tổ chức phòng ban",
19
19
  apiEndpoint: "/api/departments",
20
+ modelName: "department",
20
21
  idField: "id",
21
22
  displayField: "name",
22
23
 
@@ -11,6 +11,7 @@ export const materialCategoriesConfig: EntityConfig = {
11
11
  icon: FolderTree,
12
12
  description: "Quản lý danh mục nguyên vật liệu",
13
13
  apiEndpoint: "/api/material-categories",
14
+ modelName: "materialCategory",
14
15
  idField: "id",
15
16
  displayField: "name",
16
17
 
@@ -53,8 +53,9 @@ export function CrudExportButton({
53
53
  search: activeSearch,
54
54
  });
55
55
 
56
- // Always force format to xlsx
57
- const url = `${endpoint}?${queryParams}&format=xlsx`;
56
+ // Always force format to xlsx. Endpoint từ getEntityEndpoints có thể đã
57
+ // mang sẵn query params — nối bằng "&" thay vì "?" thứ hai.
58
+ const url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}${queryParams}&format=xlsx`;
58
59
 
59
60
  const response = await fetch(url);
60
61
  if (!response.ok) {
@@ -32,6 +32,7 @@ import { useRouter, useParams } from "next/navigation";
32
32
  import { CrudBulkActions } from "./crud-bulk-actions";
33
33
  import { CrudDeleteDialog } from "./crud-delete-dialog";
34
34
  import { crudService } from "../lib/crud-service";
35
+ import { getEntityEndpoints } from "../lib/entity-endpoints";
35
36
  import { usePrefetch } from "../../hooks";
36
37
  import type { DictionaryType } from "../../hooks";
37
38
 
@@ -618,31 +619,65 @@ function CrudPageContent({
618
619
  }
619
620
 
620
621
  default: {
621
- // Custom handler: Get original config to access handler function
622
- // NOTE: This logic is deprecated because Core package cannot import App configs directly.
623
- // Custom handlers passed via config functions are lost during serialization.
624
- // If you need custom actions with handlers, please implement them via `customActions` prop
625
- // or a registered event handler mechanism.
622
+ // Action HTTP thuần data (urlTemplate + method) sống sót qua
623
+ // serializeConfig nên khai được ngay trong entity config phía server.
624
+ if (rowAction.urlTemplate) {
625
+ // Fail-closed: khai permissionAction user không quyền thì thôi
626
+ // (menu đã ẩn, đây chốt chặn thứ hai).
627
+ if (
628
+ rowAction.permissionAction &&
629
+ !permissions[rowAction.permissionAction]
630
+ ) {
631
+ return;
632
+ }
633
+ if (
634
+ rowAction.confirmMessage &&
635
+ !window.confirm(rowAction.confirmMessage)
636
+ ) {
637
+ return;
638
+ }
639
+ const url = rowAction.urlTemplate.replace(
640
+ /\{id\}/g,
641
+ encodeURIComponent(rowId),
642
+ );
643
+ const response = await fetch(url, {
644
+ method: rowAction.method ?? "POST",
645
+ });
646
+ if (!response.ok) {
647
+ let message = `${response.status} ${response.statusText}`;
648
+ try {
649
+ const body = await response.json();
650
+ message = body.error || body.message || message;
651
+ } catch {
652
+ // body không phải JSON — giữ status text
653
+ }
654
+ throw new Error(message);
655
+ }
656
+ await fetchData(false);
657
+ toast.success(t("success", "Success"), {
658
+ description:
659
+ rowAction.successMessage ??
660
+ tMessage("updated", { entity: config.label }),
661
+ });
662
+ break;
663
+ }
626
664
 
665
+ // Custom handler qua config function bị serializeConfig strip —
666
+ // action không có urlTemplate thì phải đi qua customActions prop.
627
667
  console.warn(
628
- `Action "${action}" has no built-in handler and custom handlers via config are not supported in this architecture. Please use customActions prop.`,
668
+ `Action "${action}" has no built-in handler and custom handlers via config are not supported in this architecture. Please use customActions prop or declare urlTemplate.`,
629
669
  );
630
-
631
- /* Deprecated logic removed to fix build:
632
- if (entityName) {
633
- try {
634
- const originalConfig = getEntityConfig(entityName)
635
- ...
636
- }
637
- }
638
- */
639
670
  }
640
671
  }
641
672
  } catch (error) {
642
673
  console.error(`Error executing ${action} action:`, error);
643
674
  globalError.emit({
644
675
  title: t("error", "Error"),
645
- description: tMessage("saveFailed", { entity: rowAction.label }),
676
+ // Ưu tiên message từ API (đọc từ body JSON) — fallback câu chung.
677
+ description:
678
+ error instanceof Error && error.message
679
+ ? error.message
680
+ : tMessage("saveFailed", { entity: rowAction.label }),
646
681
  trace: error instanceof Error ? error.stack : undefined,
647
682
  });
648
683
  }
@@ -858,18 +893,14 @@ function CrudPageContent({
858
893
  className="h-6 hidden sm:block"
859
894
  />
860
895
  <div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
896
+ {/* Import/Export dùng CÙNG quy tắc endpoint từ config
897
+ (getEntityEndpoints) — trước đây export hardcode
898
+ /api/crud/... lệch với import → 404. */}
861
899
  {permissions.import && config.features?.import && (
862
900
  <Suspense fallback={null}>
863
901
  <ImportDialog
864
902
  config={config}
865
- apiUrl={(() => {
866
- // Handle endpoint with query params correctly
867
- const baseUrl = config.apiEndpoint.split("?")[0];
868
- const queryParams = config.apiEndpoint.includes("?")
869
- ? config.apiEndpoint.split("?")[1]
870
- : "";
871
- return `${baseUrl}/import${queryParams ? `?${queryParams}` : ""}`;
872
- })()}
903
+ apiUrl={getEntityEndpoints(config).import}
873
904
  canImport={permissions.import}
874
905
  />
875
906
  </Suspense>
@@ -877,7 +908,7 @@ function CrudPageContent({
877
908
  {permissions.export && config.features?.export && (
878
909
  <Suspense fallback={null}>
879
910
  <CrudExportButton
880
- endpoint={`/api/crud/${entityName || config.name}/export`}
911
+ endpoint={getEntityEndpoints(config).export}
881
912
  filters={filters}
882
913
  search={search}
883
914
  canExport={permissions.export}
@@ -4,8 +4,11 @@ import {
4
4
  Archive,
5
5
  Copy,
6
6
  Eye,
7
+ History,
7
8
  MoreHorizontal,
8
9
  Pencil,
10
+ Pin,
11
+ SquareArrowOutUpRight,
9
12
  Trash2,
10
13
  CheckCircle2,
11
14
  XCircle,
@@ -81,8 +84,15 @@ export function CrudRowActions({
81
84
  return <CheckCircle2 className="mr-2 h-4 w-4" aria-hidden="true" />;
82
85
  case "reject":
83
86
  return <XCircle className="mr-2 h-4 w-4" aria-hidden="true" />;
87
+ case "set-default":
88
+ return <Pin className="mr-2 h-4 w-4" aria-hidden="true" />;
89
+ case "view-history":
90
+ return <History className="mr-2 h-4 w-4" aria-hidden="true" />;
84
91
  default:
85
- return null;
92
+ // Action tự khai không có icon riêng — icon fallback trung tính.
93
+ return (
94
+ <SquareArrowOutUpRight className="mr-2 h-4 w-4" aria-hidden="true" />
95
+ );
86
96
  }
87
97
  };
88
98
 
@@ -139,9 +149,11 @@ export function CrudRowActions({
139
149
  }
140
150
  };
141
151
 
142
- // Check if user has permission for this custom action
143
- const hasActionPermission =
144
- action.action === "approve"
152
+ // Gate theo permissionAction khai trong config; không khai thì
153
+ // giữ special-case approve/reject cũ, còn lại luôn hiện.
154
+ const hasActionPermission = action.permissionAction
155
+ ? Boolean(permissions[action.permissionAction])
156
+ : action.action === "approve"
145
157
  ? permissions.approve
146
158
  : action.action === "reject"
147
159
  ? permissions.reject
@@ -0,0 +1,352 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // @vitest-environment node
3
+ // Route handler thuần Request/Response — PHẢI chạy env node: jsdom ghi đè
4
+ // global File làm chính parser multipart của undici (req.formData) assert chết.
5
+ import { describe, it, expect, vi, beforeEach } from "vitest";
6
+ import {
7
+ createCrudCollectionHandlers,
8
+ createCrudExportHandler,
9
+ createCrudImportHandler,
10
+ createCrudItemHandlers,
11
+ } from "./crud-route-handlers";
12
+ import { CrudRequestError } from "./lib/errors";
13
+ import type { ServerCrudService } from "./server-service";
14
+ import type { EntityConfig } from "../types";
15
+
16
+ // Admin session → getCrudPermissionsFromSession trả full quyền, test không
17
+ // phụ thuộc permission map.
18
+ const session = { user: { id: "u1", roles: ["SUPER_ADMIN"] } };
19
+
20
+ const config: EntityConfig = {
21
+ name: "job-title",
22
+ label: "Chức danh",
23
+ pluralLabel: "Chức danh",
24
+ apiEndpoint: "/api/crud/job-titles",
25
+ idField: "id",
26
+ displayField: "name",
27
+ fields: [
28
+ { name: "id", label: "ID", type: "text" },
29
+ { name: "name", label: "Tên", type: "text", filterable: true },
30
+ { name: "level", label: "Cấp", type: "number", filterable: true },
31
+ ],
32
+ };
33
+
34
+ const makeService = (): ServerCrudService => ({
35
+ list: vi.fn(async () => ({ data: [], total: 0, page: 1, pageSize: 10 })),
36
+ getById: vi.fn(async () => ({ id: "1" })),
37
+ create: vi.fn(async (_e, d) => d),
38
+ update: vi.fn(async () => ({})),
39
+ delete: vi.fn(async () => ({})),
40
+ deleteMany: vi.fn(async () => ({})),
41
+ });
42
+
43
+ const ctx = (entity: string, id?: string) => ({
44
+ params: Promise.resolve(id === undefined ? { entity } : { entity, id }),
45
+ });
46
+
47
+ describe("createCrudCollectionHandlers GET", () => {
48
+ let service: ServerCrudService;
49
+ let GET: ReturnType<typeof createCrudCollectionHandlers>["GET"];
50
+
51
+ beforeEach(() => {
52
+ service = makeService();
53
+ ({ GET } = createCrudCollectionHandlers({
54
+ getSession: () => session,
55
+ getEntityConfig: () => config,
56
+ service,
57
+ }));
58
+ });
59
+
60
+ it("filters không phải JSON → 400 (không rơi xuống 500)", async () => {
61
+ const req = new Request(
62
+ "http://t/api/crud/job-titles?filters=%7Bnot-json",
63
+ );
64
+ const res = await GET(req, ctx("job-titles") as any);
65
+ expect(res.status).toBe(400);
66
+ const body = await res.json();
67
+ expect(body.error).toContain("filters");
68
+ expect(service.list).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it("filterTree không phải JSON → 400", async () => {
72
+ const req = new Request(
73
+ "http://t/api/crud/job-titles?filterTree=%5Bbroken",
74
+ );
75
+ const res = await GET(req, ctx("job-titles") as any);
76
+ expect(res.status).toBe(400);
77
+ const body = await res.json();
78
+ expect(body.error).toContain("filterTree");
79
+ });
80
+
81
+ it("giá trị filter được ép kiểu theo field config (number)", async () => {
82
+ const filters = JSON.stringify([
83
+ { name: "level", operator: "eq", value: "5" },
84
+ ]);
85
+ const req = new Request(
86
+ `http://t/api/crud/job-titles?filters=${encodeURIComponent(filters)}`,
87
+ );
88
+ const res = await GET(req, ctx("job-titles") as any);
89
+ expect(res.status).toBe(200);
90
+ const params = (service.list as any).mock.calls[0][2];
91
+ expect(params.filters).toEqual([
92
+ { name: "level", operator: "eq", value: 5 },
93
+ ]);
94
+ });
95
+
96
+ it("filterTree hợp lệ được parse và truyền xuống service.list", async () => {
97
+ const tree = JSON.stringify({
98
+ $and: [{ field: "name", operator: "contains", value: "a" }],
99
+ });
100
+ const req = new Request(
101
+ `http://t/api/crud/job-titles?filterTree=${encodeURIComponent(tree)}`,
102
+ );
103
+ const res = await GET(req, ctx("job-titles") as any);
104
+ expect(res.status).toBe(200);
105
+ const params = (service.list as any).mock.calls[0][2];
106
+ expect(params.filterTree).toEqual({
107
+ $and: [{ field: "name", operator: "contains", value: "a" }],
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("createCrudItemHandlers", () => {
113
+ it("service ném CrudRequestError → map ra status của lỗi (400)", async () => {
114
+ const service = makeService();
115
+ (service.delete as any).mockRejectedValue(
116
+ new CrudRequestError('Id không hợp lệ cho "job-titles": "abc" (idKind=int cần số nguyên)'),
117
+ );
118
+ const { DELETE } = createCrudItemHandlers({
119
+ getSession: () => session,
120
+ getEntityConfig: () => config,
121
+ service,
122
+ });
123
+ const req = new Request("http://t/api/crud/job-titles/abc", {
124
+ method: "DELETE",
125
+ });
126
+ const res = await DELETE(req, ctx("job-titles", "abc") as any);
127
+ expect(res.status).toBe(400);
128
+ const body = await res.json();
129
+ expect(body.error).toContain("Id không hợp lệ");
130
+ });
131
+
132
+ it("DELETE truyền config xuống service (idField/idKind/modelName)", async () => {
133
+ const service = makeService();
134
+ const { DELETE } = createCrudItemHandlers({
135
+ getSession: () => session,
136
+ getEntityConfig: () => config,
137
+ service,
138
+ });
139
+ const req = new Request("http://t/api/crud/job-titles/1", {
140
+ method: "DELETE",
141
+ });
142
+ const res = await DELETE(req, ctx("job-titles", "1") as any);
143
+ expect(res.status).toBe(204);
144
+ expect(service.delete).toHaveBeenCalledWith("job-titles", "1", config);
145
+ });
146
+ });
147
+
148
+ describe("createCrudExportHandler", () => {
149
+ const staffNoExport = {
150
+ user: { id: "u2", permissionMap: { "job-title": ["view"] } },
151
+ };
152
+
153
+ it("thiếu quyền export → 403", async () => {
154
+ const { GET } = createCrudExportHandler({
155
+ getSession: () => staffNoExport,
156
+ getEntityConfig: () => config,
157
+ service: makeService(),
158
+ });
159
+ const req = new Request("http://t/api/crud/job-titles/export");
160
+ const res = await GET(req, ctx("job-titles") as any);
161
+ expect(res.status).toBe(403);
162
+ });
163
+
164
+ it("format lạ → 400", async () => {
165
+ const { GET } = createCrudExportHandler({
166
+ getSession: () => session,
167
+ getEntityConfig: () => config,
168
+ service: makeService(),
169
+ });
170
+ const req = new Request("http://t/api/crud/job-titles/export?format=pdf");
171
+ const res = await GET(req, ctx("job-titles") as any);
172
+ expect(res.status).toBe(400);
173
+ });
174
+
175
+ it("json: kéo đủ các trang qua service.list, tôn trọng filter/sort", async () => {
176
+ const service = makeService();
177
+ // Trang 1 đầy (2 dòng = pageSize), trang 2 vơi → dừng.
178
+ (service.list as any)
179
+ .mockResolvedValueOnce({ data: [{ id: "1" }, { id: "2" }], total: 3, page: 1, pageSize: 2 })
180
+ .mockResolvedValueOnce({ data: [{ id: "3" }], total: 3, page: 2, pageSize: 2 });
181
+ const { GET } = createCrudExportHandler({
182
+ getSession: () => session,
183
+ getEntityConfig: () => config,
184
+ service,
185
+ pageSize: 2,
186
+ });
187
+ const filters = encodeURIComponent(
188
+ JSON.stringify([{ name: "level", operator: "eq", value: "3" }]),
189
+ );
190
+ const req = new Request(
191
+ `http://t/api/crud/job-titles/export?format=json&sortField=name&sortDirection=desc&filters=${filters}`,
192
+ );
193
+ const res = await GET(req, ctx("job-titles") as any);
194
+ expect(res.status).toBe(200);
195
+ expect(res.headers.get("content-disposition")).toContain(".json");
196
+ const rows = JSON.parse(await res.text());
197
+ expect(rows.map((r: any) => r.id)).toEqual(["1", "2", "3"]);
198
+ // Filter chuỗi "3" đã ép number theo field type trước khi vào engine.
199
+ expect((service.list as any).mock.calls[0][2].filters[0].value).toBe(3);
200
+ expect((service.list as any).mock.calls[0][2].sort).toEqual({
201
+ field: "name",
202
+ direction: "desc",
203
+ });
204
+ });
205
+
206
+ it("csv: có BOM cho Excel + đúng content-type", async () => {
207
+ const service = makeService();
208
+ (service.list as any).mockResolvedValueOnce({
209
+ data: [{ name: "Kỹ sư" }],
210
+ total: 1, page: 1, pageSize: 500,
211
+ });
212
+ const { GET } = createCrudExportHandler({
213
+ getSession: () => session,
214
+ getEntityConfig: () => config,
215
+ service,
216
+ });
217
+ const req = new Request("http://t/api/crud/job-titles/export?format=csv");
218
+ const res = await GET(req, ctx("job-titles") as any);
219
+ // Response.text() strip BOM khi decode — soi bytes thô để chắc BOM đã gửi.
220
+ const bytes = new Uint8Array(await res.arrayBuffer());
221
+ expect([bytes[0], bytes[1], bytes[2]]).toEqual([0xef, 0xbb, 0xbf]);
222
+ const text = new TextDecoder().decode(bytes);
223
+ expect(text).toContain("Kỹ sư");
224
+ expect(res.headers.get("content-type")).toContain("text/csv");
225
+ });
226
+
227
+ it("maxRows cắt bớt dòng thừa", async () => {
228
+ const service = makeService();
229
+ (service.list as any).mockResolvedValue({
230
+ data: [{ id: "a" }, { id: "b" }],
231
+ total: 100, page: 1, pageSize: 2,
232
+ });
233
+ const { GET } = createCrudExportHandler({
234
+ getSession: () => session,
235
+ getEntityConfig: () => config,
236
+ service,
237
+ pageSize: 2,
238
+ maxRows: 3,
239
+ });
240
+ const req = new Request("http://t/api/crud/job-titles/export?format=json");
241
+ const res = await GET(req, ctx("job-titles") as any);
242
+ const rows = JSON.parse(await res.text());
243
+ expect(rows).toHaveLength(3);
244
+ });
245
+ });
246
+
247
+ describe("createCrudImportHandler", () => {
248
+ it("thiếu quyền import → 403", async () => {
249
+ const { POST } = createCrudImportHandler({
250
+ getSession: () => ({ user: { id: "u2", permissionMap: { "job-title": ["view"] } } }),
251
+ getEntityConfig: () => config,
252
+ service: makeService(),
253
+ });
254
+ const req = new Request("http://t/api/crud/job-titles/import", {
255
+ method: "POST",
256
+ headers: { "content-type": "application/json" },
257
+ body: "[]",
258
+ });
259
+ const res = await POST(req, ctx("job-titles") as any);
260
+ expect(res.status).toBe(403);
261
+ });
262
+
263
+ it("JSON array: mỗi dòng qua service.create, header label dịch về tên cột", async () => {
264
+ const service = makeService();
265
+ const { POST } = createCrudImportHandler({
266
+ getSession: () => session,
267
+ getEntityConfig: () => config,
268
+ service,
269
+ });
270
+ const req = new Request("http://t/api/crud/job-titles/import", {
271
+ method: "POST",
272
+ headers: { "content-type": "application/json" },
273
+ body: JSON.stringify([{ "Tên": "Kỹ sư", "Cấp": "2" }]),
274
+ });
275
+ const res = await POST(req, ctx("job-titles") as any);
276
+ expect(res.status).toBe(200);
277
+ const body = await res.json();
278
+ expect(body).toMatchObject({ success: true, imported: 1, failed: 0 });
279
+ expect((service.create as any).mock.calls[0][1]).toEqual({
280
+ name: "Kỹ sư",
281
+ level: "2",
282
+ });
283
+ });
284
+
285
+ it("dòng lỗi không chặn dòng sau — gom vào errors", async () => {
286
+ const service = makeService();
287
+ (service.create as any)
288
+ .mockRejectedValueOnce(new Error("Tên đã tồn tại"))
289
+ .mockResolvedValueOnce({});
290
+ const { POST } = createCrudImportHandler({
291
+ getSession: () => session,
292
+ getEntityConfig: () => config,
293
+ service,
294
+ });
295
+ const req = new Request("http://t/api/crud/job-titles/import", {
296
+ method: "POST",
297
+ headers: { "content-type": "application/json" },
298
+ body: JSON.stringify([{ name: "A" }, { name: "B" }]),
299
+ });
300
+ const res = await POST(req, ctx("job-titles") as any);
301
+ const body = await res.json();
302
+ expect(body.success).toBe(false);
303
+ expect(body.imported).toBe(1);
304
+ expect(body.failed).toBe(1);
305
+ expect(body.errors[0]).toMatchObject({ row: 1, message: "Tên đã tồn tại" });
306
+ });
307
+
308
+ it("multipart {file, format} từ ImportDialog: parse tại server", async () => {
309
+ const service = makeService();
310
+ const { POST } = createCrudImportHandler({
311
+ getSession: () => session,
312
+ getEntityConfig: () => config,
313
+ service,
314
+ });
315
+ // Dựng multipart thô — File/FormData của môi trường test (jsdom) khác
316
+ // realm với undici Request, đưa thẳng vào body sẽ hỏng serialize.
317
+ const boundary = "----vitest-boundary";
318
+ const body =
319
+ `--${boundary}\r\n` +
320
+ `Content-Disposition: form-data; name="file"; filename="rows.json"\r\n` +
321
+ `Content-Type: application/json\r\n\r\n` +
322
+ JSON.stringify([{ name: "Từ file" }]) +
323
+ `\r\n--${boundary}\r\n` +
324
+ `Content-Disposition: form-data; name="format"\r\n\r\n` +
325
+ `json\r\n--${boundary}--\r\n`;
326
+ const req = new Request("http://t/api/crud/job-titles/import", {
327
+ method: "POST",
328
+ headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
329
+ body,
330
+ });
331
+ const res = await POST(req, ctx("job-titles") as any);
332
+ expect(res.status).toBe(200);
333
+ const result = await res.json();
334
+ expect(result.imported).toBe(1);
335
+ expect((service.create as any).mock.calls[0][1]).toEqual({ name: "Từ file" });
336
+ });
337
+
338
+ it("body không phải array → 400", async () => {
339
+ const { POST } = createCrudImportHandler({
340
+ getSession: () => session,
341
+ getEntityConfig: () => config,
342
+ service: makeService(),
343
+ });
344
+ const req = new Request("http://t/api/crud/job-titles/import", {
345
+ method: "POST",
346
+ headers: { "content-type": "application/json" },
347
+ body: JSON.stringify({ not: "array" }),
348
+ });
349
+ const res = await POST(req, ctx("job-titles") as any);
350
+ expect(res.status).toBe(400);
351
+ });
352
+ });