@goplusvn/core 0.1.87 → 0.1.88
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 +1 -1
- package/src/audit/__tests__/prisma-audit-extension.test.ts +113 -0
- package/src/audit/prisma-audit-extension.ts +41 -13
- package/src/configs/status.ts +67 -0
- package/src/cron/__tests__/cron-single-runner.test.ts +260 -0
- package/src/cron/cron-schedule.ts +113 -20
- package/src/cron/db-cron-manager.ts +90 -7
- package/src/cron/simple-cron-job.ts +17 -6
- package/src/crud/__tests__/status-boolean-display.test.tsx +186 -0
- package/src/crud/components/crud-detail-dialog.tsx +21 -8
- package/src/crud/components/crud-field-renderer.tsx +20 -13
- package/src/crud/components/crud-table.tsx +13 -8
- package/src/crud/lib/coerce.ts +36 -10
- package/src/types/index.ts +9 -9
- package/src/ui/data-display/data-table/data-table.tsx +32 -4
- package/src/ui/layout/user-dropdown.tsx +30 -23
- package/src/ui/primitives/status-badge.tsx +14 -10
- package/src/ui/shared/status-indicator.tsx +266 -74
- package/src/utils/index.ts +87 -38
- package/src/workspace/__tests__/workspace-delegation.test.ts +29 -11
package/package.json
CHANGED
|
@@ -194,6 +194,119 @@ describe("createAuditExtension", () => {
|
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
// ── Khoá chính KIỂU SỐ ────────────────────────────────────────────────
|
|
198
|
+
// Bảng nghiệp vụ (meal_orders, employees, menus…) dùng serial, không cuid.
|
|
199
|
+
// Trước bản này `resourceId.slice()` ném TypeError, `catch` ngoài cùng nuốt
|
|
200
|
+
// gọn, và audit của TẤT CẢ các bảng đó không bao giờ được ghi — không ai
|
|
201
|
+
// biết vì nghiệp vụ vẫn chạy bình thường.
|
|
202
|
+
describe("khoá chính kiểu số", () => {
|
|
203
|
+
it("create trên bảng khoá số VẪN ghi audit, id thành chuỗi", async () => {
|
|
204
|
+
const query = vi
|
|
205
|
+
.fn()
|
|
206
|
+
.mockResolvedValue({ id: 1234567890, name: "Cơm sườn" });
|
|
207
|
+
|
|
208
|
+
await handler({
|
|
209
|
+
model: "SalesOrder",
|
|
210
|
+
operation: "create",
|
|
211
|
+
args: { data: { name: "Cơm sườn" } },
|
|
212
|
+
query,
|
|
213
|
+
});
|
|
214
|
+
await settle();
|
|
215
|
+
|
|
216
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
217
|
+
data: expect.objectContaining({
|
|
218
|
+
action: "create",
|
|
219
|
+
resource: "sales-order",
|
|
220
|
+
resourceId: "1234567890",
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("update tra dữ liệu cũ bằng id SỐ, không ép thành chuỗi", async () => {
|
|
226
|
+
rawClient.salesOrder.findUnique.mockResolvedValue({
|
|
227
|
+
id: 42,
|
|
228
|
+
name: "Tên cũ",
|
|
229
|
+
});
|
|
230
|
+
const query = vi.fn().mockResolvedValue({ id: 42 });
|
|
231
|
+
|
|
232
|
+
await handler({
|
|
233
|
+
model: "SalesOrder",
|
|
234
|
+
operation: "update",
|
|
235
|
+
args: { where: { id: 42 }, data: { name: "Tên mới" } },
|
|
236
|
+
query,
|
|
237
|
+
});
|
|
238
|
+
await settle();
|
|
239
|
+
|
|
240
|
+
// Ép chuỗi ở bước tra cứu là Prisma từ chối ngay: cột Int không nhận "42".
|
|
241
|
+
expect(rawClient.salesOrder.findUnique).toHaveBeenCalledWith({
|
|
242
|
+
where: { id: 42 },
|
|
243
|
+
});
|
|
244
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
245
|
+
data: expect.objectContaining({
|
|
246
|
+
action: "update",
|
|
247
|
+
resourceId: "42",
|
|
248
|
+
oldData: expect.objectContaining({ name: "Tên cũ" }),
|
|
249
|
+
newData: expect.objectContaining({ name: "Tên mới" }),
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("mô tả có đuôi #id thay vì làm vỡ cả dòng audit", async () => {
|
|
255
|
+
const query = vi.fn().mockResolvedValue({ id: 987654321, name: "X" });
|
|
256
|
+
|
|
257
|
+
await handler({
|
|
258
|
+
model: "SalesOrder",
|
|
259
|
+
operation: "create",
|
|
260
|
+
args: { data: { name: "X" } },
|
|
261
|
+
query,
|
|
262
|
+
});
|
|
263
|
+
await settle();
|
|
264
|
+
|
|
265
|
+
const arg = rawClient.auditLog.create.mock.calls[0]?.[0];
|
|
266
|
+
expect(arg.data.description).toContain("#87654321");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("xoá-theo-điều-kiện trên bảng khoá số cũng ghi được", async () => {
|
|
270
|
+
rawClient.salesOrder.findFirst.mockResolvedValue({
|
|
271
|
+
id: 7,
|
|
272
|
+
code: "SO007",
|
|
273
|
+
});
|
|
274
|
+
const query = vi.fn().mockResolvedValue({ id: 7 });
|
|
275
|
+
|
|
276
|
+
await handler({
|
|
277
|
+
model: "SalesOrder",
|
|
278
|
+
operation: "delete",
|
|
279
|
+
args: { where: { code: "SO007" } },
|
|
280
|
+
query,
|
|
281
|
+
});
|
|
282
|
+
await settle();
|
|
283
|
+
|
|
284
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
285
|
+
data: expect.objectContaining({
|
|
286
|
+
action: "delete",
|
|
287
|
+
resourceId: "7",
|
|
288
|
+
oldData: expect.objectContaining({ code: "SO007" }),
|
|
289
|
+
}),
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("id = 0 vẫn là id hợp lệ, không bị coi là 'không có'", async () => {
|
|
294
|
+
const query = vi.fn().mockResolvedValue({ id: 0, name: "Gốc" });
|
|
295
|
+
|
|
296
|
+
await handler({
|
|
297
|
+
model: "SalesOrder",
|
|
298
|
+
operation: "create",
|
|
299
|
+
args: { data: { name: "Gốc" } },
|
|
300
|
+
query,
|
|
301
|
+
});
|
|
302
|
+
await settle();
|
|
303
|
+
|
|
304
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
305
|
+
data: expect.objectContaining({ resourceId: "0" }),
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
197
310
|
it("PascalCase → kebab-case khớp mã resource RBAC", async () => {
|
|
198
311
|
const query = vi.fn().mockResolvedValue({ id: "so-1" });
|
|
199
312
|
await handler({
|
|
@@ -99,11 +99,34 @@ function toResourceName(modelName: string): string {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/** Lấy id từ where khi có — đường nhanh; không có thì fallback findFirst(where). */
|
|
102
|
-
function extractId(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
function extractId(
|
|
103
|
+
where: Record<string, any> | undefined,
|
|
104
|
+
): string | number | null {
|
|
105
|
+
const id = where?.id;
|
|
106
|
+
if (id === undefined || id === null || id === "") return null;
|
|
107
|
+
// Khoá chính KHÔNG chỉ là cuid: bảng nghiệp vụ (meal_orders, employees…)
|
|
108
|
+
// dùng serial nên `where.id` là số. Trả về GIÁ TRỊ GỐC, không ép chuỗi ở
|
|
109
|
+
// đây — `findUnique({ where: { id: "123" } })` trên cột Int là lỗi kiểu.
|
|
110
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
111
|
+
if (typeof id === "bigint") return Number(id);
|
|
112
|
+
if (typeof id === "object" && id.equals !== undefined && id.equals !== null) {
|
|
113
|
+
const eq = id.equals;
|
|
114
|
+
if (typeof eq === "string" || typeof eq === "number") return eq;
|
|
115
|
+
if (typeof eq === "bigint") return Number(eq);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Cột `resource_id` của bảng audit là CHUỖI, còn khoá chính có thể là số.
|
|
122
|
+
* Mọi id phải đi qua đây trước khi vào audit — trước đây thiếu bước này nên
|
|
123
|
+
* `resourceId.slice()` ném `TypeError` trên mọi bảng khoá số, lỗi bị nuốt ở
|
|
124
|
+
* `catch` ngoài cùng và audit log của các bảng đó IM LẶNG không bao giờ ghi.
|
|
125
|
+
*/
|
|
126
|
+
function toResourceId(id: unknown): string | null {
|
|
127
|
+
if (id === null || id === undefined || id === "") return null;
|
|
128
|
+
if (typeof id === "string") return id;
|
|
129
|
+
if (typeof id === "number" || typeof id === "bigint") return String(id);
|
|
107
130
|
return null;
|
|
108
131
|
}
|
|
109
132
|
|
|
@@ -246,7 +269,7 @@ export function createAuditExtension(options: CreateAuditExtensionOptions) {
|
|
|
246
269
|
// xoá-theo-code hay sửa-theo-email vẫn để lại dấu vết.
|
|
247
270
|
let oldData: Record<string, unknown> | null = null;
|
|
248
271
|
let oldRows: Array<Record<string, unknown>> | null = null;
|
|
249
|
-
let
|
|
272
|
+
let resourceIdRaw: string | number | null = null;
|
|
250
273
|
|
|
251
274
|
const isMutationByWhere = action === "update" || action === "delete";
|
|
252
275
|
|
|
@@ -271,16 +294,18 @@ export function createAuditExtension(options: CreateAuditExtensionOptions) {
|
|
|
271
294
|
/* nuốt — audit không được làm vỡ nghiệp vụ */
|
|
272
295
|
}
|
|
273
296
|
} else {
|
|
274
|
-
|
|
297
|
+
resourceIdRaw = extractId(where);
|
|
275
298
|
try {
|
|
276
299
|
let existing: any = null;
|
|
277
|
-
if (
|
|
300
|
+
if (resourceIdRaw !== null && delegate?.findUnique) {
|
|
278
301
|
existing = await delegate.findUnique({
|
|
279
|
-
where: { id:
|
|
302
|
+
where: { id: resourceIdRaw },
|
|
280
303
|
});
|
|
281
304
|
} else if (delegate?.findFirst) {
|
|
282
305
|
existing = await delegate.findFirst({ where });
|
|
283
|
-
if (existing?.id
|
|
306
|
+
if (existing?.id !== undefined && existing?.id !== null) {
|
|
307
|
+
resourceIdRaw = existing.id;
|
|
308
|
+
}
|
|
284
309
|
}
|
|
285
310
|
if (existing) oldData = sanitizeData(existing);
|
|
286
311
|
} catch {
|
|
@@ -436,13 +461,14 @@ export function createAuditExtension(options: CreateAuditExtensionOptions) {
|
|
|
436
461
|
// Đường một dòng
|
|
437
462
|
let newData: Record<string, unknown> | null = null;
|
|
438
463
|
if (action === "create" && result) {
|
|
439
|
-
|
|
464
|
+
resourceIdRaw = (result as any)?.id ?? null;
|
|
440
465
|
newData = sanitizeData(result);
|
|
441
466
|
} else if (action === "update") {
|
|
442
467
|
newData = sanitizeData((args as any)?.data);
|
|
443
468
|
}
|
|
444
469
|
|
|
445
470
|
const userName = actor?.userName || "Hệ thống";
|
|
471
|
+
const resourceId = toResourceId(resourceIdRaw);
|
|
446
472
|
const description = buildDescription(
|
|
447
473
|
action,
|
|
448
474
|
resource,
|
|
@@ -487,7 +513,7 @@ export function buildDescription(
|
|
|
487
513
|
userName: string,
|
|
488
514
|
oldData?: Record<string, unknown> | null,
|
|
489
515
|
newData?: Record<string, unknown> | null,
|
|
490
|
-
resourceId?: string | null,
|
|
516
|
+
resourceId?: string | number | null,
|
|
491
517
|
): string {
|
|
492
518
|
const actionLabels: Record<string, string> = {
|
|
493
519
|
create: "Tạo mới",
|
|
@@ -551,7 +577,9 @@ export function buildDescription(
|
|
|
551
577
|
]);
|
|
552
578
|
}
|
|
553
579
|
|
|
554
|
-
|
|
580
|
+
// `String(...)` là chốt chặn: dù đường nào lọt một khoá số xuống tới đây,
|
|
581
|
+
// mô tả vẫn dựng được thay vì ném lỗi và mất trắng dòng audit.
|
|
582
|
+
const idSuffix = resourceId ? ` #${String(resourceId).slice(-8)}` : "";
|
|
555
583
|
return `[${userName}] ${actionLabel} ${resource}${idSuffix}${changeSummary}`;
|
|
556
584
|
}
|
|
557
585
|
|
package/src/configs/status.ts
CHANGED
|
@@ -23,3 +23,70 @@ export function booleanToStatus(value: boolean): string {
|
|
|
23
23
|
export function statusToBoolean(value: string): boolean {
|
|
24
24
|
return value === STATUS_ACTIVE;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* CHUẨN HOÁ GIÁ TRỊ TRẠNG THÁI — một chỗ duy nhất cho cả app.
|
|
29
|
+
*
|
|
30
|
+
* Cùng một ô "Trạng thái" trên màn hình CRUD, dữ liệu về từ DB có thể là:
|
|
31
|
+
*
|
|
32
|
+
* - chuỗi `"active"` / `"inactive"` (cột text — kiểu hay dùng nhất)
|
|
33
|
+
* - boolean `true` / `false` (cột Boolean: `is_active`, `enabled`…)
|
|
34
|
+
* - số `1` / `0` (cột smallint, hoặc Prisma trả BigInt)
|
|
35
|
+
* - chuỗi `"true"` / `"1"` (giá trị đi qua query string / form)
|
|
36
|
+
*
|
|
37
|
+
* Trước đây mỗi nơi tự đoán: bảng thì so `optValue === value` (so tuyệt đối
|
|
38
|
+
* nên `true` không khớp option `"active"` ⇒ chip hiện chữ "true"), form thì
|
|
39
|
+
* so cứng `value === "active"` (nên bản ghi boolean `true` hiện công tắc TẮT).
|
|
40
|
+
* Hàm này là mẫu số chung: đưa mọi biến thể về MỘT key chuỗi rồi mới so.
|
|
41
|
+
*
|
|
42
|
+
* Trả `null` khi bỏ trống, và trả nguyên key với những trạng thái KHÔNG phải
|
|
43
|
+
* bật/tắt (`"pending"`, `"cancelled"`…) để bảng màu trạng thái vẫn tra được.
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeStatusValue(value: unknown): string | null {
|
|
46
|
+
if (value === null || value === undefined) return null;
|
|
47
|
+
if (typeof value === "boolean") {
|
|
48
|
+
return value ? STATUS_ACTIVE : STATUS_INACTIVE;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
51
|
+
return Number(value) !== 0 ? STATUS_ACTIVE : STATUS_INACTIVE;
|
|
52
|
+
}
|
|
53
|
+
if (typeof value !== "string") return null;
|
|
54
|
+
|
|
55
|
+
const key = value.trim().toLowerCase().replace(/\s+/g, "_");
|
|
56
|
+
if (key === "") return null;
|
|
57
|
+
if (["true", "1", "on", "yes", "y", "enabled"].includes(key)) {
|
|
58
|
+
return STATUS_ACTIVE;
|
|
59
|
+
}
|
|
60
|
+
if (["false", "0", "off", "no", "n", "disabled"].includes(key)) {
|
|
61
|
+
return STATUS_INACTIVE;
|
|
62
|
+
}
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Giá trị này có nghĩa là "đang bật" không? Dùng cho công tắc ở form. */
|
|
67
|
+
export function isTruthyStatus(value: unknown): boolean {
|
|
68
|
+
return normalizeStatusValue(value) === STATUS_ACTIVE;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Giá trị của một dòng dữ liệu có khớp với `option` đã khai trong FieldConfig
|
|
73
|
+
* không — SO LỎNG, theo ba nấc:
|
|
74
|
+
*
|
|
75
|
+
* 1. bằng tuyệt đối (đường nhanh, giữ nguyên hành vi cũ);
|
|
76
|
+
* 2. bằng sau khi đổi ra chuỗi (`1` khớp `"1"`);
|
|
77
|
+
* 3. bằng sau khi chuẩn hoá trạng thái (`true` khớp `"active"`).
|
|
78
|
+
*
|
|
79
|
+
* Nấc 3 chỉ nhận khi CẢ HAI vế chuẩn hoá được — nếu không, hai giá trị lạ
|
|
80
|
+
* cùng ra `null` sẽ khớp bừa với nhau.
|
|
81
|
+
*/
|
|
82
|
+
export function matchesOptionValue(
|
|
83
|
+
optionValue: unknown,
|
|
84
|
+
value: unknown,
|
|
85
|
+
): boolean {
|
|
86
|
+
if (optionValue === value) return true;
|
|
87
|
+
if (value === null || value === undefined) return false;
|
|
88
|
+
if (String(optionValue) === String(value)) return true;
|
|
89
|
+
const a = normalizeStatusValue(optionValue);
|
|
90
|
+
const b = normalizeStatusValue(value);
|
|
91
|
+
return a !== null && a === b;
|
|
92
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HỒI QUY — hai lỗi cron đã gây hậu quả thật trên prod:
|
|
3
|
+
*
|
|
4
|
+
* 1. MỌI job chạy HAI LẦN mỗi nhịp vì có hai tiến trình cùng đăng ký cron
|
|
5
|
+
* (`job_execution_logs` có 2 dòng cùng `started_at` cho mỗi nhịp). Job
|
|
6
|
+
* chốt ngày chạy lượt thứ hai ghi đè kết quả của lượt đầu.
|
|
7
|
+
* 2. Job hằng ngày nổ LỆCH 7 TIẾNG vì một tiến trình chạy TZ=UTC trong khi
|
|
8
|
+
* lịch được hiểu theo giờ địa phương của tiến trình — "30 22 * * *" nổ
|
|
9
|
+
* lúc 05:30 sáng VN và chốt "không đi làm" cho cả nhà máy.
|
|
10
|
+
*
|
|
11
|
+
* Chốt lần lượt: (1) giành lượt chạy bằng UPDATE có điều kiện trên
|
|
12
|
+
* `system_jobs`; (2) khai `timezone` cho job thì lịch bám múi giờ nghiệp vụ
|
|
13
|
+
* chứ không bám cấu hình máy.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { configureCronManager, DbCronJobManager } from "../db-cron-manager";
|
|
18
|
+
import type { CronDb } from "../db-cron-manager";
|
|
19
|
+
import {
|
|
20
|
+
nextCronDate,
|
|
21
|
+
parseCronExpression,
|
|
22
|
+
zonedParts,
|
|
23
|
+
} from "../cron-schedule";
|
|
24
|
+
|
|
25
|
+
interface JobRow {
|
|
26
|
+
name: string;
|
|
27
|
+
cronTime: string;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
status?: string;
|
|
30
|
+
lastRun?: Date | null;
|
|
31
|
+
nextRun?: Date | null;
|
|
32
|
+
error?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fake Prisma CÓ `updateMany` với đúng ngữ nghĩa UPDATE có điều kiện —
|
|
37
|
+
* đây chính là chỗ khoá: chỉ dòng nào còn thoả điều kiện mới bị ghi.
|
|
38
|
+
*/
|
|
39
|
+
function makeSharedDb() {
|
|
40
|
+
const jobs = new Map<string, JobRow>();
|
|
41
|
+
const logs: Array<{ id: string; jobName: string; status: string }> = [];
|
|
42
|
+
let seq = 0;
|
|
43
|
+
|
|
44
|
+
const db = {
|
|
45
|
+
systemJob: {
|
|
46
|
+
count: vi.fn(async () => jobs.size),
|
|
47
|
+
findUnique: vi.fn(
|
|
48
|
+
async ({ where }: { where: { name: string } }) =>
|
|
49
|
+
jobs.get(where.name) ?? null,
|
|
50
|
+
),
|
|
51
|
+
create: vi.fn(async ({ data }: { data: JobRow }) => {
|
|
52
|
+
jobs.set(data.name, { ...data });
|
|
53
|
+
return jobs.get(data.name);
|
|
54
|
+
}),
|
|
55
|
+
update: vi.fn(
|
|
56
|
+
async ({
|
|
57
|
+
where,
|
|
58
|
+
data,
|
|
59
|
+
}: {
|
|
60
|
+
where: { name: string };
|
|
61
|
+
data: Partial<JobRow>;
|
|
62
|
+
}) => {
|
|
63
|
+
const row = jobs.get(where.name);
|
|
64
|
+
if (!row) throw new Error(`no job ${where.name}`);
|
|
65
|
+
Object.assign(row, data);
|
|
66
|
+
return row;
|
|
67
|
+
},
|
|
68
|
+
),
|
|
69
|
+
updateMany: vi.fn(
|
|
70
|
+
async ({
|
|
71
|
+
where,
|
|
72
|
+
data,
|
|
73
|
+
}: {
|
|
74
|
+
where: {
|
|
75
|
+
name: string;
|
|
76
|
+
OR: Array<{ lastRun: null | { lt: Date } }>;
|
|
77
|
+
};
|
|
78
|
+
data: Partial<JobRow>;
|
|
79
|
+
}) => {
|
|
80
|
+
const row = jobs.get(where.name);
|
|
81
|
+
if (!row) return { count: 0 };
|
|
82
|
+
const floor = where.OR.find((c) => c.lastRun !== null)?.lastRun as
|
|
83
|
+
| { lt: Date }
|
|
84
|
+
| undefined;
|
|
85
|
+
const ok =
|
|
86
|
+
row.lastRun == null ||
|
|
87
|
+
(floor ? row.lastRun.getTime() < floor.lt.getTime() : false);
|
|
88
|
+
if (!ok) return { count: 0 };
|
|
89
|
+
Object.assign(row, data);
|
|
90
|
+
return { count: 1 };
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
jobExecutionLog: {
|
|
95
|
+
create: vi.fn(
|
|
96
|
+
async ({ data }: { data: { jobName: string; status: string } }) => {
|
|
97
|
+
const row = { id: `log${++seq}`, ...data };
|
|
98
|
+
logs.push(row);
|
|
99
|
+
return row;
|
|
100
|
+
},
|
|
101
|
+
),
|
|
102
|
+
update: vi.fn(async () => ({})),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return { db: db as unknown as CronDb, jobs, logs };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const silentLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
110
|
+
|
|
111
|
+
describe("giành lượt chạy — hai tiến trình, một lượt", () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
vi.useFakeTimers();
|
|
114
|
+
vi.setSystemTime(new Date("2026-08-23T03:00:00.000Z"));
|
|
115
|
+
delete process.env.GOERP_CRON_SINGLE_RUNNER;
|
|
116
|
+
});
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.useRealTimers();
|
|
119
|
+
delete process.env.GOERP_CRON_SINGLE_RUNNER;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("hai manager cùng DB, cùng nhịp → onTick chỉ chạy MỘT lần", async () => {
|
|
123
|
+
const { db, logs } = makeSharedDb();
|
|
124
|
+
configureCronManager({ db, logger: silentLogger });
|
|
125
|
+
|
|
126
|
+
const tick = vi.fn();
|
|
127
|
+
const a = new DbCronJobManager();
|
|
128
|
+
const b = new DbCronJobManager();
|
|
129
|
+
a.addJob({ name: "close-day", cronTime: "1s", onTick: tick, start: true });
|
|
130
|
+
b.addJob({ name: "close-day", cronTime: "1s", onTick: tick, start: true });
|
|
131
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
132
|
+
|
|
133
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
134
|
+
a.stopAll();
|
|
135
|
+
b.stopAll();
|
|
136
|
+
|
|
137
|
+
expect(tick).toHaveBeenCalledTimes(1);
|
|
138
|
+
// Tiến trình bị loại KHÔNG được để lại execution log — nếu không thì màn
|
|
139
|
+
// hình lịch sử vẫn hiện hai dòng mỗi nhịp như trước.
|
|
140
|
+
expect(logs.filter((l) => l.jobName === "close-day")).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("nhịp SAU vẫn chạy (khoá theo nhịp, không khoá vĩnh viễn)", async () => {
|
|
144
|
+
const { db } = makeSharedDb();
|
|
145
|
+
configureCronManager({ db, logger: silentLogger });
|
|
146
|
+
|
|
147
|
+
const tick = vi.fn();
|
|
148
|
+
const a = new DbCronJobManager();
|
|
149
|
+
a.addJob({ name: "reconcile", cronTime: "1s", onTick: tick, start: true });
|
|
150
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
151
|
+
|
|
152
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
153
|
+
// Nhịp kế tiếp phải rơi sang PHÚT khác thì mốc nhịp mới đổi.
|
|
154
|
+
vi.setSystemTime(new Date("2026-08-23T03:02:00.000Z"));
|
|
155
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
156
|
+
a.stopAll();
|
|
157
|
+
|
|
158
|
+
expect(tick).toHaveBeenCalledTimes(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("GOERP_CRON_SINGLE_RUNNER=off → giữ hành vi cũ (chạy cả hai)", async () => {
|
|
162
|
+
process.env.GOERP_CRON_SINGLE_RUNNER = "off";
|
|
163
|
+
const { db } = makeSharedDb();
|
|
164
|
+
configureCronManager({ db, logger: silentLogger });
|
|
165
|
+
|
|
166
|
+
const tick = vi.fn();
|
|
167
|
+
const a = new DbCronJobManager();
|
|
168
|
+
const b = new DbCronJobManager();
|
|
169
|
+
a.addJob({ name: "sms", cronTime: "1s", onTick: tick, start: true });
|
|
170
|
+
b.addJob({ name: "sms", cronTime: "1s", onTick: tick, start: true });
|
|
171
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
172
|
+
|
|
173
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
174
|
+
a.stopAll();
|
|
175
|
+
b.stopAll();
|
|
176
|
+
|
|
177
|
+
expect(tick).toHaveBeenCalledTimes(2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('nút "Chạy ngay" KHÔNG bị chặn dù nhịp đã có người nhận', async () => {
|
|
181
|
+
const { db } = makeSharedDb();
|
|
182
|
+
configureCronManager({ db, logger: silentLogger });
|
|
183
|
+
|
|
184
|
+
const tick = vi.fn();
|
|
185
|
+
const a = new DbCronJobManager();
|
|
186
|
+
a.addJob({ name: "menu-sync", cronTime: "1s", onTick: tick, start: true });
|
|
187
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
188
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
189
|
+
expect(tick).toHaveBeenCalledTimes(1);
|
|
190
|
+
|
|
191
|
+
await a.runJobNow("menu-sync");
|
|
192
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
193
|
+
a.stopAll();
|
|
194
|
+
|
|
195
|
+
expect(tick).toHaveBeenCalledTimes(2);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("lịch bám MÚI GIỜ NGHIỆP VỤ, không bám cấu hình máy", () => {
|
|
200
|
+
const at = (iso: string) => new Date(iso);
|
|
201
|
+
|
|
202
|
+
it("zonedParts đọc đúng giờ VN từ một mốc UTC", () => {
|
|
203
|
+
const p = zonedParts(at("2026-08-23T15:30:00.000Z"), "Asia/Ho_Chi_Minh");
|
|
204
|
+
expect(p).toMatchObject({ hour: 22, minute: 30, dayOfMonth: 23, month: 8 });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("CÙNG một lịch, hai múi giờ khác nhau → hai mốc chạy khác nhau", () => {
|
|
208
|
+
// Phép thử KHÔNG phụ thuộc múi giờ của máy chạy test: cùng biểu thức
|
|
209
|
+
// "30 22 * * *", đọc theo UTC và theo giờ VN phải lệch nhau đúng 7 tiếng.
|
|
210
|
+
// Bản cũ bỏ qua tham số múi giờ nên hai kết quả TRÙNG NHAU — đúng cái làm
|
|
211
|
+
// container UTC nổ job chốt ngày lúc 05:30 sáng VN.
|
|
212
|
+
const fields = parseCronExpression("30 22 * * *")!;
|
|
213
|
+
const from = at("2026-08-23T00:00:00.000Z");
|
|
214
|
+
const inUtc = nextCronDate(fields, from, "UTC")!;
|
|
215
|
+
const inVn = nextCronDate(fields, from, "Asia/Ho_Chi_Minh")!;
|
|
216
|
+
expect(inUtc.toISOString()).toBe("2026-08-23T22:30:00.000Z");
|
|
217
|
+
expect(inVn.toISOString()).toBe("2026-08-23T15:30:00.000Z");
|
|
218
|
+
expect(inUtc.getTime() - inVn.getTime()).toBe(7 * 60 * 60 * 1000);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('"30 22 * * *" + timezone VN → 15:30 UTC, dù máy chạy múi nào', () => {
|
|
222
|
+
const fields = parseCronExpression("30 22 * * *")!;
|
|
223
|
+
const next = nextCronDate(
|
|
224
|
+
fields,
|
|
225
|
+
at("2026-08-23T00:00:00.000Z"),
|
|
226
|
+
"Asia/Ho_Chi_Minh",
|
|
227
|
+
);
|
|
228
|
+
expect(next?.toISOString()).toBe("2026-08-23T15:30:00.000Z");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("qua nửa đêm giờ VN thì nhảy sang NGÀY HÔM SAU đúng", () => {
|
|
232
|
+
const fields = parseCronExpression("0 23 * * *")!;
|
|
233
|
+
// 17:00Z = 00:00 hôm sau giờ VN ⇒ lần chạy kế là 23:00 VN của ngày 24.
|
|
234
|
+
const next = nextCronDate(
|
|
235
|
+
fields,
|
|
236
|
+
at("2026-08-23T17:00:00.000Z"),
|
|
237
|
+
"Asia/Ho_Chi_Minh",
|
|
238
|
+
);
|
|
239
|
+
expect(next?.toISOString()).toBe("2026-08-24T16:00:00.000Z");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("thứ trong tuần cũng đọc theo múi giờ nghiệp vụ", () => {
|
|
243
|
+
// 2026-08-23 17:00Z là Chủ nhật ở UTC nhưng đã là THỨ HAI 00:00 giờ VN.
|
|
244
|
+
const fields = parseCronExpression("0 8 * * 1")!;
|
|
245
|
+
const next = nextCronDate(
|
|
246
|
+
fields,
|
|
247
|
+
at("2026-08-23T17:00:00.000Z"),
|
|
248
|
+
"Asia/Ho_Chi_Minh",
|
|
249
|
+
);
|
|
250
|
+
expect(next?.toISOString()).toBe("2026-08-24T01:00:00.000Z");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("không khai timezone → giữ nguyên hành vi cũ (giờ máy)", () => {
|
|
254
|
+
const fields = parseCronExpression("*/10 * * * *")!;
|
|
255
|
+
const from = at("2026-08-23T03:03:00.000Z");
|
|
256
|
+
const next = nextCronDate(fields, from);
|
|
257
|
+
expect(next!.getMinutes() % 10).toBe(0);
|
|
258
|
+
expect(next!.getTime()).toBeGreaterThan(from.getTime());
|
|
259
|
+
});
|
|
260
|
+
});
|