@goplusvn/core 0.1.86 → 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 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.86",
4
+ "version": "0.1.88",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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(where: Record<string, any> | undefined): string | null {
103
- if (!where?.id) return null;
104
- if (typeof where.id === "string") return where.id;
105
- if (typeof where.id === "object" && where.id?.equals)
106
- return String(where.id.equals);
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 resourceId: string | null = null;
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
- resourceId = extractId(where);
297
+ resourceIdRaw = extractId(where);
275
298
  try {
276
299
  let existing: any = null;
277
- if (resourceId && delegate?.findUnique) {
300
+ if (resourceIdRaw !== null && delegate?.findUnique) {
278
301
  existing = await delegate.findUnique({
279
- where: { id: resourceId },
302
+ where: { id: resourceIdRaw },
280
303
  });
281
304
  } else if (delegate?.findFirst) {
282
305
  existing = await delegate.findFirst({ where });
283
- if (existing?.id) resourceId = 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
- resourceId = (result as any)?.id || null;
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
- const idSuffix = resourceId ? ` #${resourceId.slice(-8)}` : "";
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
 
@@ -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
+ });