@goplusvn/core 0.1.70 → 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/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/cron/__tests__/cron-schedule.test.ts +76 -0
- package/src/cron/cron-schedule.ts +128 -0
- package/src/cron/simple-cron-job.ts +83 -69
- 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/components/crud-table.tsx +13 -1
- 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/lib/translate-config.ts +16 -2
- 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/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/guardrails/types.ts +17 -8
- package/src/providers/brand-theme.ts +20 -0
- package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
- package/src/security/index.ts +2 -0
- package/src/security/pages/sessions-page.tsx +516 -0
- package/src/types/index.ts +75 -10
- package/src/ui/auth/sign-in-form.tsx +64 -9
- 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/src/ui/errors/error-view.tsx +164 -0
- package/src/ui/errors/index.ts +2 -0
- package/src/ui/errors/not-found-view.tsx +44 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/logo.tsx +54 -19
- package/src/user/pages/users-client-page.tsx +12 -7
- package/templates/starter-app/.env.example +4 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/README.md +6 -2
- package/templates/starter-app/husky/pre-commit +10 -0
- package/templates/starter-app/package.json +27 -2
- 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/prisma/schema/organization.prisma +20 -0
- package/templates/starter-app/prisma/schema/system.prisma +74 -0
- package/templates/starter-app/prisma/seed.ts +7 -5
- package/templates/starter-app/scripts/migration-new.mjs +89 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- 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)/security/sessions/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
- package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
- package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- 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/branches/route.ts +26 -0
- 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/departments/route.ts +26 -0
- 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 +46 -0
- 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 +52 -0
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
- package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
- package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
- package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
- 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 +230 -0
- package/templates/starter-app/src/app/api/users/route.ts +141 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/icon.svg +6 -0
- package/templates/starter-app/src/app/manifest.ts +26 -0
- 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 +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/index.ts +37 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
- package/templates/starter-app/src/configs/tenant.ts +2 -1
- package/templates/starter-app/src/data/dictionary.ts +77 -4
- package/templates/starter-app/src/data/navigations.ts +60 -1
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +25 -0
- 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/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -10
- 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 +16 -7
- 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/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +49 -0
- 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 +104 -0
- 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 +10 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { EntityConfig, FieldConfig, ImportOptions } from "../../types";
|
|
2
|
+
|
|
3
|
+
import { parseFile } from "./import-export-service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Đọc body của một route import ở CẢ HAI dạng client đang tồn tại:
|
|
7
|
+
*
|
|
8
|
+
* - multipart FormData `{file, format}` — ImportDialog của core gửi lên
|
|
9
|
+
* (file xlsx/csv/json parse ngay tại server bằng parseFile);
|
|
10
|
+
* - JSON array — đường cũ: client tự parse Excel rồi POST thẳng mảng dòng.
|
|
11
|
+
*
|
|
12
|
+
* Sau khi parse, header mỗi dòng được dịch về TÊN CỘT DB, nhận cả 3 kiểu:
|
|
13
|
+
* header legacy khai tường minh (`legacyHeaders`, vd tiếng Việt của file mẫu
|
|
14
|
+
* hệ cũ), label khai trong EntityConfig (file mẫu do ImportDialog sinh dùng
|
|
15
|
+
* label) và tên cột máy. Cột không khớp mapping nào bị bỏ qua.
|
|
16
|
+
*
|
|
17
|
+
* Trả `null` khi body không đọc được thành mảng dòng (thiếu file, JSON không
|
|
18
|
+
* phải array…) — caller trả 400.
|
|
19
|
+
*/
|
|
20
|
+
export async function readImportRows(
|
|
21
|
+
req: Request,
|
|
22
|
+
config: EntityConfig,
|
|
23
|
+
legacyHeaders: Record<string, string> = {},
|
|
24
|
+
): Promise<Record<string, unknown>[] | null> {
|
|
25
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
26
|
+
let rawRows: unknown;
|
|
27
|
+
|
|
28
|
+
if (contentType.includes("multipart/form-data")) {
|
|
29
|
+
const form = await req.formData();
|
|
30
|
+
// Duck-type thay vì `instanceof File` — File có thể đến từ realm khác
|
|
31
|
+
// (undici vs jsdom/global) và instanceof xuyên realm luôn false.
|
|
32
|
+
const file = form.get("file") as File | string | null;
|
|
33
|
+
if (file === null || typeof file === "string") return null;
|
|
34
|
+
const formatField = form.get("format");
|
|
35
|
+
const format =
|
|
36
|
+
formatField === "csv" || formatField === "json" || formatField === "xlsx"
|
|
37
|
+
? formatField
|
|
38
|
+
: inferFormat(file.name);
|
|
39
|
+
rawRows = await parseFile(file, format);
|
|
40
|
+
} else {
|
|
41
|
+
try {
|
|
42
|
+
rawRows = await req.json();
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!Array.isArray(rawRows)) return null;
|
|
49
|
+
|
|
50
|
+
// Header legacy khai tường minh thắng trước; label/tên cột từ config bù sau.
|
|
51
|
+
const headerToColumn: Record<string, string> = { ...legacyHeaders };
|
|
52
|
+
for (const field of config.fields) {
|
|
53
|
+
headerToColumn[field.name] ??= field.name;
|
|
54
|
+
headerToColumn[field.label] ??= field.name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return rawRows.map((row) => {
|
|
58
|
+
const out: Record<string, unknown> = {};
|
|
59
|
+
for (const [key, value] of Object.entries(row as Record<string, unknown>)) {
|
|
60
|
+
const column = headerToColumn[key.trim()];
|
|
61
|
+
if (column) out[column] = value;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function inferFormat(fileName: string): ImportOptions["format"] {
|
|
68
|
+
const name = fileName.toLowerCase();
|
|
69
|
+
if (name.endsWith(".csv")) return "csv";
|
|
70
|
+
if (name.endsWith(".json")) return "json";
|
|
71
|
+
return "xlsx";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ép ô boolean từ file import: nhận boolean thật (đường JSON cũ), chuỗi
|
|
76
|
+
* true/false/1/0/x…, và cả LABEL của option boolean trong config ("Đang làm
|
|
77
|
+
* việc", "Ngừng bán"…). Không nhận diện được → undefined (để zod default lo).
|
|
78
|
+
*/
|
|
79
|
+
export function coerceBooleanCell(
|
|
80
|
+
value: unknown,
|
|
81
|
+
field?: FieldConfig,
|
|
82
|
+
): boolean | undefined {
|
|
83
|
+
if (typeof value === "boolean") return value;
|
|
84
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
85
|
+
const v = String(value).trim().toLowerCase();
|
|
86
|
+
if (["true", "1", "yes", "y", "x", "có", "co"].includes(v)) return true;
|
|
87
|
+
if (["false", "0", "no", "n", "không", "khong"].includes(v)) return false;
|
|
88
|
+
for (const opt of field?.options ?? []) {
|
|
89
|
+
if (
|
|
90
|
+
typeof opt === "object" &&
|
|
91
|
+
opt !== null &&
|
|
92
|
+
typeof opt.value === "boolean" &&
|
|
93
|
+
String(opt.label).trim().toLowerCase() === v
|
|
94
|
+
) {
|
|
95
|
+
return opt.value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { prepareMutationData } from "./mutation-builder";
|
|
3
|
+
import type { EntityConfig } from "../../types";
|
|
4
|
+
|
|
5
|
+
const logger = { warn: vi.fn(), error: vi.fn() };
|
|
6
|
+
const SYSTEM_FIELDS = ["id", "createdAt", "updatedAt", "createdBy", "updatedBy"];
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
|
|
9
|
+
const makeConfig = (over: Partial<EntityConfig> = {}): EntityConfig => ({
|
|
10
|
+
name: "job-title",
|
|
11
|
+
label: "Chức danh",
|
|
12
|
+
pluralLabel: "Chức danh",
|
|
13
|
+
apiEndpoint: "/api/crud/job-titles",
|
|
14
|
+
idField: "id",
|
|
15
|
+
displayField: "name",
|
|
16
|
+
fields: [
|
|
17
|
+
{ name: "id", label: "ID", type: "text" },
|
|
18
|
+
{ name: "name", label: "Tên", type: "text" },
|
|
19
|
+
{ name: "level", label: "Cấp", type: "number" },
|
|
20
|
+
{ name: "active", label: "Kích hoạt", type: "boolean" },
|
|
21
|
+
{ name: "departmentId", label: "Phòng ban", type: "select" },
|
|
22
|
+
],
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const prepare = (
|
|
27
|
+
data: Record<string, unknown>,
|
|
28
|
+
mode: "create" | "update",
|
|
29
|
+
config?: EntityConfig,
|
|
30
|
+
) => prepareMutationData({ data, config, mode, systemFields: SYSTEM_FIELDS, logger });
|
|
31
|
+
|
|
32
|
+
describe("prepareMutationData", () => {
|
|
33
|
+
describe("id theo idKind", () => {
|
|
34
|
+
it('idKind mặc định ("string"): create thiếu id thì sinh UUID', () => {
|
|
35
|
+
const out = prepare({ name: "A" }, "create", makeConfig());
|
|
36
|
+
expect(out.id).toMatch(UUID_RE);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("không có config: giữ hành vi cũ, vẫn sinh UUID", () => {
|
|
40
|
+
const out = prepare({ name: "A" }, "create");
|
|
41
|
+
expect(out.id).toMatch(UUID_RE);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("idKind int: KHÔNG BAO GIỜ sinh id (DB autoincrement)", () => {
|
|
45
|
+
const out = prepare({ name: "A" }, "create", makeConfig({ idKind: "int" }));
|
|
46
|
+
expect("id" in out).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('idKind int: id rỗng "" từ form bị bỏ hẳn key', () => {
|
|
50
|
+
const out = prepare({ id: "", name: "A" }, "create", makeConfig({ idKind: "int" }));
|
|
51
|
+
expect("id" in out).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("idKind int: id chuỗi số được ép Number", () => {
|
|
55
|
+
const out = prepare({ id: "12", name: "A" }, "create", makeConfig({ idKind: "int" }));
|
|
56
|
+
expect(out.id).toBe(12);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("idField tùy biến: sinh UUID vào đúng cột id của entity", () => {
|
|
60
|
+
const config = makeConfig({
|
|
61
|
+
idField: "code",
|
|
62
|
+
fields: [
|
|
63
|
+
{ name: "code", label: "Mã", type: "text" },
|
|
64
|
+
{ name: "name", label: "Tên", type: "text" },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
const out = prepare({ name: "A" }, "create", config);
|
|
68
|
+
expect(out.code).toMatch(UUID_RE);
|
|
69
|
+
expect("id" in out).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("coercion theo config.fields", () => {
|
|
74
|
+
it("ép number/boolean từ chuỗi form", () => {
|
|
75
|
+
const out = prepare(
|
|
76
|
+
{ name: "A", level: "5", active: "true" },
|
|
77
|
+
"create",
|
|
78
|
+
makeConfig(),
|
|
79
|
+
);
|
|
80
|
+
expect(out.level).toBe(5);
|
|
81
|
+
expect(out.active).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lọc field ngoài config kèm cảnh báo", () => {
|
|
85
|
+
const out = prepare({ name: "A", hacker: "x" }, "create", makeConfig());
|
|
86
|
+
expect("hacker" in out).toBe(false);
|
|
87
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("empty-string FK", () => {
|
|
92
|
+
it('create: departmentId "" bị bỏ, không connect', () => {
|
|
93
|
+
const out = prepare({ name: "A", departmentId: "" }, "create", makeConfig());
|
|
94
|
+
expect("departmentId" in out).toBe(false);
|
|
95
|
+
expect("department" in out).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('update: departmentId "" → disconnect relation', () => {
|
|
99
|
+
const out = prepare({ name: "A", departmentId: "" }, "update", makeConfig());
|
|
100
|
+
expect("departmentId" in out).toBe(false);
|
|
101
|
+
expect(out.department).toEqual({ disconnect: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("departmentId có giá trị → connect", () => {
|
|
105
|
+
const out = prepare(
|
|
106
|
+
{ name: "A", departmentId: "dep-1" },
|
|
107
|
+
"create",
|
|
108
|
+
makeConfig(),
|
|
109
|
+
);
|
|
110
|
+
expect(out.department).toEqual({ connect: { id: "dep-1" } });
|
|
111
|
+
expect("departmentId" in out).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1
2
|
import type { EntityConfig } from "../../types";
|
|
2
3
|
import type { ServerCrudLogger } from "../server-service";
|
|
4
|
+
import { coerceFieldValue } from "./coerce";
|
|
3
5
|
|
|
4
6
|
export interface PrepareMutationDataOptions {
|
|
5
7
|
data: any;
|
|
@@ -14,12 +16,16 @@ export interface PrepareMutationDataOptions {
|
|
|
14
16
|
export function prepareMutationData(options: PrepareMutationDataOptions) {
|
|
15
17
|
const { data, config, mode, systemFields, relationFieldSkip, logger, touchUpdatedAtOnCreate } = options;
|
|
16
18
|
|
|
19
|
+
const idField = config?.idField ?? "id";
|
|
20
|
+
const idKind = config?.idKind ?? "string";
|
|
21
|
+
|
|
17
22
|
let filtered = data;
|
|
18
|
-
|
|
23
|
+
|
|
19
24
|
if (config) {
|
|
20
25
|
// 1. Filter valid fields
|
|
21
26
|
const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
|
|
22
27
|
for (const f of systemFields) valid.add(f);
|
|
28
|
+
valid.add(idField);
|
|
23
29
|
filtered = {};
|
|
24
30
|
for (const [k, v] of Object.entries(data)) {
|
|
25
31
|
if (valid.has(k)) {
|
|
@@ -29,42 +35,28 @@ export function prepareMutationData(options: PrepareMutationDataOptions) {
|
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
// 2.
|
|
38
|
+
// 2. Ép kiểu tập trung theo config.fields (number/boolean/switch/date,
|
|
39
|
+
// "" cho FK → undefined) — xem coerceFieldValue.
|
|
33
40
|
for (const field of config.fields) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (field.type === "boolean" || field.type === "switch") {
|
|
38
|
-
let isTrue: boolean;
|
|
39
|
-
if (typeof value === "string") {
|
|
40
|
-
const lv = value.toLowerCase();
|
|
41
|
-
isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
|
|
42
|
-
} else {
|
|
43
|
-
isTrue = Boolean(value);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (field.type === "switch" && field.options && field.options.length >= 2) {
|
|
47
|
-
filtered[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
|
|
48
|
-
} else {
|
|
49
|
-
filtered[field.name] = isTrue;
|
|
50
|
-
}
|
|
51
|
-
} else if (field.type === "number" || (field.type as string) === "integer") {
|
|
52
|
-
if (typeof value === "string") {
|
|
53
|
-
if (value.trim() === "") {
|
|
54
|
-
filtered[field.name] = null;
|
|
55
|
-
} else {
|
|
56
|
-
const num = Number(value);
|
|
57
|
-
if (!isNaN(num)) filtered[field.name] = num;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
41
|
+
if (!(field.name in filtered)) continue;
|
|
42
|
+
filtered[field.name] = coerceFieldValue(field, filtered[field.name]);
|
|
61
43
|
}
|
|
62
44
|
}
|
|
63
45
|
|
|
64
46
|
// 3. System defaults for create
|
|
65
47
|
if (mode === "create") {
|
|
66
|
-
if (
|
|
67
|
-
|
|
48
|
+
if (idKind === "int") {
|
|
49
|
+
// DB autoincrement — KHÔNG BAO GIỜ tự sinh id; id rỗng từ form thì bỏ
|
|
50
|
+
// hẳn key, id chuỗi số thì ép Number cho Prisma Int.
|
|
51
|
+
const raw = filtered[idField];
|
|
52
|
+
if (raw === undefined || raw === null || raw === "") {
|
|
53
|
+
delete filtered[idField];
|
|
54
|
+
} else if (typeof raw === "string") {
|
|
55
|
+
const num = Number(raw);
|
|
56
|
+
if (Number.isInteger(num)) filtered[idField] = num;
|
|
57
|
+
}
|
|
58
|
+
} else if (!filtered[idField]) {
|
|
59
|
+
filtered[idField] = crypto.randomUUID();
|
|
68
60
|
}
|
|
69
61
|
if (touchUpdatedAtOnCreate && !filtered.updatedAt) {
|
|
70
62
|
filtered.updatedAt = new Date();
|
|
@@ -74,21 +66,22 @@ export function prepareMutationData(options: PrepareMutationDataOptions) {
|
|
|
74
66
|
// 4. Transform relation fields (*Id -> connect/disconnect)
|
|
75
67
|
const skip = new Set([
|
|
76
68
|
"id",
|
|
69
|
+
idField,
|
|
77
70
|
"createdBy",
|
|
78
71
|
"updatedBy",
|
|
79
72
|
"citizenId",
|
|
80
73
|
"targetId",
|
|
81
74
|
...(relationFieldSkip ?? []),
|
|
82
75
|
]);
|
|
83
|
-
|
|
76
|
+
|
|
84
77
|
const prismaData = { ...filtered };
|
|
85
78
|
for (const key of Object.keys(prismaData)) {
|
|
86
79
|
if (skip.has(key)) continue;
|
|
87
|
-
|
|
80
|
+
|
|
88
81
|
if (key.endsWith("Id") && key.length > 2) {
|
|
89
82
|
const rel = key.slice(0, -2);
|
|
90
83
|
const value = prismaData[key];
|
|
91
|
-
|
|
84
|
+
|
|
92
85
|
if (value && typeof value === "string" && value.trim() !== "") {
|
|
93
86
|
prismaData[rel] = { connect: { id: value } };
|
|
94
87
|
delete prismaData[key];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getCrudPermissions, mergePermissions } from "./permissions";
|
|
3
|
+
import type { Session } from "../../types";
|
|
4
|
+
|
|
5
|
+
// Session non-admin với permissionMap gọn (resource → action codes) — đúng
|
|
6
|
+
// dạng session callback nạp vào JWT.
|
|
7
|
+
const staff = {
|
|
8
|
+
user: {
|
|
9
|
+
id: "u1",
|
|
10
|
+
permissionMap: { employee: ["view", "update", "reset-password"] },
|
|
11
|
+
},
|
|
12
|
+
} as unknown as Session;
|
|
13
|
+
|
|
14
|
+
const admin = {
|
|
15
|
+
user: { id: "a1", roles: ["SUPER_ADMIN"] },
|
|
16
|
+
} as unknown as Session;
|
|
17
|
+
|
|
18
|
+
describe("getCrudPermissions + extraActions (custom action từ registry)", () => {
|
|
19
|
+
it("điền custom action theo checkPermission, thiếu quyền = false", async () => {
|
|
20
|
+
const perms = await getCrudPermissions(staff, "employee", [
|
|
21
|
+
"reset-password",
|
|
22
|
+
"resync",
|
|
23
|
+
]);
|
|
24
|
+
expect(perms.read).toBe(true);
|
|
25
|
+
expect(perms.update).toBe(true);
|
|
26
|
+
expect(perms.create).toBe(false);
|
|
27
|
+
expect(perms["reset-password"]).toBe(true);
|
|
28
|
+
expect(perms["resync"]).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("admin có mọi custom action", async () => {
|
|
32
|
+
const perms = await getCrudPermissions(admin, "employee", ["resync"]);
|
|
33
|
+
expect(perms["resync"]).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("extraActions trùng 8 action chuẩn không ghi đè kết quả chuẩn", async () => {
|
|
37
|
+
const perms = await getCrudPermissions(staff, "employee", ["update"]);
|
|
38
|
+
expect(perms.update).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("mergePermissions giữ custom action", () => {
|
|
43
|
+
it("không khai config permissions → trả nguyên map user (kể cả custom)", () => {
|
|
44
|
+
const user = { read: true, "reset-password": true };
|
|
45
|
+
expect(mergePermissions(undefined, user)).toEqual(user);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("config permissions chỉ khai 1 key → custom action KHÔNG bị rơi mất", () => {
|
|
49
|
+
const merged = mergePermissions(
|
|
50
|
+
{ create: false },
|
|
51
|
+
{ create: true, read: true, "reset-password": true },
|
|
52
|
+
);
|
|
53
|
+
expect(merged.create).toBe(false);
|
|
54
|
+
expect(merged.read).toBe(true);
|
|
55
|
+
expect(merged["reset-password"]).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -4,20 +4,29 @@ import type { Session } from "../../types";
|
|
|
4
4
|
// Nguồn permission DUY NHẤT đã harden (BYPASS_AUTH chỉ hiệu lực ngoài
|
|
5
5
|
// production, admin-role check chạy trước empty-permissions guard) — bản
|
|
6
6
|
// divergent rbac/permissions.ts cũ đã xóa 2026-07 vì thiếu guard NODE_ENV.
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
checkPermission,
|
|
9
|
+
getActionCode,
|
|
10
|
+
getCrudPermissionsFromSession,
|
|
11
|
+
} from "../../auth";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Get CRUD permissions for a user based on their session
|
|
11
15
|
* Permissions are read from session (loaded from cache in session callback)
|
|
12
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* `extraActions`: custom action ngoài 8 action CRUD chuẩn (khai trong registry,
|
|
18
|
+
* vd "reset-password", "resync") — mỗi cái tra checkPermission và điền vào map
|
|
19
|
+
* trả về, để RowAction.permissionAction gate được theo ĐÚNG action code thay vì
|
|
20
|
+
* mượn tạm "update". BYPASS_AUTH/admin đã xử lý bên trong checkPermission.
|
|
13
21
|
*/
|
|
14
22
|
export async function getCrudPermissions(
|
|
15
23
|
session: Session | null,
|
|
16
24
|
entity: string,
|
|
25
|
+
extraActions?: string[],
|
|
17
26
|
): Promise<CrudPermissions> {
|
|
18
27
|
// Get permissions from session (already loaded in JWT)
|
|
19
28
|
const perms = getCrudPermissionsFromSession(session, entity);
|
|
20
|
-
|
|
29
|
+
const result: CrudPermissions = {
|
|
21
30
|
create: perms.create,
|
|
22
31
|
read: perms.view,
|
|
23
32
|
update: perms.update,
|
|
@@ -27,12 +36,20 @@ export async function getCrudPermissions(
|
|
|
27
36
|
approve: perms.approve,
|
|
28
37
|
reject: perms.reject,
|
|
29
38
|
};
|
|
39
|
+
for (const action of extraActions ?? []) {
|
|
40
|
+
if (result[action] !== undefined) continue; // 8 action chuẩn đã có
|
|
41
|
+
result[action] = checkPermission(session, entity, getActionCode(action));
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/**
|
|
33
47
|
* Merge config permissions with user permissions
|
|
34
48
|
* User permissions take precedence
|
|
35
49
|
* Config permissions are optional, so undefined means "allow if user has permission"
|
|
50
|
+
*
|
|
51
|
+
* Merge theo TOÀN BỘ key của userPermissions (kể cả custom action) — bản cũ
|
|
52
|
+
* liệt kê cứng 8 key nên custom action bị rơi mất sau merge.
|
|
36
53
|
*/
|
|
37
54
|
export function mergePermissions(
|
|
38
55
|
configPermissions: EntityConfig["permissions"],
|
|
@@ -42,14 +59,9 @@ export function mergePermissions(
|
|
|
42
59
|
return userPermissions;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
export: (configPermissions.export ?? true) && userPermissions.export,
|
|
51
|
-
import: (configPermissions.import ?? true) && userPermissions.import,
|
|
52
|
-
approve: (configPermissions.approve ?? true) && userPermissions.approve,
|
|
53
|
-
reject: (configPermissions.reject ?? true) && userPermissions.reject,
|
|
54
|
-
};
|
|
62
|
+
const result: CrudPermissions = {};
|
|
63
|
+
for (const key of Object.keys(userPermissions)) {
|
|
64
|
+
result[key] = (configPermissions[key] ?? true) && userPermissions[key];
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
55
67
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compileFilterTree } from "./filter-tree";
|
|
2
|
+
import { CrudRequestError } from "./errors";
|
|
2
3
|
import type { CrudQueryParams, EntityConfig } from "../../types";
|
|
3
4
|
|
|
4
5
|
export interface BuildListQueryOptions {
|
|
@@ -76,12 +77,18 @@ export function buildListQuery({
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
if (filterTree) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
let compiled;
|
|
81
|
+
try {
|
|
82
|
+
compiled = compileFilterTree(filterTree, {
|
|
83
|
+
isAllowed,
|
|
84
|
+
onDisallowed: (field) => {
|
|
85
|
+
if (onDisallowedFilter) onDisallowedFilter(field);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// Cây sai cấu trúc/vượt giới hạn là lỗi CLIENT — route trả 400, không 500.
|
|
90
|
+
throw new CrudRequestError(e instanceof Error ? e.message : "filterTree không hợp lệ");
|
|
91
|
+
}
|
|
85
92
|
if (compiled) {
|
|
86
93
|
where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
|
|
87
94
|
}
|
|
@@ -29,6 +29,20 @@ function translate(
|
|
|
29
29
|
return typeof value === "string" ? value : fallback;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Chốt chặn cuối khi app THIẾU bản dịch: biến key kỹ thuật thành chữ đọc được
|
|
34
|
+
* ("crud.common.options.active" → "Active") thay vì lộ nguyên chuỗi key ra UI
|
|
35
|
+
* — chip trạng thái của app mới init từng hiện "crud.common.options.active".
|
|
36
|
+
*/
|
|
37
|
+
export function humanizeDictKey(key: string): string {
|
|
38
|
+
const last = key.split(".").pop() ?? key;
|
|
39
|
+
const words = last
|
|
40
|
+
.replace(/[-_]/g, " ")
|
|
41
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
/**
|
|
33
47
|
* Translate EntityConfig với dictionary
|
|
34
48
|
* Chạy ở server-side trước khi pass xuống client
|
|
@@ -145,7 +159,7 @@ export function translateEntityConfig(
|
|
|
145
159
|
`crud.entities.${dictKey}.fields.${field.name}.options.${valueStr}`,
|
|
146
160
|
dictionary,
|
|
147
161
|
optLabel.startsWith("crud.")
|
|
148
|
-
? translate(optLabel, dictionary, optLabel)
|
|
162
|
+
? translate(optLabel, dictionary, humanizeDictKey(optLabel))
|
|
149
163
|
: optLabel,
|
|
150
164
|
),
|
|
151
165
|
};
|
|
@@ -319,7 +333,7 @@ export function translateCrudMessage(
|
|
|
319
333
|
| undefined;
|
|
320
334
|
const commonDict = (crudDict?.common as Record<string, unknown>) || {};
|
|
321
335
|
const messagesDict = (commonDict.messages as Record<string, string>) || {};
|
|
322
|
-
let message = messagesDict[key] || key;
|
|
336
|
+
let message = messagesDict[key] || humanizeDictKey(key);
|
|
323
337
|
|
|
324
338
|
// Replace parameters
|
|
325
339
|
if (params) {
|
|
@@ -110,15 +110,18 @@ export async function EntityCrudPage({
|
|
|
110
110
|
dictionary,
|
|
111
111
|
);
|
|
112
112
|
|
|
113
|
-
// Get user permissions for this entity if available in session or passed explicitly?
|
|
114
|
-
// In vinhhoa/page.tsx: getCrudPermissions(session, entity) is called.
|
|
115
|
-
// We should probably rely on `session` having what we need OR generic permissions logic.
|
|
116
|
-
// The `mergePermissions` below merges generic config perms with USER perms.
|
|
117
|
-
// We need user perms.
|
|
118
|
-
|
|
119
|
-
// We can import getCrudPermissions from ../lib/permissions if it effectively uses session
|
|
120
113
|
const { getCrudPermissions } = await import("../lib/permissions");
|
|
121
|
-
|
|
114
|
+
// Custom action trong rowActions (permissionAction ngoài 8 action CRUD
|
|
115
|
+
// chuẩn) cũng phải vào map quyền — không gom thì nút bị fail-closed ẩn
|
|
116
|
+
// vĩnh viễn dù user CÓ quyền trong registry.
|
|
117
|
+
const extraActions = (config.rowActions ?? [])
|
|
118
|
+
.map((a) => a.permissionAction)
|
|
119
|
+
.filter((a): a is string => Boolean(a));
|
|
120
|
+
const userPermissions = await getCrudPermissions(
|
|
121
|
+
session,
|
|
122
|
+
config.permissionResource ?? entity,
|
|
123
|
+
extraActions,
|
|
124
|
+
);
|
|
122
125
|
|
|
123
126
|
const permissions = mergePermissions(config.permissions, userPermissions);
|
|
124
127
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { createServerCrudService } from "./server-service";
|
|
4
|
+
import { CrudRequestError } from "./lib/errors";
|
|
5
|
+
import type { EntityConfig } from "../types";
|
|
6
|
+
|
|
7
|
+
const makeModel = () => ({
|
|
8
|
+
findUnique: vi.fn(async () => ({ id: 1, name: "A" })),
|
|
9
|
+
findMany: vi.fn(async () => []),
|
|
10
|
+
count: vi.fn(async () => 0),
|
|
11
|
+
create: vi.fn(async ({ data }: any) => data),
|
|
12
|
+
update: vi.fn(async () => ({ id: 1 })),
|
|
13
|
+
delete: vi.fn(async () => ({ id: 1 })),
|
|
14
|
+
deleteMany: vi.fn(async () => ({ count: 2 })),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const makeConfig = (over: Partial<EntityConfig> = {}): EntityConfig => ({
|
|
18
|
+
name: "job-title",
|
|
19
|
+
label: "Chức danh",
|
|
20
|
+
pluralLabel: "Chức danh",
|
|
21
|
+
apiEndpoint: "/api/crud/job-titles",
|
|
22
|
+
idField: "id",
|
|
23
|
+
displayField: "name",
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: "id", label: "ID", type: "text" },
|
|
26
|
+
{ name: "name", label: "Tên", type: "text" },
|
|
27
|
+
],
|
|
28
|
+
...over,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const logger = { warn: vi.fn(), error: vi.fn() };
|
|
32
|
+
|
|
33
|
+
describe("createServerCrudService", () => {
|
|
34
|
+
let model: ReturnType<typeof makeModel>;
|
|
35
|
+
let prisma: Record<string, any>;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
model = makeModel();
|
|
39
|
+
// Model chỉ tồn tại dưới tên "jobTitle" — convention từ entity key khác
|
|
40
|
+
// ("weird-things") sẽ trượt nếu không đọc config.modelName.
|
|
41
|
+
prisma = { jobTitle: model };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ưu tiên config.modelName hơn convention getModelName", async () => {
|
|
45
|
+
const service = createServerCrudService({ prisma, logger });
|
|
46
|
+
const config = makeConfig({ modelName: "jobTitle" });
|
|
47
|
+
await service.getById("weird-things", "x", config);
|
|
48
|
+
expect(model.findUnique).toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("thiếu modelName thì fallback convention (job-titles → jobTitle)", async () => {
|
|
52
|
+
const service = createServerCrudService({ prisma, logger });
|
|
53
|
+
await service.getById("job-titles", "x", makeConfig());
|
|
54
|
+
expect(model.findUnique).toHaveBeenCalledWith(
|
|
55
|
+
expect.objectContaining({ where: { id: "x" } }),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("where dùng config.idField thay vì hardcode id", async () => {
|
|
60
|
+
const service = createServerCrudService({ prisma, logger });
|
|
61
|
+
const config = makeConfig({ modelName: "jobTitle", idField: "code" });
|
|
62
|
+
await service.delete("job-titles", "JT01", config);
|
|
63
|
+
expect(model.delete).toHaveBeenCalledWith({ where: { code: "JT01" } });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("idKind int: id trên URL được ép Number cho mọi method", async () => {
|
|
67
|
+
const service = createServerCrudService({ prisma, logger });
|
|
68
|
+
const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
|
|
69
|
+
|
|
70
|
+
await service.getById("job-titles", "42", config);
|
|
71
|
+
expect(model.findUnique).toHaveBeenCalledWith(
|
|
72
|
+
expect.objectContaining({ where: { id: 42 } }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await service.update("job-titles", "42", { name: "B" }, config);
|
|
76
|
+
expect(model.update).toHaveBeenCalledWith(
|
|
77
|
+
expect.objectContaining({ where: { id: 42 } }),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await service.delete("job-titles", "42", config);
|
|
81
|
+
expect(model.delete).toHaveBeenCalledWith({ where: { id: 42 } });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("idKind int: id không phải số nguyên → CrudRequestError 400", async () => {
|
|
85
|
+
const service = createServerCrudService({ prisma, logger });
|
|
86
|
+
const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
|
|
87
|
+
await expect(service.getById("job-titles", "abc", config)).rejects.toThrow(
|
|
88
|
+
CrudRequestError,
|
|
89
|
+
);
|
|
90
|
+
expect(model.findUnique).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("deleteMany: ép cả mảng id, một phần tử hỏng là fail cả request", async () => {
|
|
94
|
+
const service = createServerCrudService({ prisma, logger });
|
|
95
|
+
const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
|
|
96
|
+
|
|
97
|
+
await service.deleteMany("job-titles", ["1", "2"], config);
|
|
98
|
+
expect(model.deleteMany).toHaveBeenCalledWith({ where: { id: { in: [1, 2] } } });
|
|
99
|
+
|
|
100
|
+
await expect(
|
|
101
|
+
service.deleteMany("job-titles", ["1", "x"], config),
|
|
102
|
+
).rejects.toThrow(CrudRequestError);
|
|
103
|
+
expect(model.deleteMany).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("idKind mặc định: id giữ nguyên chuỗi (UUID)", async () => {
|
|
107
|
+
const service = createServerCrudService({ prisma, logger });
|
|
108
|
+
const config = makeConfig({ modelName: "jobTitle" });
|
|
109
|
+
await service.delete("job-titles", "uuid-1", config);
|
|
110
|
+
expect(model.delete).toHaveBeenCalledWith({ where: { id: "uuid-1" } });
|
|
111
|
+
});
|
|
112
|
+
});
|