test(e2e,unit): add navigation edge cases and component coverage
- Add missing-paths E2E spec for desktop/mobile navigation and contrast - Add local Playwright config for dev server testing - Expand unit tests for CMS data server, products, contact and UI components - Adjust jest config for new test patterns
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// ─── Mock react.cache(React 18 不支持,但 Next.js 14 需要)─────────────
|
||||
jest.mock('react', () => ({
|
||||
...(jest.requireActual('react') as Record<string, unknown>),
|
||||
cache: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
// ─── Mock @/lib/db 提供可控的 prisma 实例 ──────────────────────────────
|
||||
// 这样 data-server.ts 的 getPublishedItems 等函数会使用这个 mock prisma
|
||||
const mockContentItem = {
|
||||
findMany: jest.fn<(args: unknown) => Promise<unknown[]>>(),
|
||||
findFirst: jest.fn<(args: unknown) => Promise<unknown | null>>(),
|
||||
findUnique: jest.fn<(args: unknown) => Promise<unknown | null>>(),
|
||||
};
|
||||
|
||||
const mockContentModel = {
|
||||
findMany: jest.fn<(args: unknown) => Promise<unknown[]>>(),
|
||||
};
|
||||
|
||||
const mockContentZone = {
|
||||
findMany: jest.fn<(args: unknown) => Promise<unknown[]>>(),
|
||||
findUnique: jest.fn<(args: unknown) => Promise<unknown | null>>(),
|
||||
};
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentItem: mockContentItem,
|
||||
contentModel: mockContentModel,
|
||||
contentZone: mockContentZone,
|
||||
},
|
||||
}));
|
||||
|
||||
// 取消全局 mock,使用真实 data-server 实现
|
||||
jest.unmock('@/lib/cms/data-server');
|
||||
|
||||
// ─── 现在可以正常导入了 ────────────────────────────────────────────────────
|
||||
import * as dataServer from './data-server';
|
||||
|
||||
// ─── Mock Prisma 数据 ─────────────────────────────────────────────────────
|
||||
|
||||
const mockPrismaItem = {
|
||||
id: 'item-1',
|
||||
modelId: 'model-1',
|
||||
modelCode: 'product',
|
||||
title: '测试产品',
|
||||
slug: 'test-product',
|
||||
status: 'published',
|
||||
data: JSON.stringify({
|
||||
name: '测试产品',
|
||||
description: '这是一个测试产品',
|
||||
price: 100,
|
||||
}),
|
||||
version: 1,
|
||||
sortOrder: 1,
|
||||
activeVersion: 1,
|
||||
createdBy: 'admin',
|
||||
updatedBy: 'admin',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
publishedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
const mockPrismaModel = {
|
||||
id: 'model-1',
|
||||
code: 'product',
|
||||
name: '产品',
|
||||
description: '产品内容模型',
|
||||
fields: JSON.stringify([
|
||||
{ name: 'name', type: 'text', label: '名称', required: true },
|
||||
]),
|
||||
isPageType: true,
|
||||
urlPattern: '/products/[slug]',
|
||||
hasVersions: true,
|
||||
hasDraft: true,
|
||||
icon: 'package',
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
const mockPrismaZone = {
|
||||
id: 'zone-1',
|
||||
code: 'home-hero',
|
||||
name: '首页 Hero 区域',
|
||||
description: '首页 Hero 轮播区域',
|
||||
pageCode: 'home',
|
||||
allowedModels: JSON.stringify(['hero-banner']),
|
||||
items: JSON.stringify([
|
||||
{ itemId: 'hero-1', sortOrder: 1, variant: 'default' },
|
||||
]),
|
||||
settings: JSON.stringify({
|
||||
layout: 'carousel',
|
||||
showTitle: true,
|
||||
}),
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('data-server - CMS 数据访问层', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getPublishedItems', () => {
|
||||
it('should fetch published items for a given model code', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([mockPrismaItem]);
|
||||
|
||||
const result = await dataServer.getPublishedItems('product');
|
||||
|
||||
expect(mockContentItem.findMany).toHaveBeenCalledWith({
|
||||
where: { modelCode: 'product', status: 'published' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe('item-1');
|
||||
expect(result[0]!.title).toBe('测试产品');
|
||||
expect(result[0]!.data).toEqual({
|
||||
name: '测试产品',
|
||||
description: '这是一个测试产品',
|
||||
price: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no published items', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await dataServer.getPublishedItems('product');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should only return published items (not draft)', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([mockPrismaItem]);
|
||||
|
||||
const result = await dataServer.getPublishedItems('product');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.status).toBe('published');
|
||||
|
||||
// 验证查询条件只包含 published
|
||||
expect(mockContentItem.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: 'published',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublishedItemBySlug', () => {
|
||||
it('should fetch a single published item by slug', async () => {
|
||||
mockContentItem.findFirst.mockResolvedValue(mockPrismaItem);
|
||||
|
||||
const result = await dataServer.getPublishedItemBySlug('product', 'test-product');
|
||||
|
||||
expect(mockContentItem.findFirst).toHaveBeenCalledWith({
|
||||
where: { modelCode: 'product', slug: 'test-product', status: 'published' },
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.title).toBe('测试产品');
|
||||
});
|
||||
|
||||
it('should return null when item not found', async () => {
|
||||
mockContentItem.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await dataServer.getPublishedItemBySlug('product', 'non-existent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for draft items', async () => {
|
||||
mockContentItem.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await dataServer.getPublishedItemBySlug('product', 'draft-product');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItemById', () => {
|
||||
it('should fetch an item by ID regardless of status', async () => {
|
||||
mockContentItem.findUnique.mockResolvedValue(mockPrismaItem);
|
||||
|
||||
const result = await dataServer.getItemById('item-1');
|
||||
|
||||
expect(mockContentItem.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'item-1' },
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('item-1');
|
||||
});
|
||||
|
||||
it('should return null when item ID not found', async () => {
|
||||
mockContentItem.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await dataServer.getItemById('non-existent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPageZones', () => {
|
||||
it('should fetch zones for a page code', async () => {
|
||||
mockContentZone.findMany.mockResolvedValue([mockPrismaZone]);
|
||||
|
||||
const result = await dataServer.getPageZones('home');
|
||||
|
||||
expect(mockContentZone.findMany).toHaveBeenCalledWith({
|
||||
where: { pageCode: 'home' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.code).toBe('home-hero');
|
||||
expect(result[0]!.allowedModels).toEqual(['hero-banner']);
|
||||
expect(result[0]!.items).toEqual([
|
||||
{ itemId: 'hero-1', sortOrder: 1, variant: 'default' },
|
||||
]);
|
||||
expect(result[0]!.settings).toEqual({
|
||||
layout: 'carousel',
|
||||
showTitle: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no zones', async () => {
|
||||
mockContentZone.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await dataServer.getPageZones('unknown');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZone', () => {
|
||||
it('should fetch a single zone by code', async () => {
|
||||
mockContentZone.findUnique.mockResolvedValue(mockPrismaZone);
|
||||
|
||||
const result = await dataServer.getZone('home-hero');
|
||||
|
||||
expect(mockContentZone.findUnique).toHaveBeenCalledWith({
|
||||
where: { code: 'home-hero' },
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('首页 Hero 区域');
|
||||
});
|
||||
|
||||
it('should return null when zone not found', async () => {
|
||||
mockContentZone.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await dataServer.getZone('non-existent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentModels', () => {
|
||||
it('should fetch all content models sorted by sortOrder', async () => {
|
||||
mockContentModel.findMany.mockResolvedValue([mockPrismaModel]);
|
||||
|
||||
const result = await dataServer.getContentModels();
|
||||
|
||||
expect(mockContentModel.findMany).toHaveBeenCalledWith({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.code).toBe('product');
|
||||
expect(result[0]!.fields).toEqual([
|
||||
{ name: 'name', type: 'text', label: '名称', required: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllPublishedSlugs', () => {
|
||||
it('should fetch all slugs for published items', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([
|
||||
{ slug: 'test-product' },
|
||||
{ slug: 'another-product' },
|
||||
]);
|
||||
|
||||
const result = await dataServer.getAllPublishedSlugs('product');
|
||||
|
||||
expect(mockContentItem.findMany).toHaveBeenCalledWith({
|
||||
where: { modelCode: 'product', status: 'published' },
|
||||
select: { slug: true },
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ slug: 'test-product' },
|
||||
{ slug: 'another-product' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out null slugs', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([
|
||||
{ slug: 'test-product' },
|
||||
{ slug: null },
|
||||
]);
|
||||
|
||||
const result = await dataServer.getAllPublishedSlugs('product');
|
||||
|
||||
expect(result).toEqual([{ slug: 'test-product' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper Functions', () => {
|
||||
it('getCases should call getPublishedItems with case-study', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItems');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getCases();
|
||||
expect(spy).toHaveBeenCalledWith('case-study');
|
||||
});
|
||||
|
||||
it('getNews should call getPublishedItems with news', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItems');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getNews();
|
||||
expect(spy).toHaveBeenCalledWith('news');
|
||||
});
|
||||
|
||||
it('getServices should call getPublishedItems with service', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItems');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getServices();
|
||||
expect(spy).toHaveBeenCalledWith('service');
|
||||
});
|
||||
|
||||
it('getProducts should call getPublishedItems with product', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItems');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getProducts();
|
||||
expect(spy).toHaveBeenCalledWith('product');
|
||||
});
|
||||
|
||||
it('getSolutions should call getPublishedItems with solution', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItems');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getSolutions();
|
||||
expect(spy).toHaveBeenCalledWith('solution');
|
||||
});
|
||||
|
||||
it('getStats should call getPublishedItems with stat-item', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItems');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getStats();
|
||||
expect(spy).toHaveBeenCalledWith('stat-item');
|
||||
});
|
||||
|
||||
it('getHeroBanners should call getPublishedItems with hero-banner', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItems');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getHeroBanners();
|
||||
expect(spy).toHaveBeenCalledWith('hero-banner');
|
||||
});
|
||||
|
||||
it('getCaseBySlug should call getPublishedItemBySlug with case-study', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItemBySlug');
|
||||
spy.mockResolvedValue(null);
|
||||
|
||||
await dataServer.getCaseBySlug('test-case');
|
||||
expect(spy).toHaveBeenCalledWith('case-study', 'test-case');
|
||||
});
|
||||
|
||||
it('getNewsBySlug should call getPublishedItemBySlug with news', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItemBySlug');
|
||||
spy.mockResolvedValue(null);
|
||||
|
||||
await dataServer.getNewsBySlug('test-news');
|
||||
expect(spy).toHaveBeenCalledWith('news', 'test-news');
|
||||
});
|
||||
|
||||
it('getHomePageZones should call getPageZones with home', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPageZones');
|
||||
spy.mockResolvedValue([]);
|
||||
|
||||
await dataServer.getHomePageZones();
|
||||
expect(spy).toHaveBeenCalledWith('home');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user