安全/鉴权:登出清服务端 httpOnly 令牌、登录 IP 限流前置 bcrypt、停用账号 令牌在 6 个内联 role handler 失效、admin/items 存在性 oracle 认证前置、 同意偏好 sanitize fail-closed、api-crypto 按 HTTP 方法判体。 CMS:workflow 状态更新+审计收进交互式 $transaction(失败即回滚)、编辑器 richtext 走 textarea 防换行损毁、array/object 子字段按作用域读写防污染、 about/contact/erp-upgrade 补 ISR revalidate 与路由映射。 UI/a11y:AnimatedCounter 入视口前显起点、CLS 累计上报、MetricCard 动画入 effect 并卸载取消 rAF、后台状态四态映射/搜索防抖+序号守卫/媒体单 input/ 吞错改横幅/表单 label-aria 关联。 新增对应判别性单测与 pagination/rate-limit/client-ip/sanitize/validate-content-data 等模块及 tests-integration 真库集成层。
632 lines
21 KiB
TypeScript
632 lines
21 KiB
TypeScript
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||
import { NextRequest } from 'next/server';
|
||
|
||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||
const mockContentItemFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||
const mockContentItemCount = jest.fn<(args: unknown) => Promise<number>>();
|
||
const mockContentItemFindFirst = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||
const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||
const mockContentItemCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||
const mockContentItemUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||
const mockContentItemDelete = jest.fn<(args: unknown) => Promise<unknown>>();
|
||
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||
const mockContentModelFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||
|
||
jest.mock('@/lib/db', () => ({
|
||
prisma: {
|
||
contentItem: {
|
||
findMany: mockContentItemFindMany,
|
||
count: mockContentItemCount,
|
||
findFirst: mockContentItemFindFirst,
|
||
findUnique: mockContentItemFindUnique,
|
||
create: mockContentItemCreate,
|
||
update: mockContentItemUpdate,
|
||
delete: mockContentItemDelete,
|
||
},
|
||
contentModel: {
|
||
findUnique: mockContentModelFindUnique,
|
||
},
|
||
auditLog: {
|
||
create: mockAuditLogCreate,
|
||
},
|
||
},
|
||
}));
|
||
|
||
// ─── Mock permissions ─────────────────────────────────────────────────────
|
||
const mockRequirePermission = jest.fn<
|
||
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
|
||
>();
|
||
|
||
jest.mock('@/lib/permissions', () => ({
|
||
requirePermission: mockRequirePermission,
|
||
}));
|
||
|
||
// Q-8:PUT/DELETE 现会先 authenticateRequest 再探存在性;默认给出有效会话,
|
||
// 让既有的「已授权」用例继续走通,枚举用例单独置空。
|
||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
|
||
jest.mock('@/lib/auth', () => ({
|
||
authenticateRequest: mockAuthenticateRequest,
|
||
}));
|
||
|
||
jest.unmock('./route');
|
||
|
||
import { GET, POST, PUT, DELETE } from './route';
|
||
import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types';
|
||
|
||
const productFields = CONTENT_TYPE_CONFIGS['product'].model.fields;
|
||
|
||
function mockProductModel() {
|
||
mockContentModelFindUnique.mockResolvedValue({
|
||
id: 'model-1',
|
||
code: 'product',
|
||
fields: JSON.stringify(productFields),
|
||
});
|
||
}
|
||
|
||
function createMockRequest(options: {
|
||
url?: string;
|
||
json?: () => Promise<Record<string, unknown>>;
|
||
}): NextRequest {
|
||
return {
|
||
headers: new Headers(),
|
||
url: options.url || 'http://localhost/api/admin/items',
|
||
json: options.json || (async () => ({})),
|
||
} as unknown as NextRequest;
|
||
}
|
||
|
||
function mockAuthorized() {
|
||
mockRequirePermission.mockResolvedValue({
|
||
user: { userId: 'user-1', username: 'editor', role: 'content_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 }),
|
||
});
|
||
}
|
||
|
||
const mockItem = {
|
||
id: 'item-1',
|
||
modelId: 'model-1',
|
||
modelCode: 'news',
|
||
title: '测试新闻',
|
||
slug: 'test-news',
|
||
locale: 'zh-CN',
|
||
status: 'draft',
|
||
data: JSON.stringify({ body: 'hello' }),
|
||
version: 1,
|
||
sortOrder: 0,
|
||
createdBy: 'editor',
|
||
updatedBy: 'editor',
|
||
createdAt: new Date('2026-01-01'),
|
||
updatedAt: new Date('2026-01-01'),
|
||
publishedAt: null,
|
||
};
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
mockContentItemFindMany.mockResolvedValue([mockItem]);
|
||
mockContentItemCount.mockResolvedValue(1);
|
||
mockContentItemFindFirst.mockResolvedValue(null);
|
||
mockContentItemFindUnique.mockResolvedValue(mockItem);
|
||
mockContentItemCreate.mockResolvedValue(mockItem);
|
||
mockContentItemUpdate.mockResolvedValue(mockItem);
|
||
mockContentItemDelete.mockResolvedValue(undefined);
|
||
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
|
||
mockContentModelFindUnique.mockResolvedValue(null);
|
||
// Q-8:默认视为已登录(合法会话),使「已授权」路径不受存在性前置门影响。
|
||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'content_editor' });
|
||
});
|
||
|
||
describe('/api/admin/items', () => {
|
||
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 paginated item list', async () => {
|
||
mockAuthorized();
|
||
const response = await GET(createMockRequest({ url: 'http://localhost/api/admin/items?page=1&pageSize=10' }));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(body.items).toHaveLength(1);
|
||
expect(body.total).toBe(1);
|
||
expect(body.page).toBe(1);
|
||
expect(body.totalPages).toBe(1);
|
||
expect(body.items[0]).toMatchObject({
|
||
id: 'item-1',
|
||
title: '测试新闻',
|
||
data: { body: 'hello' },
|
||
});
|
||
});
|
||
|
||
// Q-7:`page`/`pageSize` 未经校验会把 NaN 传进 Prisma skip/take(真库抛错→500),
|
||
// 且 pageSize 无界可被用来整表拉取。修复后经 parsePagination 收敛为有界默认值。
|
||
it('sanitizes non-numeric page and clamps oversized pageSize (no NaN/ unbounded take)', async () => {
|
||
mockAuthorized();
|
||
const response = await GET(
|
||
createMockRequest({ url: 'http://localhost/api/admin/items?page=abc&pageSize=99999' })
|
||
);
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(body.page).toBe(1);
|
||
expect(body.pageSize).toBe(100);
|
||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({ skip: 0, take: 100 })
|
||
);
|
||
});
|
||
|
||
// Q-8:匿名调用者不得通过「404(存在) vs 401(不存在)」枚举内容 ID —— 认证必须先于存在性探针。
|
||
it('rejects an unauthenticated PUT with 401 WITHOUT probing item existence (no ID oracle)', async () => {
|
||
mockAuthenticateRequest.mockReturnValue(null);
|
||
const response = await PUT(
|
||
createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' })
|
||
);
|
||
expect(response.status).toBe(401);
|
||
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects an unauthenticated DELETE with 401 WITHOUT probing item existence (no ID oracle)', async () => {
|
||
mockAuthenticateRequest.mockReturnValue(null);
|
||
const response = await DELETE(
|
||
createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' })
|
||
);
|
||
expect(response.status).toBe(401);
|
||
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('filters by modelCode and status', async () => {
|
||
mockAuthorized();
|
||
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&status=published' }));
|
||
|
||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: { modelCode: 'news', status: 'published' },
|
||
})
|
||
);
|
||
expect(mockRequirePermission).toHaveBeenCalledWith(
|
||
expect.anything(),
|
||
'news',
|
||
'read'
|
||
);
|
||
});
|
||
|
||
it('applies search filter', async () => {
|
||
mockAuthorized();
|
||
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?search=测试' }));
|
||
|
||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: expect.objectContaining({
|
||
OR: [
|
||
{ title: { contains: '测试' } },
|
||
{ slug: { contains: '测试' } },
|
||
],
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('applies id filter so the editor can locate one item without paging', async () => {
|
||
mockAuthorized();
|
||
await GET(
|
||
createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&id=item-101&page=1&pageSize=1' })
|
||
);
|
||
|
||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: { modelCode: 'news', id: 'item-101' },
|
||
})
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('POST', () => {
|
||
it('returns 401 when not authenticated', async () => {
|
||
mockUnauthorized();
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({ modelId: 'model-1', modelCode: 'news', title: '新闻' }),
|
||
}));
|
||
expect(response.status).toBe(401);
|
||
});
|
||
|
||
it('returns 400 when required fields missing', async () => {
|
||
mockAuthorized();
|
||
const response = await POST(createMockRequest({ json: async () => ({ title: '仅标题' }) }));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.error).toContain('缺少必填字段');
|
||
});
|
||
|
||
it('returns 400 when slug already exists', async () => {
|
||
mockAuthorized();
|
||
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'test-news' });
|
||
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({
|
||
modelId: 'model-1',
|
||
modelCode: 'news',
|
||
title: '新新闻',
|
||
slug: 'test-news',
|
||
}),
|
||
}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.error).toContain('已存在');
|
||
});
|
||
|
||
it('creates item as draft and writes audit log', async () => {
|
||
mockAuthorized();
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({
|
||
modelId: 'model-1',
|
||
modelCode: 'news',
|
||
title: '新新闻',
|
||
slug: 'new-news',
|
||
data: { body: 'content' },
|
||
sortOrder: 1,
|
||
}),
|
||
}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(201);
|
||
expect(body.title).toBe('测试新闻');
|
||
expect(mockContentItemCreate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
modelId: 'model-1',
|
||
modelCode: 'news',
|
||
title: '新新闻',
|
||
slug: 'new-news',
|
||
status: 'draft',
|
||
sortOrder: 1,
|
||
publishedAt: null,
|
||
}),
|
||
})
|
||
);
|
||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
module: 'item',
|
||
action: 'create',
|
||
operator: 'editor',
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
|
||
// B-1:create 权限不得成为发布通道,且未知 status 不能入库(否则 workflow 表外状态会永久卡死)。
|
||
it.each(['published', 'review', 'archived', '任意字符串'])(
|
||
'refuses status "%s" on create and writes nothing',
|
||
async (status) => {
|
||
mockAuthorized();
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({
|
||
modelId: 'model-1',
|
||
modelCode: 'news',
|
||
title: '新新闻',
|
||
data: { body: 'content' },
|
||
status,
|
||
}),
|
||
}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.error).toContain('workflow');
|
||
expect(mockContentItemCreate).not.toHaveBeenCalled();
|
||
expect(mockAuditLogCreate).not.toHaveBeenCalled();
|
||
},
|
||
);
|
||
|
||
it('treats an explicit draft status as the default (no bypass, no rejection)', async () => {
|
||
mockAuthorized();
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({
|
||
modelId: 'model-1',
|
||
modelCode: 'news',
|
||
title: '新新闻',
|
||
data: { body: 'content' },
|
||
status: 'draft',
|
||
}),
|
||
}));
|
||
|
||
expect(response.status).toBe(201);
|
||
expect(mockContentItemCreate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({ status: 'draft', publishedAt: null }),
|
||
})
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('PUT', () => {
|
||
it('returns 400 when id missing', async () => {
|
||
mockAuthorized();
|
||
const response = await PUT(createMockRequest({ url: 'http://localhost/api/admin/items', json: async () => ({}) }));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.error).toBe('缺少 ID');
|
||
});
|
||
|
||
it('returns 404 when item not found', async () => {
|
||
mockAuthorized();
|
||
mockContentItemFindUnique.mockResolvedValue(null);
|
||
const response = await PUT(createMockRequest({
|
||
url: 'http://localhost/api/admin/items?id=missing',
|
||
json: async () => ({ title: '更新' }),
|
||
}));
|
||
|
||
expect(response.status).toBe(404);
|
||
});
|
||
|
||
it('rejects status update through PUT', async () => {
|
||
mockAuthorized();
|
||
const response = await PUT(createMockRequest({
|
||
url: 'http://localhost/api/admin/items?id=item-1',
|
||
json: async () => ({ status: 'published' }),
|
||
}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.error).toContain('状态变更');
|
||
});
|
||
|
||
it('accepts a PUT that echoes the unchanged status (admin autosave sends it every time)', async () => {
|
||
mockAuthorized();
|
||
const response = await PUT(createMockRequest({
|
||
url: 'http://localhost/api/admin/items?id=item-1',
|
||
json: async () => ({ title: '更新', status: 'draft' }),
|
||
}));
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({ title: '更新' }),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('returns 400 when slug duplicated', async () => {
|
||
mockAuthorized();
|
||
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'duplicated' });
|
||
|
||
const response = await PUT(createMockRequest({
|
||
url: 'http://localhost/api/admin/items?id=item-1',
|
||
json: async () => ({ slug: 'duplicated' }),
|
||
}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.error).toContain('已存在');
|
||
});
|
||
|
||
it('updates item and increments version', async () => {
|
||
mockAuthorized();
|
||
const response = await PUT(createMockRequest({
|
||
url: 'http://localhost/api/admin/items?id=item-1',
|
||
json: async () => ({ title: '更新标题', data: { body: 'new' }, sortOrder: 2 }),
|
||
}));
|
||
await response.json();
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: { id: 'item-1' },
|
||
data: expect.objectContaining({
|
||
title: '更新标题',
|
||
data: JSON.stringify({ body: 'new' }),
|
||
sortOrder: 2,
|
||
version: { increment: 1 },
|
||
updatedBy: 'editor',
|
||
}),
|
||
})
|
||
);
|
||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
module: 'item',
|
||
action: 'update',
|
||
operator: 'editor',
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('DELETE', () => {
|
||
it('returns 400 when id missing', async () => {
|
||
mockAuthorized();
|
||
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items' }));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.error).toBe('缺少 ID');
|
||
});
|
||
|
||
it('returns 404 when item not found', async () => {
|
||
mockAuthorized();
|
||
mockContentItemFindUnique.mockResolvedValue(null);
|
||
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=missing' }));
|
||
|
||
expect(response.status).toBe(404);
|
||
});
|
||
|
||
it('deletes item and audit log', async () => {
|
||
mockAuthorized();
|
||
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' }));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(body.message).toBe('删除成功');
|
||
expect(mockContentItemDelete).toHaveBeenCalledWith({ where: { id: 'item-1' } });
|
||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
module: 'item',
|
||
action: 'delete',
|
||
operator: 'editor',
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('写入侧字段声明校验(B-3)', () => {
|
||
it('rejects a metric basis outside the declared options before any write', async () => {
|
||
mockAuthorized();
|
||
mockProductModel();
|
||
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({
|
||
modelId: 'model-1',
|
||
modelCode: 'product',
|
||
title: '虚构指标产品',
|
||
data: { metrics: [{ value: '99.9%', label: '系统可用率', basis: 'customer-proven' }] },
|
||
}),
|
||
}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.code).toBe('VALIDATION_ERROR');
|
||
expect(body.details.fields).toEqual([
|
||
expect.objectContaining({ path: 'metrics[0].basis', rule: 'option' }),
|
||
]);
|
||
expect(mockContentItemCreate).not.toHaveBeenCalled();
|
||
expect(mockAuditLogCreate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('accepts a payload that stays inside the declared constraints', async () => {
|
||
mockAuthorized();
|
||
mockProductModel();
|
||
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({
|
||
modelId: 'model-1',
|
||
modelCode: 'product',
|
||
title: 'ERP 套件',
|
||
data: {
|
||
metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }],
|
||
categoryId: 'enterprise',
|
||
status: '研发中',
|
||
},
|
||
}),
|
||
}));
|
||
|
||
expect(response.status).toBe(201);
|
||
expect(mockContentItemCreate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
data: JSON.stringify({
|
||
metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }],
|
||
categoryId: 'enterprise',
|
||
status: '研发中',
|
||
}),
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('leaves keys the model never declares alone', async () => {
|
||
mockAuthorized();
|
||
mockProductModel();
|
||
|
||
const response = await POST(createMockRequest({
|
||
json: async () => ({
|
||
modelId: 'model-1',
|
||
modelCode: 'product',
|
||
title: '含未声明键',
|
||
data: { notDeclaredAtAll: { basis: 'whatever' }, freeform: '任意结构' },
|
||
}),
|
||
}));
|
||
|
||
expect(response.status).toBe(201);
|
||
});
|
||
|
||
it('stays permissive when the model row is missing or its fields column is dirty', async () => {
|
||
mockAuthorized();
|
||
mockContentModelFindUnique.mockResolvedValue({ id: 'model-1', code: 'product', fields: 'null' });
|
||
|
||
const missingModel = await POST(createMockRequest({
|
||
json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '无模型', data: {} }),
|
||
}));
|
||
expect(missingModel.status).toBe(201);
|
||
|
||
mockContentItemCreate.mockClear();
|
||
|
||
const dirtyFields = await POST(createMockRequest({
|
||
json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '脏声明', data: {} }),
|
||
}));
|
||
expect(dirtyFields.status).toBe(201);
|
||
});
|
||
|
||
it('rejects an invalid PUT payload before touching the row', async () => {
|
||
mockAuthorized();
|
||
mockProductModel();
|
||
mockContentItemFindUnique.mockResolvedValue({ ...mockItem, modelCode: 'product' });
|
||
|
||
const response = await PUT(createMockRequest({
|
||
url: 'http://localhost/api/admin/items?id=item-1',
|
||
json: async () => ({ data: { status: '已上线' } }),
|
||
}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(400);
|
||
expect(body.details.fields).toEqual([
|
||
expect.objectContaining({ path: 'status', rule: 'option' }),
|
||
]);
|
||
expect(mockContentItemUpdate).not.toHaveBeenCalled();
|
||
expect(mockAuditLogCreate).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('脏 data 列不再打断读写(B-4)', () => {
|
||
it('returns {} instead of 500 when a stored data column is not valid JSON', async () => {
|
||
mockAuthorized();
|
||
mockContentItemFindMany.mockResolvedValue([{ ...mockItem, data: 'null' }]);
|
||
|
||
const response = await GET(createMockRequest({}));
|
||
const body = await response.json();
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(body.items[0].data).toEqual({});
|
||
});
|
||
|
||
it('serialises a null PUT payload as {} so the column stays readable', async () => {
|
||
mockAuthorized();
|
||
mockContentItemFindUnique.mockResolvedValue({ ...mockItem, data: 'null' });
|
||
|
||
const response = await PUT(createMockRequest({
|
||
url: 'http://localhost/api/admin/items?id=item-1',
|
||
json: async () => ({ data: null }),
|
||
}));
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({ data: '{}' }),
|
||
})
|
||
);
|
||
});
|
||
});
|
||
});
|