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:
张翔
2026-07-08 14:47:07 +08:00
parent 7f36211342
commit a6ea2cc72c
11 changed files with 1693 additions and 34 deletions
+388
View File
@@ -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');
});
});
});