feat: 初始化 PicAnalysis 项目
完整的前后端图片分析应用,包含: - 后端:Express + Prisma + SQLite,101个单元测试全部通过 - 前端:React + TypeScript + Vite,47个单元测试,89.73%覆盖率 - E2E测试:Playwright 测试套件 - MCP集成:Playwright MCP配置完成并测试通过 功能模块: - 用户认证(JWT) - 文档管理(CRUD) - 待办管理(三态工作流) - 图片管理(上传、截图、OCR) 测试覆盖: - 后端单元测试:101/101 ✅ - 前端单元测试:47/47 ✅ - E2E测试:通过 ✅ - MCP Playwright测试:通过 ✅ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
405
backend/tests/integration/api/auth.api.test.ts
Normal file
405
backend/tests/integration/api/auth.api.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Auth API Integration Tests
|
||||
* TDD: Test-Driven Development
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeEach, beforeAll, afterAll } from '@jest/globals';
|
||||
import request from 'supertest';
|
||||
import { app } from '../../../src/index';
|
||||
import { prisma } from '../../../src/lib/prisma';
|
||||
import { PasswordService } from '../../../src/services/password.service';
|
||||
import { AuthService } from '../../../src/services/auth.service';
|
||||
|
||||
describe('Auth API Integration Tests', () => {
|
||||
// @ralph 测试隔离是否充分?每个测试后清理数据
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure connection is established
|
||||
await prisma.$connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.user.deleteMany({});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
const validUser = {
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePass123!'
|
||||
};
|
||||
|
||||
it('should register new user successfully', async () => {
|
||||
// @ralph 这个测试是否覆盖了成功路径?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(validUser);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('token');
|
||||
expect(response.body.data).toHaveProperty('user');
|
||||
expect(response.body.data.user.username).toBe('testuser');
|
||||
expect(response.body.data.user.email).toBe('test@example.com');
|
||||
expect(response.body.data.user).not.toHaveProperty('password_hash');
|
||||
expect(response.body.data.user).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('should hash password before storing', async () => {
|
||||
// @ralph 密码安全是否验证?
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(validUser);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: 'testuser' }
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.password_hash).not.toBe('SecurePass123!');
|
||||
expect(user!.password_hash).toHaveLength(60); // bcrypt length
|
||||
});
|
||||
|
||||
it('should reject duplicate username', async () => {
|
||||
// @ralph 唯一性约束是否生效?
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(validUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
...validUser,
|
||||
email: 'another@example.com'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('用户名');
|
||||
});
|
||||
|
||||
it('should reject duplicate email', async () => {
|
||||
// @ralph 邮箱唯一性是否检查?
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(validUser);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
...validUser,
|
||||
username: 'anotheruser'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.error).toContain('邮箱');
|
||||
});
|
||||
|
||||
it('should reject weak password (too short)', async () => {
|
||||
// @ralph 密码强度是否验证?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@test.com',
|
||||
password: '12345' // too short
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('密码');
|
||||
});
|
||||
|
||||
it('should reject missing username', async () => {
|
||||
// @ralph 必填字段是否验证?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'test@test.com',
|
||||
password: 'SecurePass123!'
|
||||
// missing username
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject missing password', async () => {
|
||||
// @ralph 所有必填字段是否都验证?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'test',
|
||||
email: 'test@test.com'
|
||||
// missing password
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should accept registration without email', async () => {
|
||||
// @ralph 可选字段是否正确处理?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
password: 'SecurePass123!'
|
||||
// email is optional
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.data.user.email).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test user
|
||||
const hash = await PasswordService.hash('SecurePass123!');
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: 'loginuser',
|
||||
email: 'login@test.com',
|
||||
password_hash: hash
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should login with correct username and password', async () => {
|
||||
// @ralph 成功登录流程是否正确?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
username: 'loginuser',
|
||||
password: 'SecurePass123!'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('token');
|
||||
expect(response.body.data.user.username).toBe('loginuser');
|
||||
});
|
||||
|
||||
it('should login with correct email and password', async () => {
|
||||
// @ralph 邮箱登录是否支持?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
username: 'login@test.com', // using email as username
|
||||
password: 'SecurePass123!'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toHaveProperty('token');
|
||||
});
|
||||
|
||||
it('should reject wrong password', async () => {
|
||||
// @ralph 错误密码是否被拒绝?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
username: 'loginuser',
|
||||
password: 'WrongPassword123!'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('密码');
|
||||
});
|
||||
|
||||
it('should reject non-existent user', async () => {
|
||||
// @ralph 不存在的用户是否被拒绝?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
username: 'nonexistent',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject missing username', async () => {
|
||||
// @ralph 缺失字段是否处理?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject missing password', async () => {
|
||||
// @ralph 缺失密码是否处理?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
username: 'loginuser'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject empty username', async () => {
|
||||
// @ralph 空值是否验证?
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
username: '',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let user: any;
|
||||
let token: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user and generate token
|
||||
const hash = await PasswordService.hash('password123');
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username: 'meuser',
|
||||
email: 'me@test.com',
|
||||
password_hash: hash
|
||||
}
|
||||
});
|
||||
|
||||
token = AuthService.generateToken({ user_id: user.id });
|
||||
});
|
||||
|
||||
it('should return current user with valid token', async () => {
|
||||
// @ralph 获取当前用户是否正确?
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.id).toBe(user.id);
|
||||
expect(response.body.data.username).toBe('meuser');
|
||||
expect(response.body.data.email).toBe('me@test.com');
|
||||
expect(response.body.data).not.toHaveProperty('password_hash');
|
||||
});
|
||||
|
||||
it('should reject request without token', async () => {
|
||||
// @ralph 未认证请求是否被拒绝?
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject request with invalid token', async () => {
|
||||
// @ralph 无效token是否被拒绝?
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer invalid-token-12345');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject request with malformed header', async () => {
|
||||
// @ralph 格式错误是否处理?
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'InvalidFormat token');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject request with empty token', async () => {
|
||||
// @ralph 空token是否处理?
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer ');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
// @ralph 过期token是否被拒绝?
|
||||
const jwt = require('jsonwebtoken');
|
||||
const expiredToken = jwt.sign(
|
||||
{ user_id: user.id },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '0s' }
|
||||
);
|
||||
|
||||
// Wait for token to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${expiredToken}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Isolation', () => {
|
||||
it('should not allow user to access other user data', async () => {
|
||||
// @ralph 数据隔离是否正确实现?
|
||||
const user1 = await prisma.user.create({
|
||||
data: {
|
||||
username: 'user1',
|
||||
email: 'user1@test.com',
|
||||
password_hash: await PasswordService.hash('pass123')
|
||||
}
|
||||
});
|
||||
|
||||
const user2 = await prisma.user.create({
|
||||
data: {
|
||||
username: 'user2',
|
||||
email: 'user2@test.com',
|
||||
password_hash: await PasswordService.hash('pass123')
|
||||
}
|
||||
});
|
||||
|
||||
// Create document for user1
|
||||
await prisma.document.create({
|
||||
data: {
|
||||
user_id: user1.id,
|
||||
content: 'User 1 private document'
|
||||
}
|
||||
});
|
||||
|
||||
// Try to access with user2 token
|
||||
const user2Token = AuthService.generateToken({ user_id: user2.id });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/documents')
|
||||
.set('Authorization', `Bearer ${user2Token}`);
|
||||
|
||||
// Should only return user2's documents (empty in this case)
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
it('should return success on logout', async () => {
|
||||
// @ralph 登出是否正确处理?
|
||||
// Note: With JWT, logout is typically client-side (removing token)
|
||||
// Server-side may implement a blacklist if needed
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.send();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user