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,202 +1,315 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import api from '@/lib/api';
|
import { useUsers, useUpdateUser, useDeleteUser } from '@/hooks/use-users';
|
||||||
|
import { User, UserListResponse } from '@/lib/user-api';
|
||||||
interface User {
|
import { Button } from '@/components/ui/button';
|
||||||
id: string;
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UsersManagementPage() {
|
export default function UsersManagementPage() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [page, setPage] = useState(1);
|
||||||
const [pagination, setPagination] = useState<Pagination>({
|
const [limit] = useState(20);
|
||||||
page: 1,
|
|
||||||
limit: 20,
|
|
||||||
total: 0,
|
|
||||||
totalPages: 0,
|
|
||||||
});
|
|
||||||
const [search, setSearch] = useState('');
|
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 { data, isLoading } = useUsers(page, limit, search || undefined);
|
||||||
const fetchUsers = async () => {
|
const updateUser = useUpdateUser();
|
||||||
try {
|
const deleteUser = useDeleteUser();
|
||||||
setLoading(true);
|
|
||||||
const response = await api.get('/users', {
|
const responseData = data?.data?.data as UserListResponse | undefined;
|
||||||
params: {
|
const users: User[] = responseData?.users ?? [];
|
||||||
page: pagination.page,
|
const pagination = responseData?.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 };
|
||||||
limit: pagination.limit,
|
|
||||||
search: search || undefined,
|
const handleToggleActive = (userId: string, currentActive: boolean) => {
|
||||||
},
|
updateUser.mutate(
|
||||||
});
|
{ id: userId, data: { isActive: !currentActive } },
|
||||||
setUsers(response.data.data.users);
|
{
|
||||||
setPagination(response.data.data.pagination);
|
onSuccess: () => {
|
||||||
} catch (err) {
|
alert(currentActive ? '已禁用用户' : '已启用用户');
|
||||||
console.error('Failed to fetch users:', err);
|
},
|
||||||
} finally {
|
onError: () => {
|
||||||
setLoading(false);
|
alert('操作失败,请重试');
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
};
|
||||||
|
|
||||||
fetchUsers();
|
const handleDelete = (userId: string) => {
|
||||||
}, [pagination.page, search]);
|
if (!confirm('确定要删除该用户吗?此操作不可撤销。')) return;
|
||||||
|
deleteUser.mutate(userId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
alert('用户已删除');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
alert('删除失败,请重试');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (userId: string) => {
|
const handleEditSubmit = (e: React.FormEvent) => {
|
||||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
e.preventDefault();
|
||||||
|
if (!editingUser) return;
|
||||||
|
|
||||||
try {
|
updateUser.mutate(
|
||||||
await api.delete(`/users/${userId}`);
|
{
|
||||||
setUsers(users.filter((u) => u.id !== userId));
|
id: editingUser.id,
|
||||||
} catch (err) {
|
data: {
|
||||||
console.error('Failed to delete user:', err);
|
username: editingUser.username,
|
||||||
alert('Failed to delete user');
|
firstName: editingUser.firstName || undefined,
|
||||||
}
|
lastName: editingUser.lastName || undefined,
|
||||||
|
email: editingUser.email || undefined,
|
||||||
|
phone: editingUser.phone || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
alert('用户信息已更新');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
alert('更新失败,请重试');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<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>
|
||||||
|
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder="Search users..."
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="搜索用户..."
|
||||||
className="flex-1 px-3 py-2 border rounded-md"
|
className="flex-1 px-3 py-2 border rounded-md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
<Card>
|
||||||
<table className="w-full">
|
<CardContent className="p-0">
|
||||||
<thead className="bg-gray-50">
|
<div className="overflow-x-auto">
|
||||||
<tr>
|
<table className="w-full">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<thead className="bg-gray-50">
|
||||||
User
|
<tr>
|
||||||
</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
用户
|
||||||
Email
|
</th>
|
||||||
</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
邮箱
|
||||||
Phone
|
</th>
|
||||||
</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
手机
|
||||||
Status
|
</th>
|
||||||
</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
状态
|
||||||
Created
|
</th>
|
||||||
</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
创建时间
|
||||||
Actions
|
</th>
|
||||||
</th>
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
</tr>
|
操作
|
||||||
</thead>
|
</th>
|
||||||
<tbody className="divide-y">
|
|
||||||
{loading ? (
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
users.map((user) => (
|
|
||||||
<tr key={user.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
|
||||||
{user.avatar ? (
|
|
||||||
<img src={user.avatar} alt="" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span className="text-sm font-bold text-gray-500">
|
|
||||||
{user.firstName?.[0] || user.username[0]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium">{user.username}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<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 ${
|
|
||||||
user.isActive
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-red-100 text-red-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-500">
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(user.id)}
|
|
||||||
className="text-sm text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody className="divide-y">
|
||||||
</tbody>
|
{isLoading ? (
|
||||||
</table>
|
<tr>
|
||||||
</div>
|
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
加载中...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
暂无用户数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img src={user.avatar} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-bold text-gray-500">
|
||||||
|
{user.firstName?.[0] || user.username[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<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">
|
||||||
|
<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 hover:bg-green-200'
|
||||||
|
: 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.isActive ? '已启用' : '已禁用'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{pagination.totalPages > 1 && (
|
{pagination.totalPages > 1 && (
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
显示第 {(pagination.page - 1) * pagination.limit + 1} -{' '}
|
||||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
|
{Math.min(pagination.page * pagination.limit, pagination.total)} 条,共{' '}
|
||||||
users
|
{pagination.total} 条
|
||||||
</p>
|
</p>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<Button
|
||||||
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
disabled={pagination.page === 1}
|
disabled={pagination.page === 1}
|
||||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
Previous
|
上一页
|
||||||
</button>
|
</Button>
|
||||||
<span className="px-3 py-1">
|
<span className="px-3 py-1 text-sm">
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
第 {pagination.page} / {pagination.totalPages} 页
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
disabled={pagination.page === pagination.totalPages}
|
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>
|
||||||
</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 { cn } from '@/lib/utils'
|
||||||
import { useAppStore } from '@/stores/appStore'
|
import { useAppStore } from '@/stores/appStore'
|
||||||
import { Button } from '@/components/ui/button'
|
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 Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
const sidebarItems = [
|
const sidebarItems = [
|
||||||
{ icon: Home, label: 'Home', href: '/' },
|
{ icon: Home, label: '首页', href: '/dashboard' },
|
||||||
{ icon: Folder, label: 'Files', href: '/files' },
|
{ icon: ShoppingCart, label: '订单管理', href: '/orders' },
|
||||||
{ icon: FileText, label: 'Documents', href: '/documents' },
|
{ icon: CreditCard, label: '支付订单', href: '/payments' },
|
||||||
{ icon: Users, label: 'Users', href: '/users' },
|
{ icon: Bell, label: '通知中心', href: '/notifications' },
|
||||||
{ icon: Settings, label: 'Settings', href: '/settings' },
|
{ 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() {
|
export function Sidebar() {
|
||||||
|
|
@ -29,7 +32,7 @@ export function Sidebar() {
|
||||||
<div className="flex flex-col gap-2 p-4">
|
<div className="flex flex-col gap-2 p-4">
|
||||||
{sidebarItems.map((item) => {
|
{sidebarItems.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
const isActive = pathname === item.href
|
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} href={item.href}>
|
<Link key={item.href} href={item.href}>
|
||||||
<Button
|
<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;
|
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'],
|
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||||
coverageDirectory: '../coverage',
|
coverageDirectory: '../coverage',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(uuid)/)',
|
||||||
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/$1',
|
'^@/(.*)$': '<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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ImageProcessorService } from './image-processor.service';
|
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', () => {
|
describe('ImageProcessorService', () => {
|
||||||
let service: ImageProcessorService;
|
let service: ImageProcessorService;
|
||||||
let mockSharp: jest.Mocked<typeof sharp>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockSharp = sharp as any;
|
(sharp as any).mockClear();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [ImageProcessorService],
|
providers: [ImageProcessorService],
|
||||||
|
|
@ -30,11 +47,13 @@ describe('ImageProcessorService', () => {
|
||||||
const mockResize = jest.fn().mockReturnThis();
|
const mockResize = jest.fn().mockReturnThis();
|
||||||
const mockToFormat = jest.fn().mockReturnThis();
|
const mockToFormat = jest.fn().mockReturnThis();
|
||||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
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,
|
resize: mockResize,
|
||||||
toFormat: mockToFormat,
|
toFormat: mockToFormat,
|
||||||
toBuffer: mockToBuffer,
|
toBuffer: mockToBuffer,
|
||||||
|
metadata: mockMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.processImage(mockBuffer, {
|
const result = await service.processImage(mockBuffer, {
|
||||||
|
|
@ -43,7 +62,7 @@ describe('ImageProcessorService', () => {
|
||||||
quality: 80,
|
quality: 80,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSharp).toHaveBeenCalledWith(mockBuffer);
|
expect(sharp).toHaveBeenCalledWith(mockBuffer);
|
||||||
expect(mockResize).toHaveBeenCalledWith(800, 600, {
|
expect(mockResize).toHaveBeenCalledWith(800, 600, {
|
||||||
fit: 'inside',
|
fit: 'inside',
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
|
|
@ -59,7 +78,7 @@ describe('ImageProcessorService', () => {
|
||||||
const mockToFormat = jest.fn().mockReturnThis();
|
const mockToFormat = jest.fn().mockReturnThis();
|
||||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
||||||
|
|
||||||
(mockSharp as any).mockReturnValue({
|
(sharp as any).mockReturnValue({
|
||||||
resize: mockResize,
|
resize: mockResize,
|
||||||
toFormat: mockToFormat,
|
toFormat: mockToFormat,
|
||||||
toBuffer: mockToBuffer,
|
toBuffer: mockToBuffer,
|
||||||
|
|
@ -84,7 +103,7 @@ describe('ImageProcessorService', () => {
|
||||||
const mockJpeg = jest.fn().mockReturnThis();
|
const mockJpeg = jest.fn().mockReturnThis();
|
||||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
||||||
|
|
||||||
(mockSharp as any).mockReturnValue({
|
(sharp as any).mockReturnValue({
|
||||||
resize: mockResize,
|
resize: mockResize,
|
||||||
jpeg: mockJpeg,
|
jpeg: mockJpeg,
|
||||||
toBuffer: mockToBuffer,
|
toBuffer: mockToBuffer,
|
||||||
|
|
@ -110,7 +129,7 @@ describe('ImageProcessorService', () => {
|
||||||
const mockJpeg = jest.fn().mockReturnThis();
|
const mockJpeg = jest.fn().mockReturnThis();
|
||||||
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
|
||||||
|
|
||||||
(mockSharp as any).mockReturnValue({
|
(sharp as any).mockReturnValue({
|
||||||
resize: mockResize,
|
resize: mockResize,
|
||||||
jpeg: mockJpeg,
|
jpeg: mockJpeg,
|
||||||
toBuffer: mockToBuffer,
|
toBuffer: mockToBuffer,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import * as sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { ProcessImageDto } from './dto';
|
import { ProcessImageDto } from './dto';
|
||||||
|
|
||||||
interface WatermarkPosition {
|
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(result).toEqual(channels);
|
||||||
expect(mockPrisma.paymentChannel.findMany).toHaveBeenCalledWith({
|
expect(mockPrisma.paymentChannel.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {},
|
||||||
orderBy: { sortOrder: 'asc' },
|
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