chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括: - 营销页面组件与布局持续优化(about/news/services/solutions/team 等) - 详情页四层叙事组件、布局组件、UI 组件调整 - CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展 - 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等) - ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整 - 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告 - 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock 权限中间件 ──────────────────────────────────────────────────────
|
||||
const mockRequirePermission = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
// ─── Mock 媒体服务 ────────────────────────────────────────────────────────
|
||||
const mockUploadMedia = jest.fn() as jest.Mock<any>;
|
||||
const mockDeleteMedia = jest.fn() as jest.Mock<any>;
|
||||
const mockGetMediaById = jest.fn() as jest.Mock<any>;
|
||||
const mockListMedia = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('@/lib/media/media-service', () => ({
|
||||
uploadMedia: mockUploadMedia,
|
||||
deleteMedia: mockDeleteMedia,
|
||||
getMediaById: mockGetMediaById,
|
||||
listMedia: mockListMedia,
|
||||
}));
|
||||
|
||||
import { GET, POST, DELETE } from './route';
|
||||
|
||||
function createMockRequest(options: {
|
||||
url?: string;
|
||||
formData?: () => Promise<FormData>;
|
||||
}): NextRequest {
|
||||
return {
|
||||
url: options.url || 'http://localhost/api/admin/media',
|
||||
formData: options.formData || (async () => new FormData()),
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom 的 File 未实现 arrayBuffer(),此处构造一个保留 File 实例身份
|
||||
* 但 arrayBuffer() 返回确定内容的测试文件。
|
||||
*/
|
||||
function createTestFile(
|
||||
content: string,
|
||||
name: string,
|
||||
type: string,
|
||||
declaredSize?: number
|
||||
): File {
|
||||
const file = new File([content], name, { type });
|
||||
const encoder = new TextEncoder();
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: async () => encoder.encode(content).buffer,
|
||||
configurable: true,
|
||||
});
|
||||
if (declaredSize !== undefined) {
|
||||
Object.defineProperty(file, 'size', { value: declaredSize });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function mockAuthorized() {
|
||||
mockRequirePermission.mockResolvedValue({ user: { username: 'editor' } });
|
||||
}
|
||||
|
||||
function mockUnauthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
|
||||
});
|
||||
}
|
||||
|
||||
function mockForbidden() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('/api/admin/media', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockUnauthorized();
|
||||
const request = createMockRequest({});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when authenticated but unauthorized', async () => {
|
||||
mockForbidden();
|
||||
const request = createMockRequest({});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns media list when authorized', async () => {
|
||||
mockAuthorized();
|
||||
mockListMedia.mockResolvedValue({
|
||||
items: [{ id: 'a1' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?page=1&pageSize=20' });
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(mockRequirePermission).toHaveBeenCalledWith(request, 'media', 'read');
|
||||
});
|
||||
|
||||
it('returns single media by id', async () => {
|
||||
mockAuthorized();
|
||||
mockGetMediaById.mockResolvedValue({ id: 'a1', name: 'test.png' });
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=a1' });
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.id).toBe('a1');
|
||||
expect(mockGetMediaById).toHaveBeenCalledWith('a1');
|
||||
});
|
||||
|
||||
it('returns 404 when media by id not found', async () => {
|
||||
mockAuthorized();
|
||||
mockGetMediaById.mockResolvedValue(null);
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=missing' });
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('returns 400 when no file provided', async () => {
|
||||
mockAuthorized();
|
||||
const request = createMockRequest({
|
||||
formData: async () => new FormData(),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('请选择文件');
|
||||
});
|
||||
|
||||
it('uploads single file and returns created asset', async () => {
|
||||
mockAuthorized();
|
||||
mockUploadMedia.mockResolvedValue({ id: 'asset-1' });
|
||||
|
||||
const formData = new FormData();
|
||||
const file = createTestFile('content', 'test.png', 'image/png');
|
||||
formData.append('file', file);
|
||||
|
||||
const request = createMockRequest({ formData: async () => formData });
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.id).toBe('asset-1');
|
||||
expect(mockUploadMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'test.png',
|
||||
mimeType: 'image/png',
|
||||
size: 7,
|
||||
}),
|
||||
'editor'
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads multiple files and returns array', async () => {
|
||||
mockAuthorized();
|
||||
mockUploadMedia
|
||||
.mockResolvedValueOnce({ id: 'asset-1' })
|
||||
.mockResolvedValueOnce({ id: 'asset-2' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('files', createTestFile('a', 'a.png', 'image/png'));
|
||||
formData.append('files', createTestFile('b', 'b.png', 'image/png'));
|
||||
|
||||
const request = createMockRequest({ formData: async () => formData });
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects file larger than 10MB', async () => {
|
||||
mockAuthorized();
|
||||
|
||||
const formData = new FormData();
|
||||
const largeFile = createTestFile('x', 'large.png', 'image/png', 10 * 1024 * 1024 + 1);
|
||||
formData.append('file', largeFile);
|
||||
|
||||
const request = createMockRequest({ formData: async () => formData });
|
||||
const response = await POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('10MB');
|
||||
expect(mockUploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 400 when id missing', async () => {
|
||||
mockAuthorized();
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media' });
|
||||
const response = await DELETE(request);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('deletes media by id', async () => {
|
||||
mockAuthorized();
|
||||
mockDeleteMedia.mockResolvedValue(undefined);
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=a1' });
|
||||
const response = await DELETE(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.message).toBe('删除成功');
|
||||
expect(mockDeleteMedia).toHaveBeenCalledWith('a1');
|
||||
});
|
||||
|
||||
it('returns 404 when media not found', async () => {
|
||||
mockAuthorized();
|
||||
mockDeleteMedia.mockRejectedValue(new Error('文件不存在'));
|
||||
|
||||
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=missing' });
|
||||
const response = await DELETE(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +1,118 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import {
|
||||
uploadMedia,
|
||||
deleteMedia,
|
||||
getMediaById,
|
||||
listMedia,
|
||||
} from '@/lib/media/media-service';
|
||||
import {
|
||||
success,
|
||||
unauthorized,
|
||||
notFound,
|
||||
validationError,
|
||||
internalError,
|
||||
} from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/media - 获取媒体列表
|
||||
const MODEL_CODE = 'media';
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// GET /api/admin/media - 获取媒体列表或按 ID 查询
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, MODEL_CODE, 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const mimeType = searchParams.get('mimeType');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (mimeType) {
|
||||
where.mimeType = { startsWith: mimeType };
|
||||
if (id) {
|
||||
const asset = await getMediaById(id);
|
||||
if (!asset) return notFound('文件不存在');
|
||||
return success(asset);
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.mediaAsset.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.mediaAsset.count({ where }),
|
||||
]);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const mimeType = searchParams.get('mimeType') || undefined;
|
||||
|
||||
return success({
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
});
|
||||
const result = await listMedia({ page, pageSize, mimeType });
|
||||
return success(result);
|
||||
} catch (error) {
|
||||
console.error('Get media error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/media - 上传媒体文件
|
||||
// POST /api/admin/media - 上传媒体文件(支持单文件/多文件)
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, MODEL_CODE, 'create');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const files: File[] = [];
|
||||
|
||||
if (!file) {
|
||||
// 收集所有文件字段:支持 file(单文件)和 files(多文件)
|
||||
const singleFile = formData.get('file');
|
||||
if (singleFile instanceof File) {
|
||||
files.push(singleFile);
|
||||
}
|
||||
|
||||
const multiFiles = formData.getAll('files');
|
||||
for (const f of multiFiles) {
|
||||
if (f instanceof File) files.push(f);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return validationError('请选择文件');
|
||||
}
|
||||
|
||||
// 文件大小限制 10MB
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return validationError('文件大小不能超过 10MB');
|
||||
const results = [];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return validationError(`文件 "${file.name}" 大小不能超过 10MB`);
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
const asset = await uploadMedia(
|
||||
{
|
||||
name: file.name,
|
||||
buffer,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
},
|
||||
permission.user.username
|
||||
);
|
||||
|
||||
results.push(asset);
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// 生成唯一文件名
|
||||
const ext = file.name.split('.').pop() || '';
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const uploadDir = 'public/uploads';
|
||||
const filePath = `${uploadDir}/${fileName}`;
|
||||
|
||||
// 写入文件
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
const dirPath = path.join(process.cwd(), uploadDir);
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
await fs.writeFile(path.join(process.cwd(), filePath), buffer);
|
||||
|
||||
const asset = await prisma.mediaAsset.create({
|
||||
data: {
|
||||
name: file.name,
|
||||
path: filePath,
|
||||
url: `/uploads/${fileName}`,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
width: 0,
|
||||
height: 0,
|
||||
alt: file.name,
|
||||
storageType: 'local',
|
||||
createdBy: payload.username,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
module: 'media',
|
||||
targetId: asset.id,
|
||||
action: 'create',
|
||||
operator: payload.username,
|
||||
afterData: JSON.stringify(asset),
|
||||
},
|
||||
});
|
||||
|
||||
return success(asset, 201);
|
||||
return success(files.length === 1 ? results[0] : results, 201);
|
||||
} catch (error) {
|
||||
console.error('Upload media error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/admin/media/[id] - 删除媒体文件
|
||||
// DELETE /api/admin/media?id=xxx - 删除媒体文件
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, MODEL_CODE, 'delete');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return validationError('缺少 ID');
|
||||
|
||||
const existing = await prisma.mediaAsset.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('文件不存在');
|
||||
|
||||
// 删除物理文件
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
await fs.unlink(path.join(process.cwd(), existing.path));
|
||||
} catch {
|
||||
// 文件可能已被删除,忽略
|
||||
}
|
||||
|
||||
await prisma.mediaAsset.delete({ where: { id } });
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
module: 'media',
|
||||
targetId: id,
|
||||
action: 'delete',
|
||||
operator: payload.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
},
|
||||
});
|
||||
|
||||
await deleteMedia(id);
|
||||
return success({ message: '删除成功' });
|
||||
} catch (error) {
|
||||
console.error('Delete media error:', error);
|
||||
if (error instanceof Error && error.message === '文件不存在') {
|
||||
return notFound('文件不存在');
|
||||
}
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockContentModelFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentModel: {
|
||||
findMany: mockContentModelFindMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock permissions ─────────────────────────────────────────────────────
|
||||
const mockRequirePermission = jest.fn<
|
||||
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {
|
||||
url: 'http://localhost/api/admin/models',
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function mockAuthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
user: { userId: 'user-1', username: 'admin', role: 'content_admin' },
|
||||
});
|
||||
}
|
||||
|
||||
function mockUnauthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
|
||||
});
|
||||
}
|
||||
|
||||
function mockForbidden() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
|
||||
});
|
||||
}
|
||||
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'model-1',
|
||||
code: 'news',
|
||||
name: '新闻资讯',
|
||||
description: '新闻内容模型',
|
||||
fields: JSON.stringify([
|
||||
{ code: 'title', name: '标题', type: 'text', required: true },
|
||||
{ code: 'body', name: '正文', type: 'richtext', required: false },
|
||||
]),
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
},
|
||||
{
|
||||
id: 'model-2',
|
||||
code: 'product',
|
||||
name: '产品管理',
|
||||
description: '产品内容模型',
|
||||
fields: JSON.stringify([{ code: 'name', name: '产品名称', type: 'text', required: true }]),
|
||||
sortOrder: 2,
|
||||
createdAt: new Date('2026-01-02'),
|
||||
updatedAt: new Date('2026-01-02'),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentModelFindMany.mockResolvedValue(mockModels);
|
||||
});
|
||||
|
||||
describe('GET /api/admin/models', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockUnauthorized();
|
||||
const response = await GET(createMockRequest());
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when authenticated but unauthorized', async () => {
|
||||
mockForbidden();
|
||||
const response = await GET(createMockRequest());
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns parsed model list ordered by sortOrder', async () => {
|
||||
mockAuthorized();
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0]).toMatchObject({
|
||||
code: 'news',
|
||||
name: '新闻资讯',
|
||||
fields: [
|
||||
{ code: 'title', name: '标题', type: 'text', required: true },
|
||||
{ code: 'body', name: '正文', type: 'richtext', required: false },
|
||||
],
|
||||
});
|
||||
expect(body[1]).toMatchObject({
|
||||
code: 'product',
|
||||
name: '产品管理',
|
||||
});
|
||||
expect(mockContentModelFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
mockAuthorized();
|
||||
mockContentModelFindMany.mockRejectedValue(new Error('db error'));
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { success, unauthorized, internalError } from '@/lib/api-response';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import { success, internalError } from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/models - 获取所有内容模型
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-model', 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const models = await prisma.contentModel.findMany({
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockMarkNotificationAsRead = jest.fn<(id: string, userId: string) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
markNotificationAsRead: mockMarkNotificationAsRead,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { PATCH } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockMarkNotificationAsRead.mockResolvedValue({ id: 'notif-1', read: true });
|
||||
});
|
||||
|
||||
describe('PATCH /api/admin/notifications/[id]/read', () => {
|
||||
it('marks notification as read for authenticated owner', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.read).toBe(true);
|
||||
expect(mockMarkNotificationAsRead).toHaveBeenCalledWith('notif-1', 'user-1');
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 404 when notification not found or not owned', async () => {
|
||||
mockMarkNotificationAsRead.mockRejectedValue(new Error('Record to update not found'));
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe('通知不存在');
|
||||
});
|
||||
|
||||
it('returns 500 on unexpected error', async () => {
|
||||
mockMarkNotificationAsRead.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { markNotificationAsRead } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, notFound, internalError } from '@/lib/api-response';
|
||||
|
||||
// PATCH /api/admin/notifications/[id]/read - 将单条通知标记为已读
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const notification = await markNotificationAsRead(id, user.userId);
|
||||
return success(notification);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.toLowerCase().includes('not found')) {
|
||||
return notFound('通知不存在');
|
||||
}
|
||||
console.error('Mark notification read error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockMarkAllNotificationsAsRead = jest.fn<(userId: string) => Promise<number>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
markAllNotificationsAsRead: mockMarkAllNotificationsAsRead,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { PATCH } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockMarkAllNotificationsAsRead.mockResolvedValue(5);
|
||||
});
|
||||
|
||||
describe('PATCH /api/admin/notifications/read-all', () => {
|
||||
it('marks all notifications as read for authenticated user', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.count).toBe(5);
|
||||
expect(mockMarkAllNotificationsAsRead).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 500 on unexpected error', async () => {
|
||||
mockMarkAllNotificationsAsRead.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest();
|
||||
const response = await PATCH(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { markAllNotificationsAsRead } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, internalError } from '@/lib/api-response';
|
||||
|
||||
// PATCH /api/admin/notifications/read-all - 将当前用户全部未读通知标记为已读
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const count = await markAllNotificationsAsRead(user.userId);
|
||||
return success({ count });
|
||||
} catch (error) {
|
||||
console.error('Mark all notifications read error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockGetUserNotifications = jest.fn<(userId: string, options: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
getUserNotifications: mockGetUserNotifications,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
function createMockRequest(url: string): NextRequest {
|
||||
return { url } as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockGetUserNotifications.mockResolvedValue({
|
||||
items: [{ id: 'notif-1', read: false }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/notifications', () => {
|
||||
it('returns notifications for authenticated user', async () => {
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(mockGetUserNotifications).toHaveBeenCalledWith('user-1', { page: 1, pageSize: 20, unreadOnly: false });
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('parses pagination and unreadOnly query params', async () => {
|
||||
const request = createMockRequest(
|
||||
'http://localhost/api/admin/notifications?page=2&pageSize=10&unreadOnly=true'
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(mockGetUserNotifications).toHaveBeenCalledWith('user-1', { page: 2, pageSize: 10, unreadOnly: true });
|
||||
});
|
||||
|
||||
it('returns 400 for invalid page parameter', async () => {
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications?page=0');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('page 参数非法');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid pageSize parameter', async () => {
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications?pageSize=101');
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('pageSize 参数非法');
|
||||
});
|
||||
|
||||
it('returns 500 on service error', async () => {
|
||||
mockGetUserNotifications.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest('http://localhost/api/admin/notifications');
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { getUserNotifications } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, validationError, internalError } from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/notifications - 获取当前用户通知列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20', 10);
|
||||
const unreadOnly = searchParams.get('unreadOnly') === 'true';
|
||||
|
||||
if (Number.isNaN(page) || page < 1) {
|
||||
return validationError('page 参数非法');
|
||||
}
|
||||
if (Number.isNaN(pageSize) || pageSize < 1 || pageSize > 100) {
|
||||
return validationError('pageSize 参数非法');
|
||||
}
|
||||
|
||||
const result = await getUserNotifications(user.userId, { page, pageSize, unreadOnly });
|
||||
return success(result);
|
||||
} catch (error) {
|
||||
console.error('Get notifications error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock auth ────────────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
// ─── Mock notification service ────────────────────────────────────────────
|
||||
const mockGetUnreadCount = jest.fn<(userId: string) => Promise<number>>();
|
||||
|
||||
jest.mock('@/lib/cms/notifications', () => ({
|
||||
getUnreadCount: mockGetUnreadCount,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockGetUnreadCount.mockResolvedValue(3);
|
||||
});
|
||||
|
||||
describe('GET /api/admin/notifications/unread-count', () => {
|
||||
it('returns unread count for authenticated user', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.count).toBe(3);
|
||||
expect(mockGetUnreadCount).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 500 on unexpected error', async () => {
|
||||
mockGetUnreadCount.mockRejectedValue(new Error('db error'));
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { getUnreadCount } from '@/lib/cms/notifications';
|
||||
import { success, unauthorized, internalError } from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/notifications/unread-count - 获取当前用户未读通知数量
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const count = await getUnreadCount(user.userId);
|
||||
return success({ count });
|
||||
} catch (error) {
|
||||
console.error('Get unread count error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockPermissionFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockPermissionDeleteMany = jest.fn<(args?: unknown) => Promise<unknown>>();
|
||||
const mockPermissionCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
|
||||
const mockContentModelFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
role: {
|
||||
findMany: mockRoleFindMany,
|
||||
findUnique: mockRoleFindUnique,
|
||||
},
|
||||
permission: {
|
||||
findMany: mockPermissionFindMany,
|
||||
deleteMany: mockPermissionDeleteMany,
|
||||
create: mockPermissionCreate,
|
||||
},
|
||||
contentModel: {
|
||||
findMany: mockContentModelFindMany,
|
||||
},
|
||||
userRole: {
|
||||
findMany: mockUserRoleFindMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock @/lib/auth ──────────────────────────────────────────────────────
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET, PUT } from './route';
|
||||
|
||||
function createMockRequest(body?: Record<string, unknown>): NextRequest {
|
||||
return {
|
||||
json: async () => body ?? {},
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
const mockRoles = [
|
||||
{ code: 'super_admin', name: '超级管理员', createdAt: new Date('2026-01-01') },
|
||||
{ code: 'content_admin', name: '内容管理员', createdAt: new Date('2026-01-02') },
|
||||
{ code: 'content_editor', name: '内容编辑', createdAt: new Date('2026-01-03') },
|
||||
];
|
||||
|
||||
const mockModels = [
|
||||
{ code: 'news', name: '新闻资讯', sortOrder: 1 },
|
||||
{ code: 'product', name: '产品管理', sortOrder: 2 },
|
||||
];
|
||||
|
||||
const mockPermissions = [
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'read' },
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'update' },
|
||||
];
|
||||
|
||||
function mockAuthenticated(roleCodes: string[]) {
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'admin' });
|
||||
mockUserRoleFindMany.mockResolvedValue(roleCodes.map((code) => ({ roleCode: code })));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/admin/roles', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 403 for non-super_admin users', async () => {
|
||||
mockAuthenticated(['content_admin']);
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.error).toContain('超级管理员');
|
||||
});
|
||||
|
||||
it('returns roles, permissions and models for super_admin', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindMany.mockResolvedValue(mockRoles);
|
||||
mockPermissionFindMany.mockResolvedValue(mockPermissions);
|
||||
mockContentModelFindMany.mockResolvedValue(mockModels);
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.roles).toHaveLength(3);
|
||||
expect(body.roles[0]).toEqual({ code: 'super_admin', name: '超级管理员', builtin: true });
|
||||
expect(body.permissions).toEqual(mockPermissions);
|
||||
expect(body.models).toEqual([
|
||||
{ code: 'news', name: '新闻资讯' },
|
||||
{ code: 'product', name: '产品管理' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/admin/roles', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'content_editor', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('returns 403 for non-super_admin users', async () => {
|
||||
mockAuthenticated(['content_admin']);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'content_editor', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.error).toContain('超级管理员');
|
||||
});
|
||||
|
||||
it('returns 400 when roleCode is missing', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
|
||||
const response = await PUT(createMockRequest({ permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('roleCode 必填');
|
||||
});
|
||||
|
||||
it('returns 403 when modifying super_admin', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'super_admin', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.error).toContain('不可修改');
|
||||
expect(mockPermissionDeleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when role does not exist', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'ghost', permissions: [] }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('角色不存在');
|
||||
});
|
||||
|
||||
it('replaces permissions and filters invalid actions', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' });
|
||||
mockPermissionDeleteMany.mockResolvedValue({ count: 2 });
|
||||
mockPermissionCreate.mockResolvedValue({});
|
||||
|
||||
const response = await PUT(
|
||||
createMockRequest({
|
||||
roleCode: 'content_editor',
|
||||
permissions: [
|
||||
{ modelCode: 'news', action: 'read' },
|
||||
{ modelCode: 'news', action: 'update' },
|
||||
{ modelCode: 'news', action: 'hack' },
|
||||
{ modelCode: '', action: 'read' },
|
||||
],
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPermissionDeleteMany).toHaveBeenCalledWith({ where: { roleCode: 'content_editor' } });
|
||||
expect(mockPermissionCreate).toHaveBeenCalledTimes(2);
|
||||
expect(mockPermissionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { roleCode: 'content_editor', modelCode: 'news', action: 'read' },
|
||||
})
|
||||
);
|
||||
expect(mockPermissionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { roleCode: 'content_editor', modelCode: 'news', action: 'update' },
|
||||
})
|
||||
);
|
||||
expect(body.roleCode).toBe('content_editor');
|
||||
expect(body.permissions).toContain('news:read');
|
||||
expect(body.permissions).toContain('news:update');
|
||||
expect(body.permissions).not.toContain('news:hack');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
|
||||
|
||||
// 内置角色,不允许删除
|
||||
const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor', 'reviewer', 'readonly']);
|
||||
|
||||
// GET /api/admin/roles - 获取角色列表及其权限
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
// 仅管理员可查看角色权限
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
|
||||
const roleCodes = userRoles.map((ur) => (ur as { roleCode: string }).roleCode);
|
||||
if (!roleCodes.includes('super_admin')) {
|
||||
return forbidden('仅超级管理员可管理角色权限');
|
||||
}
|
||||
|
||||
try {
|
||||
const roles = await prisma.role.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const permissions = await prisma.permission.findMany();
|
||||
const contentModels = await prisma.contentModel.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { code: true, name: true },
|
||||
});
|
||||
|
||||
return success({
|
||||
roles: roles.map((r) => ({
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
builtin: BUILTIN_ROLES.has(r.code),
|
||||
})),
|
||||
permissions: permissions.map((p) => ({
|
||||
roleCode: (p as { roleCode: string }).roleCode,
|
||||
modelCode: (p as { modelCode: string }).modelCode,
|
||||
action: (p as { action: string }).action,
|
||||
})),
|
||||
models: contentModels.map((m) => ({ code: m.code, name: m.name })),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get roles error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/admin/roles/:roleCode - 更新角色权限
|
||||
export async function PUT(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
|
||||
const roleCodes = userRoles.map((ur) => (ur as { roleCode: string }).roleCode);
|
||||
if (!roleCodes.includes('super_admin')) {
|
||||
return forbidden('仅超级管理员可管理角色权限');
|
||||
}
|
||||
|
||||
try {
|
||||
const { roleCode, permissions } = (await request.json()) as {
|
||||
roleCode: string;
|
||||
permissions: Array<{ modelCode: string; action: string }>;
|
||||
};
|
||||
|
||||
if (!roleCode || typeof roleCode !== 'string') {
|
||||
return validationError('roleCode 必填');
|
||||
}
|
||||
|
||||
if (roleCode === 'super_admin') {
|
||||
return forbidden('super_admin 权限不可修改');
|
||||
}
|
||||
|
||||
const role = await prisma.role.findUnique({ where: { code: roleCode } });
|
||||
if (!role) return validationError('角色不存在');
|
||||
|
||||
// 先删除该角色所有权限,再重新写入
|
||||
await prisma.permission.deleteMany({ where: { roleCode } });
|
||||
|
||||
const validActions = new Set(['create', 'read', 'update', 'delete', 'publish']);
|
||||
const uniqueKeys = new Set<string>();
|
||||
for (const perm of permissions || []) {
|
||||
if (!perm.modelCode || !validActions.has(perm.action)) continue;
|
||||
const key = `${perm.modelCode}:${perm.action}`;
|
||||
if (uniqueKeys.has(key)) continue;
|
||||
uniqueKeys.add(key);
|
||||
await prisma.permission.create({
|
||||
data: {
|
||||
roleCode,
|
||||
modelCode: perm.modelCode,
|
||||
action: perm.action,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return success({ roleCode, permissions: Array.from(uniqueKeys) });
|
||||
} catch (error) {
|
||||
console.error('Update roles error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockContentZoneFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
const mockContentZoneFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
const mockContentZoneCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentZoneUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentZoneDelete = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentZone: {
|
||||
findMany: mockContentZoneFindMany,
|
||||
findUnique: mockContentZoneFindUnique,
|
||||
create: mockContentZoneCreate,
|
||||
update: mockContentZoneUpdate,
|
||||
delete: mockContentZoneDelete,
|
||||
},
|
||||
auditLog: {
|
||||
create: mockAuditLogCreate,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock permissions ─────────────────────────────────────────────────────
|
||||
const mockRequirePermission = jest.fn<
|
||||
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET, POST, PUT, DELETE } from './route';
|
||||
|
||||
function createMockRequest(options: {
|
||||
url?: string;
|
||||
json?: () => Promise<Record<string, unknown>>;
|
||||
}): NextRequest {
|
||||
return {
|
||||
url: options.url || 'http://localhost/api/admin/zones',
|
||||
json: options.json || (async () => ({})),
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
function mockAuthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
user: { userId: 'user-1', username: 'admin', role: 'content_admin' },
|
||||
});
|
||||
}
|
||||
|
||||
function mockUnauthorized() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
|
||||
});
|
||||
}
|
||||
|
||||
function mockForbidden() {
|
||||
mockRequirePermission.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
|
||||
});
|
||||
}
|
||||
|
||||
const mockZone = {
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
name: '首页 Hero',
|
||||
description: '',
|
||||
pageCode: 'home',
|
||||
zoneKey: 'hero',
|
||||
allowedModels: JSON.stringify(['hero-banner']),
|
||||
items: JSON.stringify([{ itemId: 'item-1', sortOrder: 1 }]),
|
||||
settings: JSON.stringify({ layout: 'carousel' }),
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentZoneFindMany.mockResolvedValue([mockZone]);
|
||||
mockContentZoneFindUnique.mockResolvedValue(mockZone);
|
||||
mockContentZoneCreate.mockResolvedValue(mockZone);
|
||||
mockContentZoneUpdate.mockResolvedValue(mockZone);
|
||||
mockContentZoneDelete.mockResolvedValue(undefined);
|
||||
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
|
||||
});
|
||||
|
||||
describe('/api/admin/zones', () => {
|
||||
describe('GET', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockUnauthorized();
|
||||
const response = await GET(createMockRequest({}));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when authenticated but unauthorized', async () => {
|
||||
mockForbidden();
|
||||
const response = await GET(createMockRequest({}));
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns parsed zone list', async () => {
|
||||
mockAuthorized();
|
||||
const response = await GET(createMockRequest({}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
allowedModels: ['hero-banner'],
|
||||
items: [{ itemId: 'item-1', sortOrder: 1 }],
|
||||
settings: { layout: 'carousel' },
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by pageCode', async () => {
|
||||
mockAuthorized();
|
||||
await GET(createMockRequest({ url: 'http://localhost/api/admin/zones?pageCode=home' }));
|
||||
|
||||
expect(mockContentZoneFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { pageCode: 'home' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('returns 400 when code missing', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(createMockRequest({ json: async () => ({ name: '无编码' }) }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('code');
|
||||
});
|
||||
|
||||
it('creates new zone and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(
|
||||
createMockRequest({
|
||||
json: async () => ({
|
||||
code: 'home-stats',
|
||||
name: '首页数据',
|
||||
pageCode: 'home',
|
||||
zoneKey: 'stats',
|
||||
allowedModels: ['stat-item'],
|
||||
items: [],
|
||||
settings: {},
|
||||
}),
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentZoneCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
code: 'home-stats',
|
||||
allowedModels: JSON.stringify(['stat-item']),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'zone',
|
||||
action: 'create',
|
||||
operator: 'admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(body.code).toBe('home-hero');
|
||||
});
|
||||
|
||||
it('updates existing zone and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(
|
||||
createMockRequest({
|
||||
json: async () => ({
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
name: '首页 Hero 更新',
|
||||
items: [{ itemId: 'item-2', sortOrder: 2 }],
|
||||
}),
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentZoneUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'zone-1' },
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'zone',
|
||||
action: 'update',
|
||||
operator: 'admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(body.name).toBe('首页 Hero');
|
||||
});
|
||||
|
||||
it('returns 404 when updating non-existent zone', async () => {
|
||||
mockAuthorized();
|
||||
mockContentZoneFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest({
|
||||
json: async () => ({ id: 'missing', code: 'home-hero' }),
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe('区域不存在');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('delegates to POST', async () => {
|
||||
mockAuthorized();
|
||||
const response = await PUT(
|
||||
createMockRequest({
|
||||
json: async () => ({
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
name: 'PUT 更新',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentZoneUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('returns 400 when id missing', async () => {
|
||||
mockAuthorized();
|
||||
const response = await DELETE(createMockRequest({}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe('缺少 ID');
|
||||
});
|
||||
|
||||
it('returns 404 when zone not found', async () => {
|
||||
mockAuthorized();
|
||||
mockContentZoneFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/zones?id=missing' })
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe('区域不存在');
|
||||
});
|
||||
|
||||
it('deletes zone and audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await DELETE(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/zones?id=zone-1' })
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.message).toBe('删除成功');
|
||||
expect(mockContentZoneDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'zone-1' } })
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'zone',
|
||||
action: 'delete',
|
||||
operator: 'admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import {
|
||||
success,
|
||||
unauthorized,
|
||||
notFound,
|
||||
validationError,
|
||||
internalError,
|
||||
@@ -11,8 +10,8 @@ import {
|
||||
|
||||
// GET /api/admin/zones - 获取所有内容区域
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-zone', 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -35,8 +34,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// POST /api/admin/zones - 创建/更新内容区域
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-zone', 'update');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
@@ -67,7 +66,7 @@ export async function POST(request: NextRequest) {
|
||||
module: 'zone',
|
||||
targetId: id,
|
||||
action: 'update',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
afterData: JSON.stringify(zone),
|
||||
},
|
||||
@@ -80,7 +79,7 @@ export async function POST(request: NextRequest) {
|
||||
module: 'zone',
|
||||
targetId: zone.id,
|
||||
action: 'create',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
afterData: JSON.stringify(zone),
|
||||
},
|
||||
});
|
||||
@@ -105,8 +104,8 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// DELETE /api/admin/zones/[id] - 删除内容区域
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const payload = authenticateRequest(request);
|
||||
if (!payload) return unauthorized();
|
||||
const permission = await requirePermission(request, 'content-zone', 'delete');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -123,7 +122,7 @@ export async function DELETE(request: NextRequest) {
|
||||
module: 'zone',
|
||||
targetId: id,
|
||||
action: 'delete',
|
||||
operator: payload.username,
|
||||
operator: permission.user.username,
|
||||
beforeData: JSON.stringify(existing),
|
||||
},
|
||||
});
|
||||
@@ -133,4 +132,4 @@ export async function DELETE(request: NextRequest) {
|
||||
console.error('Delete zone error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user