feat: P1前端补全 + 后端测试补全 + 历史测试修复
前端新增: - 订单管理: order-api.ts + use-orders.ts + 订单列表页 + 订单详情页 (7测试) - 通知中心: notification-api.ts + use-notifications.ts + 通知列表页 + 偏好设置页 (9测试) - 用户管理: user-api.ts + use-users.ts + 用户管理页优化 (8测试) - 侧边栏导航更新: 8个中文菜单项,子路径高亮 - 修复api.ts缺少apiClient命名导出的bug 后端新增: - UserService测试: 24个用例 (100%语句覆盖) - UserController测试: 10个用例 (100%语句覆盖) - NotificationService测试: 31个用例 (98%语句覆盖) - TemplateService测试: 29个用例 (100%语句覆盖) - PreferenceService测试: 29个用例 (100%语句覆盖) 历史测试修复: - 修复uuid ESM兼容性问题 (moduleNameMapper + mock) - 修复sharp ESM兼容性问题 (jest.mock + import default) - 修复payment-channel.service.spec.ts断言缺少where参数 - 添加express mock解决Jest解析问题 全量测试: 后端331通过 + 前端30通过 = 361个测试全部通过
This commit is contained in:
parent
2efab8e712
commit
3d867331ae
|
|
@ -1,127 +1,141 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
username: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
avatar: string | null;
|
||||
isActive: boolean;
|
||||
emailVerified: boolean;
|
||||
phoneVerified: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
import { useState } from 'react';
|
||||
import { useUsers, useUpdateUser, useDeleteUser } from '@/hooks/use-users';
|
||||
import { User, UserListResponse } from '@/lib/user-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export default function UsersManagementPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(20);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/users', {
|
||||
params: {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
search: search || undefined,
|
||||
const { data, isLoading } = useUsers(page, limit, search || undefined);
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const responseData = data?.data?.data as UserListResponse | undefined;
|
||||
const users: User[] = responseData?.users ?? [];
|
||||
const pagination = responseData?.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 };
|
||||
|
||||
const handleToggleActive = (userId: string, currentActive: boolean) => {
|
||||
updateUser.mutate(
|
||||
{ id: userId, data: { isActive: !currentActive } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
alert(currentActive ? '已禁用用户' : '已启用用户');
|
||||
},
|
||||
onError: () => {
|
||||
alert('操作失败,请重试');
|
||||
},
|
||||
});
|
||||
setUsers(response.data.data.users);
|
||||
setPagination(response.data.data.pagination);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, [pagination.page, search]);
|
||||
const handleDelete = (userId: string) => {
|
||||
if (!confirm('确定要删除该用户吗?此操作不可撤销。')) return;
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: () => {
|
||||
alert('用户已删除');
|
||||
},
|
||||
onError: () => {
|
||||
alert('删除失败,请重试');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
const handleEditSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingUser) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/users/${userId}`);
|
||||
setUsers(users.filter((u) => u.id !== userId));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete user:', err);
|
||||
alert('Failed to delete user');
|
||||
updateUser.mutate(
|
||||
{
|
||||
id: editingUser.id,
|
||||
data: {
|
||||
username: editingUser.username,
|
||||
firstName: editingUser.firstName || undefined,
|
||||
lastName: editingUser.lastName || undefined,
|
||||
email: editingUser.email || undefined,
|
||||
phone: editingUser.phone || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditingUser(null);
|
||||
alert('用户信息已更新');
|
||||
},
|
||||
onError: () => {
|
||||
alert('更新失败,请重试');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">User Management</h1>
|
||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="搜索用户..."
|
||||
className="flex-1 px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
User
|
||||
用户
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Email
|
||||
邮箱
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Phone
|
||||
手机
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Created
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
Loading...
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
No users found
|
||||
暂无用户数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
|
@ -144,25 +158,41 @@ export default function UsersManagementPage() {
|
|||
<td className="px-4 py-3 text-sm">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
<button
|
||||
onClick={() => handleToggleActive(user.id, user.isActive)}
|
||||
className={`px-2 py-1 text-xs rounded-full cursor-pointer transition-colors ${
|
||||
user.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{user.isActive ? '已启用' : '已禁用'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
id: user.id,
|
||||
username: user.username || '',
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
})
|
||||
}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Delete
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -171,32 +201,115 @@ export default function UsersManagementPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
|
||||
users
|
||||
显示第 {(pagination.page - 1) * pagination.limit + 1} -{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} 条,共{' '}
|
||||
{pagination.total} 条
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
上一页
|
||||
</Button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
第 {pagination.page} / {pagination.totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md space-y-4">
|
||||
<h2 className="text-lg font-semibold">编辑用户</h2>
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingUser.username}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, username: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">姓</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingUser.lastName}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, lastName: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingUser.firstName}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, firstName: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editingUser.email}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">手机</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={editingUser.phone}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, phone: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateUser.isPending}>
|
||||
{updateUser.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Info, AlertTriangle, XCircle, CheckCircle, Trash2, Bell, Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
useMarkAsRead,
|
||||
useMarkAllAsRead,
|
||||
useDeleteNotification,
|
||||
} from '@/hooks/use-notifications';
|
||||
import type { Notification, NotificationChannelType, NotificationType } from '@/lib/notification-api';
|
||||
|
||||
const typeIconMap: Record<NotificationType, React.ReactNode> = {
|
||||
info: <Info className="h-5 w-5 text-blue-500" />,
|
||||
warning: <AlertTriangle className="h-5 w-5 text-yellow-500" />,
|
||||
error: <XCircle className="h-5 w-5 text-red-500" />,
|
||||
success: <CheckCircle className="h-5 w-5 text-green-500" />,
|
||||
};
|
||||
|
||||
const channelOptions: { label: string; value: NotificationChannelType | '' }[] = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '站内', value: 'in-app' },
|
||||
{ label: '邮件', value: 'email' },
|
||||
{ label: '短信', value: 'sms' },
|
||||
{ label: '企微', value: 'wecom' },
|
||||
{ label: '推送', value: 'push' },
|
||||
];
|
||||
|
||||
const typeOptions: { label: string; value: NotificationType | '' }[] = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '信息', value: 'info' },
|
||||
{ label: '警告', value: 'warning' },
|
||||
{ label: '错误', value: 'error' },
|
||||
{ label: '成功', value: 'success' },
|
||||
];
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [channel, setChannel] = useState<NotificationChannelType | ''>('');
|
||||
const [type, setType] = useState<NotificationType | ''>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 20;
|
||||
|
||||
const { data: notificationsData, isLoading } = useNotifications(
|
||||
page,
|
||||
limit,
|
||||
channel || undefined,
|
||||
undefined,
|
||||
type || undefined,
|
||||
);
|
||||
const { data: unreadData } = useUnreadCount();
|
||||
const markAsRead = useMarkAsRead();
|
||||
const markAllAsRead = useMarkAllAsRead();
|
||||
const deleteNotification = useDeleteNotification();
|
||||
|
||||
const notifications: Notification[] = notificationsData?.data?.notifications || [];
|
||||
const total: number = notificationsData?.data?.total || 0;
|
||||
const unreadCount: number = unreadData?.data?.count || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const handleMarkAsRead = (id: string) => {
|
||||
markAsRead.mutate(id);
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = () => {
|
||||
markAllAsRead.mutate();
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteNotification.mutate(id);
|
||||
};
|
||||
|
||||
const handleChannelChange = (value: NotificationChannelType | '') => {
|
||||
setChannel(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleTypeChange = (value: NotificationType | '') => {
|
||||
setType(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">通知中心</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
|
||||
{unreadCount} 条未读
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={markAllAsRead.isPending || unreadCount === 0}
|
||||
className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
全部标记已读
|
||||
</button>
|
||||
<Link
|
||||
href="/notifications/preferences"
|
||||
className="inline-flex items-center gap-1 px-4 py-2 text-sm border rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
偏好设置
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-600">渠道:</span>
|
||||
<div className="flex gap-1">
|
||||
{channelOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleChannelChange(opt.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md ${
|
||||
channel === opt.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-600">类型:</span>
|
||||
<div className="flex gap-1">
|
||||
{typeOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleTypeChange(opt.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md ${
|
||||
type === opt.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Bell className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>暂无通知</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`border rounded-lg p-4 flex items-start gap-3 transition-colors ${
|
||||
notification.readAt ? 'bg-white' : 'bg-blue-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{typeIconMap[notification.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`text-sm font-medium ${notification.readAt ? 'text-gray-700' : 'text-gray-900'}`}>
|
||||
{notification.title}
|
||||
</h3>
|
||||
{!notification.readAt && (
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{notification.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
<span>{new Date(notification.createdAt).toLocaleString('zh-CN')}</span>
|
||||
<span className="inline-flex px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">
|
||||
{notification.channel === 'in-app' ? '站内' :
|
||||
notification.channel === 'email' ? '邮件' :
|
||||
notification.channel === 'sms' ? '短信' :
|
||||
notification.channel === 'wecom' ? '企微' :
|
||||
notification.channel === 'push' ? '推送' : notification.channel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!notification.readAt && (
|
||||
<button
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
disabled={markAsRead.isPending}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded-md disabled:opacity-50"
|
||||
title="标记已读"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
disabled={deleteNotification.isPending}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md disabled:opacity-50"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
useNotificationPreferences,
|
||||
useUpdateNotificationPreferences,
|
||||
} from '@/hooks/use-notifications';
|
||||
import type { UpdateNotificationPreferenceData, NotificationPreference } from '@/lib/notification-api';
|
||||
|
||||
const defaultPreferences: UpdateNotificationPreferenceData = {
|
||||
emailEnabled: true,
|
||||
smsEnabled: true,
|
||||
pushEnabled: true,
|
||||
inAppEnabled: true,
|
||||
securityEnabled: true,
|
||||
marketingEnabled: false,
|
||||
systemEnabled: true,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
timezone: 'Asia/Shanghai',
|
||||
};
|
||||
|
||||
function preferenceToForm(p: NotificationPreference): UpdateNotificationPreferenceData {
|
||||
return {
|
||||
emailEnabled: p.emailEnabled,
|
||||
smsEnabled: p.smsEnabled,
|
||||
pushEnabled: p.pushEnabled,
|
||||
inAppEnabled: p.inAppEnabled,
|
||||
securityEnabled: p.securityEnabled,
|
||||
marketingEnabled: p.marketingEnabled,
|
||||
systemEnabled: p.systemEnabled,
|
||||
quietStartHour: p.quietStartHour,
|
||||
quietEndHour: p.quietEndHour,
|
||||
timezone: p.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
function PreferencesForm({ preferences }: { preferences?: NotificationPreference }) {
|
||||
const updatePreferences = useUpdateNotificationPreferences();
|
||||
|
||||
const [form, setForm] = useState<UpdateNotificationPreferenceData>(
|
||||
preferences ? preferenceToForm(preferences) : defaultPreferences,
|
||||
);
|
||||
|
||||
const handleToggle = (key: keyof UpdateNotificationPreferenceData) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updatePreferences.mutate(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">通知渠道</h2>
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
label="邮件通知"
|
||||
description="通过邮件接收通知"
|
||||
enabled={!!form.emailEnabled}
|
||||
onToggle={() => handleToggle('emailEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="短信通知"
|
||||
description="通过短信接收通知"
|
||||
enabled={!!form.smsEnabled}
|
||||
onToggle={() => handleToggle('smsEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="推送通知"
|
||||
description="通过浏览器推送接收通知"
|
||||
enabled={!!form.pushEnabled}
|
||||
onToggle={() => handleToggle('pushEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="站内通知"
|
||||
description="在站内消息中心接收通知"
|
||||
enabled={!!form.inAppEnabled}
|
||||
onToggle={() => handleToggle('inAppEnabled')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">通知类型</h2>
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
label="安全通知"
|
||||
description="登录提醒、密码修改等安全相关通知"
|
||||
enabled={!!form.securityEnabled}
|
||||
onToggle={() => handleToggle('securityEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="营销通知"
|
||||
description="优惠活动、产品推荐等营销相关通知"
|
||||
enabled={!!form.marketingEnabled}
|
||||
onToggle={() => handleToggle('marketingEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="系统通知"
|
||||
description="系统维护、服务变更等系统相关通知"
|
||||
enabled={!!form.systemEnabled}
|
||||
onToggle={() => handleToggle('systemEnabled')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">静音时段</h2>
|
||||
<p className="text-sm text-gray-500">在指定时段内不会收到通知推送</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">开始时间</label>
|
||||
<select
|
||||
value={form.quietStartHour ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
quietStartHour: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{String(i).padStart(2, '0')}:00
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">结束时间</label>
|
||||
<select
|
||||
value={form.quietEndHour ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
quietEndHour: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{String(i).padStart(2, '0')}:00
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updatePreferences.isPending}
|
||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updatePreferences.isPending ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
|
||||
{updatePreferences.isSuccess && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-md text-green-600 text-sm">
|
||||
设置已保存
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updatePreferences.isError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
保存失败,请重试
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationPreferencesPage() {
|
||||
const { data: preferencesData, isLoading } = useNotificationPreferences();
|
||||
|
||||
const preferences = preferencesData?.data;
|
||||
|
||||
const formKey = useMemo(() => preferences?.id ?? 'default', [preferences?.id]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="p-2 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">通知偏好设置</h1>
|
||||
</div>
|
||||
|
||||
<PreferencesForm key={formKey} preferences={preferences} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{label}</p>
|
||||
<p className="text-xs text-gray-500">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-primary' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useOrder, useCancelOrder, useUpdateOrderStatus } from '@/hooks/use-orders';
|
||||
import { OrderItem, OrderStatusType } from '@/lib/order-api';
|
||||
|
||||
const STATUS_LABEL_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
SHIPPED: '已发货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const STATUS_STYLE_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
COMPLETED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-800',
|
||||
REFUNDED: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const TIMELINE_FIELDS: { status: OrderStatusType; timeField: string; label: string }[] = [
|
||||
{ status: 'PENDING', timeField: 'createdAt', label: '创建订单' },
|
||||
{ status: 'PAID', timeField: 'paidAt', label: '支付成功' },
|
||||
{ status: 'SHIPPED', timeField: 'shippedAt', label: '已发货' },
|
||||
{ status: 'COMPLETED', timeField: 'completedAt', label: '已完成' },
|
||||
];
|
||||
|
||||
const STATUS_FLOW: OrderStatusType[] = ['PENDING', 'PAID', 'SHIPPED', 'COMPLETED'];
|
||||
|
||||
const NEXT_STATUS_MAP: Partial<Record<OrderStatusType, OrderStatusType>> = {
|
||||
PENDING: 'PAID',
|
||||
PAID: 'SHIPPED',
|
||||
SHIPPED: 'COMPLETED',
|
||||
};
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const { data: orderData, isLoading } = useOrder(id);
|
||||
const cancelOrder = useCancelOrder();
|
||||
const updateStatus = useUpdateOrderStatus();
|
||||
|
||||
const order = orderData?.data;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 text-center text-gray-500">加载中...</div>;
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return <div className="p-6 text-center text-gray-500">订单不存在</div>;
|
||||
}
|
||||
|
||||
const currentStatus = order.status as OrderStatusType;
|
||||
const nextStatus = NEXT_STATUS_MAP[currentStatus];
|
||||
const canCancel = currentStatus === 'PENDING' || currentStatus === 'PAID';
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('确定要取消该订单吗?')) return;
|
||||
try {
|
||||
await cancelOrder.mutateAsync(id);
|
||||
} catch {
|
||||
alert('取消订单失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (status: OrderStatusType) => {
|
||||
try {
|
||||
await updateStatus.mutateAsync({ id, status });
|
||||
} catch {
|
||||
alert('更新状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const isTerminalStatus = currentStatus === 'CANCELLED' || currentStatus === 'REFUNDED';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => router.push('/orders')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
← 返回列表
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold">订单详情</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">基本信息</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">订单号</span>
|
||||
<p className="font-medium">{order.orderNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">状态</span>
|
||||
<p>
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${STATUS_STYLE_MAP[currentStatus]}`}>
|
||||
{STATUS_LABEL_MAP[currentStatus]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">订单金额</span>
|
||||
<p className="font-medium">¥{order.totalAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">实付金额</span>
|
||||
<p className="font-medium">¥{order.paidAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">优惠金额</span>
|
||||
<p className="font-medium">¥{order.discountAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">支付方式</span>
|
||||
<p className="font-medium">{order.paymentMethod || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">创建时间</span>
|
||||
<p className="font-medium">{new Date(order.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">备注</span>
|
||||
<p className="font-medium">{order.remark || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">订单商品</h2>
|
||||
{order.items && order.items.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">商品</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">单价</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">数量</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">小计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{order.items.map((item: OrderItem) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.productImage && (
|
||||
<img src={item.productImage} alt="" className="w-8 h-8 rounded object-cover" />
|
||||
)}
|
||||
<span>{item.productName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">¥{item.unitPrice.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">¥{item.totalAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">暂无商品信息</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">状态时间线</h2>
|
||||
<div className="space-y-4">
|
||||
{TIMELINE_FIELDS.map((step, index) => {
|
||||
const isReached = !isTerminalStatus && STATUS_FLOW.indexOf(currentStatus) >= index;
|
||||
const timeValue = order[step.timeField as keyof typeof order] as string | undefined;
|
||||
|
||||
return (
|
||||
<div key={step.status} className="flex items-start space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full mt-1 ${isReached ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isReached ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.label}
|
||||
</p>
|
||||
{timeValue && (
|
||||
<p className="text-xs text-gray-500">{new Date(timeValue).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(currentStatus === 'CANCELLED' || currentStatus === 'REFUNDED') && (
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="w-3 h-3 rounded-full mt-1 bg-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{STATUS_LABEL_MAP[currentStatus]}
|
||||
</p>
|
||||
{(order.cancelledAt) && (
|
||||
<p className="text-xs text-gray-500">{new Date(order.cancelledAt).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6 space-y-3">
|
||||
<h2 className="text-lg font-semibold">操作</h2>
|
||||
{nextStatus && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(nextStatus)}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{currentStatus === 'PENDING' ? '确认支付' :
|
||||
currentStatus === 'PAID' ? '标记发货' :
|
||||
currentStatus === 'SHIPPED' ? '确认收货' : '更新状态'}
|
||||
</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelOrder.isPending}
|
||||
className="w-full px-4 py-2 border border-red-300 text-red-600 rounded-md hover:bg-red-50 disabled:opacity-50 text-sm"
|
||||
>
|
||||
取消订单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useOrders, useCancelOrder } from '@/hooks/use-orders';
|
||||
import { Order, OrderStatusType } from '@/lib/order-api';
|
||||
|
||||
const STATUS_OPTIONS: { label: string; value?: OrderStatusType }[] = [
|
||||
{ label: '全部' },
|
||||
{ label: '待支付', value: 'PENDING' },
|
||||
{ label: '已支付', value: 'PAID' },
|
||||
{ label: '已发货', value: 'SHIPPED' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
{ label: '已退款', value: 'REFUNDED' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
SHIPPED: '已发货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const STATUS_STYLE_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
COMPLETED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-800',
|
||||
REFUNDED: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<OrderStatusType | undefined>();
|
||||
const limit = 20;
|
||||
|
||||
const { data: ordersData, isLoading } = useOrders(page, limit, statusFilter);
|
||||
const cancelOrder = useCancelOrder();
|
||||
|
||||
const orders = ordersData?.data?.orders || [];
|
||||
const pagination = ordersData?.data?.pagination;
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm('确定要取消该订单吗?')) return;
|
||||
try {
|
||||
await cancelOrder.mutateAsync(id);
|
||||
} catch {
|
||||
alert('取消订单失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">订单管理</h1>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => {
|
||||
setStatusFilter(opt.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm rounded-md border ${
|
||||
statusFilter === opt.value
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无订单</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">订单号</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">金额</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">状态</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">创建时间</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{orders.map((order: Order) => (
|
||||
<tr
|
||||
key={order.id}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => router.push(`/orders/${order.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium">{order.orderNo}</td>
|
||||
<td className="px-4 py-3 text-sm">¥{order.totalAmount.toFixed(2)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${STATUS_STYLE_MAP[order.status as OrderStatusType] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{STATUS_LABEL_MAP[order.status as OrderStatusType] || order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(order.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{(order.status === 'PENDING' || order.status === 'PAID') && (
|
||||
<button
|
||||
onClick={() => handleCancel(order.id)}
|
||||
disabled={cancelOrder.isPending}
|
||||
className="text-sm text-red-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
取消订单
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
第 {(pagination.page - 1) * pagination.limit + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} 条,共 {pagination.total} 条
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
第 {pagination.page} / {pagination.totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === pagination.totalPages}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,16 +3,19 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Home, Settings, Users, FileText, Folder } from 'lucide-react'
|
||||
import { Home, Settings, Users, Folder, ShoppingCart, Bell, CreditCard, Shield } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: Home, label: 'Home', href: '/' },
|
||||
{ icon: Folder, label: 'Files', href: '/files' },
|
||||
{ icon: FileText, label: 'Documents', href: '/documents' },
|
||||
{ icon: Users, label: 'Users', href: '/users' },
|
||||
{ icon: Settings, label: 'Settings', href: '/settings' },
|
||||
{ icon: Home, label: '首页', href: '/dashboard' },
|
||||
{ icon: ShoppingCart, label: '订单管理', href: '/orders' },
|
||||
{ icon: CreditCard, label: '支付订单', href: '/payments' },
|
||||
{ icon: Bell, label: '通知中心', href: '/notifications' },
|
||||
{ icon: Folder, label: '文件管理', href: '/files' },
|
||||
{ icon: Users, label: '用户管理', href: '/admin/users' },
|
||||
{ icon: Shield, label: '角色权限', href: '/admin/roles' },
|
||||
{ icon: Settings, label: '设置', href: '/settings' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
|
|
@ -29,7 +32,7 @@ export function Sidebar() {
|
|||
<div className="flex flex-col gap-2 p-4">
|
||||
{sidebarItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
notificationApi,
|
||||
NotificationChannelType,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
UpdateNotificationPreferenceData,
|
||||
} from '@/lib/notification-api';
|
||||
|
||||
export const useNotifications = (
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
channel?: NotificationChannelType,
|
||||
status?: NotificationStatus,
|
||||
type?: NotificationType,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', page, limit, channel, status, type],
|
||||
queryFn: () => notificationApi.getNotifications(page, limit, channel, status, type),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnreadCount = () => {
|
||||
return useQuery({
|
||||
queryKey: ['notificationUnreadCount'],
|
||||
queryFn: () => notificationApi.getUnreadCount(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useNotification = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['notification', id],
|
||||
queryFn: () => id ? notificationApi.getNotification(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkAsRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationApi.markAsRead(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notification', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationUnreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkAllAsRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => notificationApi.markAllAsRead(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationUnreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteNotification = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationApi.deleteNotification(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationUnreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useNotificationPreferences = () => {
|
||||
return useQuery({
|
||||
queryKey: ['notificationPreferences'],
|
||||
queryFn: () => notificationApi.getPreferences(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateNotificationPreferences = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateNotificationPreferenceData) => notificationApi.updatePreferences(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationPreferences'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { orderApi, CreateOrderRequest, OrderStatusType } from '@/lib/order-api';
|
||||
|
||||
export const useOrders = (page: number = 1, limit: number = 20, status?: OrderStatusType) => {
|
||||
return useQuery({
|
||||
queryKey: ['orders', page, limit, status],
|
||||
queryFn: () => orderApi.getOrders(page, limit, status),
|
||||
});
|
||||
};
|
||||
|
||||
export const useOrder = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['order', id],
|
||||
queryFn: () => id ? orderApi.getOrder(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useOrderStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['orderStats'],
|
||||
queryFn: () => orderApi.getOrderStats(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateOrder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateOrderRequest) => orderApi.createOrder(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orderStats'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateOrderStatus = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: OrderStatusType }) =>
|
||||
orderApi.updateOrderStatus(id, status),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['order', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orderStats'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelOrder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => orderApi.cancelOrder(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['order', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orderStats'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userApi, UpdateUserRequest } from '@/lib/user-api';
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
return useQuery({
|
||||
queryKey: ['currentUser'],
|
||||
queryFn: () => userApi.getCurrentUser(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUsers = (page: number = 1, limit: number = 20, search?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['users', page, limit, search],
|
||||
queryFn: () => userApi.getUsers(page, limit, search),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUser = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['user', id],
|
||||
queryFn: () => id ? userApi.getUser(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCurrentUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateUserRequest) => userApi.updateCurrentUser(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateUserRequest }) =>
|
||||
userApi.updateUser(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => userApi.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadAvatar = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => userApi.uploadAvatar(file),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { notificationApi } from '../notification-api';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiClient } from '../api';
|
||||
|
||||
const mockedGet = vi.mocked(apiClient.get);
|
||||
const mockedPut = vi.mocked(apiClient.put);
|
||||
const mockedDelete = vi.mocked(apiClient.delete);
|
||||
|
||||
describe('notificationApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getNotifications', () => {
|
||||
it('should call GET /notifications with default params', async () => {
|
||||
mockedGet.mockResolvedValue({ data: { notifications: [], total: 0 } });
|
||||
await notificationApi.getNotifications(1, 20);
|
||||
expect(mockedGet).toHaveBeenCalledWith('/notifications', {
|
||||
params: { page: 1, limit: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call GET /notifications with all filter params', async () => {
|
||||
mockedGet.mockResolvedValue({ data: { notifications: [], total: 0 } });
|
||||
await notificationApi.getNotifications(1, 10, 'in-app', 'sent', 'info');
|
||||
expect(mockedGet).toHaveBeenCalledWith('/notifications', {
|
||||
params: { page: 1, limit: 10, channel: 'in-app', status: 'sent', type: 'info' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('should call GET /notifications/unread-count', async () => {
|
||||
mockedGet.mockResolvedValue({ data: { count: 5 } });
|
||||
const result = await notificationApi.getUnreadCount();
|
||||
expect(mockedGet).toHaveBeenCalledWith('/notifications/unread-count');
|
||||
expect(result.data).toEqual({ count: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotification', () => {
|
||||
it('should call GET /notifications/:id', async () => {
|
||||
mockedGet.mockResolvedValue({ data: { id: '123', title: 'Test' } });
|
||||
await notificationApi.getNotification('123');
|
||||
expect(mockedGet).toHaveBeenCalledWith('/notifications/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsRead', () => {
|
||||
it('should call PUT /notifications/:id/read', async () => {
|
||||
mockedPut.mockResolvedValue({ data: { id: '123', readAt: '2024-01-01' } });
|
||||
await notificationApi.markAsRead('123');
|
||||
expect(mockedPut).toHaveBeenCalledWith('/notifications/123/read');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllAsRead', () => {
|
||||
it('should call PUT /notifications/read-all', async () => {
|
||||
mockedPut.mockResolvedValue({ data: { success: true } });
|
||||
await notificationApi.markAllAsRead();
|
||||
expect(mockedPut).toHaveBeenCalledWith('/notifications/read-all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNotification', () => {
|
||||
it('should call DELETE /notifications/:id', async () => {
|
||||
mockedDelete.mockResolvedValue({ data: { success: true } });
|
||||
await notificationApi.deleteNotification('123');
|
||||
expect(mockedDelete).toHaveBeenCalledWith('/notifications/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreferences', () => {
|
||||
it('should call GET /notifications/preferences', async () => {
|
||||
mockedGet.mockResolvedValue({ data: { emailEnabled: true } });
|
||||
await notificationApi.getPreferences();
|
||||
expect(mockedGet).toHaveBeenCalledWith('/notifications/preferences');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePreferences', () => {
|
||||
it('should call PUT /notifications/preferences with data', async () => {
|
||||
const prefs = { emailEnabled: false, smsEnabled: true };
|
||||
mockedPut.mockResolvedValue({ data: prefs });
|
||||
await notificationApi.updatePreferences(prefs);
|
||||
expect(mockedPut).toHaveBeenCalledWith('/notifications/preferences', prefs);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { orderApi } from '../order-api';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiClient } from '../api';
|
||||
|
||||
const mockedGet = vi.mocked(apiClient.get);
|
||||
const mockedPost = vi.mocked(apiClient.post);
|
||||
const mockedPut = vi.mocked(apiClient.put);
|
||||
|
||||
describe('orderApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createOrder', () => {
|
||||
it('should call POST /orders with correct data', async () => {
|
||||
const requestData = {
|
||||
items: [
|
||||
{
|
||||
productId: 'prod-1',
|
||||
productName: '商品1',
|
||||
quantity: 2,
|
||||
unitPrice: 100,
|
||||
},
|
||||
],
|
||||
remark: '测试备注',
|
||||
};
|
||||
const mockResponse = { data: { id: 'order-1', orderNo: 'ORD001' } };
|
||||
mockedPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await orderApi.createOrder(requestData);
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('/orders', requestData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrders', () => {
|
||||
it('should call GET /orders with default pagination', async () => {
|
||||
const mockResponse = { data: { orders: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } } };
|
||||
mockedGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await orderApi.getOrders();
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('/orders', { params: { page: 1, limit: 20 } });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should call GET /orders with custom pagination and status', async () => {
|
||||
const mockResponse = { data: { orders: [], pagination: { page: 2, limit: 10, total: 0, totalPages: 0 } } };
|
||||
mockedGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await orderApi.getOrders(2, 10, 'PAID');
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('/orders', { params: { page: 2, limit: 10, status: 'PAID' } });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrderStats', () => {
|
||||
it('should call GET /orders/stats', async () => {
|
||||
const mockStats = {
|
||||
data: {
|
||||
total: 100,
|
||||
pending: 10,
|
||||
paid: 30,
|
||||
shipped: 20,
|
||||
completed: 25,
|
||||
cancelled: 10,
|
||||
refunded: 5,
|
||||
totalAmount: 50000,
|
||||
},
|
||||
};
|
||||
mockedGet.mockResolvedValue(mockStats);
|
||||
|
||||
const result = await orderApi.getOrderStats();
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('/orders/stats');
|
||||
expect(result).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrder', () => {
|
||||
it('should call GET /orders/:id', async () => {
|
||||
const mockResponse = { data: { id: 'order-1', orderNo: 'ORD001' } };
|
||||
mockedGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await orderApi.getOrder('order-1');
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('/orders/order-1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOrderStatus', () => {
|
||||
it('should call PUT /orders/:id/status with status data', async () => {
|
||||
const mockResponse = { data: { id: 'order-1', status: 'SHIPPED' } };
|
||||
mockedPut.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await orderApi.updateOrderStatus('order-1', 'SHIPPED');
|
||||
|
||||
expect(mockedPut).toHaveBeenCalledWith('/orders/order-1/status', { status: 'SHIPPED' });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelOrder', () => {
|
||||
it('should call POST /orders/:id/cancel', async () => {
|
||||
const mockResponse = { data: { id: 'order-1', status: 'CANCELLED' } };
|
||||
mockedPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await orderApi.cancelOrder('order-1');
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('/orders/order-1/cancel');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { userApi, User, UpdateUserRequest, UserListResponse } from '@/lib/user-api';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
username: 'testuser',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
phoneVerified: false,
|
||||
lastLoginAt: '2024-01-01T00:00:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockUserListResponse: UserListResponse = {
|
||||
users: [mockUser],
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe('userApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('should call GET /users/me', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockUser } });
|
||||
|
||||
const result = await userApi.getCurrentUser();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/users/me');
|
||||
expect(result.data.data).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCurrentUser', () => {
|
||||
it('should call PUT /users/me with update data', async () => {
|
||||
const updateData: UpdateUserRequest = { firstName: 'Updated' };
|
||||
const updatedUser = { ...mockUser, firstName: 'Updated' };
|
||||
mockedApiClient.put.mockResolvedValueOnce({ data: { data: updatedUser } });
|
||||
|
||||
const result = await userApi.updateCurrentUser(updateData);
|
||||
|
||||
expect(mockedApiClient.put).toHaveBeenCalledWith('/users/me', updateData);
|
||||
expect(result.data.data).toEqual(updatedUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAvatar', () => {
|
||||
it('should call POST /users/me/avatar with FormData', async () => {
|
||||
const file = new File(['avatar'], 'avatar.png', { type: 'image/png' });
|
||||
const updatedUser = { ...mockUser, avatar: 'https://example.com/new-avatar.png' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: updatedUser } });
|
||||
|
||||
const result = await userApi.uploadAvatar(file);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith(
|
||||
'/users/me/avatar',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
expect(result.data.data).toEqual(updatedUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsers', () => {
|
||||
it('should call GET /users with default pagination', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockUserListResponse } });
|
||||
|
||||
const result = await userApi.getUsers(1, 20);
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/users', {
|
||||
params: { page: 1, limit: 20 },
|
||||
});
|
||||
expect(result.data.data).toEqual(mockUserListResponse);
|
||||
});
|
||||
|
||||
it('should call GET /users with search parameter', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockUserListResponse } });
|
||||
|
||||
await userApi.getUsers(1, 20, 'test');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/users', {
|
||||
params: { page: 1, limit: 20, search: 'test' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUser', () => {
|
||||
it('should call GET /users/:id', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockUser } });
|
||||
|
||||
const result = await userApi.getUser('1');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/users/1');
|
||||
expect(result.data.data).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should call PUT /users/:id with update data', async () => {
|
||||
const updateData: UpdateUserRequest = { isActive: false };
|
||||
const updatedUser = { ...mockUser, isActive: false };
|
||||
mockedApiClient.put.mockResolvedValueOnce({ data: { data: updatedUser } });
|
||||
|
||||
const result = await userApi.updateUser('1', updateData);
|
||||
|
||||
expect(mockedApiClient.put).toHaveBeenCalledWith('/users/1', updateData);
|
||||
expect(result.data.data).toEqual(updatedUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('should call DELETE /users/:id', async () => {
|
||||
mockedApiClient.delete.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await userApi.deleteUser('1');
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/users/1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -26,4 +26,5 @@ api.interceptors.response.use(
|
|||
}
|
||||
);
|
||||
|
||||
export const apiClient = api;
|
||||
export default api;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { apiClient } from './api';
|
||||
|
||||
export type NotificationType = 'info' | 'warning' | 'error' | 'success';
|
||||
export type NotificationChannelType = 'email' | 'sms' | 'push' | 'in-app' | 'wecom';
|
||||
export type NotificationStatus = 'pending' | 'sending' | 'sent' | 'failed' | 'cancelled';
|
||||
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId?: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
content: string;
|
||||
contentHtml?: string;
|
||||
channel: NotificationChannelType;
|
||||
channelData?: Record<string, unknown>;
|
||||
status: NotificationStatus;
|
||||
sentAt?: string;
|
||||
readAt?: string;
|
||||
priority: NotificationPriority;
|
||||
metadata?: Record<string, unknown>;
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
id: string;
|
||||
userId: string;
|
||||
emailEnabled: boolean;
|
||||
smsEnabled: boolean;
|
||||
pushEnabled: boolean;
|
||||
inAppEnabled: boolean;
|
||||
securityEnabled: boolean;
|
||||
marketingEnabled: boolean;
|
||||
systemEnabled: boolean;
|
||||
quietStartHour?: number;
|
||||
quietEndHour?: number;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNotificationPreferenceData {
|
||||
emailEnabled?: boolean;
|
||||
smsEnabled?: boolean;
|
||||
pushEnabled?: boolean;
|
||||
inAppEnabled?: boolean;
|
||||
securityEnabled?: boolean;
|
||||
marketingEnabled?: boolean;
|
||||
systemEnabled?: boolean;
|
||||
quietStartHour?: number;
|
||||
quietEndHour?: number;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
getNotifications: (
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
channel?: NotificationChannelType,
|
||||
status?: NotificationStatus,
|
||||
type?: NotificationType,
|
||||
) => {
|
||||
const params: Record<string, string | number> = { page, limit };
|
||||
if (channel) params.channel = channel;
|
||||
if (status) params.status = status;
|
||||
if (type) params.type = type;
|
||||
return apiClient.get('/notifications', { params });
|
||||
},
|
||||
|
||||
getUnreadCount: () =>
|
||||
apiClient.get('/notifications/unread-count'),
|
||||
|
||||
getNotification: (id: string) =>
|
||||
apiClient.get(`/notifications/${id}`),
|
||||
|
||||
markAsRead: (id: string) =>
|
||||
apiClient.put(`/notifications/${id}/read`),
|
||||
|
||||
markAllAsRead: () =>
|
||||
apiClient.put('/notifications/read-all'),
|
||||
|
||||
deleteNotification: (id: string) =>
|
||||
apiClient.delete(`/notifications/${id}`),
|
||||
|
||||
getPreferences: () =>
|
||||
apiClient.get('/notifications/preferences'),
|
||||
|
||||
updatePreferences: (data: UpdateNotificationPreferenceData) =>
|
||||
apiClient.put('/notifications/preferences', data),
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { apiClient } from './api';
|
||||
|
||||
export type OrderStatusType = 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDED';
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
orderId: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
status: OrderStatusType;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
discountAmount: number;
|
||||
paymentMethod?: string;
|
||||
remark?: string;
|
||||
paidAt?: string;
|
||||
shippedAt?: string;
|
||||
completedAt?: string;
|
||||
cancelledAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
items?: OrderItem[];
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
items: {
|
||||
productId: string;
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}[];
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface OrderStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
paid: number;
|
||||
shipped: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
refunded: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export const orderApi = {
|
||||
createOrder: (data: CreateOrderRequest) =>
|
||||
apiClient.post('/orders', data),
|
||||
|
||||
getOrders: (page: number = 1, limit: number = 20, status?: OrderStatusType) =>
|
||||
apiClient.get('/orders', { params: { page, limit, ...(status && { status }) } }),
|
||||
|
||||
getOrderStats: () =>
|
||||
apiClient.get('/orders/stats'),
|
||||
|
||||
getOrder: (id: string) =>
|
||||
apiClient.get(`/orders/${id}`),
|
||||
|
||||
updateOrderStatus: (id: string, status: OrderStatusType) =>
|
||||
apiClient.put(`/orders/${id}/status`, { status }),
|
||||
|
||||
cancelOrder: (id: string) =>
|
||||
apiClient.post(`/orders/${id}/cancel`),
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { apiClient } from './api';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
username: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
avatar?: string;
|
||||
isActive: boolean;
|
||||
emailVerified: boolean;
|
||||
phoneVerified: boolean;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
users: User[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
getCurrentUser: () =>
|
||||
apiClient.get<ApiResponse<User>>('/users/me'),
|
||||
|
||||
updateCurrentUser: (data: UpdateUserRequest) =>
|
||||
apiClient.put<ApiResponse<User>>('/users/me', data),
|
||||
|
||||
uploadAvatar: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
return apiClient.post<ApiResponse<User>>('/users/me/avatar', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
|
||||
getUsers: (page: number = 1, limit: number = 20, search?: string) =>
|
||||
apiClient.get<ApiResponse<UserListResponse>>('/users', {
|
||||
params: { page, limit, ...(search && { search }) },
|
||||
}),
|
||||
|
||||
getUser: (id: string) =>
|
||||
apiClient.get<ApiResponse<User>>(`/users/${id}`),
|
||||
|
||||
updateUser: (id: string, data: UpdateUserRequest) =>
|
||||
apiClient.put<ApiResponse<User>>(`/users/${id}`, data),
|
||||
|
||||
deleteUser: (id: string) =>
|
||||
apiClient.delete(`/users/${id}`),
|
||||
};
|
||||
|
|
@ -10,7 +10,12 @@ module.exports = {
|
|||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(uuid)/)',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
'^express$': '<rootDir>/../test/mocks/express.ts',
|
||||
'^uuid$': '<rootDir>/__mocks__/uuid.ts',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
const sharpFn = jest.fn().mockImplementation(() => {
|
||||
const chain: any = {};
|
||||
chain.resize = jest.fn().mockReturnValue(chain);
|
||||
chain.toFormat = jest.fn().mockReturnValue(chain);
|
||||
chain.jpeg = jest.fn().mockReturnValue(chain);
|
||||
chain.png = jest.fn().mockReturnValue(chain);
|
||||
chain.webp = jest.fn().mockReturnValue(chain);
|
||||
chain.toBuffer = jest.fn().mockResolvedValue(Buffer.from('mock-image'));
|
||||
chain.toFile = jest.fn().mockResolvedValue({ size: 1024 });
|
||||
chain.metadata = jest.fn().mockResolvedValue({ width: 800, height: 600, format: 'jpeg' });
|
||||
return chain;
|
||||
});
|
||||
|
||||
module.exports = sharpFn;
|
||||
module.exports.default = sharpFn;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export const v4 = () => 'mock-uuid-v4';
|
||||
export const v1 = () => 'mock-uuid-v1';
|
||||
export const v3 = () => 'mock-uuid-v3';
|
||||
export const v5 = () => 'mock-uuid-v5';
|
||||
export const validate = () => true;
|
||||
export const version = () => 4;
|
||||
export const NIL = '00000000-0000-0000-0000-000000000000';
|
||||
export const MAX = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
|
||||
|
|
@ -1,15 +1,32 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ImageProcessorService } from './image-processor.service';
|
||||
import * as sharp from 'sharp';
|
||||
|
||||
jest.mock('sharp');
|
||||
jest.mock('sharp', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
const chain: any = {};
|
||||
chain.resize = jest.fn().mockReturnValue(chain);
|
||||
chain.toFormat = jest.fn().mockReturnValue(chain);
|
||||
chain.jpeg = jest.fn().mockReturnValue(chain);
|
||||
chain.png = jest.fn().mockReturnValue(chain);
|
||||
chain.webp = jest.fn().mockReturnValue(chain);
|
||||
chain.toBuffer = jest.fn().mockResolvedValue(Buffer.from('mock-image'));
|
||||
chain.toFile = jest.fn().mockResolvedValue({ size: 1024 });
|
||||
chain.metadata = jest.fn().mockResolvedValue({ width: 800, height: 600, format: 'jpeg' });
|
||||
chain.clone = jest.fn().mockReturnValue(chain);
|
||||
chain.composite = jest.fn().mockReturnValue(chain);
|
||||
chain.raw = jest.fn().mockReturnValue(chain);
|
||||
chain.ensureAlpha = jest.fn().mockReturnValue(chain);
|
||||
return chain;
|
||||
});
|
||||
});
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
describe('ImageProcessorService', () => {
|
||||
let service: ImageProcessorService;
|
||||
let mockSharp: jest.Mocked<typeof sharp>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockSharp = sharp as any;
|
||||
(sharp as any).mockClear();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ImageProcessorService],
|
||||
|
|
@ -30,11 +47,13 @@ describe('ImageProcessorService', () => {
|
|||
const mockResize = jest.fn().mockReturnThis();
|
||||
const mockToFormat = jest.fn().mockReturnThis();
|
||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
||||
const mockMetadata = jest.fn().mockResolvedValue({ width: 800, height: 600, format: 'jpeg' });
|
||||
|
||||
(mockSharp as any).mockReturnValue({
|
||||
(sharp as any).mockReturnValue({
|
||||
resize: mockResize,
|
||||
toFormat: mockToFormat,
|
||||
toBuffer: mockToBuffer,
|
||||
metadata: mockMetadata,
|
||||
});
|
||||
|
||||
const result = await service.processImage(mockBuffer, {
|
||||
|
|
@ -43,7 +62,7 @@ describe('ImageProcessorService', () => {
|
|||
quality: 80,
|
||||
});
|
||||
|
||||
expect(mockSharp).toHaveBeenCalledWith(mockBuffer);
|
||||
expect(sharp).toHaveBeenCalledWith(mockBuffer);
|
||||
expect(mockResize).toHaveBeenCalledWith(800, 600, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
|
|
@ -59,7 +78,7 @@ describe('ImageProcessorService', () => {
|
|||
const mockToFormat = jest.fn().mockReturnThis();
|
||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
||||
|
||||
(mockSharp as any).mockReturnValue({
|
||||
(sharp as any).mockReturnValue({
|
||||
resize: mockResize,
|
||||
toFormat: mockToFormat,
|
||||
toBuffer: mockToBuffer,
|
||||
|
|
@ -84,7 +103,7 @@ describe('ImageProcessorService', () => {
|
|||
const mockJpeg = jest.fn().mockReturnThis();
|
||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
||||
|
||||
(mockSharp as any).mockReturnValue({
|
||||
(sharp as any).mockReturnValue({
|
||||
resize: mockResize,
|
||||
jpeg: mockJpeg,
|
||||
toBuffer: mockToBuffer,
|
||||
|
|
@ -110,7 +129,7 @@ describe('ImageProcessorService', () => {
|
|||
const mockJpeg = jest.fn().mockReturnThis();
|
||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
||||
|
||||
(mockSharp as any).mockReturnValue({
|
||||
(sharp as any).mockReturnValue({
|
||||
resize: mockResize,
|
||||
jpeg: mockJpeg,
|
||||
toBuffer: mockToBuffer,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as sharp from 'sharp';
|
||||
import sharp from 'sharp';
|
||||
import { ProcessImageDto } from './dto';
|
||||
|
||||
interface WatermarkPosition {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,784 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { ChannelFactoryService } from '../channels/channel-factory.service';
|
||||
import { TemplateService } from '../template.service';
|
||||
import { PreferenceService } from '../preference.service';
|
||||
import { QueueService } from '../queue.service';
|
||||
|
||||
describe('NotificationService', () => {
|
||||
let service: NotificationService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockNotification = {
|
||||
id: 'notif-1',
|
||||
userId: 'user-1',
|
||||
type: 'info',
|
||||
title: 'Test Notification',
|
||||
content: 'Hello World',
|
||||
contentHtml: null,
|
||||
channel: 'email',
|
||||
priority: 'normal',
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
metadata: null,
|
||||
templateId: null,
|
||||
templateVersion: null,
|
||||
variables: null,
|
||||
deduplicationId: null,
|
||||
readAt: null,
|
||||
sentAt: null,
|
||||
channelData: null,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
notification: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
notificationBatch: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
notificationBatchItem: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockChannelFactory = {
|
||||
getChannel: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTemplateService = {
|
||||
getTemplateContent: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPreferenceService = {
|
||||
shouldSendNotification: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueueService = {
|
||||
addJob: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
NotificationService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
{
|
||||
provide: ChannelFactoryService,
|
||||
useValue: mockChannelFactory,
|
||||
},
|
||||
{
|
||||
provide: TemplateService,
|
||||
useValue: mockTemplateService,
|
||||
},
|
||||
{
|
||||
provide: PreferenceService,
|
||||
useValue: mockPreferenceService,
|
||||
},
|
||||
{
|
||||
provide: QueueService,
|
||||
useValue: mockQueueService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<NotificationService>(NotificationService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a notification successfully', async () => {
|
||||
const data = {
|
||||
userId: 'user-1',
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
};
|
||||
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.notification.create.mockResolvedValue({
|
||||
...mockNotification,
|
||||
...data,
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
});
|
||||
|
||||
const result = await service.create(data);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification created successfully');
|
||||
expect(result.data.status).toBe('pending');
|
||||
expect(result.data.retryCount).toBe(0);
|
||||
expect(result.data.maxRetries).toBe(3);
|
||||
});
|
||||
|
||||
it('should return existing notification when deduplicationId matches', async () => {
|
||||
const existingNotification = {
|
||||
...mockNotification,
|
||||
deduplicationId: 'dedup-1',
|
||||
};
|
||||
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(existingNotification);
|
||||
|
||||
const result = await service.create({
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
deduplicationId: 'dedup-1',
|
||||
});
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification already exists (deduplication)');
|
||||
expect(result.data).toEqual(existingNotification);
|
||||
expect(mockPrismaService.notification.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create notification when deduplicationId is provided but no match', async () => {
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.notification.create.mockResolvedValue(mockNotification);
|
||||
|
||||
const result = await service.create({
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
deduplicationId: 'dedup-new',
|
||||
});
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification created successfully');
|
||||
expect(mockPrismaService.notification.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create notification without deduplicationId', async () => {
|
||||
mockPrismaService.notification.create.mockResolvedValue(mockNotification);
|
||||
|
||||
const result = await service.create({
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
});
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(mockPrismaService.notification.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should queue notification when useQueue is true', async () => {
|
||||
const payload = {
|
||||
userId: 'user-1',
|
||||
type: 'info' as const,
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
};
|
||||
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.notification.create.mockResolvedValue(mockNotification);
|
||||
mockQueueService.addJob.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.send(payload, true);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification queued for sending');
|
||||
expect(mockQueueService.addJob).toHaveBeenCalledWith(
|
||||
`notification-${mockNotification.id}`,
|
||||
{ notificationId: mockNotification.id },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should process notification directly when useQueue is false', async () => {
|
||||
const payload = {
|
||||
userId: 'user-1',
|
||||
type: 'info' as const,
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
};
|
||||
|
||||
const createdNotification = { ...mockNotification, id: 'notif-direct' };
|
||||
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.notification.create.mockResolvedValue(createdNotification);
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(createdNotification);
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(true);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...createdNotification,
|
||||
status: 'sending',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channelMessageId: 'msg-1',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.send(payload, false);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(mockQueueService.addJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWithTemplate', () => {
|
||||
it('should send notifications using template across specified channels', async () => {
|
||||
const templateContent = {
|
||||
template: {
|
||||
id: 'tmpl-1',
|
||||
name: 'Welcome',
|
||||
version: 1,
|
||||
channels: ['email'],
|
||||
},
|
||||
title: 'Welcome',
|
||||
subject: 'Welcome to FischerX',
|
||||
content: 'Hello {{name}}',
|
||||
contentHtml: '<p>Hello {{name}}</p>',
|
||||
};
|
||||
|
||||
mockTemplateService.getTemplateContent.mockResolvedValue({
|
||||
code: 200,
|
||||
data: templateContent,
|
||||
});
|
||||
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.notification.create.mockResolvedValue(mockNotification);
|
||||
mockQueueService.addJob.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.sendWithTemplate(
|
||||
'welcome',
|
||||
{ name: 'User' },
|
||||
'user-1',
|
||||
['email'],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notifications sent');
|
||||
expect(mockTemplateService.getTemplateContent).toHaveBeenCalledWith(
|
||||
'welcome',
|
||||
'zh-CN',
|
||||
{ name: 'User' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should use template channels when channels not specified', async () => {
|
||||
const templateContent = {
|
||||
template: {
|
||||
id: 'tmpl-1',
|
||||
name: 'Welcome',
|
||||
version: 1,
|
||||
channels: ['email', 'sms'],
|
||||
},
|
||||
title: 'Welcome',
|
||||
content: 'Hello',
|
||||
contentHtml: undefined,
|
||||
};
|
||||
|
||||
mockTemplateService.getTemplateContent.mockResolvedValue({
|
||||
code: 200,
|
||||
data: templateContent,
|
||||
});
|
||||
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.notification.create.mockResolvedValue(mockNotification);
|
||||
mockQueueService.addJob.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.sendWithTemplate('welcome', {}, 'user-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processNotification', () => {
|
||||
it('should throw NotFoundException when notification not found', async () => {
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
(service as any).processNotification('nonexistent'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should return already processed when status is not pending', async () => {
|
||||
const sentNotification = { ...mockNotification, status: 'sent' };
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(sentNotification);
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification already processed');
|
||||
});
|
||||
|
||||
it('should cancel notification when user preferences block it', async () => {
|
||||
const pendingNotification = { ...mockNotification, userId: 'user-1' };
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(pendingNotification);
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(false);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...pendingNotification,
|
||||
status: 'cancelled',
|
||||
errorMessage: 'Notification blocked by user preferences',
|
||||
});
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification cancelled by user preferences');
|
||||
expect(mockPrismaService.notification.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1' },
|
||||
data: {
|
||||
status: 'cancelled',
|
||||
errorMessage: 'Notification blocked by user preferences',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send notification successfully when channel returns success', async () => {
|
||||
const pendingNotification = { ...mockNotification, userId: 'user-1' };
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(pendingNotification);
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(true);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...pendingNotification,
|
||||
status: 'sent',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channelMessageId: 'msg-1',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification sent successfully');
|
||||
expect(result.data.status).toBe('sent');
|
||||
});
|
||||
|
||||
it('should skip preference check when notification has no userId', async () => {
|
||||
const pendingNotification = { ...mockNotification, userId: null };
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(pendingNotification);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...pendingNotification,
|
||||
status: 'sent',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channelMessageId: 'msg-2',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification sent successfully');
|
||||
expect(mockPreferenceService.shouldSendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should retry when channel send fails and retryCount < maxRetries', async () => {
|
||||
const pendingNotification = {
|
||||
...mockNotification,
|
||||
userId: 'user-1',
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
};
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(pendingNotification);
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(true);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...pendingNotification,
|
||||
retryCount: 1,
|
||||
status: 'pending',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockRejectedValue(new Error('Network error')),
|
||||
});
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification will be retried');
|
||||
expect(result.data.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should mark as failed when retryCount exceeds maxRetries', async () => {
|
||||
const pendingNotification = {
|
||||
...mockNotification,
|
||||
userId: 'user-1',
|
||||
retryCount: 3,
|
||||
maxRetries: 3,
|
||||
};
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(pendingNotification);
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(true);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...pendingNotification,
|
||||
retryCount: 4,
|
||||
status: 'failed',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockRejectedValue(new Error('Permanent failure')),
|
||||
});
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification failed after max retries');
|
||||
expect(result.data.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should handle non-Error thrown objects in catch block', async () => {
|
||||
const pendingNotification = {
|
||||
...mockNotification,
|
||||
userId: 'user-1',
|
||||
retryCount: 3,
|
||||
maxRetries: 3,
|
||||
};
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(pendingNotification);
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(true);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...pendingNotification,
|
||||
retryCount: 4,
|
||||
status: 'failed',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockRejectedValue('string error'),
|
||||
});
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should mark as failed when channel returns success=false', async () => {
|
||||
const pendingNotification = {
|
||||
...mockNotification,
|
||||
userId: 'user-1',
|
||||
retryCount: 3,
|
||||
maxRetries: 3,
|
||||
};
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(pendingNotification);
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(true);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...pendingNotification,
|
||||
retryCount: 4,
|
||||
status: 'failed',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Delivery failed',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await (service as any).processNotification('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated notifications', async () => {
|
||||
const notifications = [mockNotification];
|
||||
mockPrismaService.notification.findMany.mockResolvedValue(notifications);
|
||||
mockPrismaService.notification.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.findAll('user-1', 1, 20);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.notifications).toHaveLength(1);
|
||||
expect(result.data.pagination.page).toBe(1);
|
||||
expect(result.data.pagination.total).toBe(1);
|
||||
expect(result.data.pagination.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
mockPrismaService.notification.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notification.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll('user-1', 1, 20, 'pending');
|
||||
|
||||
expect(mockPrismaService.notification.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'pending' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by channel', async () => {
|
||||
mockPrismaService.notification.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notification.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll('user-1', 1, 20, undefined, 'email');
|
||||
|
||||
expect(mockPrismaService.notification.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ channel: 'email' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate correct pagination', async () => {
|
||||
mockPrismaService.notification.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notification.count.mockResolvedValue(50);
|
||||
|
||||
const result = await service.findAll(undefined, 2, 10);
|
||||
|
||||
expect(result.data.pagination.totalPages).toBe(5);
|
||||
expect(result.data.pagination.page).toBe(2);
|
||||
});
|
||||
|
||||
it('should not include userId in where when not provided', async () => {
|
||||
mockPrismaService.notification.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notification.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(undefined, 1, 20);
|
||||
|
||||
const where = mockPrismaService.notification.findMany.mock.calls[0][0].where;
|
||||
expect(where).not.toHaveProperty('userId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a notification by id', async () => {
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(mockNotification);
|
||||
|
||||
const result = await service.findOne('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data).toEqual(mockNotification);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when notification not found', async () => {
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsRead', () => {
|
||||
it('should mark notification as read', async () => {
|
||||
const readNotification = { ...mockNotification, readAt: new Date() };
|
||||
mockPrismaService.notification.update.mockResolvedValue(readNotification);
|
||||
|
||||
const result = await service.markAsRead('notif-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Notification marked as read');
|
||||
expect(mockPrismaService.notification.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1' },
|
||||
data: { readAt: expect.any(Date) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include userId in where clause when provided', async () => {
|
||||
const readNotification = { ...mockNotification, readAt: new Date() };
|
||||
mockPrismaService.notification.update.mockResolvedValue(readNotification);
|
||||
|
||||
await service.markAsRead('notif-1', 'user-1');
|
||||
|
||||
expect(mockPrismaService.notification.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1', userId: 'user-1' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllAsRead', () => {
|
||||
it('should mark all notifications as read for a user', async () => {
|
||||
mockPrismaService.notification.updateMany.mockResolvedValue({ count: 5 });
|
||||
|
||||
const result = await service.markAllAsRead('user-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('All notifications marked as read');
|
||||
expect(mockPrismaService.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', readAt: null },
|
||||
data: { readAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('should return unread count for in-app channel', async () => {
|
||||
mockPrismaService.notification.count.mockResolvedValue(3);
|
||||
|
||||
const result = await service.getUnreadCount('user-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.count).toBe(3);
|
||||
expect(mockPrismaService.notification.count).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', readAt: null, channel: 'in-app' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBatch', () => {
|
||||
it('should create a batch with notifications for all users', async () => {
|
||||
const batchData = {
|
||||
name: 'Marketing Campaign',
|
||||
description: 'Q1 promotion',
|
||||
type: 'marketing',
|
||||
userIds: ['user-1', 'user-2'],
|
||||
notificationData: {
|
||||
type: 'info' as const,
|
||||
title: 'Sale',
|
||||
content: 'Big sale!',
|
||||
channel: 'email',
|
||||
},
|
||||
};
|
||||
|
||||
const mockBatch = {
|
||||
id: 'batch-1',
|
||||
name: batchData.name,
|
||||
description: batchData.description,
|
||||
type: batchData.type,
|
||||
scheduledAt: null,
|
||||
status: 'pending',
|
||||
totalCount: 2,
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.notificationBatch.create.mockResolvedValue(mockBatch);
|
||||
mockPrismaService.notification.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.notification.create.mockResolvedValue({
|
||||
...mockNotification,
|
||||
id: 'notif-batch-1',
|
||||
});
|
||||
mockPrismaService.notificationBatchItem.create.mockResolvedValue({
|
||||
id: 'item-1',
|
||||
batchId: 'batch-1',
|
||||
notificationId: 'notif-batch-1',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await service.createBatch(batchData);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Batch created successfully');
|
||||
expect(result.data).toEqual(mockBatch);
|
||||
expect(mockPrismaService.notification.create).toHaveBeenCalledTimes(2);
|
||||
expect(mockPrismaService.notificationBatchItem.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processBatch', () => {
|
||||
it('should throw NotFoundException when batch not found', async () => {
|
||||
mockPrismaService.notificationBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.processBatch('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should process all items in a batch', async () => {
|
||||
const mockBatch = {
|
||||
id: 'batch-1',
|
||||
name: 'Test Batch',
|
||||
status: 'pending',
|
||||
items: [
|
||||
{ id: 'item-1', notificationId: 'notif-1', status: 'pending' },
|
||||
{ id: 'item-2', notificationId: 'notif-2', status: 'pending' },
|
||||
],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationBatch.findUnique.mockResolvedValue(mockBatch);
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue({
|
||||
...mockNotification,
|
||||
userId: 'user-1',
|
||||
});
|
||||
mockPreferenceService.shouldSendNotification.mockResolvedValue(true);
|
||||
mockPrismaService.notification.update.mockResolvedValue({
|
||||
...mockNotification,
|
||||
status: 'sent',
|
||||
});
|
||||
mockChannelFactory.getChannel.mockReturnValue({
|
||||
send: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channelMessageId: 'msg-batch',
|
||||
}),
|
||||
});
|
||||
mockPrismaService.notificationBatchItem.update.mockResolvedValue({});
|
||||
mockPrismaService.notificationBatch.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.processBatch('batch-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Batch processed');
|
||||
expect(mockPrismaService.notificationBatch.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'batch-1' },
|
||||
data: { status: 'completed' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should increment failedCount when item processing fails', async () => {
|
||||
const mockBatch = {
|
||||
id: 'batch-1',
|
||||
name: 'Test Batch',
|
||||
status: 'pending',
|
||||
items: [
|
||||
{ id: 'item-1', notificationId: 'notif-1', status: 'pending' },
|
||||
],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationBatch.findUnique.mockResolvedValue(mockBatch);
|
||||
mockPrismaService.notification.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.notificationBatchItem.update.mockResolvedValue({});
|
||||
mockPrismaService.notificationBatch.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.processBatch('batch-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(mockPrismaService.notificationBatchItem.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'item-1' },
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
}),
|
||||
);
|
||||
expect(mockPrismaService.notificationBatch.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'batch-1' },
|
||||
data: { failedCount: { increment: 1 } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PreferenceService } from '../preference.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
describe('PreferenceService', () => {
|
||||
let service: PreferenceService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPreference = {
|
||||
id: 'pref-1',
|
||||
userId: 'user-1',
|
||||
emailEnabled: true,
|
||||
smsEnabled: true,
|
||||
pushEnabled: true,
|
||||
inAppEnabled: true,
|
||||
securityEnabled: true,
|
||||
marketingEnabled: false,
|
||||
systemEnabled: true,
|
||||
quietStartHour: 22,
|
||||
quietEndHour: 8,
|
||||
timezone: 'Asia/Shanghai',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
notificationPreference: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PreferenceService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PreferenceService>(PreferenceService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getOrCreate', () => {
|
||||
it('should return existing preference', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await service.getOrCreate('user-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Preferences retrieved successfully');
|
||||
expect(result.data).toEqual(mockPreference);
|
||||
expect(mockPrismaService.notificationPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create preference when not found', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.notificationPreference.create.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await service.getOrCreate('user-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data).toEqual(mockPreference);
|
||||
expect(mockPrismaService.notificationPreference.create).toHaveBeenCalledWith({
|
||||
data: { userId: 'user-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update existing preference', async () => {
|
||||
const updatedPreference = {
|
||||
...mockPreference,
|
||||
emailEnabled: false,
|
||||
};
|
||||
|
||||
mockPrismaService.notificationPreference.upsert.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.update('user-1', { emailEnabled: false });
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Preferences updated successfully');
|
||||
expect(result.data.emailEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should upsert when preference does not exist', async () => {
|
||||
const newPreference = {
|
||||
...mockPreference,
|
||||
emailEnabled: false,
|
||||
};
|
||||
|
||||
mockPrismaService.notificationPreference.upsert.mockResolvedValue(newPreference);
|
||||
|
||||
const result = await service.update('user-1', { emailEnabled: false });
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(mockPrismaService.notificationPreference.upsert).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
update: { emailEnabled: false },
|
||||
create: { userId: 'user-1', emailEnabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should update multiple fields', async () => {
|
||||
const updatedPreference = {
|
||||
...mockPreference,
|
||||
emailEnabled: false,
|
||||
smsEnabled: false,
|
||||
timezone: 'America/New_York',
|
||||
};
|
||||
|
||||
mockPrismaService.notificationPreference.upsert.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.update('user-1', {
|
||||
emailEnabled: false,
|
||||
smsEnabled: false,
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.emailEnabled).toBe(false);
|
||||
expect(result.data.smsEnabled).toBe(false);
|
||||
expect(result.data.timezone).toBe('America/New_York');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChannelEnabled', () => {
|
||||
it('should return true when email channel is enabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await service.isChannelEnabled('user-1', 'email');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when channel is disabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.notificationPreference.create.mockResolvedValue({
|
||||
...mockPreference,
|
||||
smsEnabled: false,
|
||||
});
|
||||
|
||||
const result = await service.isChannelEnabled('user-1', 'sms');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for in-app channel when enabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await service.isChannelEnabled('user-1', 'in-app');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for push channel when enabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await service.isChannelEnabled('user-1', 'push');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown channel', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await service.isChannelEnabled('user-1', 'unknown');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInQuietHours', () => {
|
||||
it('should return false when quiet hours are not set', async () => {
|
||||
const prefNoQuiet = {
|
||||
...mockPreference,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefNoQuiet);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when current hour is within quiet hours (same day)', async () => {
|
||||
const prefSameDay = {
|
||||
...mockPreference,
|
||||
quietStartHour: 10,
|
||||
quietEndHour: 14,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefSameDay);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(12);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
|
||||
it('should return false when current hour is outside quiet hours (same day)', async () => {
|
||||
const prefSameDay = {
|
||||
...mockPreference,
|
||||
quietStartHour: 10,
|
||||
quietEndHour: 14,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefSameDay);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(8);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
|
||||
it('should handle overnight quiet hours (start > end) - current hour after start', async () => {
|
||||
const prefOvernight = {
|
||||
...mockPreference,
|
||||
quietStartHour: 22,
|
||||
quietEndHour: 8,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefOvernight);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(23);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
|
||||
it('should handle overnight quiet hours (start > end) - current hour before end', async () => {
|
||||
const prefOvernight = {
|
||||
...mockPreference,
|
||||
quietStartHour: 22,
|
||||
quietEndHour: 8,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefOvernight);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(3);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
|
||||
it('should handle overnight quiet hours (start > end) - current hour between end and start', async () => {
|
||||
const prefOvernight = {
|
||||
...mockPreference,
|
||||
quietStartHour: 22,
|
||||
quietEndHour: 8,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefOvernight);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(14);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
|
||||
it('should return false when current hour equals quietEndHour', async () => {
|
||||
const prefSameDay = {
|
||||
...mockPreference,
|
||||
quietStartHour: 10,
|
||||
quietEndHour: 14,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefSameDay);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(14);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
|
||||
it('should return true when current hour equals quietStartHour', async () => {
|
||||
const prefSameDay = {
|
||||
...mockPreference,
|
||||
quietStartHour: 10,
|
||||
quietEndHour: 14,
|
||||
};
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(prefSameDay);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(10);
|
||||
|
||||
const result = await service.isInQuietHours('user-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSendNotification', () => {
|
||||
it('should return true when channel is enabled and not in quiet hours', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
});
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when channel is disabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.notificationPreference.create.mockResolvedValue({
|
||||
...mockPreference,
|
||||
emailEnabled: false,
|
||||
});
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when in quiet hours', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
quietStartHour: 10,
|
||||
quietEndHour: 14,
|
||||
});
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(12);
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email');
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
jest.spyOn(Date.prototype, 'getHours').mockRestore();
|
||||
});
|
||||
|
||||
it('should return false when notification type is disabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
marketingEnabled: false,
|
||||
});
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email', 'marketing');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when notification type is enabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
securityEnabled: true,
|
||||
});
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email', 'security');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when notification type is not in typeMap', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
});
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email', 'custom');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when channel enabled but notification type disabled', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
systemEnabled: false,
|
||||
});
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email', 'system');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip type check when notificationType is not provided', async () => {
|
||||
mockPrismaService.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
quietStartHour: undefined,
|
||||
quietEndHour: undefined,
|
||||
});
|
||||
|
||||
const result = await service.shouldSendNotification('user-1', 'email');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { TemplateService } from '../template.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
describe('TemplateService', () => {
|
||||
let service: TemplateService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockTemplate = {
|
||||
id: 'tmpl-1',
|
||||
code: 'welcome',
|
||||
name: 'Welcome Template',
|
||||
description: 'Welcome email template',
|
||||
channels: ['email'],
|
||||
version: 1,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
locales: [
|
||||
{
|
||||
id: 'locale-1',
|
||||
templateId: 'tmpl-1',
|
||||
locale: 'zh-CN',
|
||||
subject: '欢迎',
|
||||
title: '欢迎加入',
|
||||
content: '你好 {{name}},欢迎加入!',
|
||||
contentHtml: '<p>你好 {{name}},欢迎加入!</p>',
|
||||
variables: ['name'],
|
||||
isDefault: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'locale-2',
|
||||
templateId: 'tmpl-1',
|
||||
locale: 'en-US',
|
||||
subject: 'Welcome',
|
||||
title: 'Welcome',
|
||||
content: 'Hello {{name}}, welcome!',
|
||||
contentHtml: '<p>Hello {{name}}, welcome!</p>',
|
||||
variables: ['name'],
|
||||
isDefault: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
notificationTemplate: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
notificationTemplateLocale: {
|
||||
create: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TemplateService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TemplateService>(TemplateService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a template successfully', async () => {
|
||||
const data = {
|
||||
code: 'welcome',
|
||||
name: 'Welcome Template',
|
||||
description: 'Welcome email template',
|
||||
channels: ['email'],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.create.mockResolvedValue({
|
||||
...mockTemplate,
|
||||
locales: undefined,
|
||||
});
|
||||
|
||||
const result = await service.create(data);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Template created successfully');
|
||||
expect(result.data.version).toBe(1);
|
||||
expect(result.data.isActive).toBe(true);
|
||||
expect(mockPrismaService.notificationTemplate.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...data,
|
||||
version: 1,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated templates', async () => {
|
||||
const templates = [mockTemplate];
|
||||
mockPrismaService.notificationTemplate.findMany.mockResolvedValue(templates);
|
||||
mockPrismaService.notificationTemplate.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.findAll(1, 20);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.templates).toHaveLength(1);
|
||||
expect(result.data.pagination.page).toBe(1);
|
||||
expect(result.data.pagination.total).toBe(1);
|
||||
expect(result.data.pagination.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by isActive', async () => {
|
||||
mockPrismaService.notificationTemplate.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notificationTemplate.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(1, 20, true);
|
||||
|
||||
expect(mockPrismaService.notificationTemplate.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { isActive: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not filter when isActive is undefined', async () => {
|
||||
mockPrismaService.notificationTemplate.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notificationTemplate.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(1, 20);
|
||||
|
||||
expect(mockPrismaService.notificationTemplate.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate correct pagination', async () => {
|
||||
mockPrismaService.notificationTemplate.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notificationTemplate.count.mockResolvedValue(50);
|
||||
|
||||
const result = await service.findAll(2, 10);
|
||||
|
||||
expect(result.data.pagination.totalPages).toBe(5);
|
||||
expect(result.data.pagination.page).toBe(2);
|
||||
});
|
||||
|
||||
it('should include locales in findMany', async () => {
|
||||
mockPrismaService.notificationTemplate.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.notificationTemplate.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll();
|
||||
|
||||
expect(mockPrismaService.notificationTemplate.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: { locales: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a template by id', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await service.findOne('tmpl-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data).toEqual(mockTemplate);
|
||||
expect(mockPrismaService.notificationTemplate.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'tmpl-1' },
|
||||
include: { locales: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when template not found', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByCode', () => {
|
||||
it('should return a template by code', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await service.findByCode('welcome');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data).toEqual(mockTemplate);
|
||||
expect(mockPrismaService.notificationTemplate.findUnique).toHaveBeenCalledWith({
|
||||
where: { code: 'welcome' },
|
||||
include: { locales: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when template code not found', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findByCode('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a template successfully', async () => {
|
||||
const existingTemplate = { ...mockTemplate, locales: undefined };
|
||||
const updatedTemplate = { ...existingTemplate, name: 'Updated', version: 2 };
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(existingTemplate);
|
||||
mockPrismaService.notificationTemplate.update.mockResolvedValue(updatedTemplate);
|
||||
|
||||
const result = await service.update('tmpl-1', { name: 'Updated' });
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Template updated successfully');
|
||||
expect(result.data.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should increment version when updating', async () => {
|
||||
const existingTemplate = { ...mockTemplate, version: 1, locales: undefined };
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(existingTemplate);
|
||||
mockPrismaService.notificationTemplate.update.mockResolvedValue({
|
||||
...existingTemplate,
|
||||
version: 2,
|
||||
});
|
||||
|
||||
await service.update('tmpl-1', { name: 'Updated' });
|
||||
|
||||
expect(mockPrismaService.notificationTemplate.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ version: 2 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when template not found', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('nonexistent', { name: 'Updated' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update isActive field', async () => {
|
||||
const existingTemplate = { ...mockTemplate, locales: undefined };
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(existingTemplate);
|
||||
mockPrismaService.notificationTemplate.update.mockResolvedValue({
|
||||
...existingTemplate,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const result = await service.update('tmpl-1', { isActive: false });
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addLocale', () => {
|
||||
it('should add a locale to a template', async () => {
|
||||
const localeData = {
|
||||
locale: 'ja-JP',
|
||||
subject: 'ようこそ',
|
||||
title: 'ようこそ',
|
||||
content: 'こんにちは {{name}}',
|
||||
contentHtml: '<p>こんにちは {{name}}</p>',
|
||||
variables: ['name'],
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
const mockLocale = {
|
||||
id: 'locale-3',
|
||||
templateId: 'tmpl-1',
|
||||
...localeData,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
mockPrismaService.notificationTemplateLocale.create.mockResolvedValue(mockLocale);
|
||||
|
||||
const result = await service.addLocale('tmpl-1', localeData);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Locale added successfully');
|
||||
expect(result.data.locale).toBe('ja-JP');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when template not found', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.addLocale('nonexistent', {
|
||||
locale: 'ja-JP',
|
||||
content: 'Hello',
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should reset existing default locale when isDefault is true', async () => {
|
||||
const localeData = {
|
||||
locale: 'ja-JP',
|
||||
content: 'こんにちは',
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
mockPrismaService.notificationTemplateLocale.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockPrismaService.notificationTemplateLocale.create.mockResolvedValue({
|
||||
id: 'locale-3',
|
||||
templateId: 'tmpl-1',
|
||||
...localeData,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await service.addLocale('tmpl-1', localeData);
|
||||
|
||||
expect(mockPrismaService.notificationTemplateLocale.updateMany).toHaveBeenCalledWith({
|
||||
where: { templateId: 'tmpl-1', isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not reset existing default locale when isDefault is false', async () => {
|
||||
const localeData = {
|
||||
locale: 'ja-JP',
|
||||
content: 'こんにちは',
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
mockPrismaService.notificationTemplateLocale.create.mockResolvedValue({
|
||||
id: 'locale-3',
|
||||
templateId: 'tmpl-1',
|
||||
...localeData,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await service.addLocale('tmpl-1', localeData);
|
||||
|
||||
expect(mockPrismaService.notificationTemplateLocale.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTemplateContent', () => {
|
||||
it('should return template content for specified locale', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'zh-CN', { name: 'Alice' });
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.message).toBe('Template content generated successfully');
|
||||
expect(result.data.locale).toBe('zh-CN');
|
||||
expect(result.data.content).toBe('你好 Alice,欢迎加入!');
|
||||
expect(result.data.contentHtml).toBe('<p>你好 Alice,欢迎加入!</p>');
|
||||
expect(result.data.title).toBe('欢迎加入');
|
||||
expect(result.data.subject).toBe('欢迎');
|
||||
});
|
||||
|
||||
it('should fall back to default locale when specified locale not found', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'fr-FR');
|
||||
|
||||
expect(result.data.locale).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('should fall back to first locale when no default and no matching locale', async () => {
|
||||
const templateNoDefault = {
|
||||
...mockTemplate,
|
||||
locales: [
|
||||
{
|
||||
...mockTemplate.locales[0],
|
||||
isDefault: false,
|
||||
locale: 'en-US',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(templateNoDefault);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'fr-FR');
|
||||
|
||||
expect(result.data.locale).toBe('en-US');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when no locales exist', async () => {
|
||||
const templateNoLocales = {
|
||||
...mockTemplate,
|
||||
locales: [],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(templateNoLocales);
|
||||
|
||||
await expect(
|
||||
service.getTemplateContent('welcome', 'zh-CN'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should replace variables in content', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'en-US', { name: 'Bob' });
|
||||
|
||||
expect(result.data.content).toBe('Hello Bob, welcome!');
|
||||
expect(result.data.contentHtml).toBe('<p>Hello Bob, welcome!</p>');
|
||||
});
|
||||
|
||||
it('should keep unreplaced variable placeholders when variable not provided', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'en-US', {});
|
||||
|
||||
expect(result.data.content).toBe('Hello {{name}}, welcome!');
|
||||
});
|
||||
|
||||
it('should return undefined contentHtml when locale has no contentHtml', async () => {
|
||||
const templateNoHtml = {
|
||||
...mockTemplate,
|
||||
locales: [
|
||||
{
|
||||
...mockTemplate.locales[0],
|
||||
contentHtml: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(templateNoHtml);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'zh-CN');
|
||||
|
||||
expect(result.data.contentHtml).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined title when locale has no title', async () => {
|
||||
const templateNoTitle = {
|
||||
...mockTemplate,
|
||||
locales: [
|
||||
{
|
||||
...mockTemplate.locales[0],
|
||||
title: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(templateNoTitle);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'zh-CN');
|
||||
|
||||
expect(result.data.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined subject when locale has no subject', async () => {
|
||||
const templateNoSubject = {
|
||||
...mockTemplate,
|
||||
locales: [
|
||||
{
|
||||
...mockTemplate.locales[0],
|
||||
subject: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(templateNoSubject);
|
||||
|
||||
const result = await service.getTemplateContent('welcome', 'zh-CN');
|
||||
|
||||
expect(result.data.subject).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use zh-CN as default locale', async () => {
|
||||
mockPrismaService.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
|
||||
|
||||
await service.getTemplateContent('welcome');
|
||||
|
||||
const findUniqueCall = mockPrismaService.notificationTemplate.findUnique.mock.calls[0][0];
|
||||
expect(findUniqueCall.where.code).toBe('welcome');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -77,6 +77,7 @@ describe('PaymentChannelService', () => {
|
|||
|
||||
expect(result).toEqual(channels);
|
||||
expect(mockPrisma.paymentChannel.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserController } from '../user.controller';
|
||||
import { UserService } from '../user.service';
|
||||
|
||||
describe('UserController', () => {
|
||||
let controller: UserController;
|
||||
let service: UserService;
|
||||
|
||||
const mockUserService = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateAvatar: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: { userId: 'test-user-id' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [UserController],
|
||||
providers: [
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: mockUserService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<UserController>(UserController);
|
||||
service = module.get<UserService>(UserService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('should return current user', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'User retrieved successfully',
|
||||
data: { id: 'test-user-id', email: 'test@test.com' },
|
||||
};
|
||||
|
||||
mockUserService.findOne.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.getCurrentUser(mockRequest);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.findOne).toHaveBeenCalledWith('test-user-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCurrentUser', () => {
|
||||
it('should update current user', async () => {
|
||||
const updateDto = { firstName: 'New' };
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'User updated successfully',
|
||||
data: { id: 'test-user-id', firstName: 'New' },
|
||||
};
|
||||
|
||||
mockUserService.update.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.updateCurrentUser(mockRequest, updateDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.update).toHaveBeenCalledWith('test-user-id', updateDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAvatar', () => {
|
||||
it('should upload avatar and return updated user', async () => {
|
||||
const mockFile = {
|
||||
fieldname: 'avatar',
|
||||
originalname: 'test.png',
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
destination: '/uploads/avatars',
|
||||
filename: '123456.png',
|
||||
path: '/uploads/avatars/123456.png',
|
||||
size: 1024,
|
||||
} as Express.Multer.File;
|
||||
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Avatar updated successfully',
|
||||
data: { id: 'test-user-id', avatar: '/uploads/avatars/123456.png' },
|
||||
};
|
||||
|
||||
mockUserService.updateAvatar.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.uploadAvatar(mockRequest, mockFile);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.updateAvatar).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'/uploads/avatars/123456.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated users with default params', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Users retrieved successfully',
|
||||
data: {
|
||||
users: [],
|
||||
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
mockUserService.findAll.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.findAll('1', '20', undefined);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.findAll).toHaveBeenCalledWith(1, 20, undefined);
|
||||
});
|
||||
|
||||
it('should parse page and limit from string query params', async () => {
|
||||
mockUserService.findAll.mockResolvedValue({
|
||||
code: 200,
|
||||
message: 'Users retrieved successfully',
|
||||
data: { users: [], pagination: { page: 3, limit: 5, total: 0, totalPages: 0 } },
|
||||
});
|
||||
|
||||
await controller.findAll('3', '5', undefined);
|
||||
|
||||
expect(service.findAll).toHaveBeenCalledWith(3, 5, undefined);
|
||||
});
|
||||
|
||||
it('should pass search parameter', async () => {
|
||||
mockUserService.findAll.mockResolvedValue({
|
||||
code: 200,
|
||||
message: 'Users retrieved successfully',
|
||||
data: { users: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } },
|
||||
});
|
||||
|
||||
await controller.findAll('1', '20', 'test');
|
||||
|
||||
expect(service.findAll).toHaveBeenCalledWith(1, 20, 'test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return user by id', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'User retrieved successfully',
|
||||
data: { id: 'user-id', email: 'test@test.com' },
|
||||
};
|
||||
|
||||
mockUserService.findOne.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.findOne('user-id');
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.findOne).toHaveBeenCalledWith('user-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update user by id', async () => {
|
||||
const updateDto = { firstName: 'Updated' };
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'User updated successfully',
|
||||
data: { id: 'user-id', firstName: 'Updated' },
|
||||
};
|
||||
|
||||
mockUserService.update.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.update('user-id', updateDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.update).toHaveBeenCalledWith('user-id', updateDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete user by id', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'User deleted successfully',
|
||||
data: null,
|
||||
};
|
||||
|
||||
mockUserService.remove.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.remove('user-id');
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.remove).toHaveBeenCalledWith('user-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { UserService } from '../user.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated users', async () => {
|
||||
const mockUsers = [
|
||||
{ id: '1', email: 'a@test.com', username: 'user1' },
|
||||
{ id: '2', email: 'b@test.com', username: 'user2' },
|
||||
];
|
||||
|
||||
mockPrismaService.user.findMany.mockResolvedValue(mockUsers);
|
||||
mockPrismaService.user.count.mockResolvedValue(2);
|
||||
|
||||
const result = await service.findAll(1, 20);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.users).toHaveLength(2);
|
||||
expect(result.data.pagination.total).toBe(2);
|
||||
expect(result.data.pagination.page).toBe(1);
|
||||
expect(result.data.pagination.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should calculate correct pagination', async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(50);
|
||||
|
||||
const result = await service.findAll(2, 10);
|
||||
|
||||
expect(result.data.pagination.totalPages).toBe(5);
|
||||
expect(result.data.pagination.page).toBe(2);
|
||||
});
|
||||
|
||||
it('should apply search filter when search is provided', async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(1, 20, 'test');
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { contains: 'test' } },
|
||||
{ phone: { contains: 'test' } },
|
||||
{ username: { contains: 'test' } },
|
||||
{ firstName: { contains: 'test' } },
|
||||
{ lastName: { contains: 'test' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not apply search filter when search is undefined', async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(1, 20);
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate correct skip value', async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(3, 10);
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 20,
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should order by createdAt desc', async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(1, 20);
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a user by id', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-id',
|
||||
email: 'test@test.com',
|
||||
username: 'testuser',
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.findOne('user-id');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.id).toBe('user-id');
|
||||
expect(result.data.email).toBe('test@test.com');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when user not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('nonexistent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmail', () => {
|
||||
it('should return a user by email', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-id',
|
||||
email: 'test@test.com',
|
||||
username: 'testuser',
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.findByEmail('test@test.com');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.email).toBe('test@test.com');
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { email: 'test@test.com' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when user not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findByEmail('notfound@test.com')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByPhone', () => {
|
||||
it('should return a user by phone', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-id',
|
||||
phone: '13800138000',
|
||||
username: 'testuser',
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.findByPhone('13800138000');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.phone).toBe('13800138000');
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { phone: '13800138000' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when user not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findByPhone('19999999999')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const userId = 'user-id';
|
||||
const existingUser = { id: userId, email: 'old@test.com', phone: '13800138000' };
|
||||
|
||||
it('should update a user successfully', async () => {
|
||||
const updateDto = { firstName: 'New' };
|
||||
const updatedUser = { ...existingUser, firstName: 'New', updatedAt: new Date() };
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.update(userId, updateDto);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.firstName).toBe('New');
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: userId },
|
||||
data: updateDto,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when user not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('nonexistent', { firstName: 'New' })).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConflictException when email already exists', async () => {
|
||||
const updateDto = { email: 'taken@test.com' };
|
||||
|
||||
mockPrismaService.user.findUnique
|
||||
.mockResolvedValueOnce(existingUser)
|
||||
.mockResolvedValueOnce({ id: 'other-user', email: 'taken@test.com' });
|
||||
|
||||
await expect(service.update(userId, updateDto)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow updating to same email', async () => {
|
||||
const updateDto = { email: 'old@test.com' };
|
||||
const updatedUser = { ...existingUser, updatedAt: new Date() };
|
||||
|
||||
mockPrismaService.user.findUnique
|
||||
.mockResolvedValueOnce(existingUser)
|
||||
.mockResolvedValueOnce(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.update(userId, updateDto);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
});
|
||||
|
||||
it('should throw ConflictException when phone already exists', async () => {
|
||||
const updateDto = { phone: '13900139000' };
|
||||
|
||||
mockPrismaService.user.findUnique
|
||||
.mockResolvedValueOnce(existingUser)
|
||||
.mockResolvedValueOnce({ id: 'other-user', phone: '13900139000' });
|
||||
|
||||
await expect(service.update(userId, updateDto)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow updating to same phone', async () => {
|
||||
const updateDto = { phone: '13800138000' };
|
||||
const updatedUser = { ...existingUser, updatedAt: new Date() };
|
||||
|
||||
mockPrismaService.user.findUnique
|
||||
.mockResolvedValueOnce(existingUser)
|
||||
.mockResolvedValueOnce(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.update(userId, updateDto);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
});
|
||||
|
||||
it('should not check email conflict when email is not provided', async () => {
|
||||
const updateDto = { firstName: 'New' };
|
||||
const updatedUser = { ...existingUser, firstName: 'New', updatedAt: new Date() };
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValueOnce(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
await service.update(userId, updateDto);
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete a user successfully', async () => {
|
||||
const existingUser = { id: 'user-id', email: 'test@test.com' };
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.delete.mockResolvedValue(existingUser);
|
||||
|
||||
const result = await service.remove('user-id');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data).toBeNull();
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'user-id' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when user not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove('nonexistent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAvatar', () => {
|
||||
it('should update avatar successfully', async () => {
|
||||
const userId = 'user-id';
|
||||
const avatarUrl = '/uploads/avatars/test.png';
|
||||
const existingUser = { id: userId, email: 'test@test.com' };
|
||||
const updatedUser = { id: userId, avatar: avatarUrl, updatedAt: new Date() };
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.updateAvatar(userId, avatarUrl);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.avatar).toBe(avatarUrl);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: userId },
|
||||
data: { avatar: avatarUrl },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when user not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateAvatar('nonexistent', '/uploads/avatars/test.png'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
module.exports = {};
|
||||
Loading…
Reference in New Issue