@goplusvn/core 0.1.52 → 0.1.54

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,192 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ import {
4
+ configureNotificationService,
5
+ markRead,
6
+ notify,
7
+ } from "../notification-service"
8
+ import type { NotificationDb, NotifyInput } from "../notification-service"
9
+
10
+ interface FakeData {
11
+ rolePermissions: Array<{ resourceCode: string; actionCode: string; roleCode: string }>
12
+ userRoles: Array<{ roleCode: string; userId: string }>
13
+ userBranches: Array<{ branchId: string; userId: string; isDefault: boolean }>
14
+ users: Array<{ id: string; isActive: boolean }>
15
+ }
16
+
17
+ function makeDb(data: FakeData) {
18
+ const created: Array<Record<string, unknown>> = []
19
+ const db = {
20
+ notification: {
21
+ createMany: vi.fn(async ({ data: rows }: { data: Array<Record<string, unknown>> }) => {
22
+ created.push(...rows)
23
+ return { count: rows.length }
24
+ }),
25
+ findMany: vi.fn(async () => []),
26
+ count: vi.fn(async () => 0),
27
+ updateMany: vi.fn(async () => ({ count: 1 })),
28
+ },
29
+ rolePermission: {
30
+ findMany: vi.fn(async ({ where }: { where: { resourceCode: string; actionCode: string } }) =>
31
+ data.rolePermissions
32
+ .filter(
33
+ (r) =>
34
+ r.resourceCode === where.resourceCode &&
35
+ r.actionCode === where.actionCode,
36
+ )
37
+ .map((r) => ({ roleCode: r.roleCode })),
38
+ ),
39
+ },
40
+ userRole: {
41
+ findMany: vi.fn(async ({ where }: { where: { roleCode: { in: string[] } } }) =>
42
+ data.userRoles
43
+ .filter((r) => where.roleCode.in.includes(r.roleCode))
44
+ .map((r) => ({ userId: r.userId })),
45
+ ),
46
+ },
47
+ userBranch: {
48
+ findMany: vi.fn(
49
+ async ({
50
+ where,
51
+ }: {
52
+ where: { branchId: string; userId: { in: string[] }; isDefault?: boolean }
53
+ }) =>
54
+ data.userBranches
55
+ .filter(
56
+ (b) =>
57
+ b.branchId === where.branchId &&
58
+ where.userId.in.includes(b.userId) &&
59
+ (where.isDefault === undefined || b.isDefault === where.isDefault),
60
+ )
61
+ .map((b) => ({ userId: b.userId })),
62
+ ),
63
+ },
64
+ user: {
65
+ findMany: vi.fn(async ({ where }: { where: { id: { in: string[] } } }) =>
66
+ data.users
67
+ .filter((u) => where.id.in.includes(u.id) && u.isActive)
68
+ .map((u) => ({ id: u.id })),
69
+ ),
70
+ },
71
+ }
72
+ return { db: db as unknown as NotificationDb, created }
73
+ }
74
+
75
+ const BASE: FakeData = {
76
+ rolePermissions: [
77
+ { resourceCode: "payment-request", actionCode: "approve", roleCode: "MANAGER" },
78
+ ],
79
+ userRoles: [
80
+ { roleCode: "MANAGER", userId: "m1" },
81
+ { roleCode: "MANAGER", userId: "m2" },
82
+ { roleCode: "ACCOUNTANT", userId: "a1" },
83
+ ],
84
+ userBranches: [
85
+ { branchId: "b1", userId: "m1", isDefault: true },
86
+ { branchId: "b1", userId: "m2", isDefault: false },
87
+ ],
88
+ users: [
89
+ { id: "m1", isActive: true },
90
+ { id: "m2", isActive: true },
91
+ { id: "a1", isActive: true },
92
+ { id: "locked", isActive: false },
93
+ { id: "u1", isActive: true },
94
+ ],
95
+ }
96
+
97
+ const INPUT: Omit<NotifyInput, "userIds"> = { title: "T", content: "C" }
98
+
99
+ describe("notify — resolveRecipients", () => {
100
+ let created: Array<Record<string, unknown>>
101
+
102
+ function setup(
103
+ data: FakeData = BASE,
104
+ afterNotify?: (recipients: string[], input: NotifyInput) => Promise<unknown>,
105
+ ) {
106
+ const made = makeDb(data)
107
+ created = made.created
108
+ configureNotificationService({ db: made.db, afterNotify })
109
+ }
110
+
111
+ beforeEach(() => setup())
112
+
113
+ it("userIds tường minh: lọc falsy + user bị khoá, exclude actor", async () => {
114
+ const n = await notify({
115
+ ...INPUT,
116
+ userIds: ["u1", null, undefined, "locked", "m1"],
117
+ excludeUserId: "m1",
118
+ })
119
+ expect(n).toBe(1)
120
+ expect(created.map((c) => c.userId)).toEqual(["u1"])
121
+ })
122
+
123
+ it("roleCodes + branchId (member): chỉ user được gán chi nhánh", async () => {
124
+ const n = await notify({ ...INPUT, roleCodes: ["MANAGER"], branchId: "b1" })
125
+ expect(n).toBe(2) // m1 + m2 đều thuộc b1
126
+ })
127
+
128
+ it("branchScope default: chỉ user có isDefault tại chi nhánh", async () => {
129
+ const n = await notify({
130
+ ...INPUT,
131
+ roleCodes: ["MANAGER"],
132
+ branchId: "b1",
133
+ branchScope: "default",
134
+ })
135
+ expect(n).toBe(1)
136
+ expect(created[0].userId).toBe("m1")
137
+ })
138
+
139
+ it("permission → suy ra roleCodes từ rolePermission", async () => {
140
+ const n = await notify({
141
+ ...INPUT,
142
+ permission: { resourceCode: "payment-request", actionCode: "approve" },
143
+ })
144
+ expect(n).toBe(2) // MANAGER: m1, m2
145
+ })
146
+
147
+ it("roleCodesAllBranches bỏ qua lọc chi nhánh + gộp distinct", async () => {
148
+ const n = await notify({
149
+ ...INPUT,
150
+ roleCodes: ["MANAGER"],
151
+ branchId: "b1",
152
+ branchScope: "default", // member lọc còn m1
153
+ roleCodesAllBranches: ["ACCOUNTANT"], // a1 vào bất kể CN
154
+ })
155
+ expect(n).toBe(2)
156
+ expect(created.map((c) => c.userId).sort()).toEqual(["a1", "m1"])
157
+ })
158
+
159
+ it("afterNotify được gọi với recipients sau khi ghi DB", async () => {
160
+ const after = vi.fn(async () => {})
161
+ setup(BASE, after)
162
+ await notify({ ...INPUT, userIds: ["u1"] })
163
+ await new Promise((r) => setTimeout(r, 5))
164
+ expect(after).toHaveBeenCalledWith(["u1"], expect.objectContaining({ title: "T" }))
165
+ })
166
+
167
+ it("không người nhận → 0 row, afterNotify KHÔNG gọi", async () => {
168
+ const after = vi.fn(async () => {})
169
+ setup(BASE, after)
170
+ const n = await notify({ ...INPUT, userIds: ["locked"] })
171
+ expect(n).toBe(0)
172
+ expect(after).not.toHaveBeenCalled()
173
+ })
174
+
175
+ it("db nổ → notify nuốt lỗi trả 0 (KHÔNG throw — không chặn nghiệp vụ)", async () => {
176
+ const made = makeDb(BASE)
177
+ ;(made.db.notification.createMany as ReturnType<typeof vi.fn>).mockRejectedValue(
178
+ new Error("db down"),
179
+ )
180
+ configureNotificationService({ db: made.db })
181
+ await expect(notify({ ...INPUT, userIds: ["u1"] })).resolves.toBe(0)
182
+ })
183
+ })
184
+
185
+ describe("markRead", () => {
186
+ it("ids rỗng → 0, không đụng db", async () => {
187
+ const made = makeDb(BASE)
188
+ configureNotificationService({ db: made.db })
189
+ expect(await markRead("u1", [])).toBe(0)
190
+ expect(made.db.notification.updateMany).not.toHaveBeenCalled()
191
+ })
192
+ })