@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.
@@ -0,0 +1,459 @@
1
+ import { SimpleCronJob } from "../infrastructure/cron/cron-manager";
2
+
3
+ import type {
4
+ CronJob,
5
+ CronJobManager,
6
+ CronJobOptions,
7
+ } from "../infrastructure/cron/types";
8
+
9
+ /**
10
+ * Cron CÓ TRẠNG THÁI TRONG DB — engine dùng chung mọi app goerp (bảng
11
+ * `system_jobs` + `job_execution_logs` ship qua `goerp-features sync`, feature
12
+ * system-jobs). Bản `infrastructure/cron` chỉ hẹn giờ trong RAM: restart là
13
+ * mất bật/tắt, không có lịch sử chạy, admin không nhìn thấy gì.
14
+ *
15
+ * Vai trò từng lớp:
16
+ * - `SimpleCronJob` (infrastructure): bấm giờ setInterval thuần.
17
+ * - lớp này: DB là NGUỒN SỰ THẬT của cờ enabled + cronTime, bọc mỗi lần chạy
18
+ * thành 1 row `job_execution_logs` (running → success|failed, kèm actions
19
+ * do job tự ghi qua `getJobExecutionContext().log()`).
20
+ *
21
+ * App cắm qua `configureCronManager({ db, logger? })` (cùng khuôn
22
+ * configureTaskRunner/configureNotificationService), rồi đăng ký job trong
23
+ * `instrumentation.ts`.
24
+ *
25
+ * BỀN VỚI APP CHƯA SYNC FEATURE: thiếu model systemJob/jobExecutionLog thì
26
+ * chạy chế độ memory-only thay vì nổ — cron là hạ tầng nền, không được làm
27
+ * chết boot.
28
+ */
29
+
30
+ export interface ActionLog {
31
+ time: string;
32
+ action: string;
33
+ details?: string;
34
+ }
35
+
36
+ /** Ngữ cảnh của LẦN CHẠY hiện tại — job dùng để ghi diễn biến vào log. */
37
+ export interface JobExecutionContext {
38
+ logId: string;
39
+ jobName: string;
40
+ startedAt: Date;
41
+ actions: ActionLog[];
42
+ log: (action: string, details?: string) => void;
43
+ }
44
+
45
+ /**
46
+ * Delegate Prisma tối thiểu — structural, args `any` CÓ CHỦ ĐÍCH (client
47
+ * Prisma sinh ra hẹp hơn structural type — bài học SettingsDb/TaskDb).
48
+ * Cả 2 model đều optional: app chưa `goerp-features sync` vẫn chạy được.
49
+ */
50
+ export interface CronDb {
51
+ systemJob?: {
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ count(args?: any): Promise<number>;
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ findUnique(args: any): Promise<any>;
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ create(args: any): Promise<any>;
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ update(args: any): Promise<any>;
60
+ };
61
+ jobExecutionLog?: {
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ create(args: any): Promise<any>;
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ update(args: any): Promise<any>;
66
+ };
67
+ }
68
+
69
+ export interface CronLogger {
70
+ info(message: string, meta?: unknown): void;
71
+ warn(message: string, meta?: unknown): void;
72
+ error(message: string, meta?: unknown): void;
73
+ }
74
+
75
+ interface CronConfig {
76
+ db: CronDb;
77
+ /** Bỏ trống thì dùng console (prefix [cron]). */
78
+ logger?: CronLogger;
79
+ }
80
+
81
+ const consoleLogger: CronLogger = {
82
+ info: (m, meta) => console.info(`[cron] ${m}`, meta ?? ""),
83
+ warn: (m, meta) => console.warn(`[cron] ${m}`, meta ?? ""),
84
+ error: (m, meta) => console.error(`[cron] ${m}`, meta ?? ""),
85
+ };
86
+
87
+ let config: CronConfig | null = null;
88
+
89
+ export function configureCronManager(next: CronConfig): void {
90
+ config = next;
91
+ }
92
+
93
+ function requireConfig(): CronConfig {
94
+ if (!config) {
95
+ throw new Error(
96
+ "[cron] chưa configureCronManager({ db, logger? }) — gọi 1 lần lúc khởi tạo app (instrumentation).",
97
+ );
98
+ }
99
+ return config;
100
+ }
101
+
102
+ function log(): CronLogger {
103
+ return config?.logger ?? consoleLogger;
104
+ }
105
+
106
+ let currentExecutionContext: JobExecutionContext | null = null;
107
+
108
+ /** Job đang chạy gọi để ghi diễn biến vào `job_execution_logs.actions`. */
109
+ export function getJobExecutionContext(): JobExecutionContext | null {
110
+ return currentExecutionContext;
111
+ }
112
+
113
+ export class DbCronJobManager implements CronJobManager {
114
+ private jobs = new Map<string, CronJob>();
115
+ /** onTick GỐC của app — giữ để updateJob dựng lại job với cronTime mới. */
116
+ private jobCallbacks = new Map<string, () => void | Promise<void>>();
117
+ /** onTick ĐÃ BỌC (ghi log) — "chạy ngay" phải dùng bản này để có lịch sử. */
118
+ private wrappedCallbacks = new Map<string, () => void | Promise<void>>();
119
+ private isInitialized = false;
120
+
121
+ async init(): Promise<void> {
122
+ if (this.isInitialized) return;
123
+ try {
124
+ const { db } = requireConfig();
125
+ if (db.systemJob) {
126
+ const jobsCount = await db.systemJob.count();
127
+ log().info("DbCronJobManager initialized", { jobsCount });
128
+ } else {
129
+ log().warn(
130
+ "Thiếu model SystemJob (app chưa goerp-features sync system-jobs) — cron chạy memory-only",
131
+ );
132
+ }
133
+ } catch (error) {
134
+ log().error("Khởi tạo DbCronJobManager lỗi", error);
135
+ } finally {
136
+ // Đánh dấu đã init kể cả khi lỗi — tránh vòng lặp thử lại mỗi request.
137
+ this.isInitialized = true;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Bọc onTick: mở row execution log → chạy → đóng row (success/failed) và
143
+ * cập nhật trạng thái job. Dùng CHUNG cho addJob/updateJob/chạy-tay để
144
+ * mọi đường chạy đều để lại lịch sử.
145
+ */
146
+ private wrapOnTick(
147
+ name: string,
148
+ onTick: () => void | Promise<void>,
149
+ ): () => Promise<void> {
150
+ return async () => {
151
+ const { db } = requireConfig();
152
+ const startedAt = new Date();
153
+ let logId: string | null = null;
154
+
155
+ if (db.jobExecutionLog) {
156
+ try {
157
+ const entry = await db.jobExecutionLog.create({
158
+ data: { jobName: name, startedAt, status: "running", actions: [] },
159
+ });
160
+ logId = entry.id;
161
+ } catch (e) {
162
+ log().error(`Tạo execution log cho ${name} lỗi`, e);
163
+ }
164
+ }
165
+
166
+ const actions: ActionLog[] = [];
167
+ currentExecutionContext = {
168
+ logId: logId ?? "",
169
+ jobName: name,
170
+ startedAt,
171
+ actions,
172
+ log: (action, details) =>
173
+ actions.push({ time: new Date().toISOString(), action, details }),
174
+ };
175
+
176
+ await this.recordJobRun(name, "running");
177
+
178
+ try {
179
+ await onTick();
180
+ const finishedAt = new Date();
181
+ await this.closeExecutionLog(logId, name, {
182
+ finishedAt,
183
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
184
+ status: "success",
185
+ actions,
186
+ });
187
+ await this.recordJobRun(name, "idle", finishedAt);
188
+ } catch (error) {
189
+ const finishedAt = new Date();
190
+ await this.closeExecutionLog(logId, name, {
191
+ finishedAt,
192
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
193
+ status: "failed",
194
+ actions,
195
+ error: String(error),
196
+ });
197
+ await this.recordJobRun(name, "failed", undefined, String(error));
198
+ throw error;
199
+ } finally {
200
+ currentExecutionContext = null;
201
+ }
202
+ };
203
+ }
204
+
205
+ private async closeExecutionLog(
206
+ logId: string | null,
207
+ name: string,
208
+ result: {
209
+ finishedAt: Date;
210
+ durationMs: number;
211
+ status: "success" | "failed";
212
+ actions: ActionLog[];
213
+ error?: string;
214
+ },
215
+ ): Promise<void> {
216
+ const { db } = requireConfig();
217
+ if (!logId || !db.jobExecutionLog) return;
218
+ try {
219
+ await db.jobExecutionLog.update({
220
+ where: { id: logId },
221
+ data: {
222
+ finishedAt: result.finishedAt,
223
+ durationMs: result.durationMs,
224
+ status: result.status,
225
+ actions: result.actions,
226
+ error: result.error,
227
+ summary:
228
+ result.status === "success"
229
+ ? `Completed in ${result.durationMs}ms with ${result.actions.length} actions`
230
+ : `Failed after ${result.durationMs}ms: ${result.error}`,
231
+ },
232
+ });
233
+ } catch (e) {
234
+ log().error(`Cập nhật execution log cho ${name} lỗi`, e);
235
+ }
236
+ }
237
+
238
+ addJob(options: CronJobOptions): CronJob {
239
+ this.jobCallbacks.set(options.name, options.onTick);
240
+
241
+ const wrappedOnTick = this.wrapOnTick(options.name, options.onTick);
242
+ const wrappedOptions: CronJobOptions = {
243
+ ...options,
244
+ onTick: wrappedOnTick,
245
+ };
246
+
247
+ const job = new SimpleCronJob(wrappedOptions);
248
+ this.jobs.set(options.name, job);
249
+ this.wrappedCallbacks.set(options.name, wrappedOnTick);
250
+
251
+ // Đồng bộ DB bất đồng bộ: DB quyết định job có chạy hay không.
252
+ void this.syncJobWithDb(wrappedOptions, job).catch((e) =>
253
+ log().error(`Đồng bộ job ${options.name} lỗi`, e),
254
+ );
255
+
256
+ return job;
257
+ }
258
+
259
+ private async syncJobWithDb(
260
+ options: CronJobOptions,
261
+ job: CronJob,
262
+ /** Ý chí ADMIN (updateJob truyền vào) — ghi đè cờ enabled đang có trong DB. */
263
+ enabledOverride?: boolean,
264
+ ): Promise<void> {
265
+ const { db } = requireConfig();
266
+ if (!db.systemJob) {
267
+ if ((enabledOverride ?? options.start ?? true) !== false) job.start();
268
+ return;
269
+ }
270
+
271
+ try {
272
+ let dbJob = await db.systemJob.findUnique({
273
+ where: { name: options.name },
274
+ });
275
+
276
+ if (!dbJob) {
277
+ dbJob = await db.systemJob.create({
278
+ data: {
279
+ name: options.name,
280
+ cronTime: options.cronTime,
281
+ enabled: enabledOverride ?? options.start ?? true,
282
+ status: "idle",
283
+ },
284
+ });
285
+ } else if (
286
+ enabledOverride !== undefined &&
287
+ dbJob.enabled !== enabledOverride
288
+ ) {
289
+ // Không ghi xuống DB thì vòng dưới sẽ đọc lại cờ cũ và bật lại job.
290
+ await db.systemJob.update({
291
+ where: { name: options.name },
292
+ data: { enabled: enabledOverride },
293
+ });
294
+ dbJob.enabled = enabledOverride;
295
+ }
296
+
297
+ // DB là nguồn sự thật: admin tắt job thì deploy mới không tự bật lại.
298
+ if (dbJob.enabled && !job.isRunning()) job.start();
299
+ else if (!dbJob.enabled && job.isRunning()) job.stop();
300
+
301
+ // Lịch trong CODE thắng khi lập trình viên đổi — ghi ngược lại DB.
302
+ if (dbJob.cronTime !== options.cronTime) {
303
+ await db.systemJob.update({
304
+ where: { name: options.name },
305
+ data: { cronTime: options.cronTime },
306
+ });
307
+ }
308
+ } catch (e) {
309
+ log().error(`Đồng bộ job ${options.name} với DB lỗi`, e);
310
+ }
311
+ }
312
+
313
+ private async recordJobRun(
314
+ name: string,
315
+ status: string,
316
+ lastRun?: Date,
317
+ error?: string,
318
+ ): Promise<void> {
319
+ const { db } = requireConfig();
320
+ if (!db.systemJob) return;
321
+ try {
322
+ await db.systemJob.update({
323
+ where: { name },
324
+ data: {
325
+ status,
326
+ lastRun,
327
+ error: error ?? null, // chạy được thì xoá lỗi cũ
328
+ nextRun: this.getJob(name)?.nextDate(),
329
+ },
330
+ });
331
+ } catch (e) {
332
+ log().error(`Cập nhật trạng thái job ${name} lỗi`, e);
333
+ }
334
+ }
335
+
336
+ removeJob(name: string): void {
337
+ const job = this.jobs.get(name);
338
+ if (!job) return;
339
+ job.stop();
340
+ this.jobs.delete(name);
341
+ this.jobCallbacks.delete(name);
342
+ this.wrappedCallbacks.delete(name);
343
+ }
344
+
345
+ getJob(name: string): CronJob | undefined {
346
+ return this.jobs.get(name);
347
+ }
348
+
349
+ getAllJobs(): CronJob[] {
350
+ return Array.from(this.jobs.values());
351
+ }
352
+
353
+ startAll(): void {
354
+ this.jobs.forEach((job) => {
355
+ if (!job.isRunning()) job.start();
356
+ });
357
+ }
358
+
359
+ stopAll(): void {
360
+ this.jobs.forEach((job) => job.stop());
361
+ }
362
+
363
+ /** Trạng thái runtime cho trang admin. */
364
+ getJobsInfo(): Array<{
365
+ name: string;
366
+ cronTime: string;
367
+ isRunning: boolean;
368
+ nextDate: Date | null;
369
+ }> {
370
+ return Array.from(this.jobs.values()).map((job) => ({
371
+ name: job.name,
372
+ cronTime: job.cronTime,
373
+ isRunning: job.isRunning(),
374
+ nextDate: job.nextDate(),
375
+ }));
376
+ }
377
+
378
+ async toggleJob(name: string): Promise<boolean> {
379
+ const job = this.jobs.get(name);
380
+ if (!job) return false;
381
+
382
+ const newState = !job.isRunning();
383
+ if (newState) job.start();
384
+ else job.stop();
385
+
386
+ const { db } = requireConfig();
387
+ if (db.systemJob) {
388
+ try {
389
+ await db.systemJob.update({
390
+ where: { name },
391
+ data: { enabled: newState },
392
+ });
393
+ } catch (e) {
394
+ log().error(`Ghi cờ enabled của job ${name} lỗi`, e);
395
+ }
396
+ }
397
+ return true;
398
+ }
399
+
400
+ /** Chạy tay (fire-and-forget) — vẫn đi qua bản BỌC nên có execution log. */
401
+ async runJobNow(name: string): Promise<boolean> {
402
+ const callback = this.wrappedCallbacks.get(name);
403
+ if (!callback) return false;
404
+ void Promise.resolve(callback()).catch((e) =>
405
+ log().error(`Chạy tay job ${name} lỗi`, e),
406
+ );
407
+ return true;
408
+ }
409
+
410
+ /** Đổi lịch: dựng lại job từ onTick GỐC, giữ nguyên đường ghi log. */
411
+ async updateJob(
412
+ name: string,
413
+ newCronTime: string,
414
+ enabled?: boolean,
415
+ ): Promise<boolean> {
416
+ const existingJob = this.jobs.get(name);
417
+ const originalOnTick = this.jobCallbacks.get(name);
418
+ if (!existingJob || !originalOnTick) {
419
+ log().warn(`Job ${name} không tồn tại — bỏ qua updateJob`);
420
+ return false;
421
+ }
422
+
423
+ const wasRunning = existingJob.isRunning();
424
+ existingJob.stop();
425
+ this.jobs.delete(name);
426
+
427
+ const { db } = requireConfig();
428
+ let finalEnabled = enabled;
429
+ if (finalEnabled === undefined && db.systemJob) {
430
+ try {
431
+ const dbJob = await db.systemJob.findUnique({ where: { name } });
432
+ finalEnabled = dbJob?.enabled ?? wasRunning;
433
+ } catch (e) {
434
+ log().error(`Đọc trạng thái job ${name} từ DB lỗi`, e);
435
+ finalEnabled = wasRunning;
436
+ }
437
+ }
438
+
439
+ const wrappedOnTick = this.wrapOnTick(name, originalOnTick);
440
+ const newJobOptions: CronJobOptions = {
441
+ name,
442
+ cronTime: newCronTime,
443
+ onTick: wrappedOnTick,
444
+ start: finalEnabled ?? false,
445
+ };
446
+
447
+ const newJob = new SimpleCronJob(newJobOptions);
448
+ this.jobs.set(name, newJob);
449
+ this.jobCallbacks.set(name, originalOnTick);
450
+ this.wrappedCallbacks.set(name, wrappedOnTick);
451
+
452
+ await this.syncJobWithDb(newJobOptions, newJob, enabled);
453
+ log().info(`Đổi lịch job ${name} → ${newCronTime}`);
454
+ return true;
455
+ }
456
+ }
457
+
458
+ /** Singleton dùng chung app (đăng ký job ở instrumentation). */
459
+ export const cronManager = new DbCronJobManager();
@@ -0,0 +1,24 @@
1
+ export {
2
+ configureCronManager,
3
+ cronManager,
4
+ DbCronJobManager,
5
+ getJobExecutionContext,
6
+ } from "./db-cron-manager";
7
+ export type {
8
+ ActionLog,
9
+ CronDb,
10
+ CronLogger,
11
+ JobExecutionContext,
12
+ } from "./db-cron-manager";
13
+
14
+ // Lớp hẹn giờ thuần RAM — dùng lại khi app không cần trạng thái DB.
15
+ export {
16
+ cronJobManager,
17
+ CronJobManagerImpl,
18
+ SimpleCronJob,
19
+ } from "../infrastructure/cron/cron-manager";
20
+ export type {
21
+ CronJob,
22
+ CronJobManager,
23
+ CronJobOptions,
24
+ } from "../infrastructure/cron/types";
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Smoke render trang "Nhật ký hệ thống": mount thật (jsdom) với fetch giả.
3
+ * Trang chỉ mở được sau đăng nhập quyền admin nên không kiểm được bằng HTTP
4
+ * ẩn danh — test này bắt lỗi import/hook trước khi app tiêu thụ, và chốt hai
5
+ * điều dễ vỡ khi app truyền cấu hình riêng: nhãn hành động của app phải thắng,
6
+ * và bộ lọc đối tượng phải BIẾN MẤT khi app không khai `resources`.
7
+ */
8
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import { SystemAuditPage } from "../system-audit-page";
12
+
13
+ const LOG = {
14
+ id: "log-1",
15
+ action: "update",
16
+ resource: "customer",
17
+ resourceId: "cust-1",
18
+ userId: "u1",
19
+ description: "[Kế toán] Cập nhật customer",
20
+ oldData: { name: "Tên cũ" },
21
+ newData: { name: "Tên mới" },
22
+ createdAt: "2026-08-01T06:00:00.000Z",
23
+ user: null,
24
+ };
25
+
26
+ function mockFetch(logs: unknown[] = [LOG], total = logs.length) {
27
+ const calls: string[] = [];
28
+ global.fetch = vi.fn(async (input: RequestInfo | URL) => {
29
+ calls.push(String(input));
30
+ return new Response(
31
+ JSON.stringify({
32
+ data: logs,
33
+ meta: { total, skip: 0, take: 30, hasMore: total > 30 },
34
+ }),
35
+ { status: 200, headers: { "Content-Type": "application/json" } },
36
+ );
37
+ }) as unknown as typeof fetch;
38
+ return calls;
39
+ }
40
+
41
+ describe("SystemAuditPage", () => {
42
+ afterEach(() => vi.restoreAllMocks());
43
+
44
+ it("render nhật ký từ API, bóc tên người ra khỏi tiền tố [Tên]", async () => {
45
+ mockFetch();
46
+ render(<SystemAuditPage />);
47
+
48
+ await waitFor(() => expect(screen.getByText("Cập nhật")).toBeDefined());
49
+ // "[Kế toán]" là tiền tố trong description → cột Người thực hiện, và mô tả
50
+ // hiển thị đã cắt tiền tố.
51
+ expect(screen.getByText("Kế toán")).toBeDefined();
52
+ expect(screen.getByText("Cập nhật customer")).toBeDefined();
53
+ expect(screen.getByText("cust-1")).toBeDefined();
54
+ });
55
+
56
+ it("bung dòng mới hiện diff cũ → mới", async () => {
57
+ mockFetch();
58
+ render(<SystemAuditPage />);
59
+
60
+ await waitFor(() => expect(screen.getByText("Cập nhật")).toBeDefined());
61
+ expect(screen.queryByText("Tên cũ")).toBeNull();
62
+
63
+ const toggle = screen
64
+ .getAllByRole("button")
65
+ .find((b) => b.className.includes("h-6 w-6"))!;
66
+ fireEvent.click(toggle);
67
+
68
+ expect(screen.getByText("Tên cũ")).toBeDefined();
69
+ expect(screen.getByText("Tên mới")).toBeDefined();
70
+ });
71
+
72
+ it("nhãn/màu hành động của app thắng mặc định của core", async () => {
73
+ mockFetch([{ ...LOG, action: "confirm-payment" }]);
74
+ render(
75
+ <SystemAuditPage
76
+ actions={[
77
+ {
78
+ value: "confirm-payment",
79
+ label: "Xác nhận thanh toán",
80
+ color: "bg-amber-500",
81
+ },
82
+ ]}
83
+ />,
84
+ );
85
+
86
+ await waitFor(() =>
87
+ expect(screen.getByText("Xác nhận thanh toán")).toBeDefined(),
88
+ );
89
+ });
90
+
91
+ it("không khai resources thì ẩn hẳn bộ lọc đối tượng", async () => {
92
+ mockFetch();
93
+ const { rerender } = render(<SystemAuditPage />);
94
+
95
+ await waitFor(() => expect(screen.getByText("Cập nhật")).toBeDefined());
96
+ expect(screen.queryByText("Tất cả đối tượng")).toBeNull();
97
+
98
+ rerender(
99
+ <SystemAuditPage
100
+ resources={[{ value: "customer", label: "Khách hàng" }]}
101
+ />,
102
+ );
103
+ await waitFor(() =>
104
+ expect(screen.getByText("Tất cả đối tượng")).toBeDefined(),
105
+ );
106
+ });
107
+
108
+ it("gửi bộ lọc + phân trang XUỐNG SERVER (bảng này hàng triệu dòng)", async () => {
109
+ const calls = mockFetch([LOG], 100);
110
+ render(<SystemAuditPage apiUrl="/api/audit" />);
111
+
112
+ await waitFor(() => expect(calls.length).toBeGreaterThan(0));
113
+ expect(calls[0]).toContain("/api/audit?");
114
+ expect(calls[0]).toContain("skip=0");
115
+ expect(calls[0]).toContain("take=30");
116
+
117
+ // total 100 > PAGE_SIZE → có phân trang, sang trang 2 phải gọi lại API.
118
+ fireEvent.click(
119
+ screen.getAllByRole("button").find((b) => b.className.includes("h-7"))!,
120
+ );
121
+ fireEvent.click(
122
+ screen
123
+ .getAllByRole("button")
124
+ .filter((b) => b.className.includes("h-7"))[1],
125
+ );
126
+
127
+ await waitFor(() =>
128
+ expect(calls.some((u) => u.includes("skip=30"))).toBe(true),
129
+ );
130
+ });
131
+
132
+ it("rỗng thì báo không tìm thấy, không vỡ layout", async () => {
133
+ mockFetch([], 0);
134
+ render(<SystemAuditPage />);
135
+
136
+ await waitFor(() =>
137
+ expect(screen.getByText("Không tìm thấy nhật ký nào")).toBeDefined(),
138
+ );
139
+ });
140
+ });