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:
fischer 2026-05-25 13:16:34 +08:00
parent 2efab8e712
commit 3d867331ae
28 changed files with 4334 additions and 179 deletions

View File

@ -1,202 +1,315 @@
'use client';
import { useState, useEffect } from 'react';
import api from '@/lib/api';
interface User {
id: string;
email: string | null;
phone: string | null;
username: string;
firstName: string | null;
lastName: string | null;
avatar: string | null;
isActive: boolean;
emailVerified: boolean;
phoneVerified: boolean;
lastLoginAt: Date | null;
createdAt: Date;
}
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
import { useState } from 'react';
import { useUsers, useUpdateUser, useDeleteUser } from '@/hooks/use-users';
import { User, UserListResponse } from '@/lib/user-api';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
export default function UsersManagementPage() {
const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
});
const [page, setPage] = useState(1);
const [limit] = useState(20);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [editingUser, setEditingUser] = useState<{
id: string;
username: string;
firstName: string;
lastName: string;
email: string;
phone: string;
} | null>(null);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const response = await api.get('/users', {
params: {
page: pagination.page,
limit: pagination.limit,
search: search || undefined,
},
});
setUsers(response.data.data.users);
setPagination(response.data.data.pagination);
} catch (err) {
console.error('Failed to fetch users:', err);
} finally {
setLoading(false);
const { data, isLoading } = useUsers(page, limit, search || undefined);
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const responseData = data?.data?.data as UserListResponse | undefined;
const users: User[] = responseData?.users ?? [];
const pagination = responseData?.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 };
const handleToggleActive = (userId: string, currentActive: boolean) => {
updateUser.mutate(
{ id: userId, data: { isActive: !currentActive } },
{
onSuccess: () => {
alert(currentActive ? '已禁用用户' : '已启用用户');
},
onError: () => {
alert('操作失败,请重试');
},
}
};
);
};
fetchUsers();
}, [pagination.page, search]);
const handleDelete = (userId: string) => {
if (!confirm('确定要删除该用户吗?此操作不可撤销。')) return;
deleteUser.mutate(userId, {
onSuccess: () => {
alert('用户已删除');
},
onError: () => {
alert('删除失败,请重试');
},
});
};
const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
const handleEditSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!editingUser) return;
try {
await api.delete(`/users/${userId}`);
setUsers(users.filter((u) => u.id !== userId));
} catch (err) {
console.error('Failed to delete user:', err);
alert('Failed to delete user');
}
updateUser.mutate(
{
id: editingUser.id,
data: {
username: editingUser.username,
firstName: editingUser.firstName || undefined,
lastName: editingUser.lastName || undefined,
email: editingUser.email || undefined,
phone: editingUser.phone || undefined,
},
},
{
onSuccess: () => {
setEditingUser(null);
alert('用户信息已更新');
},
onError: () => {
alert('更新失败,请重试');
},
}
);
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">User Management</h1>
<h1 className="text-2xl font-bold"></h1>
</div>
<div className="flex space-x-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="搜索用户..."
className="flex-1 px-3 py-2 border rounded-md"
/>
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
User
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Phone
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<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>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
</th>
</tr>
))
)}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<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 && (
<div className="flex justify-between items-center">
<p className="text-sm text-gray-500">
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
users
{(pagination.page - 1) * pagination.limit + 1} -{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} {' '}
{pagination.total}
</p>
<div className="flex space-x-2">
<button
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={pagination.page === 1}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-3 py-1">
Page {pagination.page} of {pagination.totalPages}
</Button>
<span className="px-3 py-1 text-sm">
{pagination.page} / {pagination.totalPages}
</span>
<button
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={pagination.page === pagination.totalPages}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</Button>
</div>
</div>
)}
{editingUser && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md space-y-4">
<h2 className="text-lg font-semibold"></h2>
<form onSubmit={handleEditSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={editingUser.username}
onChange={(e) =>
setEditingUser({ ...editingUser, username: e.target.value })
}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={editingUser.lastName}
onChange={(e) =>
setEditingUser({ ...editingUser, lastName: e.target.value })
}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={editingUser.firstName}
onChange={(e) =>
setEditingUser({ ...editingUser, firstName: e.target.value })
}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
value={editingUser.email}
onChange={(e) =>
setEditingUser({ ...editingUser, email: e.target.value })
}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="tel"
value={editingUser.phone}
onChange={(e) =>
setEditingUser({ ...editingUser, phone: e.target.value })
}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div className="flex justify-end space-x-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setEditingUser(null)}
>
</Button>
<Button type="submit" disabled={updateUser.isPending}>
{updateUser.isPending ? '保存中...' : '保存'}
</Button>
</div>
</form>
</div>
</div>
)}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -3,16 +3,19 @@
import { cn } from '@/lib/utils'
import { useAppStore } from '@/stores/appStore'
import { Button } from '@/components/ui/button'
import { Home, Settings, Users, FileText, Folder } from 'lucide-react'
import { Home, Settings, Users, Folder, ShoppingCart, Bell, CreditCard, Shield } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const sidebarItems = [
{ icon: Home, label: 'Home', href: '/' },
{ icon: Folder, label: 'Files', href: '/files' },
{ icon: FileText, label: 'Documents', href: '/documents' },
{ icon: Users, label: 'Users', href: '/users' },
{ icon: Settings, label: 'Settings', href: '/settings' },
{ icon: Home, label: '首页', href: '/dashboard' },
{ icon: ShoppingCart, label: '订单管理', href: '/orders' },
{ icon: CreditCard, label: '支付订单', href: '/payments' },
{ icon: Bell, label: '通知中心', href: '/notifications' },
{ icon: Folder, label: '文件管理', href: '/files' },
{ icon: Users, label: '用户管理', href: '/admin/users' },
{ icon: Shield, label: '角色权限', href: '/admin/roles' },
{ icon: Settings, label: '设置', href: '/settings' },
]
export function Sidebar() {
@ -29,7 +32,7 @@ export function Sidebar() {
<div className="flex flex-col gap-2 p-4">
{sidebarItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
return (
<Link key={item.href} href={item.href}>
<Button

View File

@ -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'] });
},
});
};

