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,436 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, initial, animate, whileInView, viewport, className, style, ...props }: any) => (
|
||||
<div
|
||||
data-testid="motion-div"
|
||||
data-initial={JSON.stringify(initial)}
|
||||
data-animate={JSON.stringify(animate)}
|
||||
data-while-inview={JSON.stringify(whileInView)}
|
||||
data-viewport={JSON.stringify(viewport)}
|
||||
className={className}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
p: ({ children, className, ...props }: any) => (
|
||||
<p data-testid="motion-p" className={className} {...props}>{children}</p>
|
||||
),
|
||||
h1: ({ children, className, ...props }: any) => (
|
||||
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
|
||||
),
|
||||
span: ({ children, className, ...props }: any) => (
|
||||
<span data-testid="motion-span" className={className} {...props}>{children}</span>
|
||||
),
|
||||
},
|
||||
useInView: jest.fn(() => true),
|
||||
AnimatePresence: ({ children }: any) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSearchParams: jest.fn(() => new URLSearchParams()),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
trackContactForm: jest.fn(),
|
||||
trackConversion: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, type, disabled, className, ...props }: any) => (
|
||||
<button type={type} disabled={disabled} className={className} data-testid="mock-button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/labeled-input', () => ({
|
||||
LabeledInput: ({ label, error, id, placeholder, required, value, onChange, onBlur, name, type, 'data-testid': dataTestId, ...props }: any) => (
|
||||
<div data-testid="labeled-input-wrapper">
|
||||
{label && <label htmlFor={id} data-testid="input-label">{label}</label>}
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type || 'text'}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
data-testid={dataTestId || 'labeled-input'}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span data-testid="input-error" className="text-brand text-sm mt-1">{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/labeled-textarea', () => ({
|
||||
LabeledTextarea: ({ label, error, id, placeholder, required, value, onChange, onBlur, name, rows, 'data-testid': dataTestId, ...props }: any) => (
|
||||
<div data-testid="labeled-textarea-wrapper">
|
||||
{label && <label htmlFor={id} data-testid="textarea-label">{label}</label>}
|
||||
<textarea
|
||||
id={id}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
rows={rows}
|
||||
data-testid={dataTestId || 'labeled-textarea'}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span data-testid="textarea-error" className="text-brand text-sm mt-1">{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/toast', () => ({
|
||||
Toast: ({ message, type, onClose, ...props }: any) => (
|
||||
<div data-testid="toast" data-toast-type={type} {...props}>
|
||||
<span data-testid="toast-message">{message}</span>
|
||||
<button data-testid="toast-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/seo/structured-data', () => ({
|
||||
BreadcrumbSchema: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/tooltip', () => ({
|
||||
LegacyTooltip: ({ children, content }: any) => (
|
||||
<span data-testid="legacy-tooltip" data-tip={content}>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => {
|
||||
const mockIcon = (name: string) => {
|
||||
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
Mail: mockIcon('mail'),
|
||||
MapPin: mockIcon('map-pin'),
|
||||
Send: mockIcon('send'),
|
||||
Loader2: mockIcon('loader2'),
|
||||
Clock: mockIcon('clock'),
|
||||
HeadphonesIcon: mockIcon('headphones'),
|
||||
CheckCircle2: mockIcon('check-circle2'),
|
||||
HelpCircle: mockIcon('help-circle'),
|
||||
ArrowRight: mockIcon('arrow-right'),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Mock Data ────────────────────────────────────────────────────────────
|
||||
|
||||
const mockContactData = {
|
||||
heroSubtitle: 'GET IN TOUCH',
|
||||
heroTitle: '让我们\n开启对话',
|
||||
heroDescription: '无论您有任何疑问或需求,我们的团队都会在24小时内回复。',
|
||||
contactTitle: '联系方式',
|
||||
emailLabel: '电子邮箱',
|
||||
addressLabel: '公司地址',
|
||||
email: 'contact@novalon.cn',
|
||||
address: '成都市高新区天府大道中段',
|
||||
workHoursTitle: '工作时间',
|
||||
dayLabel: '周一至周五',
|
||||
hours: '09:00 — 18:00',
|
||||
commitmentTitle: '我们的承诺',
|
||||
commitmentItems: [
|
||||
'24小时内回复您的咨询',
|
||||
'免费提供初步方案评估',
|
||||
'严格保密您的业务信息',
|
||||
],
|
||||
formTitle: '发送消息',
|
||||
successTitle: '消息已发送',
|
||||
successMessage: '感谢您的留言,我们会尽快与您联系。',
|
||||
submitLabel: '发送消息',
|
||||
submittingLabel: '发送中...',
|
||||
ctaSubtitle: 'Ready to Start',
|
||||
ctaTitle: '准备好开启数字化转型了吗?',
|
||||
ctaDescription: '立即预约免费咨询,我们的专家团队将为您量身定制最佳方案。',
|
||||
ctaPrimaryLabel: '预约免费咨询',
|
||||
ctaPrimaryHref: '/contact',
|
||||
ctaSecondaryLabel: '了解更多',
|
||||
ctaSecondaryHref: '/solutions',
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getSubmitButton(): HTMLButtonElement {
|
||||
// "发送消息"同时出现在 form title 和 button label 中,需用 getAllByText 筛选
|
||||
const elements = screen.getAllByText('发送消息');
|
||||
const btnEl = elements.find(el => el.closest('button'));
|
||||
return (btnEl?.closest('button') || elements[0]?.closest('button')) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function renderContactContent(data: Record<string, unknown> = mockContactData) {
|
||||
return import('./contact-content-v3').then((mod) => {
|
||||
const Component = mod.default;
|
||||
return render(<Component data={data} />);
|
||||
});
|
||||
}
|
||||
|
||||
function fillFormFields() {
|
||||
const nameInput = screen.getByTestId('name-input') as HTMLInputElement;
|
||||
const phoneInput = screen.getByTestId('phone-input') as HTMLInputElement;
|
||||
const emailInput = screen.getByTestId('email-input') as HTMLInputElement;
|
||||
const subjectInput = screen.getByTestId('subject-input') as HTMLInputElement;
|
||||
const messageInput = screen.getByTestId('message-input') as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: '张三' } });
|
||||
fireEvent.change(phoneInput, { target: { value: '13800138000' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'zhangsan@example.com' } });
|
||||
fireEvent.change(subjectInput, { target: { value: '咨询产品方案' } });
|
||||
fireEvent.change(messageInput, { target: { value: '您好,我想了解贵公司的ERP产品和CRM产品,请提供详细方案和报价。' } });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ContactContentV3 - 联系表单集成测试', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// 模拟全局 fetch
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: 'true' }),
|
||||
} as Response)
|
||||
) as any;
|
||||
|
||||
// jsdom 默认不实现 scrollIntoView
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render hero section with subtitle and title', async () => {
|
||||
await renderContactContent();
|
||||
expect(screen.getByText('GET IN TOUCH')).toBeInTheDocument();
|
||||
expect(screen.getByText('开启对话')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render hero description', async () => {
|
||||
await renderContactContent();
|
||||
expect(screen.getByText(/无论您有任何疑问或需求/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact info section', async () => {
|
||||
await renderContactContent();
|
||||
expect(screen.getByText('联系方式')).toBeInTheDocument();
|
||||
expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
|
||||
expect(screen.getByText('成都市高新区天府大道中段')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render work hours section', async () => {
|
||||
await renderContactContent();
|
||||
expect(screen.getByText('工作时间')).toBeInTheDocument();
|
||||
expect(screen.getByText('周一至周五')).toBeInTheDocument();
|
||||
expect(screen.getByText('09:00 — 18:00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render commitment items', async () => {
|
||||
await renderContactContent();
|
||||
expect(screen.getByText('24小时内回复您的咨询')).toBeInTheDocument();
|
||||
expect(screen.getByText('免费提供初步方案评估')).toBeInTheDocument();
|
||||
expect(screen.getByText('严格保密您的业务信息')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render form section with title', async () => {
|
||||
await renderContactContent();
|
||||
// "发送消息"同时出现在 form title 和 button 上
|
||||
expect(screen.getAllByText('发送消息').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render all form fields', async () => {
|
||||
await renderContactContent();
|
||||
expect(screen.getByTestId('name-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('phone-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('subject-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render submit button', async () => {
|
||||
await renderContactContent();
|
||||
// "发送消息"同时出现在 form title 和 button 上,用 getAllByText
|
||||
const elements = screen.getAllByText('发送消息');
|
||||
const buttonElements = elements.filter((el) => el.closest('button'));
|
||||
expect(buttonElements.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render CTA section', async () => {
|
||||
await renderContactContent();
|
||||
expect(screen.getByText('准备好开启数字化转型了吗?')).toBeInTheDocument();
|
||||
expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
|
||||
expect(screen.getByText('了解更多')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render email link with correct href', async () => {
|
||||
await renderContactContent();
|
||||
const emailLink = screen.getByTestId('email-link');
|
||||
expect(emailLink).toHaveAttribute('href', 'mailto:contact@novalon.cn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no data is provided', async () => {
|
||||
await renderContactContent({});
|
||||
expect(screen.getByText('内容暂未发布')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when data is null-like', async () => {
|
||||
await renderContactContent({} as any);
|
||||
expect(screen.getByText('内容暂未发布')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should show validation errors when submitting empty form', async () => {
|
||||
await renderContactContent();
|
||||
const submitButton = getSubmitButton();
|
||||
await act(async () => { submitButton.click(); });
|
||||
|
||||
// Toast should show with error count
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
});
|
||||
const toastMessage = screen.getByTestId('toast-message');
|
||||
expect(toastMessage.textContent).toContain('请检查表单');
|
||||
});
|
||||
|
||||
it('should show error for invalid phone number on blur', async () => {
|
||||
await renderContactContent();
|
||||
const phoneInput = screen.getByTestId('phone-input') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(phoneInput, { target: { value: '12345' } });
|
||||
fireEvent.blur(phoneInput, { target: { value: '12345' } });
|
||||
|
||||
// Validation error should be shown
|
||||
const errorElements = screen.getAllByTestId('input-error');
|
||||
const phoneError = errorElements.find((el) => el.textContent?.includes('手机号码'));
|
||||
expect(phoneError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show error for invalid email on blur', async () => {
|
||||
await renderContactContent();
|
||||
const emailInput = screen.getByTestId('email-input') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
|
||||
fireEvent.blur(emailInput, { target: { value: 'invalid-email' } });
|
||||
|
||||
const errorElements = screen.getAllByTestId('input-error');
|
||||
const emailError = errorElements.find((el) => el.textContent?.includes('邮箱'));
|
||||
expect(emailError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should submit form successfully and show success state', async () => {
|
||||
// Mock fetch to return success
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: 'true' }),
|
||||
} as Response)
|
||||
) as any;
|
||||
|
||||
await renderContactContent();
|
||||
|
||||
fillFormFields();
|
||||
|
||||
const submitButton = getSubmitButton();
|
||||
await act(async () => { submitButton.click(); });
|
||||
|
||||
// Should show success state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('消息已发送')).toBeInTheDocument();
|
||||
});
|
||||
// 成功消息同时出现在 form 成功状态和 toast 中,用 getAllByText
|
||||
expect(screen.getAllByText('感谢您的留言,我们会尽快与您联系。').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Should have called analytics
|
||||
const { trackContactForm, trackConversion } = await import('@/lib/analytics');
|
||||
expect(trackContactForm).toHaveBeenCalled();
|
||||
expect(trackConversion).toHaveBeenCalledWith('contact_form_submission');
|
||||
});
|
||||
|
||||
it('should show error toast when API returns error', async () => {
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: '服务器错误,请稍后重试' }),
|
||||
} as Response)
|
||||
) as any;
|
||||
|
||||
await renderContactContent();
|
||||
|
||||
fillFormFields();
|
||||
|
||||
const submitButton = getSubmitButton();
|
||||
await act(async () => { submitButton.click(); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
});
|
||||
const toastMessage = screen.getByTestId('toast-message');
|
||||
expect(toastMessage.textContent).toContain('服务器错误');
|
||||
});
|
||||
|
||||
it('should show error toast on network failure', async () => {
|
||||
global.fetch = jest.fn(() => Promise.reject(new Error('Network error'))) as any;
|
||||
|
||||
await renderContactContent();
|
||||
|
||||
fillFormFields();
|
||||
|
||||
const submitButton = getSubmitButton();
|
||||
await act(async () => { submitButton.click(); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
});
|
||||
const toastMessage = screen.getByTestId('toast-message');
|
||||
expect(toastMessage.textContent).toContain('网络错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', async () => {
|
||||
await renderContactContent();
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
expect(h1).toBeInTheDocument();
|
||||
// Should have section headings as h2 or h3
|
||||
const headings = screen.getAllByRole('heading');
|
||||
expect(headings.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should have honeypot field for spam prevention', async () => {
|
||||
await renderContactContent();
|
||||
const honeypot = document.querySelector('input[aria-hidden="true"]');
|
||||
expect(honeypot).toBeInTheDocument();
|
||||
expect(honeypot).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { Product } from '@/lib/constants/products';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, initial, animate, whileHover, whileInView, viewport, className, style, onMouseMove, onMouseEnter, onMouseLeave, ref, ...props }: any) => (
|
||||
<div
|
||||
data-testid="motion-div"
|
||||
data-initial={JSON.stringify(initial)}
|
||||
data-animate={JSON.stringify(animate)}
|
||||
data-while-hover={JSON.stringify(whileHover)}
|
||||
data-while-inview={JSON.stringify(whileInView)}
|
||||
data-viewport={JSON.stringify(viewport)}
|
||||
className={className}
|
||||
style={style}
|
||||
ref={ref}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
h1: ({ children, className, ...props }: any) => (
|
||||
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
|
||||
),
|
||||
p: ({ children, className, ...props }: any) => (
|
||||
<p data-testid="motion-p" className={className} {...props}>{children}</p>
|
||||
),
|
||||
span: ({ children, className, ...props }: any) => (
|
||||
<span data-testid="motion-span" className={className} {...props}>{children}</span>
|
||||
),
|
||||
a: ({ children, href, className, ...props }: any) => (
|
||||
<a href={href} className={className} data-testid="motion-a" {...props}>{children}</a>
|
||||
),
|
||||
},
|
||||
useInView: jest.fn(() => true),
|
||||
AnimatePresence: ({ children }: any) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/use-reduced-motion', () => ({
|
||||
useReducedMotion: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/scroll-reveal', () => ({
|
||||
ScrollReveal: ({ children, className }: any) => (
|
||||
<div data-testid="scroll-reveal" className={className}>{children}</div>
|
||||
),
|
||||
StaggerReveal: ({ children, className }: any) => (
|
||||
<div data-testid="stagger-reveal" className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, className, ...props }: any) => (
|
||||
<button className={className} data-testid="mock-button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/page-decoration', () => ({
|
||||
SectionLabel: ({ children }: any) => <span data-testid="section-label">{children}</span>,
|
||||
EASE_OUT: [0.22, 1, 0.36, 1],
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => {
|
||||
const mockIcon = (name: string) => {
|
||||
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
ArrowRight: mockIcon('arrow-right'),
|
||||
Package: mockIcon('package'),
|
||||
Cpu: mockIcon('cpu'),
|
||||
BarChart3: mockIcon('barchart3'),
|
||||
Users: mockIcon('users'),
|
||||
Settings: mockIcon('settings'),
|
||||
FileText: mockIcon('file-text'),
|
||||
CheckCircle2: mockIcon('check-circle2'),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Mock Data ────────────────────────────────────────────────────────────
|
||||
|
||||
const mockEnterpriseProducts: Product[] = [
|
||||
{
|
||||
id: 'erp',
|
||||
title: '睿新ERP管理系统',
|
||||
category: '企业旗舰系列',
|
||||
categoryId: 'enterprise',
|
||||
description: '覆盖企业财务、采购、库存、生产全流程',
|
||||
overview: 'ERP 系统概述',
|
||||
features: ['财务管理', '供应链管理'],
|
||||
benefits: ['降本增效'],
|
||||
tags: ['ERP', '核心系统'],
|
||||
status: '已发布',
|
||||
image: '/erp.png',
|
||||
heroThemeId: 'erp',
|
||||
bundle: 'enterprise',
|
||||
scenario: '面向中大型制造与零售企业的核心运营系统',
|
||||
metrics: [
|
||||
{ value: '10 万级', label: 'SKU 支撑' },
|
||||
{ value: '200+', label: '标准报表模板' },
|
||||
{ value: '99.5%', label: '数据准确率' },
|
||||
],
|
||||
capabilities: ['财务核算', '采购管理', '生产计划', '库存控制'],
|
||||
process: [],
|
||||
specs: [],
|
||||
caseStudies: [],
|
||||
dataProofs: [],
|
||||
certifications: [],
|
||||
},
|
||||
{
|
||||
id: 'crm',
|
||||
title: '睿新客户关系管理系统',
|
||||
category: '企业旗舰系列',
|
||||
categoryId: 'enterprise',
|
||||
description: '全渠道客户管理平台',
|
||||
overview: 'CRM 系统概述',
|
||||
features: ['客户管理', '销售自动化'],
|
||||
benefits: ['提升转化'],
|
||||
tags: ['CRM', '客户管理'],
|
||||
status: '已发布',
|
||||
image: '/crm.png',
|
||||
heroThemeId: 'crm',
|
||||
bundle: 'enterprise',
|
||||
scenario: '面向销售驱动型企业的一体化客户运营管理平台',
|
||||
metrics: [
|
||||
{ value: '360°', label: '客户画像' },
|
||||
{ value: '3x', label: '转化率提升' },
|
||||
{ value: '<2h', label: '线索响应' },
|
||||
],
|
||||
capabilities: ['线索管理', '商机跟进', '合同审批', '售后工单'],
|
||||
process: [],
|
||||
specs: [],
|
||||
caseStudies: [],
|
||||
dataProofs: [],
|
||||
certifications: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockSpecializedProducts: Product[] = [
|
||||
{
|
||||
id: 'novavis',
|
||||
title: 'NovaVis 数据可视化平台',
|
||||
category: '专业产品',
|
||||
categoryId: 'specialized',
|
||||
description: '专业数据可视化工具',
|
||||
overview: 'NovaVis 概述',
|
||||
features: ['大屏展示', '报表设计'],
|
||||
benefits: ['直观呈现'],
|
||||
tags: ['数据可视化', 'BI'],
|
||||
status: '研发中',
|
||||
image: '/novavis.png',
|
||||
heroThemeId: 'novavis',
|
||||
bundle: 'standalone',
|
||||
scenario: '面向执法机关的离线智能数据分析与资金关系图谱平台',
|
||||
metrics: [
|
||||
{ value: '100万+', label: '秒级处理' },
|
||||
{ value: '<30秒', label: '分析耗时' },
|
||||
{ value: '20层+', label: '资金追踪' },
|
||||
],
|
||||
capabilities: ['资金追踪', '关系图谱', 'AI 分析', '离线运行'],
|
||||
process: [],
|
||||
specs: [],
|
||||
caseStudies: [],
|
||||
dataProofs: [],
|
||||
certifications: [],
|
||||
},
|
||||
];
|
||||
|
||||
const allMockProducts = [...mockEnterpriseProducts, ...mockSpecializedProducts];
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsContentV3 - 产品列表页集成测试', () => {
|
||||
it('should render hero section with title', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getByText('用自研产品组合')).toBeInTheDocument();
|
||||
expect(screen.getByText('核心系统升级')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render hero description text', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getByText(/从数据智能到业务协同/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render enterprise products section', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getAllByText('企业套装').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render specialized products section', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getAllByText('专业产品').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render enterprise product cards with titles', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
// Each enterprise product title should appear 3 times: hero stats, product section, and suite combos section
|
||||
expect(screen.getAllByText('睿新ERP管理系统').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('睿新客户关系管理系统').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render specialized product cards with titles', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getAllByText('NovaVis 数据可视化平台').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render product capabilities on cards', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getByText('财务核算')).toBeInTheDocument();
|
||||
expect(screen.getByText('线索管理')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render product scenarios on cards', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getAllByText('面向中大型制造与零售企业的核心运营系统').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('面向销售驱动型企业的一体化客户运营管理平台').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should link product cards to detail pages', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
const erpLinks = screen.getAllByRole('link');
|
||||
const erpDetailLink = erpLinks.find((link) => link.getAttribute('href') === '/products/erp');
|
||||
expect(erpDetailLink).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render suite combos section', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getByText('推荐产品组合')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CTA section', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
|
||||
expect(screen.getByText('浏览行业方案')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render stats section with product count', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
expect(screen.getByText('产品线')).toBeInTheDocument();
|
||||
expect(screen.getByText('自研率')).toBeInTheDocument();
|
||||
expect(screen.getByText('技术覆盖')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty products list gracefully', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={[]} />);
|
||||
|
||||
// Hero section should still render
|
||||
expect(screen.getByText('用自研产品组合')).toBeInTheDocument();
|
||||
|
||||
// The enterprise section should still render (even without products)
|
||||
expect(screen.getAllByText('企业套装').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// No product detail links should be present when products are empty
|
||||
const links = screen.queryAllByRole('link');
|
||||
const productLinks = links.filter((l) => l.getAttribute('href')?.startsWith('/products/'));
|
||||
expect(productLinks.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should have proper CTA links pointing to contact and solutions', async () => {
|
||||
const { default: ProductsContentV3 } = await import('./products-content-v3');
|
||||
render(<ProductsContentV3 products={allMockProducts} />);
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
const contactLinks = links.filter((l) => l.getAttribute('href') === '/contact');
|
||||
const solutionsLinks = links.filter((l) => l.getAttribute('href') === '/solutions');
|
||||
expect(contactLinks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(solutionsLinks.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -258,6 +258,7 @@ const mockProduct = {
|
||||
specs: ['支持 10 万并发', '99.9% 可用性'],
|
||||
tags: ['ERP', '企业管理'],
|
||||
heroThemeId: 'erp',
|
||||
bundle: 'enterprise' as const,
|
||||
caseStudies: [
|
||||
{ client: '某制造企业', industry: '制造业', challenge: '库存管理混乱', solution: '实施ERP系统', result: '库存周转率提升200%' },
|
||||
],
|
||||
@@ -403,8 +404,7 @@ describe('ProductValueSection', () => {
|
||||
it('should render product title and overview', async () => {
|
||||
const { ProductValueSection } = await import('./detail-product-value');
|
||||
render(<ProductValueSection product={mockProduct} />);
|
||||
expect(screen.getByText('核心能力')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockProduct.title)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /为企业打造的/ })).toHaveTextContent(mockProduct.title);
|
||||
expect(screen.getByText(mockProduct.overview)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -434,7 +434,6 @@ describe('ProductValueSection', () => {
|
||||
it('should render data proofs when available', async () => {
|
||||
const { ProductValueSection } = await import('./detail-product-value');
|
||||
render(<ProductValueSection product={mockProduct} />);
|
||||
expect(screen.getByText('数据说话')).toBeInTheDocument();
|
||||
expect(screen.getByText('用真实效果证明价值')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -442,7 +441,7 @@ describe('ProductValueSection', () => {
|
||||
const { ProductValueSection } = await import('./detail-product-value');
|
||||
const productWithoutData = { ...mockProduct, dataProofs: [] };
|
||||
render(<ProductValueSection product={productWithoutData} />);
|
||||
expect(screen.queryByText('数据说话')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('用真实效果证明价值')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ink glow cards for features', async () => {
|
||||
@@ -468,7 +467,6 @@ describe('DetailTrustSection', () => {
|
||||
dataProofs={mockProduct.dataProofs}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('数据说话')).toBeInTheDocument();
|
||||
expect(screen.getByText('用真实效果证明价值')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockProduct.dataProofs[0]!.metric)).toBeInTheDocument();
|
||||
});
|
||||
@@ -480,7 +478,6 @@ describe('DetailTrustSection', () => {
|
||||
caseStudies={mockProduct.caseStudies}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('客户案例')).toBeInTheDocument();
|
||||
expect(screen.getByText('他们已经实现了转型突破')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('case-study-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('case-client')).toHaveTextContent('某制造企业');
|
||||
@@ -508,8 +505,8 @@ describe('DetailTrustSection', () => {
|
||||
accentColor="#C41E3A"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('数据说话')).toBeInTheDocument();
|
||||
expect(screen.getByText('客户案例')).toBeInTheDocument();
|
||||
expect(screen.getByText('用真实效果证明价值')).toBeInTheDocument();
|
||||
expect(screen.getByText('他们已经实现了转型突破')).toBeInTheDocument();
|
||||
expect(screen.getByText('资质认证')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,11 +248,10 @@ describe('ProductCard', () => {
|
||||
});
|
||||
|
||||
describe('ServiceCard', () => {
|
||||
it('should render number, title, description and link', async () => {
|
||||
it('should render title, description and link', async () => {
|
||||
const { ServiceCard } = await import('./service-card');
|
||||
render(
|
||||
<ServiceCard
|
||||
number="01"
|
||||
title="战略咨询"
|
||||
description="业务诊断与战略规划"
|
||||
href="/services/consulting"
|
||||
@@ -268,7 +267,6 @@ describe('ServiceCard', () => {
|
||||
const { ServiceCard } = await import('./service-card');
|
||||
render(
|
||||
<ServiceCard
|
||||
number="02"
|
||||
title="Data"
|
||||
description="Analysis"
|
||||
href="/services/data"
|
||||
@@ -280,7 +278,7 @@ describe('ServiceCard', () => {
|
||||
expect(target).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom color', async () => {
|
||||
it('should accept optional number and color props for compatibility', async () => {
|
||||
const { ServiceCard } = await import('./service-card');
|
||||
render(
|
||||
<ServiceCard
|
||||
|
||||
@@ -10,7 +10,7 @@ jest.mock('@/hooks/use-reduced-motion', () => ({
|
||||
describe('ScrollProgress', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock document height for predictable progress calculation
|
||||
// Mock document/scroll geometry for predictable progress calculation
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', {
|
||||
value: 1000,
|
||||
configurable: true,
|
||||
@@ -19,6 +19,11 @@ describe('ScrollProgress', () => {
|
||||
value: 500,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
value: 0,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should always render the progress container', () => {
|
||||
@@ -44,8 +49,10 @@ describe('ScrollProgress', () => {
|
||||
const { container } = render(<ScrollProgress />);
|
||||
const innerBar = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||
expect(innerBar).toBeInTheDocument();
|
||||
// 默认 progress 为 0,宽度为 0%
|
||||
expect(innerBar.style.width).toBe('0%');
|
||||
// 默认 progress 为 0,使用 transform scaleX 实现宽度变化
|
||||
expect(innerBar.style.width).toBe('100%');
|
||||
expect(innerBar.style.transform).toBe('scaleX(0)');
|
||||
expect(innerBar.style.transformOrigin).toBe('left');
|
||||
});
|
||||
|
||||
it('should apply default height of 2px', () => {
|
||||
|
||||
@@ -48,13 +48,16 @@ export function ScrollProgress({
|
||||
height: `${height}px`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
data-testid="scroll-progress"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${shouldReduceMotion ? 0 : progress}%`,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: color,
|
||||
transition: shouldReduceMotion ? 'none' : 'width 0.1s linear',
|
||||
transformOrigin: 'left',
|
||||
transform: `scaleX(${shouldReduceMotion ? 0 : progress / 100})`,
|
||||
transition: shouldReduceMotion ? 'none' : 'transform 0.1s linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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