@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.
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/types/index.ts +75 -10
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +20 -9
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
- package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
- package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
- package/templates/starter-app/src/app/api/users/route.ts +11 -9
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +1 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
- package/templates/starter-app/src/data/dictionary.ts +2 -1
- package/templates/starter-app/src/data/navigations.ts +12 -2
- package/templates/starter-app/src/lib/action-guard.ts +3 -4
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -12
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +13 -5
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
- package/templates/starter-app/src/server/services/company-service.ts +4 -2
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +2 -2
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +8 -0
- 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.
|
|
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
|
|
10
|
+
// `Session` PHẢI export từ đây: đây là 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
|
|
@@ -53,8 +53,9 @@ export function CrudExportButton({
|
|
|
53
53
|
search: activeSearch,
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
// Always force format to xlsx
|
|
57
|
-
|
|
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
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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 mà user không có quyền thì thôi
|
|
626
|
+
// (menu đã ẩn, đây là 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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
});
|