View File

@ -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'] });
},
});
};

View File

@ -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'] });
},
});
};

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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');
});
});
});

View File

@ -26,4 +26,5 @@ api.interceptors.response.use(
}
);
export const apiClient = api;
export default api;

View File

@ -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),
};

View File

@ -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`),
};

View File

@ -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}`),
};

View File

@ -10,7 +10,12 @@ module.exports = {
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
transformIgnorePatterns: [
'node_modules/(?!(uuid)/)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^express$': '<rootDir>/../test/mocks/express.ts',
'^uuid$': '<rootDir>/__mocks__/uuid.ts',
},
};

View File

@ -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;

View File

@ -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';

View File

@ -1,15 +1,32 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ImageProcessorService } from './image-processor.service';
import * as sharp from 'sharp';
jest.mock('sharp');
jest.mock('sharp', () => {
return jest.fn().mockImplementation(() => {
const chain: any = {};
chain.resize = jest.fn().mockReturnValue(chain);
chain.toFormat = jest.fn().mockReturnValue(chain);
chain.jpeg = jest.fn().mockReturnValue(chain);
chain.png = jest.fn().mockReturnValue(chain);
chain.webp = jest.fn().mockReturnValue(chain);
chain.toBuffer = jest.fn().mockResolvedValue(Buffer.from('mock-image'));
chain.toFile = jest.fn().mockResolvedValue({ size: 1024 });
chain.metadata = jest.fn().mockResolvedValue({ width: 800, height: 600, format: 'jpeg' });
chain.clone = jest.fn().mockReturnValue(chain);
chain.composite = jest.fn().mockReturnValue(chain);
chain.raw = jest.fn().mockReturnValue(chain);
chain.ensureAlpha = jest.fn().mockReturnValue(chain);
return chain;
});
});
import sharp from 'sharp';
describe('ImageProcessorService', () => {
let service: ImageProcessorService;
let mockSharp: jest.Mocked<typeof sharp>;
beforeEach(async () => {
mockSharp = sharp as any;
(sharp as any).mockClear();
const module: TestingModule = await Test.createTestingModule({
providers: [ImageProcessorService],
@ -30,11 +47,13 @@ describe('ImageProcessorService', () => {
const mockResize = jest.fn().mockReturnThis();
const mockToFormat = jest.fn().mockReturnThis();
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
const mockMetadata = jest.fn().mockResolvedValue({ width: 800, height: 600, format: 'jpeg' });
(mockSharp as any).mockReturnValue({
(sharp as any).mockReturnValue({
resize: mockResize,
toFormat: mockToFormat,
toBuffer: mockToBuffer,
metadata: mockMetadata,
});
const result = await service.processImage(mockBuffer, {
@ -43,7 +62,7 @@ describe('ImageProcessorService', () => {
quality: 80,
});
expect(mockSharp).toHaveBeenCalledWith(mockBuffer);
expect(sharp).toHaveBeenCalledWith(mockBuffer);
expect(mockResize).toHaveBeenCalledWith(800, 600, {
fit: 'inside',
withoutEnlargement: true,
@ -59,7 +78,7 @@ describe('ImageProcessorService', () => {
const mockToFormat = jest.fn().mockReturnThis();
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
(mockSharp as any).mockReturnValue({
(sharp as any).mockReturnValue({
resize: mockResize,
toFormat: mockToFormat,
toBuffer: mockToBuffer,
@ -84,7 +103,7 @@ describe('ImageProcessorService', () => {
const mockJpeg = jest.fn().mockReturnThis();
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
(mockSharp as any).mockReturnValue({
(sharp as any).mockReturnValue({
resize: mockResize,
jpeg: mockJpeg,
toBuffer: mockToBuffer,
@ -110,7 +129,7 @@ describe('ImageProcessorService', () => {
const mockJpeg = jest.fn().mockReturnThis();
const mockToBuffer = jest.fn().mockResolvedValue(mockResultBuffer);
(mockSharp as any).mockReturnValue({
(sharp as any).mockReturnValue({
resize: mockResize,
jpeg: mockJpeg,
toBuffer: mockToBuffer,

View File

@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import * as sharp from 'sharp';
import sharp from 'sharp';
import { ProcessImageDto } from './dto';
interface WatermarkPosition {

View File

@ -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 } },
}),
);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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');
});
});
});

View File

@ -77,6 +77,7 @@ describe('PaymentChannelService', () => {
expect(result).toEqual(channels);
expect(mockPrisma.paymentChannel.findMany).toHaveBeenCalledWith({
where: {},
orderBy: { sortOrder: 'asc' },
});
});

View File

@ -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');
});
});
});

View File

@ -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);
});
});
});

View File

@ -0,0 +1 @@
module.exports = {};