@goplusvn/core 0.1.53 → 0.1.55
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/features/README.md +11 -1
- package/features/audit-logs/README.md +18 -0
- package/features/audit-logs/migrations/0001_init.sql +33 -0
- package/features/audit-logs/schema.prisma +20 -0
- package/features/system-jobs/README.md +40 -0
- package/features/system-jobs/migrations/0001_init.sql +47 -0
- package/features/system-jobs/schema.prisma +42 -0
- package/package.json +5 -1
- package/src/audit/__tests__/audit-context.test.ts +174 -0
- package/src/audit/__tests__/prisma-audit-extension.test.ts +426 -0
- package/src/audit/audit-actor.ts +42 -0
- package/src/audit/audit-context.ts +47 -0
- package/src/audit/entity-audit.ts +71 -0
- package/src/audit/index.ts +29 -10
- package/src/audit/prisma-audit-extension.ts +572 -0
- package/src/cron/__tests__/db-cron-manager.test.ts +316 -0
- package/src/cron/db-cron-manager.ts +459 -0
- package/src/cron/index.ts +24 -0
- package/src/system/pages/__tests__/system-audit-page.test.tsx +140 -0
- package/src/system/pages/__tests__/system-jobs-page.test.tsx +92 -0
- package/src/system/pages/system-audit-page.tsx +532 -0
- package/src/system/pages/system-jobs-page.tsx +571 -0
- package/src/ui/management/audit-log-page.tsx +12 -207
- package/src/audit/audit-manager.ts +0 -139
- package/src/audit/memory-audit-logger.ts +0 -86
- package/src/audit/types.ts +0 -50
package/features/README.md
CHANGED
|
@@ -34,8 +34,18 @@ GENERATED — đừng sửa tay) và tạo thư mục migration
|
|
|
34
34
|
- `notifications` — bảng `notifications` + `push_subscriptions` cho thông báo
|
|
35
35
|
in-app NHÂN VIÊN. Runtime: `@goerp/core/notification`
|
|
36
36
|
(`configureNotificationService`, seam `afterNotify` để app cắm web push/email)
|
|
37
|
-
|
|
37
|
+
và UI `@goerp/core/notification/ui` (chuông + hộp thư). YÊU CẦU model `User`
|
|
38
38
|
của app khai back-relation — xem `features/notifications/README.md`.
|
|
39
|
+
- `system-jobs` — bảng `system_jobs` + `job_execution_logs` cho cron có trạng
|
|
40
|
+
thái DB. Runtime: `@goerp/core/cron` (`configureCronManager`) + trang admin
|
|
41
|
+
`system/pages/system-jobs-page` (app cung cấp API, mẫu vinhhoa
|
|
42
|
+
/api/admin/system/jobs).
|
|
43
|
+
- `audit-logs` — bảng `audit_logs` cho nhật ký thay đổi dữ liệu. Runtime:
|
|
44
|
+
`@goerp/core/audit` (`createAuditExtension` chộp tự động create/update/delete
|
|
45
|
+
ở tầng Prisma, `logEntityAction` ghi hành động nghiệp vụ, `withAuditContext`
|
|
46
|
+
đưa actor xuống) + trang admin `system/pages/system-audit-page`. YÊU CẦU
|
|
47
|
+
back-relation `auditLogs AuditLog[]` trên model `User` — xem
|
|
48
|
+
`features/audit-logs/README.md`.
|
|
39
49
|
- `error-logs` — bảng `error_logs` cho hệ ghi lỗi server
|
|
40
50
|
(`createErrorLogger`/`buildServerError` ở `errors/server-error`) + trang
|
|
41
51
|
admin `system/pages/error-logs-page` (app cung cấp API, mẫu vinhhoa
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Feature: audit-logs
|
|
2
|
+
|
|
3
|
+
Bảng `audit_logs` cho `@goerp/core/audit`: extension Prisma
|
|
4
|
+
(`createAuditExtension`) chộp tự động create/update/delete, `logEntityAction`
|
|
5
|
+
ghi hành động nghiệp vụ, trang admin `SystemAuditPage` đọc.
|
|
6
|
+
|
|
7
|
+
**Yêu cầu phía app (Prisma bắt buộc 2 chiều relation):** model `User` phải khai
|
|
8
|
+
|
|
9
|
+
```prisma
|
|
10
|
+
auditLogs AuditLog[]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Bảng người dùng phải tên `users`** — SQL neo FK `audit_logs.user_id → users.id`.
|
|
14
|
+
|
|
15
|
+
**Trước khi bật extension, xem lại `skipModels`:** mặc định core đã bỏ qua bảng
|
|
16
|
+
hạ tầng (audit/auth/log/task/notification). App còn bảng ghi dày kiểu hàng đợi
|
|
17
|
+
tin nhắn thì khai thêm, không thì nhật ký nghiệp vụ bị rác nhấn chìm — đo ở
|
|
18
|
+
vinhhoa: 67% số hàng audit 7 ngày là từ 1 bảng hàng đợi ZNS.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- goerp feature: audit-logs — bước 0001 (idempotent). Bảng `audit_logs` cho
|
|
2
|
+
-- @goerp/core/audit: extension Prisma chộp create/update/delete tự động +
|
|
3
|
+
-- logEntityAction ghi hành động nghiệp vụ, và trang admin SystemAuditPage đọc.
|
|
4
|
+
-- Tên index/constraint giữ đúng bản Prisma sinh ra để app đã có bảng (vinhhoa)
|
|
5
|
+
-- chạy lại là no-op, không đẻ index trùng.
|
|
6
|
+
CREATE TABLE IF NOT EXISTS "audit_logs" (
|
|
7
|
+
"id" TEXT NOT NULL,
|
|
8
|
+
"user_id" TEXT,
|
|
9
|
+
"action" TEXT NOT NULL,
|
|
10
|
+
"resource" TEXT NOT NULL,
|
|
11
|
+
"resource_id" TEXT,
|
|
12
|
+
"old_data" JSONB,
|
|
13
|
+
"new_data" JSONB,
|
|
14
|
+
"ip_address" TEXT,
|
|
15
|
+
"user_agent" TEXT,
|
|
16
|
+
"description" TEXT,
|
|
17
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
18
|
+
|
|
19
|
+
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE INDEX IF NOT EXISTS "audit_logs_user_id_idx" ON "audit_logs"("user_id");
|
|
23
|
+
CREATE INDEX IF NOT EXISTS "audit_logs_action_idx" ON "audit_logs"("action");
|
|
24
|
+
CREATE INDEX IF NOT EXISTS "audit_logs_resource_idx" ON "audit_logs"("resource");
|
|
25
|
+
CREATE INDEX IF NOT EXISTS "audit_logs_created_at_idx" ON "audit_logs"("created_at");
|
|
26
|
+
|
|
27
|
+
-- FK: PG không có ADD CONSTRAINT IF NOT EXISTS → DO-block kiểm tra pg_constraint.
|
|
28
|
+
DO $$ BEGIN
|
|
29
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'audit_logs_user_id_fkey') THEN
|
|
30
|
+
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey"
|
|
31
|
+
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
32
|
+
END IF;
|
|
33
|
+
END $$;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
model AuditLog {
|
|
2
|
+
id String @id @default(cuid()) @map("id")
|
|
3
|
+
userId String? @map("user_id")
|
|
4
|
+
action String @map("action")
|
|
5
|
+
resource String @map("resource")
|
|
6
|
+
resourceId String? @map("resource_id")
|
|
7
|
+
oldData Json? @map("old_data")
|
|
8
|
+
newData Json? @map("new_data")
|
|
9
|
+
ipAddress String? @map("ip_address")
|
|
10
|
+
userAgent String? @map("user_agent")
|
|
11
|
+
description String? @map("description")
|
|
12
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
13
|
+
user User? @relation(fields: [userId], references: [id])
|
|
14
|
+
|
|
15
|
+
@@index([userId])
|
|
16
|
+
@@index([action])
|
|
17
|
+
@@index([resource])
|
|
18
|
+
@@index([createdAt])
|
|
19
|
+
@@map("audit_logs")
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Feature: system-jobs
|
|
2
|
+
|
|
3
|
+
Bảng `system_jobs` + `job_execution_logs` cho engine `@goerp/core/cron`
|
|
4
|
+
(`configureCronManager`) và trang admin
|
|
5
|
+
`@goerp/core/system/pages/system-jobs-page`.
|
|
6
|
+
|
|
7
|
+
Không phụ thuộc model nào của app (không FK sang `users`) — sync xong là dùng
|
|
8
|
+
được ngay.
|
|
9
|
+
|
|
10
|
+
## Cắm vào app
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// src/lib/cron.ts (hoặc nơi khởi tạo)
|
|
14
|
+
import { configureCronManager, cronManager } from "@goerp/core/cron"
|
|
15
|
+
import { db } from "@/lib/prisma"
|
|
16
|
+
|
|
17
|
+
configureCronManager({ db })
|
|
18
|
+
export { cronManager }
|
|
19
|
+
|
|
20
|
+
// instrumentation.ts — đăng ký job
|
|
21
|
+
cronManager.addJob({ name: "sync-gold-price", cronTime: "5m", onTick: syncGoldPrice })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Trong job, ghi diễn biến để trang admin xem lại được:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { getJobExecutionContext } from "@goerp/core/cron"
|
|
28
|
+
getJobExecutionContext()?.log("Đã đồng bộ", `${n} bản ghi`)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API app phải cung cấp (trang admin gọi)
|
|
32
|
+
|
|
33
|
+
- `GET <apiUrl>` → `{ data: SystemJob[] }` (gộp `cronManager.getJobsInfo()`
|
|
34
|
+
với row DB, thêm cờ `inMemory`)
|
|
35
|
+
- `POST <apiUrl>` `{ name, action: "toggle" | "run" | "remove" }`
|
|
36
|
+
- `GET <apiUrl>/<name>/history?skip&take` → `{ data, meta.total }`
|
|
37
|
+
|
|
38
|
+
Mẫu chuẩn: vinhhoa `src/app/api/admin/system/jobs/`. Quyền do app gác
|
|
39
|
+
(`apiHandler(..., { resource, action })`) — Permission Registry của app là
|
|
40
|
+
nguồn sự thật.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- goerp feature: system-jobs — bước 0001 (idempotent). 2 bảng cho engine cron
|
|
2
|
+
-- có trạng thái DB (@goerp/core/cron, configureCronManager) + trang admin
|
|
3
|
+
-- SystemJobsPage: cấu hình job (bật/tắt, lịch) và lịch sử từng lần chạy.
|
|
4
|
+
-- Tên index/constraint giữ đúng bản Prisma sinh ra để app đã có bảng (vinhhoa)
|
|
5
|
+
-- chạy lại là no-op, không đẻ index trùng.
|
|
6
|
+
CREATE TABLE IF NOT EXISTS "system_jobs" (
|
|
7
|
+
"id" TEXT NOT NULL,
|
|
8
|
+
"name" TEXT NOT NULL,
|
|
9
|
+
"cron_time" TEXT NOT NULL,
|
|
10
|
+
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
|
11
|
+
"last_run" TIMESTAMP(3),
|
|
12
|
+
"next_run" TIMESTAMP(3),
|
|
13
|
+
"status" TEXT NOT NULL DEFAULT 'idle',
|
|
14
|
+
"error" TEXT,
|
|
15
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
16
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
17
|
+
"metadata" JSONB,
|
|
18
|
+
|
|
19
|
+
CONSTRAINT "system_jobs_pkey" PRIMARY KEY ("id")
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "system_jobs_name_key" ON "system_jobs"("name");
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS "job_execution_logs" (
|
|
25
|
+
"id" TEXT NOT NULL,
|
|
26
|
+
"job_name" TEXT NOT NULL,
|
|
27
|
+
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
28
|
+
"finished_at" TIMESTAMP(3),
|
|
29
|
+
"duration_ms" INTEGER,
|
|
30
|
+
"status" TEXT NOT NULL DEFAULT 'running',
|
|
31
|
+
"error" TEXT,
|
|
32
|
+
"actions" JSONB,
|
|
33
|
+
"summary" TEXT,
|
|
34
|
+
|
|
35
|
+
CONSTRAINT "job_execution_logs_pkey" PRIMARY KEY ("id")
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS "job_execution_logs_job_name_idx" ON "job_execution_logs"("job_name");
|
|
39
|
+
CREATE INDEX IF NOT EXISTS "job_execution_logs_started_at_idx" ON "job_execution_logs"("started_at");
|
|
40
|
+
|
|
41
|
+
-- FK: PG không có ADD CONSTRAINT IF NOT EXISTS → DO-block kiểm tra pg_constraint.
|
|
42
|
+
DO $$ BEGIN
|
|
43
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'job_execution_logs_job_name_fkey') THEN
|
|
44
|
+
ALTER TABLE "job_execution_logs" ADD CONSTRAINT "job_execution_logs_job_name_fkey"
|
|
45
|
+
FOREIGN KEY ("job_name") REFERENCES "system_jobs"("name") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
46
|
+
END IF;
|
|
47
|
+
END $$;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/// Job định kỳ có TRẠNG THÁI TRONG DB (engine `@goerp/core/cron`). DB là nguồn
|
|
2
|
+
/// sự thật của cờ enabled + lịch chạy: admin tắt job thì deploy mới không tự
|
|
3
|
+
/// bật lại, và restart tiến trình không mất cấu hình. `name` trùng tên đăng ký
|
|
4
|
+
/// trong code (`cronManager.addJob({ name })`) nên là khoá tự nhiên.
|
|
5
|
+
model SystemJob {
|
|
6
|
+
id String @id @default(cuid()) @map("id")
|
|
7
|
+
name String @unique @map("name")
|
|
8
|
+
cronTime String @map("cron_time") // "5m" | "1h" | "0 * * * *"
|
|
9
|
+
enabled Boolean @default(true) @map("enabled")
|
|
10
|
+
lastRun DateTime? @map("last_run")
|
|
11
|
+
nextRun DateTime? @map("next_run")
|
|
12
|
+
status String @default("idle") @map("status") // idle | running | failed
|
|
13
|
+
error String? @map("error") // lỗi lần chạy gần nhất (xoá khi chạy lại được)
|
|
14
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
15
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
16
|
+
metadata Json? @map("metadata")
|
|
17
|
+
executionLogs JobExecutionLog[]
|
|
18
|
+
|
|
19
|
+
@@map("system_jobs")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Một LẦN chạy của job: mở row lúc bắt đầu (status=running) và đóng lúc kết
|
|
23
|
+
/// thúc. `actions` là nhật ký diễn biến do chính job ghi qua
|
|
24
|
+
/// `getJobExecutionContext().log()` — thứ giúp admin biết job "chạy rồi" đã
|
|
25
|
+
/// làm được gì, không chỉ thành công/thất bại.
|
|
26
|
+
model JobExecutionLog {
|
|
27
|
+
id String @id @default(cuid()) @map("id")
|
|
28
|
+
jobName String @map("job_name")
|
|
29
|
+
startedAt DateTime @default(now()) @map("started_at")
|
|
30
|
+
finishedAt DateTime? @map("finished_at")
|
|
31
|
+
durationMs Int? @map("duration_ms")
|
|
32
|
+
status String @default("running") @map("status") // running | success | failed
|
|
33
|
+
error String? @map("error")
|
|
34
|
+
actions Json? @map("actions") // [{ time, action, details? }]
|
|
35
|
+
summary String? @map("summary")
|
|
36
|
+
|
|
37
|
+
job SystemJob @relation(fields: [jobName], references: [name], onDelete: Cascade)
|
|
38
|
+
|
|
39
|
+
@@index([jobName])
|
|
40
|
+
@@index([startedAt])
|
|
41
|
+
@@map("job_execution_logs")
|
|
42
|
+
}
|
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.55",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -80,6 +80,10 @@
|
|
|
80
80
|
"./system/pages/system-settings-page": "./src/system/pages/system-settings-page.tsx",
|
|
81
81
|
"./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
|
|
82
82
|
"./system/pages/error-logs-page": "./src/system/pages/error-logs-page.tsx",
|
|
83
|
+
"./system/pages/system-jobs-page": "./src/system/pages/system-jobs-page.tsx",
|
|
84
|
+
"./system/pages/system-audit-page": "./src/system/pages/system-audit-page.tsx",
|
|
85
|
+
"./cron": "./src/cron/index.ts",
|
|
86
|
+
"./audit": "./src/audit/index.ts",
|
|
83
87
|
"./rbac/role-service": "./src/rbac/role-service.ts",
|
|
84
88
|
"./rbac/resource-service": "./src/rbac/resource-service.ts",
|
|
85
89
|
"./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { getAuditContext, withAuditContext } from "../audit-context";
|
|
4
|
+
import { resolveAuditActor } from "../audit-actor";
|
|
5
|
+
import { logEntityAction } from "../entity-audit";
|
|
6
|
+
|
|
7
|
+
describe("withAuditContext / getAuditContext", () => {
|
|
8
|
+
it("ngoài context trả undefined (extension tự biết là không có actor)", () => {
|
|
9
|
+
expect(getAuditContext()).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("giữ context xuyên qua await", async () => {
|
|
13
|
+
await withAuditContext(
|
|
14
|
+
{ userId: "user-1", userName: "Nguyễn Văn A" },
|
|
15
|
+
async () => {
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
expect(getAuditContext()).toEqual({
|
|
18
|
+
userId: "user-1",
|
|
19
|
+
userName: "Nguyễn Văn A",
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("lồng nhau: thoát context trong thì context ngoài trở lại", async () => {
|
|
26
|
+
await withAuditContext({ userId: "outer" }, async () => {
|
|
27
|
+
expect(getAuditContext()?.userId).toBe("outer");
|
|
28
|
+
await withAuditContext({ userId: "inner" }, async () => {
|
|
29
|
+
expect(getAuditContext()?.userId).toBe("inner");
|
|
30
|
+
});
|
|
31
|
+
expect(getAuditContext()?.userId).toBe("outer");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("trả về giá trị của fn và không nuốt lỗi", async () => {
|
|
36
|
+
await expect(
|
|
37
|
+
withAuditContext({ userId: "u" }, async () => 42),
|
|
38
|
+
).resolves.toBe(42);
|
|
39
|
+
await expect(
|
|
40
|
+
withAuditContext({ userId: "u" }, async () => {
|
|
41
|
+
throw new Error("test error");
|
|
42
|
+
}),
|
|
43
|
+
).rejects.toThrow("test error");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("resolveAuditActor", () => {
|
|
48
|
+
it("phiên thường: lấy id + tên người dùng", async () => {
|
|
49
|
+
await expect(
|
|
50
|
+
resolveAuditActor({
|
|
51
|
+
user: { id: "u1", name: "Kế toán", email: "kt@x.vn" },
|
|
52
|
+
}),
|
|
53
|
+
).resolves.toEqual({ userId: "u1", userName: "Kế toán" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("không có tên thì rơi về email", async () => {
|
|
57
|
+
await expect(
|
|
58
|
+
resolveAuditActor({ user: { id: "u1", name: null, email: "kt@x.vn" } }),
|
|
59
|
+
).resolves.toEqual({ userId: "u1", userName: "kt@x.vn" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("mạo danh: audit quy về ADMIN THẬT, không phải người bị mạo danh", async () => {
|
|
63
|
+
const resolveName = vi.fn().mockResolvedValue("Quản trị");
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
resolveAuditActor(
|
|
67
|
+
{ user: { id: "u-nv", name: "Nhân viên" }, impersonatedBy: "u-admin" },
|
|
68
|
+
resolveName,
|
|
69
|
+
),
|
|
70
|
+
).resolves.toEqual({
|
|
71
|
+
userId: "u-admin",
|
|
72
|
+
userName: "Quản trị (mạo danh Nhân viên)",
|
|
73
|
+
});
|
|
74
|
+
expect(resolveName).toHaveBeenCalledWith("u-admin");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("tra tên admin hỏng vẫn ghi được actor", async () => {
|
|
78
|
+
await expect(
|
|
79
|
+
resolveAuditActor(
|
|
80
|
+
{ user: { id: "u-nv", name: "Nhân viên" }, impersonatedBy: "u-admin" },
|
|
81
|
+
async () => {
|
|
82
|
+
throw new Error("DB down");
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
).resolves.toEqual({
|
|
86
|
+
userId: "u-admin",
|
|
87
|
+
userName: "Admin (mạo danh Nhân viên)",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("logEntityAction", () => {
|
|
93
|
+
const makeClient = () => ({
|
|
94
|
+
auditLog: { create: vi.fn().mockResolvedValue({ id: "a1" }) },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("ghi qua ĐÚNG client được truyền (tx → rollback cùng nghiệp vụ)", async () => {
|
|
98
|
+
const tx = makeClient();
|
|
99
|
+
await logEntityAction({
|
|
100
|
+
resource: "sales-order",
|
|
101
|
+
resourceId: "so-1",
|
|
102
|
+
action: "confirm-payment",
|
|
103
|
+
description: "Xác nhận thanh toán",
|
|
104
|
+
userId: "u1",
|
|
105
|
+
userName: "Kế toán",
|
|
106
|
+
referenceNumber: "DH001",
|
|
107
|
+
newData: { amount: 500000 },
|
|
108
|
+
client: tx,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
|
112
|
+
data: {
|
|
113
|
+
action: "confirm-payment",
|
|
114
|
+
resource: "sales-order",
|
|
115
|
+
resourceId: "so-1",
|
|
116
|
+
userId: "u1",
|
|
117
|
+
description: "[Kế toán] Xác nhận thanh toán",
|
|
118
|
+
oldData: undefined,
|
|
119
|
+
newData: { amount: 500000, orderNumber: "DH001" },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("không có userName thì mô tả giữ nguyên, không có tiền tố []", async () => {
|
|
125
|
+
const client = makeClient();
|
|
126
|
+
await logEntityAction({
|
|
127
|
+
resource: "sales-order",
|
|
128
|
+
resourceId: "so-1",
|
|
129
|
+
action: "cancel",
|
|
130
|
+
description: "Huỷ đơn",
|
|
131
|
+
client,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(client.auditLog.create.mock.calls[0][0].data.description).toBe(
|
|
135
|
+
"Huỷ đơn",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("chỉ có số chứng từ vẫn nhét được vào newData để tra cứu", async () => {
|
|
140
|
+
const client = makeClient();
|
|
141
|
+
await logEntityAction({
|
|
142
|
+
resource: "purchase-order",
|
|
143
|
+
resourceId: "po-1",
|
|
144
|
+
action: "receive",
|
|
145
|
+
description: "Nhận hàng",
|
|
146
|
+
referenceNumber: "PN001",
|
|
147
|
+
client,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(client.auditLog.create.mock.calls[0][0].data.newData).toEqual({
|
|
151
|
+
orderNumber: "PN001",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("audit hỏng KHÔNG throw ra nghiệp vụ", async () => {
|
|
156
|
+
const client = {
|
|
157
|
+
auditLog: { create: vi.fn().mockRejectedValue(new Error("DB fail")) },
|
|
158
|
+
};
|
|
159
|
+
const onError = vi.fn();
|
|
160
|
+
|
|
161
|
+
await expect(
|
|
162
|
+
logEntityAction({
|
|
163
|
+
resource: "sales-order",
|
|
164
|
+
resourceId: "so-1",
|
|
165
|
+
action: "cancel",
|
|
166
|
+
description: "Huỷ đơn",
|
|
167
|
+
client,
|
|
168
|
+
onError,
|
|
169
|
+
}),
|
|
170
|
+
).resolves.toBeUndefined();
|
|
171
|
+
|
|
172
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
173
|
+
});
|
|
174
|
+
});
|