@goplusvn/core 0.1.52 → 0.1.53
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 +5 -0
- package/features/notifications/README.md +14 -0
- package/features/notifications/migrations/0001_init.sql +59 -0
- package/features/notifications/schema.prisma +46 -0
- package/package.json +3 -1
- package/src/notification/__tests__/notification-service.test.ts +192 -0
- package/src/notification/__tests__/notification-ui.test.tsx +62 -0
- package/src/notification/index.ts +15 -13
- package/src/notification/notification-service.ts +270 -96
- package/src/notification/ui/index.ts +2 -0
- package/src/notification/ui/notification-bell.tsx +244 -0
- package/src/notification/ui/notifications-inbox.tsx +193 -0
- package/src/notification/storage/in-memory.ts +0 -56
- package/src/notification/storage/index.ts +0 -1
- package/src/notification/types.ts +0 -51
package/features/README.md
CHANGED
|
@@ -31,6 +31,11 @@ GENERATED — đừng sửa tay) và tạo thư mục migration
|
|
|
31
31
|
- `background-tasks` — bảng `background_tasks` cho trung tâm tác vụ nền
|
|
32
32
|
(export/import chạy nền). Runtime: `@goerp/core/tasks`
|
|
33
33
|
(`configureTaskRunner`) + UI `@goerp/core/tasks/ui`.
|
|
34
|
+
- `notifications` — bảng `notifications` + `push_subscriptions` cho thông báo
|
|
35
|
+
in-app NHÂN VIÊN. Runtime: `@goerp/core/notification`
|
|
36
|
+
(`configureNotificationService`, seam `afterNotify` để app cắm web push/email)
|
|
37
|
+
+ UI `@goerp/core/notification/ui` (chuông + hộp thư). YÊU CẦU model `User`
|
|
38
|
+
của app khai back-relation — xem `features/notifications/README.md`.
|
|
34
39
|
- `error-logs` — bảng `error_logs` cho hệ ghi lỗi server
|
|
35
40
|
(`createErrorLogger`/`buildServerError` ở `errors/server-error`) + trang
|
|
36
41
|
admin `system/pages/error-logs-page` (app cung cấp API, mẫu vinhhoa
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Feature: notifications
|
|
2
|
+
|
|
3
|
+
Bảng `notifications` + `push_subscriptions` cho engine
|
|
4
|
+
`@goerp/core/notification` (`configureNotificationService`).
|
|
5
|
+
|
|
6
|
+
**Yêu cầu phía app (Prisma bắt buộc 2 chiều relation):** model `User` phải khai
|
|
7
|
+
|
|
8
|
+
```prisma
|
|
9
|
+
notifications Notification[]
|
|
10
|
+
pushSubscriptions PushSubscription[]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
App template (F2) có sẵn; app cũ thêm 2 dòng trên vào model User trước khi
|
|
14
|
+
`goerp-features sync`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
-- goerp feature: notifications — bước 0001 (idempotent). 2 bảng: thông báo
|
|
2
|
+
-- in-app (fan-out mỗi người 1 row) + web push subscription theo thiết bị.
|
|
3
|
+
-- YÊU CẦU: bảng "users" tồn tại (schema auth/RBAC chung của goerp) và model
|
|
4
|
+
-- User của app khai back-relation `notifications Notification[]` +
|
|
5
|
+
-- `pushSubscriptions PushSubscription[]` (xem features/notifications/README).
|
|
6
|
+
CREATE TABLE IF NOT EXISTS "notifications" (
|
|
7
|
+
"id" TEXT NOT NULL,
|
|
8
|
+
"user_id" TEXT NOT NULL,
|
|
9
|
+
"type" TEXT NOT NULL DEFAULT 'info',
|
|
10
|
+
"category" TEXT,
|
|
11
|
+
"title" TEXT NOT NULL,
|
|
12
|
+
"content" TEXT NOT NULL,
|
|
13
|
+
"url" TEXT,
|
|
14
|
+
"icon_name" TEXT,
|
|
15
|
+
"resource_type" TEXT,
|
|
16
|
+
"resource_id" TEXT,
|
|
17
|
+
"is_read" BOOLEAN NOT NULL DEFAULT false,
|
|
18
|
+
"read_at" TIMESTAMP(3),
|
|
19
|
+
"created_by" TEXT,
|
|
20
|
+
"meta" JSONB,
|
|
21
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
22
|
+
|
|
23
|
+
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE INDEX IF NOT EXISTS "notifications_user_id_is_read_idx" ON "notifications"("user_id", "is_read");
|
|
27
|
+
CREATE INDEX IF NOT EXISTS "notifications_user_id_created_at_idx" ON "notifications"("user_id", "created_at");
|
|
28
|
+
CREATE INDEX IF NOT EXISTS "notifications_resource_type_resource_id_idx" ON "notifications"("resource_type", "resource_id");
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
|
31
|
+
"id" TEXT NOT NULL,
|
|
32
|
+
"user_id" TEXT NOT NULL,
|
|
33
|
+
"endpoint" TEXT NOT NULL,
|
|
34
|
+
"p256dh" TEXT NOT NULL,
|
|
35
|
+
"auth" TEXT NOT NULL,
|
|
36
|
+
"user_agent" TEXT,
|
|
37
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
38
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
39
|
+
|
|
40
|
+
CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id")
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "push_subscriptions_endpoint_key" ON "push_subscriptions"("endpoint");
|
|
44
|
+
CREATE INDEX IF NOT EXISTS "push_subscriptions_user_id_idx" ON "push_subscriptions"("user_id");
|
|
45
|
+
|
|
46
|
+
-- FK: PG không có ADD CONSTRAINT IF NOT EXISTS → DO-block kiểm tra pg_constraint.
|
|
47
|
+
DO $$ BEGIN
|
|
48
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'notifications_user_id_fkey') THEN
|
|
49
|
+
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey"
|
|
50
|
+
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
51
|
+
END IF;
|
|
52
|
+
END $$;
|
|
53
|
+
|
|
54
|
+
DO $$ BEGIN
|
|
55
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'push_subscriptions_user_id_fkey') THEN
|
|
56
|
+
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_fkey"
|
|
57
|
+
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
58
|
+
END IF;
|
|
59
|
+
END $$;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/// Thông báo in-app cho NHÂN VIÊN (khác ZaloNotification / TriAnhZnsMessage — đó
|
|
2
|
+
/// là ZNS gửi KHÁCH). Mỗi row là 1 tin gửi tới 1 user (fan-out ở tầng service,
|
|
3
|
+
/// không broadcast 1-row-nhiều-người). Best-effort: emit không được chặn nghiệp vụ.
|
|
4
|
+
model Notification {
|
|
5
|
+
id String @id @default(cuid()) @map("id")
|
|
6
|
+
userId String @map("user_id") // người NHẬN
|
|
7
|
+
type String @default("info") @map("type") // info | success | warning | error | approval
|
|
8
|
+
category String? @map("category") // sales-order | payment | payment-request | misa | purchase-order | system
|
|
9
|
+
title String @map("title")
|
|
10
|
+
content String @map("content") @db.Text
|
|
11
|
+
url String? @map("url") // deep-link tới đối tượng
|
|
12
|
+
iconName String? @map("icon_name") // lucide icon cho dropdown
|
|
13
|
+
resourceType String? @map("resource_type") // "SalesOrder" | "PaymentRequest" | "PurchaseOrder"…
|
|
14
|
+
resourceId String? @map("resource_id")
|
|
15
|
+
isRead Boolean @default(false) @map("is_read")
|
|
16
|
+
readAt DateTime? @map("read_at")
|
|
17
|
+
createdBy String? @map("created_by") // actor gây ra sự kiện (null = hệ thống)
|
|
18
|
+
meta Json? @map("meta")
|
|
19
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
20
|
+
|
|
21
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
22
|
+
|
|
23
|
+
@@index([userId, isRead])
|
|
24
|
+
@@index([userId, createdAt])
|
|
25
|
+
@@index([resourceType, resourceId])
|
|
26
|
+
@@map("notifications")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Web Push subscription của 1 thiết bị (PWA iOS/Android/desktop). 1 user nhiều
|
|
30
|
+
/// row = nhiều thiết bị. `endpoint` (URL APNs/FCM) là khoá dedup tự nhiên; push
|
|
31
|
+
/// service trả 404/410 → XÓA row (chuẩn giao thức), không dùng cờ active.
|
|
32
|
+
model PushSubscription {
|
|
33
|
+
id String @id @default(cuid()) @map("id")
|
|
34
|
+
userId String @map("user_id")
|
|
35
|
+
endpoint String @unique @map("endpoint")
|
|
36
|
+
p256dh String @map("p256dh") // khoá mã hoá payload (từ PushSubscription.toJSON)
|
|
37
|
+
auth String @map("auth")
|
|
38
|
+
userAgent String? @map("user_agent") // nhận diện thiết bị khi user quản lý danh sách
|
|
39
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
40
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
41
|
+
|
|
42
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
43
|
+
|
|
44
|
+
@@index([userId])
|
|
45
|
+
@@map("push_subscriptions")
|
|
46
|
+
}
|
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.53",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"./assets/*": "./src/assets/*",
|
|
44
44
|
"./styles/*": "./src/styles/*",
|
|
45
45
|
"./auth/api-handler": "./src/auth/api-handler.ts",
|
|
46
|
+
"./notification": "./src/notification/index.ts",
|
|
47
|
+
"./notification/ui": "./src/notification/ui/index.ts",
|
|
46
48
|
"./tasks": "./src/tasks/index.ts",
|
|
47
49
|
"./tasks/ui": "./src/tasks/ui/task-list-client.tsx",
|
|
48
50
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke render chuông + hộp thư: mount thật (jsdom) với fetch giả — bắt lỗi
|
|
3
|
+
* import/hook sai trước khi app tiêu thụ, vì UI này chạy trong MainLayout của
|
|
4
|
+
* MỌI trang (vỡ là vỡ toàn app).
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
8
|
+
|
|
9
|
+
vi.mock("next/navigation", () => ({
|
|
10
|
+
useParams: () => ({ lang: "vi" }),
|
|
11
|
+
useRouter: () => ({ push: vi.fn() }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { NotificationBell } from "../ui/notification-bell"
|
|
15
|
+
import { NotificationsInbox } from "../ui/notifications-inbox"
|
|
16
|
+
|
|
17
|
+
const ITEM = {
|
|
18
|
+
id: "n1",
|
|
19
|
+
type: "info",
|
|
20
|
+
category: "system",
|
|
21
|
+
title: "Đơn hàng mới",
|
|
22
|
+
content: "Đơn DH001 vừa được tạo",
|
|
23
|
+
url: "/sales-orders/1",
|
|
24
|
+
iconName: null,
|
|
25
|
+
isRead: false,
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockFetch() {
|
|
30
|
+
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
31
|
+
const url = String(input)
|
|
32
|
+
const body = url.includes("unread-count")
|
|
33
|
+
? { count: 3 }
|
|
34
|
+
: { items: [ITEM], nextCursor: null, total: 1, unreadCount: 3 }
|
|
35
|
+
return new Response(JSON.stringify(body), {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
})
|
|
39
|
+
}) as unknown as typeof fetch
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("UI thông báo", () => {
|
|
43
|
+
beforeEach(() => mockFetch())
|
|
44
|
+
|
|
45
|
+
it("chuông render badge số chưa đọc", async () => {
|
|
46
|
+
render(<NotificationBell />)
|
|
47
|
+
expect(screen.getByLabelText("Thông báo")).toBeDefined()
|
|
48
|
+
await waitFor(() => expect(screen.getByRole("status").textContent).toBe("3"))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("mở chuông: có tin + settingsSlot của app (vd toggle web push)", async () => {
|
|
52
|
+
render(<NotificationBell settingsSlot={<span>Bật thông báo đẩy</span>} />)
|
|
53
|
+
fireEvent.click(screen.getByLabelText("Thông báo"))
|
|
54
|
+
await waitFor(() => expect(screen.getByText("Đơn hàng mới")).toBeDefined())
|
|
55
|
+
expect(screen.getByText("Bật thông báo đẩy")).toBeDefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("hộp thư render danh sách từ API", async () => {
|
|
59
|
+
render(<NotificationsInbox />)
|
|
60
|
+
await waitFor(() => expect(screen.getByText("Đơn hàng mới")).toBeDefined())
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
// Thông báo in-app — engine thật (thay bản mock InMemoryStorage cũ, không app
|
|
2
|
+
// nào dùng). Bảng ship qua `goerp-features sync` (feature notifications).
|
|
3
|
+
// App wiring mẫu: vinhhoa src/server/services/notification-service.ts
|
|
4
|
+
// (configureNotificationService + afterNotify=web-push).
|
|
1
5
|
export {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
NotificationServiceOptions,
|
|
14
|
-
} from "./types";
|
|
6
|
+
configureNotificationService,
|
|
7
|
+
getUnreadCount,
|
|
8
|
+
listNotifications,
|
|
9
|
+
markAllRead,
|
|
10
|
+
markRead,
|
|
11
|
+
notify,
|
|
12
|
+
type ListParams,
|
|
13
|
+
type NotificationDb,
|
|
14
|
+
type NotificationType,
|
|
15
|
+
type NotifyInput,
|
|
16
|
+
} from "./notification-service"
|