@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.
- package/features/README.md +9 -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/features/system-jobs/README.md +40 -0
- package/features/system-jobs/migrations/0001_init.sql +47 -0
- package/features/system-jobs/schema.prisma +42 -0
- package/package.json +5 -1
- package/src/cron/__tests__/db-cron-manager.test.ts +316 -0
- package/src/cron/db-cron-manager.ts +459 -0
- package/src/cron/index.ts +24 -0
- 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/system/pages/__tests__/system-jobs-page.test.tsx +92 -0
- package/src/system/pages/system-jobs-page.tsx +571 -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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
import type React from "react"
|
|
5
|
+
import { useParams, useRouter } from "next/navigation"
|
|
6
|
+
import {
|
|
7
|
+
Badge,
|
|
8
|
+
Button,
|
|
9
|
+
DynamicIcon,
|
|
10
|
+
Popover,
|
|
11
|
+
PopoverContent,
|
|
12
|
+
PopoverTrigger,
|
|
13
|
+
ScrollArea,
|
|
14
|
+
} from "../../ui/primitives"
|
|
15
|
+
import { toast } from "sonner"
|
|
16
|
+
import useSWR from "swr"
|
|
17
|
+
import { Bell, CheckCheck } from "lucide-react"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Chuông thông báo in-app dùng chung mọi app goerp — truyền vào MainLayout qua
|
|
22
|
+
* prop `notificationSlot`. Engine + bảng: @goerp/core/notification + feature
|
|
23
|
+
* notifications; app cung cấp API mỏng (mẫu vinhhoa /api/notifications*).
|
|
24
|
+
* Tự fetch dữ liệu app:
|
|
25
|
+
* - badge: poll GET /api/notifications/unread-count mỗi 45s
|
|
26
|
+
* - list: fetch GET /api/notifications khi MỞ dropdown
|
|
27
|
+
* - toast khi số chưa đọc tăng giữa 2 lần poll (tin mới)
|
|
28
|
+
* Không dùng SSE (nhất quán codebase — polling).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface NotiItem {
|
|
32
|
+
id: string
|
|
33
|
+
type: string
|
|
34
|
+
category: string | null
|
|
35
|
+
title: string
|
|
36
|
+
content: string
|
|
37
|
+
url: string | null
|
|
38
|
+
iconName: string | null
|
|
39
|
+
isRead: boolean
|
|
40
|
+
createdAt: string
|
|
41
|
+
}
|
|
42
|
+
interface ListResp {
|
|
43
|
+
items: NotiItem[]
|
|
44
|
+
nextCursor: string | null
|
|
45
|
+
total: number
|
|
46
|
+
unreadCount: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const fetcher = (url: string) =>
|
|
50
|
+
fetch(url).then((r) => {
|
|
51
|
+
if (!r.ok) throw new Error(String(r.status))
|
|
52
|
+
return r.json()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function timeAgo(d: string): string {
|
|
56
|
+
const s = Math.floor((Date.now() - new Date(d).getTime()) / 1000)
|
|
57
|
+
if (s < 60) return "vừa xong"
|
|
58
|
+
const m = Math.floor(s / 60)
|
|
59
|
+
if (m < 60) return `${m} phút trước`
|
|
60
|
+
const h = Math.floor(m / 60)
|
|
61
|
+
if (h < 24) return `${h} giờ trước`
|
|
62
|
+
const day = Math.floor(h / 24)
|
|
63
|
+
if (day < 7) return `${day} ngày trước`
|
|
64
|
+
return new Date(d).toLocaleDateString("vi-VN")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function NotificationBell({
|
|
68
|
+
settingsSlot,
|
|
69
|
+
}: {
|
|
70
|
+
/** Slot cài đặt dưới danh sách (vd toggle Web Push của app). */
|
|
71
|
+
settingsSlot?: React.ReactNode
|
|
72
|
+
}) {
|
|
73
|
+
const router = useRouter()
|
|
74
|
+
const params = useParams()
|
|
75
|
+
const lang = (params?.lang as string) || "vi"
|
|
76
|
+
const [open, setOpen] = useState(false)
|
|
77
|
+
|
|
78
|
+
// Badge: poll số chưa đọc (nhẹ) — chạy nền kể cả khi đóng.
|
|
79
|
+
const { data: countData, mutate: mutateCount } = useSWR<{ count: number }>(
|
|
80
|
+
"/api/notifications/unread-count",
|
|
81
|
+
fetcher,
|
|
82
|
+
{ refreshInterval: 45_000, revalidateOnFocus: true }
|
|
83
|
+
)
|
|
84
|
+
const unread = countData?.count ?? 0
|
|
85
|
+
|
|
86
|
+
// List: chỉ fetch khi mở dropdown.
|
|
87
|
+
const { data: listData, mutate: mutateList } = useSWR<ListResp>(
|
|
88
|
+
open ? "/api/notifications?take=10&filter=all" : null,
|
|
89
|
+
fetcher
|
|
90
|
+
)
|
|
91
|
+
const items = listData?.items ?? []
|
|
92
|
+
|
|
93
|
+
// Toast khi có tin mới (số chưa đọc tăng).
|
|
94
|
+
const prevUnread = useRef<number | null>(null)
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (countData?.count == null) return
|
|
97
|
+
const cur = countData.count
|
|
98
|
+
if (prevUnread.current != null && cur > prevUnread.current) {
|
|
99
|
+
toast.info("Bạn có thông báo mới", {
|
|
100
|
+
description: `${cur} thông báo chưa đọc`,
|
|
101
|
+
})
|
|
102
|
+
if (open) void mutateList()
|
|
103
|
+
}
|
|
104
|
+
prevUnread.current = cur
|
|
105
|
+
}, [countData?.count, open, mutateList])
|
|
106
|
+
|
|
107
|
+
// Badge số trên icon PWA (iOS 16.4+/Android khi đã cài) — best-effort.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!("setAppBadge" in navigator)) return
|
|
110
|
+
if (unread > 0) void navigator.setAppBadge(unread).catch(() => {})
|
|
111
|
+
else void navigator.clearAppBadge?.().catch(() => {})
|
|
112
|
+
}, [unread])
|
|
113
|
+
|
|
114
|
+
async function markRead(ids: string[]) {
|
|
115
|
+
try {
|
|
116
|
+
await fetch("/api/notifications/read", {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify({ ids }),
|
|
120
|
+
})
|
|
121
|
+
void mutateCount()
|
|
122
|
+
void mutateList()
|
|
123
|
+
} catch {
|
|
124
|
+
/* best-effort */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function markAllRead() {
|
|
129
|
+
try {
|
|
130
|
+
await fetch("/api/notifications/read", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ all: true }),
|
|
134
|
+
})
|
|
135
|
+
void mutateCount()
|
|
136
|
+
void mutateList()
|
|
137
|
+
} catch {
|
|
138
|
+
/* best-effort */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function onItemClick(n: NotiItem) {
|
|
143
|
+
if (!n.isRead) void markRead([n.id])
|
|
144
|
+
setOpen(false)
|
|
145
|
+
if (n.url) router.push(`/${lang}${n.url}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<Popover open={open} onOpenChange={setOpen} modal>
|
|
150
|
+
<PopoverTrigger asChild>
|
|
151
|
+
<Button
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="icon"
|
|
154
|
+
className="relative"
|
|
155
|
+
aria-label="Thông báo"
|
|
156
|
+
>
|
|
157
|
+
<Bell className="size-4" />
|
|
158
|
+
{unread > 0 && (
|
|
159
|
+
<Badge
|
|
160
|
+
// top-0 (không thò LÊN khỏi nút) để header h-11 trong SidebarInset
|
|
161
|
+
// overflow-hidden KHÔNG cắt mất phần trên của badge; vẫn nằm góc
|
|
162
|
+
// trên-phải nút, thò nhẹ sang phải.
|
|
163
|
+
className="absolute top-0 -end-1 h-4 min-w-4 justify-center px-1 text-[10px] leading-none"
|
|
164
|
+
aria-live="polite"
|
|
165
|
+
role="status"
|
|
166
|
+
>
|
|
167
|
+
{unread > 99 ? "99+" : unread}
|
|
168
|
+
</Badge>
|
|
169
|
+
)}
|
|
170
|
+
</Button>
|
|
171
|
+
</PopoverTrigger>
|
|
172
|
+
<PopoverContent align="end" className="w-[380px] p-0">
|
|
173
|
+
<div className="flex items-center justify-between border-b border-border p-3">
|
|
174
|
+
<h3 className="text-sm font-semibold">Thông báo</h3>
|
|
175
|
+
{unread > 0 && (
|
|
176
|
+
<Button
|
|
177
|
+
variant="link"
|
|
178
|
+
className="h-auto gap-1 p-0 text-xs text-primary"
|
|
179
|
+
onClick={markAllRead}
|
|
180
|
+
>
|
|
181
|
+
<CheckCheck className="size-3.5" />
|
|
182
|
+
Đánh dấu đã đọc tất cả
|
|
183
|
+
</Button>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<ScrollArea className="max-h-[340px]">
|
|
188
|
+
{items.length === 0 ? (
|
|
189
|
+
<p className="px-6 py-10 text-center text-sm text-muted-foreground">
|
|
190
|
+
Chưa có thông báo
|
|
191
|
+
</p>
|
|
192
|
+
) : (
|
|
193
|
+
<ul className="divide-y divide-border">
|
|
194
|
+
{items.map((n) => (
|
|
195
|
+
<li key={n.id}>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={() => onItemClick(n)}
|
|
199
|
+
className="flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-accent hover:text-accent-foreground"
|
|
200
|
+
>
|
|
201
|
+
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
202
|
+
<DynamicIcon
|
|
203
|
+
name={(n.iconName as any) || "Bell"}
|
|
204
|
+
className="size-4"
|
|
205
|
+
/>
|
|
206
|
+
</span>
|
|
207
|
+
<span className="min-w-0 flex-1">
|
|
208
|
+
<span className="block truncate text-sm font-medium">
|
|
209
|
+
{n.title}
|
|
210
|
+
</span>
|
|
211
|
+
<span className="mt-0.5 block text-xs text-muted-foreground line-clamp-2">
|
|
212
|
+
{n.content}
|
|
213
|
+
</span>
|
|
214
|
+
<span className="mt-1 block text-[11px] text-muted-foreground">
|
|
215
|
+
{timeAgo(n.createdAt)}
|
|
216
|
+
</span>
|
|
217
|
+
</span>
|
|
218
|
+
{!n.isRead && (
|
|
219
|
+
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
|
|
220
|
+
)}
|
|
221
|
+
</button>
|
|
222
|
+
</li>
|
|
223
|
+
))}
|
|
224
|
+
</ul>
|
|
225
|
+
)}
|
|
226
|
+
</ScrollArea>
|
|
227
|
+
|
|
228
|
+
<div className="border-t border-border p-2">
|
|
229
|
+
{settingsSlot}
|
|
230
|
+
<Button
|
|
231
|
+
variant="ghost"
|
|
232
|
+
className="w-full text-sm text-primary"
|
|
233
|
+
onClick={() => {
|
|
234
|
+
setOpen(false)
|
|
235
|
+
router.push(`/${lang}/notifications`)
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
Xem tất cả thông báo
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
</PopoverContent>
|
|
242
|
+
</Popover>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { useParams, useRouter } from "next/navigation"
|
|
5
|
+
import { Badge, Button, DynamicIcon } from "../../ui/primitives"
|
|
6
|
+
import useSWRInfinite from "swr/infinite"
|
|
7
|
+
import { CheckCheck, Inbox } from "lucide-react"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Trang hộp thư thông báo đầy đủ (Phase 1 — bản gọn để "Xem tất cả" trong chuông
|
|
11
|
+
* hoạt động). Lọc tất cả/chưa đọc, tải thêm (cursor), đánh dấu đã đọc.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface NotiItem {
|
|
15
|
+
id: string
|
|
16
|
+
type: string
|
|
17
|
+
category: string | null
|
|
18
|
+
title: string
|
|
19
|
+
content: string
|
|
20
|
+
url: string | null
|
|
21
|
+
iconName: string | null
|
|
22
|
+
isRead: boolean
|
|
23
|
+
createdAt: string
|
|
24
|
+
}
|
|
25
|
+
interface ListResp {
|
|
26
|
+
items: NotiItem[]
|
|
27
|
+
nextCursor: string | null
|
|
28
|
+
total: number
|
|
29
|
+
unreadCount: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fetcher = (url: string) =>
|
|
33
|
+
fetch(url).then((r) => {
|
|
34
|
+
if (!r.ok) throw new Error(String(r.status))
|
|
35
|
+
return r.json()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function timeAgo(d: string): string {
|
|
39
|
+
const s = Math.floor((Date.now() - new Date(d).getTime()) / 1000)
|
|
40
|
+
if (s < 60) return "vừa xong"
|
|
41
|
+
const m = Math.floor(s / 60)
|
|
42
|
+
if (m < 60) return `${m} phút trước`
|
|
43
|
+
const h = Math.floor(m / 60)
|
|
44
|
+
if (h < 24) return `${h} giờ trước`
|
|
45
|
+
const day = Math.floor(h / 24)
|
|
46
|
+
if (day < 7) return `${day} ngày trước`
|
|
47
|
+
return new Date(d).toLocaleDateString("vi-VN")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const makeGetKey =
|
|
51
|
+
(filter: "all" | "unread") => (index: number, prev: ListResp | null) => {
|
|
52
|
+
if (prev && !prev.nextCursor) return null // hết trang
|
|
53
|
+
const cursor = index === 0 ? "" : prev?.nextCursor
|
|
54
|
+
return `/api/notifications?take=20&filter=${filter}${cursor ? `&cursor=${cursor}` : ""}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function NotificationsInbox() {
|
|
58
|
+
const router = useRouter()
|
|
59
|
+
const params = useParams()
|
|
60
|
+
const lang = (params?.lang as string) || "vi"
|
|
61
|
+
const [tab, setTab] = useState<"all" | "unread">("all")
|
|
62
|
+
|
|
63
|
+
// Hai bộ SWRInfinite riêng cho 2 tab → chuyển tab tức thì, không lẫn cache.
|
|
64
|
+
const all = useSWRInfinite<ListResp>(makeGetKey("all"), fetcher)
|
|
65
|
+
const unread = useSWRInfinite<ListResp>(makeGetKey("unread"), fetcher)
|
|
66
|
+
const swr = tab === "all" ? all : unread
|
|
67
|
+
|
|
68
|
+
const pages = swr.data ?? []
|
|
69
|
+
const items = pages.flatMap((p) => p.items)
|
|
70
|
+
const unreadCount = (all.data ?? [])[0]?.unreadCount ?? 0
|
|
71
|
+
const hasMore =
|
|
72
|
+
pages.length > 0 && pages[pages.length - 1]?.nextCursor != null
|
|
73
|
+
const loading = swr.isLoading || swr.isValidating
|
|
74
|
+
|
|
75
|
+
function refresh() {
|
|
76
|
+
void all.mutate()
|
|
77
|
+
void unread.mutate()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function markAllRead() {
|
|
81
|
+
await fetch("/api/notifications/read", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ all: true }),
|
|
85
|
+
}).catch(() => {})
|
|
86
|
+
refresh()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function onItemClick(n: NotiItem) {
|
|
90
|
+
if (!n.isRead) {
|
|
91
|
+
await fetch("/api/notifications/read", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ ids: [n.id] }),
|
|
95
|
+
}).catch(() => {})
|
|
96
|
+
refresh()
|
|
97
|
+
}
|
|
98
|
+
if (n.url) router.push(`/${lang}${n.url}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="mx-auto w-full max-w-3xl">
|
|
103
|
+
<div className="mb-4 flex items-center justify-between">
|
|
104
|
+
<div>
|
|
105
|
+
<h1 className="text-xl font-semibold">Thông báo</h1>
|
|
106
|
+
<p className="text-sm text-muted-foreground">
|
|
107
|
+
{unreadCount > 0
|
|
108
|
+
? `${unreadCount} thông báo chưa đọc`
|
|
109
|
+
: "Đã đọc hết"}
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
{unreadCount > 0 && (
|
|
113
|
+
<Button variant="outline" size="sm" onClick={markAllRead}>
|
|
114
|
+
<CheckCheck className="mr-1 size-4" />
|
|
115
|
+
Đánh dấu đã đọc tất cả
|
|
116
|
+
</Button>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="mb-3 flex gap-2">
|
|
121
|
+
<Button
|
|
122
|
+
variant={tab === "all" ? "default" : "ghost"}
|
|
123
|
+
size="sm"
|
|
124
|
+
onClick={() => setTab("all")}
|
|
125
|
+
>
|
|
126
|
+
Tất cả
|
|
127
|
+
</Button>
|
|
128
|
+
<Button
|
|
129
|
+
variant={tab === "unread" ? "default" : "ghost"}
|
|
130
|
+
size="sm"
|
|
131
|
+
onClick={() => setTab("unread")}
|
|
132
|
+
>
|
|
133
|
+
Chưa đọc
|
|
134
|
+
{unreadCount > 0 && <Badge className="ml-1">{unreadCount}</Badge>}
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="rounded-lg border border-border bg-card">
|
|
139
|
+
{items.length === 0 && !loading ? (
|
|
140
|
+
<div className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
|
|
141
|
+
<Inbox className="size-8" />
|
|
142
|
+
<p className="text-sm">Chưa có thông báo</p>
|
|
143
|
+
</div>
|
|
144
|
+
) : (
|
|
145
|
+
<ul className="divide-y divide-border">
|
|
146
|
+
{items.map((n) => (
|
|
147
|
+
<li key={n.id}>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => onItemClick(n)}
|
|
151
|
+
className={`flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-accent hover:text-accent-foreground ${
|
|
152
|
+
n.isRead ? "" : "bg-primary/5"
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
156
|
+
<DynamicIcon
|
|
157
|
+
name={(n.iconName as any) || "Bell"}
|
|
158
|
+
className="size-4"
|
|
159
|
+
/>
|
|
160
|
+
</span>
|
|
161
|
+
<span className="min-w-0 flex-1">
|
|
162
|
+
<span className="block text-sm font-medium">{n.title}</span>
|
|
163
|
+
<span className="mt-0.5 block text-sm text-muted-foreground">
|
|
164
|
+
{n.content}
|
|
165
|
+
</span>
|
|
166
|
+
<span className="mt-1 block text-xs text-muted-foreground">
|
|
167
|
+
{timeAgo(n.createdAt)}
|
|
168
|
+
</span>
|
|
169
|
+
</span>
|
|
170
|
+
{!n.isRead && (
|
|
171
|
+
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
</li>
|
|
175
|
+
))}
|
|
176
|
+
</ul>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{hasMore && (
|
|
181
|
+
<div className="mt-4 flex justify-center">
|
|
182
|
+
<Button
|
|
183
|
+
variant="outline"
|
|
184
|
+
onClick={() => swr.setSize(swr.size + 1)}
|
|
185
|
+
disabled={loading}
|
|
186
|
+
>
|
|
187
|
+
{loading ? "Đang tải…" : "Tải thêm"}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke render trang "Tác vụ định kỳ": mount thật (jsdom) với fetch giả — bắt
|
|
3
|
+
* lỗi import/hook trước khi app tiêu thụ. Trang này chỉ mở được sau đăng nhập
|
|
4
|
+
* (quyền admin) nên không kiểm được bằng cách gọi HTTP ẩn danh.
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { SystemJobsPage } from "../system-jobs-page";
|
|
10
|
+
|
|
11
|
+
const JOB = {
|
|
12
|
+
name: "zns-dispatch",
|
|
13
|
+
cronTime: "* * * * *",
|
|
14
|
+
isRunning: true,
|
|
15
|
+
nextDate: null,
|
|
16
|
+
enabled: true,
|
|
17
|
+
status: "idle" as const,
|
|
18
|
+
lastRun: "2026-08-01T06:00:00.000Z",
|
|
19
|
+
nextRun: "2026-08-01T06:01:00.000Z",
|
|
20
|
+
error: null,
|
|
21
|
+
inMemory: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LOG = {
|
|
25
|
+
id: "log1",
|
|
26
|
+
jobName: "zns-dispatch",
|
|
27
|
+
startedAt: "2026-08-01T06:00:00.000Z",
|
|
28
|
+
finishedAt: "2026-08-01T06:00:02.000Z",
|
|
29
|
+
durationMs: 2000,
|
|
30
|
+
status: "success" as const,
|
|
31
|
+
error: null,
|
|
32
|
+
summary: null,
|
|
33
|
+
actions: [{ time: "2026-08-01T06:00:01.000Z", action: "Gửi 3 tin ZNS" }],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function mockFetch(jobs = [JOB]) {
|
|
37
|
+
const calls: string[] = [];
|
|
38
|
+
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
39
|
+
const url = String(input);
|
|
40
|
+
calls.push(url);
|
|
41
|
+
const body = url.includes("/history")
|
|
42
|
+
? { data: [LOG], meta: { total: 1 } }
|
|
43
|
+
: { data: jobs };
|
|
44
|
+
return new Response(JSON.stringify(body), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
}) as unknown as typeof fetch;
|
|
49
|
+
return calls;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("SystemJobsPage", () => {
|
|
53
|
+
afterEach(() => vi.restoreAllMocks());
|
|
54
|
+
|
|
55
|
+
it("render job từ API kèm lịch chạy", async () => {
|
|
56
|
+
mockFetch();
|
|
57
|
+
render(<SystemJobsPage />);
|
|
58
|
+
|
|
59
|
+
await waitFor(() => expect(screen.getByText("zns-dispatch")).toBeDefined());
|
|
60
|
+
expect(screen.getByText("* * * * *")).toBeDefined();
|
|
61
|
+
expect(screen.getByText("Chờ")).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("bung dòng → gọi endpoint lịch sử và hiện actions của lần chạy", async () => {
|
|
65
|
+
const calls = mockFetch();
|
|
66
|
+
render(<SystemJobsPage apiUrl="/api/jobs" />);
|
|
67
|
+
|
|
68
|
+
await waitFor(() => expect(screen.getByText("zns-dispatch")).toBeDefined());
|
|
69
|
+
fireEvent.click(screen.getByText("zns-dispatch"));
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(screen.getByText("Thành công")).toBeDefined());
|
|
72
|
+
expect(
|
|
73
|
+
calls.some((u) => u.startsWith("/api/jobs/zns-dispatch/history?")),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Bung tiếp một lần chạy mới thấy nhật ký actions.
|
|
77
|
+
fireEvent.click(screen.getByText("Thành công"));
|
|
78
|
+
expect(screen.getByText("Gửi 3 tin ZNS")).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("job chỉ-còn-trong-DB: khoá nút chạy/tạm dừng", async () => {
|
|
82
|
+
mockFetch([{ ...JOB, inMemory: false }]);
|
|
83
|
+
render(<SystemJobsPage />);
|
|
84
|
+
|
|
85
|
+
await waitFor(() => expect(screen.getByText("Chỉ lịch sử")).toBeDefined());
|
|
86
|
+
const actionButtons = screen
|
|
87
|
+
.getAllByRole("button")
|
|
88
|
+
.filter((b) => b.getAttribute("title")?.includes("Không khả dụng"));
|
|
89
|
+
expect(actionButtons).toHaveLength(2);
|
|
90
|
+
actionButtons.forEach((b) => expect(b.hasAttribute("disabled")).toBe(true));
|
|
91
|
+
});
|
|
92
|
+
});
|