From 65ea3f0e7e9aed2ceb8eb981f4f51720a59bab27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Tue, 10 Mar 2026 12:41:17 +0800 Subject: [PATCH] test: add API route tests --- jest.setup.js | 188 ++++++++++++++ src/app/api/auth/[...nextauth]/route.test.ts | 39 +++ src/app/api/contact/route.test.ts | 250 +++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 jest.setup.js create mode 100644 src/app/api/auth/[...nextauth]/route.test.ts create mode 100644 src/app/api/contact/route.test.ts diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..dbe7948 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,188 @@ +jest.mock('next-auth', () => { + return { + __esModule: true, + default: jest.fn(() => ({ + handlers: { + authOptions: { + providers: [], + callbacks: {}, + pages: {}, + session: {}, + }, + }, + signIn: jest.fn(), + signOut: jest.fn(), + auth: jest.fn(), + })), + getServerSession: jest.fn(), + }; +}); + +jest.mock('next-auth/providers/credentials', () => + jest.fn(() => ({ + name: '邮箱密码', + credentials: { + email: { label: '邮箱', type: 'email' }, + password: { label: '密码', type: 'password' }, + }, + authorize: jest.fn(), + })) +); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => 'test-id-123'), +})); + +jest.mock('next/dynamic', () => ({ + __esModule: true, + default: (importFn, options) => { + const MockComponent = (props) => null; + MockComponent.displayName = 'DynamicComponent'; + MockComponent.preload = () => Promise.resolve(); + return MockComponent; + }, +})); + +jest.mock('next/server', () => ({ + NextRequest: class MockNextRequest { + constructor(input, init = {}) { + this.url = typeof input === 'string' ? input : input.url; + this.method = init.method || 'GET'; + this.headers = new Headers(init.headers); + this.body = init.body; + } + + async json() { + return typeof this.body === 'string' ? JSON.parse(this.body) : this.body; + } + }, + NextResponse: { + json: (body, init = {}) => ({ + status: init.status || 200, + json: async () => body, + }), + }, +})); + +global.console = { + ...console, + error: jest.fn(), + warn: jest.fn(), + log: jest.fn(), +}; + +class MockIntersectionObserver { + constructor(callback, options = {}) { + this.callback = callback; + this.options = options; + this.elements = new Set(); + this.observationEntries = []; + } + + observe(element) { + this.elements.add(element); + const entry = { + isIntersecting: true, + target: element, + boundingClientRect: element.getBoundingClientRect ? element.getBoundingClientRect() : {}, + intersectionRatio: 1, + intersectionRect: {}, + rootBounds: {}, + time: Date.now(), + }; + this.observationEntries.push(entry); + this.callback(this.observationEntries, this); + } + + unobserve(element) { + this.elements.delete(element); + this.observationEntries = this.observationEntries.filter( + entry => entry.target !== element + ); + } + + disconnect() { + this.elements.clear(); + this.observationEntries = []; + } + + takeRecords() { + return this.observationEntries; + } +} + +global.IntersectionObserver = MockIntersectionObserver; +global.IntersectionObserverEntry = class IntersectionObserverEntry { + constructor() { + this.isIntersecting = true; + this.target = {}; + this.boundingClientRect = {}; + this.intersectionRatio = 1; + this.intersectionRect = {}; + this.rootBounds = {}; + this.time = Date.now(); + } +}; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.Request = class Request { + constructor(input, init = {}) { + this.url = typeof input === 'string' ? input : input.url; + this.method = init.method || 'GET'; + this.headers = new Headers(init.headers); + this.body = init.body; + } + + async json() { + return typeof this.body === 'string' ? JSON.parse(this.body) : this.body; + } +}; + +global.Headers = class Headers { + constructor(init = {}) { + this.headers = {}; + if (init) { + Object.entries(init).forEach(([key, value]) => { + this.headers[key.toLowerCase()] = value; + }); + } + } + + get(name) { + return this.headers[name.toLowerCase()]; + } + + set(name, value) { + this.headers[name.toLowerCase()] = value; + } +}; + +global.Response = class Response { + constructor(body, init = {}) { + this.body = body; + this.status = init.status || 200; + this.statusText = init.statusText || 'OK'; + this.headers = new Headers(init.headers); + } + + async json() { + return typeof this.body === 'string' ? JSON.parse(this.body) : this.body; + } + + async text() { + return String(this.body); + } +}; \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.test.ts b/src/app/api/auth/[...nextauth]/route.test.ts new file mode 100644 index 0000000..8c9549d --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.test.ts @@ -0,0 +1,39 @@ +import { GET, POST } from './route'; + +jest.mock('@/lib/auth', () => ({ + handlers: { + GET: jest.fn(() => new Response('GET response')), + POST: jest.fn(() => new Response('POST response')), + }, +})); + +describe('/api/auth/[...nextauth]', () => { + describe('GET handler', () => { + it('should export GET handler', () => { + expect(typeof GET).toBe('function'); + }); + + it('should call auth GET handler', async () => { + const response = await GET(new Request('http://localhost/api/auth/signin')); + expect(response).toBeDefined(); + expect(response.status).toBe(200); + }); + }); + + describe('POST handler', () => { + it('should export POST handler', () => { + expect(typeof POST).toBe('function'); + }); + + it('should call auth POST handler', async () => { + const response = await POST( + new Request('http://localhost/api/auth/signin', { + method: 'POST', + body: JSON.stringify({ email: 'test@example.com', password: 'password' }), + }) + ); + expect(response).toBeDefined(); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/src/app/api/contact/route.test.ts b/src/app/api/contact/route.test.ts new file mode 100644 index 0000000..af63823 --- /dev/null +++ b/src/app/api/contact/route.test.ts @@ -0,0 +1,250 @@ +import { POST } from './route'; +import { NextRequest } from 'next/server'; + +jest.mock('resend', () => { + const mockSend = jest.fn(); + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + emails: { + send: mockSend, + }, + })), + Resend: jest.fn().mockImplementation(() => ({ + emails: { + send: mockSend, + }, + })), + }; +}); + +describe('/api/contact', () => { + let mockRequest: NextRequest; + let mockSend: any; + + beforeEach(() => { + const { default: Resend } = require('resend'); + const resendInstance = new Resend(); + mockSend = resendInstance.emails.send; + mockSend.mockClear(); + }); + + const createMockRequest = (body: any): NextRequest => { + return { + json: async () => body, + } as unknown as NextRequest; + }; + + it('should handle POST request with valid data', async () => { + mockSend.mockResolvedValue({ + data: { id: 'test-id' }, + error: null, + }); + + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + subject: 'Test Subject', + message: 'Test Message', + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('消息已发送'); + expect(mockSend).toHaveBeenCalled(); + }); + + it('should validate required fields', async () => { + mockRequest = createMockRequest({ + name: 'Test User', + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('请填写必填字段'); + }); + + it('should validate email format', async () => { + mockRequest = createMockRequest({ + name: 'Test User', + email: 'invalid-email', + subject: 'Test Subject', + message: 'Test Message', + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('请输入有效的邮箱地址'); + }); + + it('should reject honeypot field', async () => { + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + subject: 'Test Subject', + message: 'Test Message', + website: 'spam-bot', + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('should reject submission too fast', async () => { + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + subject: 'Test Subject', + message: 'Test Message', + submitTime: Date.now().toString(), + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('提交过快,请稍后再试'); + }); + + it('should validate math captcha', async () => { + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + subject: 'Test Subject', + message: 'Test Message', + mathHash: 'invalid-hash', + mathTimestamp: Date.now().toString(), + mathAnswer: '5', + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('验证码错误,请重新计算'); + }); + + it('should handle Resend API error', async () => { + mockSend.mockResolvedValue({ + data: null, + error: { message: 'API Error' }, + }); + + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + subject: 'Test Subject', + message: 'Test Message', + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('邮件发送失败,请稍后重试'); + }); + + it('should handle JSON parsing error', async () => { + mockRequest = { + json: async () => { + throw new Error('Invalid JSON'); + }, + } as unknown as NextRequest; + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('提交失败,请重试'); + }); + + it('should accept valid submission with phone', async () => { + mockSend.mockResolvedValue({ + data: { id: 'test-id' }, + error: null, + }); + + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + phone: '13800138000', + subject: 'Test Subject', + message: 'Test Message', + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockSend).toHaveBeenCalled(); + }); + + it('should accept valid submission with math captcha', async () => { + mockSend.mockResolvedValue({ + data: { id: 'test-id' }, + error: null, + }); + + const timestamp = Date.now(); + const answer = '5'; + const hash = btoa(`${answer}-${timestamp}`); + + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + subject: 'Test Subject', + message: 'Test Message', + mathHash: hash, + mathTimestamp: timestamp.toString(), + mathAnswer: answer, + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockSend).toHaveBeenCalled(); + }); + + it('should handle submission after minimum time', async () => { + mockSend.mockResolvedValue({ + data: { id: 'test-id' }, + error: null, + }); + + const pastTime = Date.now() - 3000; + mockRequest = createMockRequest({ + name: 'Test User', + email: 'test@example.com', + subject: 'Test Subject', + message: 'Test Message', + submitTime: pastTime.toString(), + }); + + const response = await POST(mockRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockSend).toHaveBeenCalled(); + }); +});