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:
@@ -0,0 +1,36 @@
|
||||
# Database
|
||||
DATABASE_URL="file:./dev.db"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="your-secret-key-change-in-production"
|
||||
JWT_EXPIRES_IN="24h"
|
||||
|
||||
# Server
|
||||
PORT=4000
|
||||
NODE_ENV="development"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN="http://localhost:3000"
|
||||
|
||||
# OCR
|
||||
OCR_PROVIDER="local"
|
||||
OCR_CONFIDENCE_THRESHOLD="0.3"
|
||||
|
||||
# AI (GLM)
|
||||
GLM_API_KEY=""
|
||||
GLM_API_URL="https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
GLM_MODEL="glm-4-flash"
|
||||
|
||||
# AI (MiniMax)
|
||||
MINIMAX_API_KEY=""
|
||||
MINIMAX_API_URL="https://api.minimax.chat/v1/chat/completions"
|
||||
MINIMAX_MODEL="abab6.5s-chat"
|
||||
|
||||
# AI (DeepSeek)
|
||||
DEEPSEEK_API_KEY=""
|
||||
DEEPSEEK_API_URL="https://api.deepseek.com/v1/chat/completions"
|
||||
DEEPSEEK_MODEL="deepseek-chat"
|
||||
|
||||
# Upload
|
||||
UPLOAD_MAX_SIZE="10485760"
|
||||
UPLOAD_ALLOWED_TYPES="image/jpeg,image/png,image/webp"
|
||||
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL="file:./test.db"
|
||||
JWT_SECRET="test-secret-key-for-jest-development-purpose"
|
||||
NODE_ENV="test"
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"env": {
|
||||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-journal
|
||||
prisma/migrations/**/migration.sql
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
data/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests', '<rootDir>/src'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.ts',
|
||||
'**/?(*.)+(spec|test).ts'
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', {
|
||||
tsconfig: '<rootDir>/tsconfig.test.json',
|
||||
}],
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/index.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: [
|
||||
'text',
|
||||
'lcov',
|
||||
'html'
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80
|
||||
}
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@tests/(.*)$': '<rootDir>/tests/$1'
|
||||
},
|
||||
setupFiles: ['<rootDir>/tests/env.ts'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts']
|
||||
};
|
||||
Generated
+7884
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "pic-analysis-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "OCR and Intelligent Document Management System - Backend",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"keywords": ["ocr", "document-management", "ai", "tdd"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"eslint": "^9.17.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^5.22.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,176 @@
|
||||
// Prisma Schema - 图片OCR与智能文档管理系统
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// 用户
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String? @unique
|
||||
password_hash String
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
documents Document[]
|
||||
todos Todo[]
|
||||
categories Category[]
|
||||
tags Tag[]
|
||||
images Image[]
|
||||
configs Config[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// 文档
|
||||
model Document {
|
||||
id String @id @default(uuid())
|
||||
user_id String
|
||||
title String?
|
||||
content String
|
||||
category_id String?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
category Category? @relation(fields: [category_id], references: [id])
|
||||
images Image[]
|
||||
todos Todo[]
|
||||
aiAnalysis AIAnalysis?
|
||||
|
||||
@@index([user_id])
|
||||
@@index([category_id])
|
||||
@@map("documents")
|
||||
}
|
||||
|
||||
// 图片
|
||||
model Image {
|
||||
id String @id @default(uuid())
|
||||
user_id String
|
||||
document_id String?
|
||||
file_path String
|
||||
file_size Int
|
||||
mime_type String
|
||||
ocr_result String?
|
||||
ocr_confidence Float?
|
||||
processing_status String @default("pending")
|
||||
quality_score Float?
|
||||
error_message String?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
document Document? @relation(fields: [document_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([user_id])
|
||||
@@index([processing_status])
|
||||
@@index([document_id])
|
||||
@@map("images")
|
||||
}
|
||||
|
||||
// 待办事项
|
||||
model Todo {
|
||||
id String @id @default(uuid())
|
||||
user_id String
|
||||
document_id String?
|
||||
title String
|
||||
description String?
|
||||
priority String @default("medium")
|
||||
status String @default("pending")
|
||||
due_date DateTime?
|
||||
category_id String?
|
||||
completed_at DateTime?
|
||||
confirmed_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
document Document? @relation(fields: [document_id], references: [id], onDelete: SetNull)
|
||||
category Category? @relation(fields: [category_id], references: [id])
|
||||
|
||||
@@index([user_id])
|
||||
@@index([status])
|
||||
@@index([category_id])
|
||||
@@map("todos")
|
||||
}
|
||||
|
||||
// 分类
|
||||
model Category {
|
||||
id String @id @default(uuid())
|
||||
user_id String
|
||||
name String
|
||||
type String
|
||||
color String?
|
||||
icon String?
|
||||
parent_id String?
|
||||
sort_order Int @default(0)
|
||||
usage_count Int @default(0)
|
||||
is_ai_created Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
parent Category? @relation("CategoryToCategory", fields: [parent_id], references: [id])
|
||||
children Category[] @relation("CategoryToCategory")
|
||||
documents Document[]
|
||||
todos Todo[]
|
||||
|
||||
@@index([user_id])
|
||||
@@index([type])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
// 标签
|
||||
model Tag {
|
||||
id String @id @default(uuid())
|
||||
user_id String
|
||||
name String
|
||||
color String?
|
||||
usage_count Int @default(0)
|
||||
is_ai_created Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([user_id, name])
|
||||
@@index([user_id])
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
// AI分析结果
|
||||
model AIAnalysis {
|
||||
id String @id @default(uuid())
|
||||
document_id String @unique
|
||||
provider String
|
||||
model String
|
||||
suggested_tags String
|
||||
suggested_category String?
|
||||
summary String?
|
||||
raw_response String
|
||||
created_at DateTime @default(now())
|
||||
|
||||
document Document @relation(fields: [document_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ai_analyses")
|
||||
}
|
||||
|
||||
// 配置
|
||||
model Config {
|
||||
id String @id @default(uuid())
|
||||
user_id String
|
||||
key String
|
||||
value String
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([user_id, key])
|
||||
@@index([user_id])
|
||||
@@map("configs")
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Auth Controller
|
||||
* Handles authentication requests (register, login, logout, me)
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { PasswordService } from '../services/password.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export class AuthController {
|
||||
/**
|
||||
* Register a new user
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
static async register(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: '用户名和密码必填',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check password strength
|
||||
const strengthCheck = PasswordService.checkStrength(password);
|
||||
if (!strengthCheck.isStrong) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: strengthCheck.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: '用户名已存在',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
if (email) {
|
||||
const existingEmail = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: '邮箱已被注册',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const password_hash = await PasswordService.hash(password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate token
|
||||
const token = AuthService.generateToken({ user_id: user.id });
|
||||
|
||||
// Return user data (without password)
|
||||
const { password_hash: _, ...userWithoutPassword } = user;
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
user: userWithoutPassword,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '注册失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
static async login(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: '用户名和密码必填',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by username or email
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { email: username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: '用户名或密码错误',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await PasswordService.verify(password, user.password_hash);
|
||||
|
||||
if (!isValid) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: '用户名或密码错误',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = AuthService.generateToken({ user_id: user.id });
|
||||
|
||||
// Return user data (without password)
|
||||
const { password_hash: _, ...userWithoutPassword } = user;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
user: userWithoutPassword,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '登录失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
static async me(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// User is attached by authenticate middleware
|
||||
const userId = req.user!.user_id;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: '用户不存在',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return user data (without password)
|
||||
const { password_hash: _, ...userWithoutPassword } = user;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get me error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取用户信息失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
* POST /api/auth/logout
|
||||
* Note: With JWT, logout is client-side (remove token)
|
||||
*/
|
||||
static async logout(_req: Request, res: Response): Promise<void> {
|
||||
// With JWT, logout is typically handled client-side
|
||||
// Server-side may implement token blacklist if needed
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '登出成功',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Document Controller
|
||||
* Handles document API requests
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { DocumentService } from '../services/document.service';
|
||||
|
||||
export class DocumentController {
|
||||
/**
|
||||
* Create a new document
|
||||
* POST /api/documents
|
||||
*/
|
||||
static async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { content, title, category_id } = req.body;
|
||||
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content,
|
||||
title,
|
||||
category_id,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: document,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '创建文档失败';
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document by ID
|
||||
* GET /api/documents/:id
|
||||
*/
|
||||
static async getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const document = await DocumentService.findById(id, userId);
|
||||
|
||||
if (!document) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: '文档不存在',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: document,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '获取文档失败';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update document
|
||||
* PUT /api/documents/:id
|
||||
*/
|
||||
static async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
const { content, title, category_id } = req.body;
|
||||
|
||||
const document = await DocumentService.update(id, userId, {
|
||||
content,
|
||||
title,
|
||||
category_id,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: document,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '更新文档失败';
|
||||
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete document
|
||||
* DELETE /api/documents/:id
|
||||
*/
|
||||
static async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
await DocumentService.delete(id, userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '文档已删除',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除文档失败';
|
||||
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user documents
|
||||
* GET /api/documents
|
||||
*/
|
||||
static async getUserDocuments(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { category_id, page, limit, search } = req.query;
|
||||
|
||||
let documents;
|
||||
|
||||
if (search && typeof search === 'string') {
|
||||
documents = await DocumentService.search(userId, search);
|
||||
} else {
|
||||
documents = await DocumentService.findByUser(userId, {
|
||||
category_id: category_id as string | undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: documents,
|
||||
count: documents.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '获取文档列表失败';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Image Controller
|
||||
* Handles image upload and management
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { ImageService } from '../services/image.service';
|
||||
|
||||
export class ImageController {
|
||||
/**
|
||||
* Upload image (creates record)
|
||||
* POST /api/images
|
||||
*/
|
||||
static async upload(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
// Assuming file is processed by multer middleware
|
||||
const { file_path, file_size, mime_type } = req.body;
|
||||
const { document_id } = req.body;
|
||||
|
||||
const image = await ImageService.create({
|
||||
user_id: userId,
|
||||
file_path,
|
||||
file_size,
|
||||
mime_type,
|
||||
document_id,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: image,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '上传图片失败';
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image by ID
|
||||
* GET /api/images/:id
|
||||
*/
|
||||
static async getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const image = await ImageService.findById(id, userId);
|
||||
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: '图片不存在',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: image,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取图片失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update OCR result
|
||||
* PUT /api/images/:id/ocr
|
||||
*/
|
||||
static async updateOCR(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
const { text, confidence } = req.body;
|
||||
|
||||
const image = await ImageService.updateOCRResult(id, userId, text, confidence);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: image,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '更新OCR结果失败';
|
||||
res.status(message.includes('not found') ? 404 : 400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link image to document
|
||||
* PUT /api/images/:id/link
|
||||
*/
|
||||
static async linkToDocument(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
const { document_id } = req.body;
|
||||
|
||||
const image = await ImageService.linkToDocument(id, userId, document_id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: image,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '关联文档失败';
|
||||
res.status(message.includes('not found') ? 404 : 400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending images
|
||||
* GET /api/images/pending
|
||||
*/
|
||||
static async getPending(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const images = await ImageService.getPendingImages(userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: images,
|
||||
count: images.length,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取待处理图片失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user images
|
||||
* GET /api/images
|
||||
*/
|
||||
static async getUserImages(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { document_id } = req.query;
|
||||
|
||||
const images = await ImageService.findByUser(
|
||||
userId,
|
||||
document_id as string | undefined
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: images,
|
||||
count: images.length,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取图片列表失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete image
|
||||
* DELETE /api/images/:id
|
||||
*/
|
||||
static async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
await ImageService.delete(id, userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '图片已删除',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除图片失败';
|
||||
res.status(message.includes('not found') ? 404 : 400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Todo Controller
|
||||
* Handles todo API requests with three-state workflow
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { TodoService } from '../services/todo.service';
|
||||
|
||||
export class TodoController {
|
||||
/**
|
||||
* Create a new todo
|
||||
* POST /api/todos
|
||||
*/
|
||||
static async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { title, description, priority, due_date, category_id, document_id } = req.body;
|
||||
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
due_date: due_date ? new Date(due_date) : undefined,
|
||||
category_id,
|
||||
document_id,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: todo,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '创建待办失败';
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get todo by ID
|
||||
* GET /api/todos/:id
|
||||
*/
|
||||
static async getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const todo = await TodoService.findById(id, userId);
|
||||
|
||||
if (!todo) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: '待办不存在',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: todo,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '获取待办失败';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update todo
|
||||
* PUT /api/todos/:id
|
||||
*/
|
||||
static async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
const { title, description, priority, status, due_date, category_id } = req.body;
|
||||
|
||||
const todo = await TodoService.update(id, userId, {
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
status,
|
||||
due_date: due_date ? new Date(due_date) : undefined,
|
||||
category_id,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: todo,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '更新待办失败';
|
||||
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete todo
|
||||
* DELETE /api/todos/:id
|
||||
*/
|
||||
static async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
await TodoService.delete(id, userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '待办已删除',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除待办失败';
|
||||
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user todos
|
||||
* GET /api/todos
|
||||
*/
|
||||
static async getUserTodos(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const { status, priority, category_id, page, limit } = req.query;
|
||||
|
||||
const todos = await TodoService.findByUser(userId, {
|
||||
status: status as any,
|
||||
priority: priority as any,
|
||||
category_id: category_id as string | undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: todos,
|
||||
count: todos.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '获取待办列表失败';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending todos
|
||||
* GET /api/todos/pending
|
||||
*/
|
||||
static async getPending(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const todos = await TodoService.getPendingTodos(userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: todos,
|
||||
count: todos.length,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取待办列表失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completed todos
|
||||
* GET /api/todos/completed
|
||||
*/
|
||||
static async getCompleted(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const todos = await TodoService.getCompletedTodos(userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: todos,
|
||||
count: todos.length,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取已完成列表失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confirmed todos
|
||||
* GET /api/todos/confirmed
|
||||
*/
|
||||
static async getConfirmed(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.user_id;
|
||||
const todos = await TodoService.getConfirmedTodos(userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: todos,
|
||||
count: todos.length,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取已确认列表失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Express Application Entry Point
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './routes/auth.routes';
|
||||
import documentRoutes from './routes/document.routes';
|
||||
import todoRoutes from './routes/todo.routes';
|
||||
import imageRoutes from './routes/image.routes';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
exposedHeaders: ['Content-Type'],
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ success: true, message: 'API is running' });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
app.use('/api/todos', todoRoutes);
|
||||
app.use('/api/images', imageRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Not found',
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({
|
||||
success: false,
|
||||
error: err.message || 'Internal server error',
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
if (require.main === module) {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
export { app };
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Prisma Client Singleton
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
datasources: {
|
||||
db: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Authentication Middleware
|
||||
* Verifies JWT tokens and protects routes
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* Extend Express Request to include user property
|
||||
*/
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: {
|
||||
user_id: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
* Protects routes by verifying JWT tokens
|
||||
*/
|
||||
export const authenticate = (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
// Extract token from Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = AuthService.extractTokenFromHeader(authHeader);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'No token provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = AuthService.verifyToken(token);
|
||||
|
||||
// Attach user info to request
|
||||
req.user = {
|
||||
user_id: payload.user_id,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional authentication middleware
|
||||
* Attaches user info if token is present, but doesn't block if missing
|
||||
*/
|
||||
export const optionalAuthenticate = (req: Request, _res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = AuthService.extractTokenFromHeader(authHeader);
|
||||
|
||||
if (token) {
|
||||
const payload = AuthService.verifyToken(token);
|
||||
req.user = {
|
||||
user_id: payload.user_id,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, continue without user
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Auth Routes
|
||||
* Authentication API endpoints
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { AuthController } from '../controllers/auth.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @route POST /api/auth/register
|
||||
* @desc Register a new user
|
||||
* @access Public
|
||||
*/
|
||||
router.post('/register', AuthController.register);
|
||||
|
||||
/**
|
||||
* @route POST /api/auth/login
|
||||
* @desc Login user
|
||||
* @access Public
|
||||
*/
|
||||
router.post('/login', AuthController.login);
|
||||
|
||||
/**
|
||||
* @route POST /api/auth/logout
|
||||
* @desc Logout user
|
||||
* @access Private
|
||||
*/
|
||||
router.post('/logout', authenticate, AuthController.logout);
|
||||
|
||||
/**
|
||||
* @route GET /api/auth/me
|
||||
* @desc Get current user info
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/me', authenticate, AuthController.me);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Document Routes
|
||||
* Document API endpoints
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { DocumentController } from '../controllers/document.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @route POST /api/documents
|
||||
* @desc Create a new document
|
||||
* @access Private
|
||||
*/
|
||||
router.post('/', authenticate, DocumentController.create);
|
||||
|
||||
/**
|
||||
* @route GET /api/documents
|
||||
* @desc Get user documents
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/', authenticate, DocumentController.getUserDocuments);
|
||||
|
||||
/**
|
||||
* @route GET /api/documents/:id
|
||||
* @desc Get document by ID
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/:id', authenticate, DocumentController.getById);
|
||||
|
||||
/**
|
||||
* @route PUT /api/documents/:id
|
||||
* @desc Update document
|
||||
* @access Private
|
||||
*/
|
||||
router.put('/:id', authenticate, DocumentController.update);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/documents/:id
|
||||
* @desc Delete document
|
||||
* @access Private
|
||||
*/
|
||||
router.delete('/:id', authenticate, DocumentController.delete);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Image Routes
|
||||
* Image API endpoints
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { ImageController } from '../controllers/image.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @route POST /api/images
|
||||
* @desc Upload image
|
||||
* @access Private
|
||||
*/
|
||||
router.post('/', authenticate, ImageController.upload);
|
||||
|
||||
/**
|
||||
* @route GET /api/images
|
||||
* @desc Get user images
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/', authenticate, ImageController.getUserImages);
|
||||
|
||||
/**
|
||||
* @route GET /api/images/pending
|
||||
* @desc Get pending images (OCR failed)
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/pending', authenticate, ImageController.getPending);
|
||||
|
||||
/**
|
||||
* @route GET /api/images/:id
|
||||
* @desc Get image by ID
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/:id', authenticate, ImageController.getById);
|
||||
|
||||
/**
|
||||
* @route PUT /api/images/:id/ocr
|
||||
* @desc Update OCR result
|
||||
* @access Private
|
||||
*/
|
||||
router.put('/:id/ocr', authenticate, ImageController.updateOCR);
|
||||
|
||||
/**
|
||||
* @route PUT /api/images/:id/link
|
||||
* @desc Link image to document
|
||||
* @access Private
|
||||
*/
|
||||
router.put('/:id/link', authenticate, ImageController.linkToDocument);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/images/:id
|
||||
* @desc Delete image
|
||||
* @access Private
|
||||
*/
|
||||
router.delete('/:id', authenticate, ImageController.delete);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Todo Routes
|
||||
* Todo API endpoints with three-state workflow
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { TodoController } from '../controllers/todo.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @route POST /api/todos
|
||||
* @desc Create a new todo
|
||||
* @access Private
|
||||
*/
|
||||
router.post('/', authenticate, TodoController.create);
|
||||
|
||||
/**
|
||||
* @route GET /api/todos
|
||||
* @desc Get user todos
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/', authenticate, TodoController.getUserTodos);
|
||||
|
||||
/**
|
||||
* @route GET /api/todos/pending
|
||||
* @desc Get pending todos
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/pending', authenticate, TodoController.getPending);
|
||||
|
||||
/**
|
||||
* @route GET /api/todos/completed
|
||||
* @desc Get completed todos
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/completed', authenticate, TodoController.getCompleted);
|
||||
|
||||
/**
|
||||
* @route GET /api/todos/confirmed
|
||||
* @desc Get confirmed todos
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/confirmed', authenticate, TodoController.getConfirmed);
|
||||
|
||||
/**
|
||||
* @route GET /api/todos/:id
|
||||
* @desc Get todo by ID
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/:id', authenticate, TodoController.getById);
|
||||
|
||||
/**
|
||||
* @route PUT /api/todos/:id
|
||||
* @desc Update todo
|
||||
* @access Private
|
||||
*/
|
||||
router.put('/:id', authenticate, TodoController.update);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/todos/:id
|
||||
* @desc Delete todo
|
||||
* @access Private
|
||||
*/
|
||||
router.delete('/:id', authenticate, TodoController.delete);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Auth Service
|
||||
* Handles JWT token generation and verification
|
||||
*/
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface TokenPayload {
|
||||
user_id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private static get SECRET(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret || secret === 'default-secret' || secret.length < 10) {
|
||||
throw new Error('JWT_SECRET environment variable is not set or is too weak');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
private static readonly EXPIRES_IN = '24h';
|
||||
|
||||
/**
|
||||
* Generate a JWT token
|
||||
* @param payload - Token payload
|
||||
* @returns string - JWT token
|
||||
*/
|
||||
static generateToken(payload: TokenPayload): string {
|
||||
// Get secret which will throw if invalid
|
||||
const secret = this.SECRET;
|
||||
|
||||
return jwt.sign(payload, secret, {
|
||||
expiresIn: this.EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
* @param token - JWT token
|
||||
* @returns TokenPayload - Decoded token payload
|
||||
*/
|
||||
static verifyToken(token: string): TokenPayload {
|
||||
try {
|
||||
const secret = this.SECRET;
|
||||
const decoded = jwt.verify(token, secret) as TokenPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
* @param authHeader - Authorization header value
|
||||
* @returns string | null - Extracted token or null
|
||||
*/
|
||||
static extractTokenFromHeader(authHeader: string | undefined): string | null {
|
||||
if (!authHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle "Bearer <token>" format
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Handle raw token
|
||||
return authHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an existing token
|
||||
* @param token - Existing token
|
||||
* @returns string - New token
|
||||
*/
|
||||
static refreshToken(token: string): string {
|
||||
const decoded = this.verifyToken(token);
|
||||
// Add a small delay to ensure different iat
|
||||
// Generate new token with same payload
|
||||
const secret = this.SECRET;
|
||||
return jwt.sign({ user_id: decoded.user_id }, secret, {
|
||||
expiresIn: this.EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Document Service
|
||||
* Handles document CRUD operations
|
||||
*/
|
||||
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export interface CreateDocumentInput {
|
||||
user_id: string;
|
||||
content: string;
|
||||
title?: string | null;
|
||||
category_id?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentInput {
|
||||
content?: string;
|
||||
title?: string | null;
|
||||
category_id?: string | null;
|
||||
}
|
||||
|
||||
export interface FindDocumentOptions {
|
||||
category_id?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class DocumentService {
|
||||
/**
|
||||
* Create a new document
|
||||
*/
|
||||
static async create(input: CreateDocumentInput) {
|
||||
// Validate content is not empty
|
||||
if (!input.content || input.content.trim().length === 0) {
|
||||
throw new Error('Document content cannot be empty');
|
||||
}
|
||||
|
||||
return prisma.document.create({
|
||||
data: {
|
||||
user_id: input.user_id,
|
||||
content: input.content,
|
||||
title: input.title ?? null,
|
||||
category_id: input.category_id ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find document by ID (ensuring user owns it)
|
||||
*/
|
||||
static async findById(id: string, userId: string) {
|
||||
return prisma.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update document
|
||||
*/
|
||||
static async update(id: string, userId: string, input: UpdateDocumentInput) {
|
||||
// Verify document exists and user owns it
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) {
|
||||
throw new Error('Document not found or access denied');
|
||||
}
|
||||
|
||||
const updateData: Prisma.DocumentUpdateInput = {};
|
||||
if (input.content !== undefined) {
|
||||
updateData.content = input.content;
|
||||
}
|
||||
if (input.title !== undefined) {
|
||||
updateData.title = input.title;
|
||||
}
|
||||
if (input.category_id !== undefined) {
|
||||
updateData.category = input.category_id ? { connect: { id: input.category_id } } : { disconnect: true };
|
||||
}
|
||||
|
||||
return prisma.document.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete document
|
||||
*/
|
||||
static async delete(id: string, userId: string) {
|
||||
// Verify document exists and user owns it
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) {
|
||||
throw new Error('Document not found or access denied');
|
||||
}
|
||||
|
||||
await prisma.document.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all documents for a user
|
||||
*/
|
||||
static async findByUser(userId: string, options: FindDocumentOptions = {}) {
|
||||
const where: Prisma.DocumentWhereInput = {
|
||||
user_id: userId,
|
||||
};
|
||||
|
||||
if (options.category_id) {
|
||||
where.category_id = options.category_id;
|
||||
}
|
||||
|
||||
const page = options.page ?? 1;
|
||||
const limit = options.limit ?? 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
return prisma.document.findMany({
|
||||
where,
|
||||
orderBy: { created_at: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search documents by content or title
|
||||
*/
|
||||
static async search(userId: string, searchTerm: string) {
|
||||
const where: Prisma.DocumentWhereInput = {
|
||||
user_id: userId,
|
||||
};
|
||||
|
||||
if (searchTerm && searchTerm.trim().length > 0) {
|
||||
where.OR = [
|
||||
{ content: { contains: searchTerm } },
|
||||
{ title: { contains: searchTerm } },
|
||||
];
|
||||
}
|
||||
|
||||
return prisma.document.findMany({
|
||||
where,
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Image Service
|
||||
* Handles image upload and management
|
||||
*/
|
||||
|
||||
import { prisma } from '../lib/prisma';
|
||||
|
||||
export interface CreateImageInput {
|
||||
user_id: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
document_id?: string | null;
|
||||
}
|
||||
|
||||
export class ImageService {
|
||||
/**
|
||||
* Create a new image record
|
||||
*/
|
||||
static async create(input: CreateImageInput) {
|
||||
return prisma.image.create({
|
||||
data: {
|
||||
user_id: input.user_id,
|
||||
file_path: input.file_path,
|
||||
file_size: input.file_size,
|
||||
mime_type: input.mime_type,
|
||||
document_id: input.document_id ?? null,
|
||||
processing_status: 'pending',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find image by ID
|
||||
*/
|
||||
static async findById(id: string, userId: string) {
|
||||
return prisma.image.findFirst({
|
||||
where: {
|
||||
id,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update OCR result
|
||||
*/
|
||||
static async updateOCRResult(id: string, userId: string, text: string, confidence: number) {
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) {
|
||||
throw new Error('Image not found or access denied');
|
||||
}
|
||||
|
||||
return prisma.image.update({
|
||||
where: { id },
|
||||
data: {
|
||||
ocr_result: text,
|
||||
ocr_confidence: confidence,
|
||||
processing_status: confidence >= 0.3 ? 'completed' : 'failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link image to document
|
||||
*/
|
||||
static async linkToDocument(imageId: string, userId: string, documentId: string) {
|
||||
const image = await this.findById(imageId, userId);
|
||||
if (!image) {
|
||||
throw new Error('Image not found or access denied');
|
||||
}
|
||||
|
||||
// Verify document belongs to user
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found or access denied');
|
||||
}
|
||||
|
||||
return prisma.image.update({
|
||||
where: { id: imageId },
|
||||
data: {
|
||||
document_id: documentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending images (OCR failed or low confidence)
|
||||
*/
|
||||
static async getPendingImages(userId: string) {
|
||||
return prisma.image.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
processing_status: 'failed',
|
||||
OR: [
|
||||
{
|
||||
ocr_confidence: {
|
||||
lt: 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
ocr_confidence: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all images for a user
|
||||
*/
|
||||
static async findByUser(userId: string, documentId?: string) {
|
||||
const where: any = {
|
||||
user_id: userId,
|
||||
};
|
||||
|
||||
if (documentId) {
|
||||
where.document_id = documentId;
|
||||
}
|
||||
|
||||
return prisma.image.findMany({
|
||||
where,
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete image
|
||||
*/
|
||||
static async delete(id: string, userId: string) {
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) {
|
||||
throw new Error('Image not found or access denied');
|
||||
}
|
||||
|
||||
await prisma.image.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* OCR Service
|
||||
* Handles OCR processing and confidence validation
|
||||
*/
|
||||
|
||||
export interface OCRResult {
|
||||
text: string;
|
||||
confidence: number;
|
||||
shouldCreateDocument: boolean;
|
||||
}
|
||||
|
||||
export interface OCRProviderOptions {
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
export class OCRService {
|
||||
private static readonly DEFAULT_TIMEOUT = 10000; // 10 seconds
|
||||
private static readonly DEFAULT_RETRIES = 2;
|
||||
|
||||
/**
|
||||
* Determine if document should be created based on confidence
|
||||
* @param confidence - OCR confidence score (0-1)
|
||||
* @param threshold - Minimum threshold (default 0.3)
|
||||
* @returns boolean - True if document should be created
|
||||
*/
|
||||
static shouldCreateDocument(
|
||||
confidence: number,
|
||||
threshold: number = 0.3
|
||||
): boolean {
|
||||
// Validate inputs
|
||||
if (!this.isValidConfidence(confidence)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return confidence >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate confidence score is in valid range
|
||||
* @param confidence - Confidence score to validate
|
||||
* @returns boolean - True if valid
|
||||
*/
|
||||
static isValidConfidence(confidence: number): boolean {
|
||||
return typeof confidence === 'number' &&
|
||||
!isNaN(confidence) &&
|
||||
confidence >= 0 &&
|
||||
confidence <= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial processing status
|
||||
* @returns string - Initial status
|
||||
*/
|
||||
static getInitialStatus(): string {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OCR with retry logic
|
||||
* @param imageId - Image ID to process
|
||||
* @param provider - OCR provider function
|
||||
* @param options - OCR options
|
||||
* @returns Promise<OCRResult> - OCR result
|
||||
*/
|
||||
static async process(
|
||||
imageId: string,
|
||||
provider: (id: string) => Promise<{ text: string; confidence: number }>,
|
||||
options: OCRProviderOptions = {}
|
||||
): Promise<OCRResult> {
|
||||
const timeout = options.timeout ?? this.DEFAULT_TIMEOUT;
|
||||
const retries = options.retries ?? this.DEFAULT_RETRIES;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
// Add timeout to provider call
|
||||
const result = await Promise.race([
|
||||
provider(imageId),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('OCR timeout')), timeout)
|
||||
),
|
||||
]);
|
||||
|
||||
const shouldCreate = this.shouldCreateDocument(result.confidence);
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
confidence: result.confidence,
|
||||
shouldCreateDocument: shouldCreate,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// Don't retry on certain errors
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === 'invalid image format' ||
|
||||
error.message.includes('Invalid'))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Retry on transient errors
|
||||
if (attempt < retries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('OCR processing failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Password Service
|
||||
* Handles password hashing and verification using bcrypt
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
export interface StrengthResult {
|
||||
isStrong: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class PasswordService {
|
||||
private static readonly SALT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Hash a plain text password using bcrypt
|
||||
* @param password - Plain text password
|
||||
* @returns Promise<string> - Hashed password
|
||||
*/
|
||||
static async hash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, this.SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a plain text password against a hash
|
||||
* @param password - Plain text password
|
||||
* @param hash - Hashed password
|
||||
* @returns Promise<boolean> - True if password matches
|
||||
* @throws Error if hash format is invalid
|
||||
*/
|
||||
static async verify(password: string, hash: string): Promise<boolean> {
|
||||
// Validate hash format (bcrypt hashes are 60 chars and start with $2a$, $2b$, or $2y$)
|
||||
if (!hash || typeof hash !== 'string') {
|
||||
throw new Error('Invalid hash format');
|
||||
}
|
||||
|
||||
const bcryptHashRegex = /^\$2[aby]\$[\d]+\$./;
|
||||
if (!bcryptHashRegex.test(hash)) {
|
||||
throw new Error('Invalid hash format');
|
||||
}
|
||||
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check password strength
|
||||
* @param password - Plain text password
|
||||
* @returns StrengthResult - Strength check result
|
||||
*/
|
||||
static checkStrength(password: string): StrengthResult {
|
||||
if (password.length < 8) {
|
||||
return { isStrong: false, reason: '密码长度至少8个字符' };
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
return { isStrong: false, reason: '密码需要包含大写字母' };
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
return { isStrong: false, reason: '密码需要包含小写字母' };
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
return { isStrong: false, reason: '密码需要包含数字' };
|
||||
}
|
||||
|
||||
if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) {
|
||||
return { isStrong: false, reason: '密码需要包含特殊字符' };
|
||||
}
|
||||
|
||||
return { isStrong: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Todo Service
|
||||
* Handles todo CRUD operations with three-state workflow
|
||||
*/
|
||||
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export type TodoStatus = 'pending' | 'completed' | 'confirmed';
|
||||
export type TodoPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export interface CreateTodoInput {
|
||||
user_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
priority?: TodoPriority;
|
||||
status?: TodoStatus;
|
||||
due_date?: Date | null;
|
||||
category_id?: string | null;
|
||||
document_id?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateTodoInput {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
priority?: TodoPriority;
|
||||
status?: TodoStatus;
|
||||
due_date?: Date | null;
|
||||
category_id?: string | null;
|
||||
document_id?: string | null;
|
||||
}
|
||||
|
||||
export interface FindTodoOptions {
|
||||
status?: TodoStatus;
|
||||
priority?: TodoPriority;
|
||||
category_id?: string;
|
||||
document_id?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
overdue?: boolean;
|
||||
}
|
||||
|
||||
export class TodoService {
|
||||
/**
|
||||
* Create a new todo
|
||||
*/
|
||||
static async create(input: CreateTodoInput) {
|
||||
// Validate title is not empty
|
||||
if (!input.title || input.title.trim().length === 0) {
|
||||
throw new Error('Todo title cannot be empty');
|
||||
}
|
||||
|
||||
// Validate priority if provided
|
||||
const validPriorities: TodoPriority[] = ['low', 'medium', 'high', 'urgent'];
|
||||
if (input.priority && !validPriorities.includes(input.priority)) {
|
||||
throw new Error(`Invalid priority. Must be one of: ${validPriorities.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate status if provided
|
||||
const validStatuses: TodoStatus[] = ['pending', 'completed', 'confirmed'];
|
||||
if (input.status && !validStatuses.includes(input.status)) {
|
||||
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
|
||||
return prisma.todo.create({
|
||||
data: {
|
||||
user_id: input.user_id,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
priority: input.priority ?? 'medium',
|
||||
status: input.status ?? 'pending',
|
||||
due_date: input.due_date ?? null,
|
||||
category_id: input.category_id ?? null,
|
||||
document_id: input.document_id ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find todo by ID (ensuring user owns it)
|
||||
*/
|
||||
static async findById(id: string, userId: string) {
|
||||
return prisma.todo.findFirst({
|
||||
where: {
|
||||
id,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update todo
|
||||
*/
|
||||
static async update(id: string, userId: string, input: UpdateTodoInput) {
|
||||
// Verify todo exists and user owns it
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) {
|
||||
throw new Error('Todo not found or access denied');
|
||||
}
|
||||
|
||||
const updateData: Prisma.TodoUpdateInput = {};
|
||||
|
||||
if (input.title !== undefined) {
|
||||
updateData.title = input.title;
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
updateData.description = input.description;
|
||||
}
|
||||
if (input.priority !== undefined) {
|
||||
updateData.priority = input.priority;
|
||||
}
|
||||
if (input.category_id !== undefined) {
|
||||
updateData.category = input.category_id ? { connect: { id: input.category_id } } : { disconnect: true };
|
||||
}
|
||||
if (input.document_id !== undefined) {
|
||||
updateData.document = input.document_id ? { connect: { id: input.document_id } } : { disconnect: true };
|
||||
}
|
||||
if (input.due_date !== undefined) {
|
||||
updateData.due_date = input.due_date;
|
||||
}
|
||||
|
||||
// Handle status transitions with timestamps
|
||||
if (input.status !== undefined) {
|
||||
updateData.status = input.status;
|
||||
|
||||
if (input.status === 'completed' && existing.status !== 'completed' && existing.status !== 'confirmed') {
|
||||
updateData.completed_at = new Date();
|
||||
} else if (input.status === 'confirmed') {
|
||||
updateData.confirmed_at = new Date();
|
||||
// Ensure completed_at is set if not already
|
||||
if (!existing.completed_at) {
|
||||
updateData.completed_at = new Date();
|
||||
}
|
||||
} else if (input.status === 'pending') {
|
||||
// Clear timestamps when reverting to pending
|
||||
updateData.completed_at = null;
|
||||
updateData.confirmed_at = null;
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.todo.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete todo
|
||||
*/
|
||||
static async delete(id: string, userId: string) {
|
||||
// Verify todo exists and user owns it
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) {
|
||||
throw new Error('Todo not found or access denied');
|
||||
}
|
||||
|
||||
await prisma.todo.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all todos for a user with filters
|
||||
*/
|
||||
static async findByUser(userId: string, options: FindTodoOptions = {}) {
|
||||
const where: Prisma.TodoWhereInput = {
|
||||
user_id: userId,
|
||||
};
|
||||
|
||||
if (options.status) {
|
||||
where.status = options.status;
|
||||
}
|
||||
|
||||
if (options.priority) {
|
||||
where.priority = options.priority;
|
||||
}
|
||||
|
||||
if (options.category_id) {
|
||||
where.category_id = options.category_id;
|
||||
}
|
||||
|
||||
if (options.document_id) {
|
||||
where.document_id = options.document_id;
|
||||
}
|
||||
|
||||
if (options.overdue) {
|
||||
where.due_date = {
|
||||
lt: new Date(),
|
||||
};
|
||||
// Only pending todos can be overdue
|
||||
where.status = 'pending';
|
||||
}
|
||||
|
||||
const page = options.page ?? 1;
|
||||
const limit = options.limit ?? 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Fetch more than needed to allow for post-sorting
|
||||
const fetchLimit = limit * 2;
|
||||
|
||||
let todos = await prisma.todo.findMany({
|
||||
where,
|
||||
orderBy: [{ created_at: 'desc' }],
|
||||
skip: 0,
|
||||
take: fetchLimit,
|
||||
});
|
||||
|
||||
// Sort by priority in application layer
|
||||
const priorityOrder: Record<TodoPriority, number> = { urgent: 0, high: 1, medium: 2, low: 3 };
|
||||
todos = todos.sort((a, b) => {
|
||||
const priorityDiff = priorityOrder[a.priority as TodoPriority] - priorityOrder[b.priority as TodoPriority];
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
// If same priority, sort by created date
|
||||
return b.created_at.getTime() - a.created_at.getTime();
|
||||
});
|
||||
|
||||
// Apply pagination after sorting
|
||||
return todos.slice(skip, skip + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending todos
|
||||
*/
|
||||
static async getPendingTodos(userId: string, limit = 50) {
|
||||
return prisma.todo.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 'pending',
|
||||
},
|
||||
orderBy: [{ created_at: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completed todos
|
||||
*/
|
||||
static async getCompletedTodos(userId: string, limit = 50) {
|
||||
return prisma.todo.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 'completed',
|
||||
},
|
||||
orderBy: [{ completed_at: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confirmed todos
|
||||
*/
|
||||
static async getConfirmedTodos(userId: string, limit = 50) {
|
||||
return prisma.todo.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 'confirmed',
|
||||
},
|
||||
orderBy: [{ confirmed_at: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 测试后端 API 编码
|
||||
*/
|
||||
const axios = require('axios');
|
||||
|
||||
async function testEncoding() {
|
||||
try {
|
||||
// 首先登录
|
||||
console.log('1. 登录...');
|
||||
const loginResponse = await axios.post('http://localhost:4000/api/auth/login', {
|
||||
username: 'testuser',
|
||||
password: 'Password123@'
|
||||
});
|
||||
|
||||
const token = loginResponse.data.data.token;
|
||||
console.log('✅ 登录成功');
|
||||
|
||||
// 获取文档
|
||||
console.log('\n2. 获取文档...');
|
||||
const docsResponse = await axios.get('http://localhost:4000/api/documents', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('响应头:', docsResponse.headers['content-type']);
|
||||
console.log('响应数据:', JSON.stringify(docsResponse.data, null, 2));
|
||||
|
||||
// 检查中文字符
|
||||
if (docsResponse.data.data && docsResponse.data.data.length > 0) {
|
||||
const firstDoc = docsResponse.data.data[0];
|
||||
console.log('\n第一个文档:');
|
||||
console.log(' 标题:', firstDoc.title);
|
||||
console.log(' 内容:', firstDoc.content?.substring(0, 50));
|
||||
|
||||
// 检查编码
|
||||
const hasChinese = /[\u4e00-\u9fa5]/.test(firstDoc.title + firstDoc.content);
|
||||
console.log(' 包含中文:', hasChinese ? '是' : '否');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 错误:', error.message);
|
||||
if (error.response) {
|
||||
console.error('响应数据:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testEncoding();
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test Environment Setup
|
||||
* Load test environment variables before tests run
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Use development database for tests (simplest approach)
|
||||
process.env.DATABASE_URL = 'file:./dev.db';
|
||||
process.env.JWT_SECRET = 'test-secret-key-for-jest-development-purpose';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Initialize test database if needed
|
||||
const dbPath = path.join(__dirname, '..', 'dev.db');
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const schemaPath = path.join(__dirname, '..', 'prisma', 'schema.prisma');
|
||||
execSync(`npx prisma db push --schema="${schemaPath}" --skip-generate`, {
|
||||
stdio: 'inherit',
|
||||
cwd: path.join(__dirname, '..'),
|
||||
windowsHide: true
|
||||
});
|
||||
console.log('✅ Test database initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize test database:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
console.log('✅ Using existing database');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Jest Test Setup
|
||||
*/
|
||||
|
||||
// Make this a module
|
||||
export {};
|
||||
|
||||
// Extend Jest matchers
|
||||
declare global {
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toBeValidUUID(): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom matchers
|
||||
expect.extend({
|
||||
toBeValidUUID(received: string) {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const pass = uuidRegex.test(received);
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected ${received} not to be a valid UUID`
|
||||
: `Expected ${received} to be a valid UUID`,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Auth Service Unit Tests
|
||||
* TDD: Test-Driven Development
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import { AuthService } from '../../../src/services/auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ralph 测试隔离是否充分?
|
||||
process.env = { ...originalEnv, JWT_SECRET: 'test-secret-key' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('should generate valid JWT token', () => {
|
||||
// @ralph 这个测试是否覆盖了核心功能?
|
||||
const payload = { user_id: 'user-123' };
|
||||
const token = AuthService.generateToken(payload);
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.split('.')).toHaveLength(3); // JWT format
|
||||
});
|
||||
|
||||
it('should include user_id in token payload', () => {
|
||||
// @ralph payload是否正确编码?
|
||||
const payload = { user_id: 'user-123' };
|
||||
const token = AuthService.generateToken(payload);
|
||||
|
||||
const decoded = AuthService.verifyToken(token);
|
||||
expect(decoded.user_id).toBe('user-123');
|
||||
});
|
||||
|
||||
it('should set 24 hour expiration', () => {
|
||||
// @ralph 过期时间是否正确?
|
||||
const token = AuthService.generateToken({ user_id: 'test' });
|
||||
const decoded = JSON.parse(atob(token.split('.')[1]));
|
||||
|
||||
const exp = decoded.exp;
|
||||
const iat = decoded.iat;
|
||||
expect(exp - iat).toBe(24 * 60 * 60); // 24 hours
|
||||
});
|
||||
|
||||
it('should handle additional payload data', () => {
|
||||
// @ralph 扩展性是否考虑?
|
||||
const payload = {
|
||||
user_id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
role: 'user'
|
||||
};
|
||||
const token = AuthService.generateToken(payload);
|
||||
|
||||
const decoded = AuthService.verifyToken(token);
|
||||
expect(decoded.email).toBe('test@example.com');
|
||||
expect(decoded.role).toBe('user');
|
||||
});
|
||||
|
||||
it('should throw error without JWT_SECRET', () => {
|
||||
// @ralph 错误处理是否完善?
|
||||
delete process.env.JWT_SECRET;
|
||||
|
||||
expect(() => {
|
||||
AuthService.generateToken({ user_id: 'test' });
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyToken', () => {
|
||||
it('should verify valid token', () => {
|
||||
// @ralph 验证逻辑是否正确?
|
||||
const payload = { user_id: 'user-123' };
|
||||
const token = AuthService.generateToken(payload);
|
||||
const decoded = AuthService.verifyToken(token);
|
||||
|
||||
expect(decoded.user_id).toBe('user-123');
|
||||
expect(decoded).toHaveProperty('iat');
|
||||
expect(decoded).toHaveProperty('exp');
|
||||
});
|
||||
|
||||
it('should reject expired token', () => {
|
||||
// @ralph 过期检查是否生效?
|
||||
// Create a token that expired immediately
|
||||
const jwt = require('jsonwebtoken');
|
||||
const expiredToken = jwt.sign(
|
||||
{ user_id: 'test' },
|
||||
process.env.JWT_SECRET || 'test-secret-key-for-jest',
|
||||
{ expiresIn: '0s' }
|
||||
);
|
||||
|
||||
// TokenExpiredError is only thrown when the current time is past the exp time
|
||||
// Since we can't reliably test this without waiting, we accept "Invalid token"
|
||||
expect(() => {
|
||||
AuthService.verifyToken(expiredToken);
|
||||
}).toThrow(); // Will throw either "Token expired" or "Invalid token" depending on timing
|
||||
});
|
||||
|
||||
it('should reject malformed token', () => {
|
||||
// @ralph 格式验证是否严格?
|
||||
const malformedTokens = [
|
||||
'not-a-token',
|
||||
'header.payload', // missing signature
|
||||
'',
|
||||
'a.b.c.d', // too many parts
|
||||
'a.b' // too few parts
|
||||
];
|
||||
|
||||
malformedTokens.forEach(token => {
|
||||
expect(() => {
|
||||
AuthService.verifyToken(token);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject token with wrong secret', () => {
|
||||
// @ralph 签名验证是否正确?
|
||||
const jwt = require('jsonwebtoken');
|
||||
const token = jwt.sign({ user_id: 'test' }, 'wrong-secret');
|
||||
|
||||
expect(() => {
|
||||
AuthService.verifyToken(token);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should reject tampered token', () => {
|
||||
// @ralph 篡改检测是否有效?
|
||||
const token = AuthService.generateToken({ user_id: 'test' });
|
||||
const tamperedToken = token.slice(0, -1) + 'X'; // Change last char
|
||||
|
||||
expect(() => {
|
||||
AuthService.verifyToken(tamperedToken);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokenFromHeader', () => {
|
||||
it('should extract token from Bearer header', () => {
|
||||
// @ralph 提取逻辑是否正确?
|
||||
const header = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx';
|
||||
const token = AuthService.extractTokenFromHeader(header);
|
||||
|
||||
expect(token).toContain('eyJ');
|
||||
});
|
||||
|
||||
it('should handle missing Bearer prefix', () => {
|
||||
// @ralph 容错性是否足够?
|
||||
const token = AuthService.extractTokenFromHeader('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx');
|
||||
expect(token).toContain('eyJ');
|
||||
});
|
||||
|
||||
it('should handle empty header', () => {
|
||||
// @ralph 边界条件是否处理?
|
||||
const token = AuthService.extractTokenFromHeader('');
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined header', () => {
|
||||
// @ralph 空值处理是否完善?
|
||||
const token = AuthService.extractTokenFromHeader(undefined as any);
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('token refresh', () => {
|
||||
it('should generate new token from old', async () => {
|
||||
// @ralph 刷新逻辑是否正确?
|
||||
const oldToken = AuthService.generateToken({ user_id: 'user-123' });
|
||||
|
||||
// Wait to ensure different iat (JWT uses seconds, so we need > 1 second)
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const newToken = AuthService.refreshToken(oldToken);
|
||||
|
||||
expect(newToken).toBeDefined();
|
||||
expect(newToken).not.toBe(oldToken);
|
||||
|
||||
const decoded = AuthService.verifyToken(newToken);
|
||||
expect(decoded.user_id).toBe('user-123');
|
||||
});
|
||||
|
||||
it('should extend expiration on refresh', async () => {
|
||||
// @ralph 期限是否正确延长?
|
||||
const oldToken = AuthService.generateToken({ user_id: 'test' });
|
||||
const oldDecoded = JSON.parse(atob(oldToken.split('.')[1]));
|
||||
|
||||
// Wait to ensure different iat
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const newToken = AuthService.refreshToken(oldToken);
|
||||
const newDecoded = JSON.parse(atob(newToken.split('.')[1]));
|
||||
|
||||
expect(newDecoded.exp).toBeGreaterThan(oldDecoded.exp);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Document Service Unit Tests
|
||||
* TDD: Test-Driven Development
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import { DocumentService } from '../../../src/services/document.service';
|
||||
import { prisma } from '../../../src/lib/prisma';
|
||||
|
||||
describe('DocumentService', () => {
|
||||
// @ralph 我要测试什么?
|
||||
// - 创建文档
|
||||
// - 更新文档内容
|
||||
// - 删除文档
|
||||
// - 获取用户文档列表
|
||||
// - 按分类筛选文档
|
||||
// - 边界情况:空内容、特殊字符、长文本
|
||||
|
||||
let userId: string;
|
||||
let categoryId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user and category
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: `testuser_${Date.now()}`,
|
||||
email: `test_${Date.now()}@example.com`,
|
||||
password_hash: '$2a$10$test',
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
name: 'Test Category',
|
||||
type: 'document',
|
||||
},
|
||||
});
|
||||
categoryId = category.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await prisma.document.deleteMany({ where: { user_id: userId } });
|
||||
await prisma.category.deleteMany({ where: { user_id: userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new document', async () => {
|
||||
// @ralph 正常路径是否覆盖?
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Test document content',
|
||||
title: 'Test Document',
|
||||
category_id: categoryId,
|
||||
});
|
||||
|
||||
expect(document).toBeDefined();
|
||||
expect(document.id).toBeDefined();
|
||||
expect(document.content).toBe('Test document content');
|
||||
expect(document.title).toBe('Test Document');
|
||||
expect(document.user_id).toBe(userId);
|
||||
expect(document.category_id).toBe(categoryId);
|
||||
});
|
||||
|
||||
it('should create document with minimal required fields', async () => {
|
||||
// @ralph 最小输入是否处理?
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Minimal content',
|
||||
});
|
||||
|
||||
expect(document).toBeDefined();
|
||||
expect(document.content).toBe('Minimal content');
|
||||
expect(document.title).toBeNull();
|
||||
expect(document.category_id).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject empty content', async () => {
|
||||
// @ralph 无效输入是否拒绝?
|
||||
await expect(
|
||||
DocumentService.create({
|
||||
user_id: userId,
|
||||
content: '',
|
||||
})
|
||||
).rejects.toThrow('content');
|
||||
});
|
||||
|
||||
it('should handle long content', async () => {
|
||||
// @ralph 边界条件是否测试?
|
||||
const longContent = 'A'.repeat(10000);
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: longContent,
|
||||
});
|
||||
|
||||
expect(document.content).toBe(longContent);
|
||||
});
|
||||
|
||||
it('should handle special characters in content', async () => {
|
||||
// @ralph 特殊字符是否正确处理?
|
||||
const specialContent = 'Test with 中文 and émojis 🎉 and "quotes"';
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: specialContent,
|
||||
});
|
||||
|
||||
expect(document.content).toBe(specialContent);
|
||||
});
|
||||
|
||||
it('should reject non-existent category', async () => {
|
||||
// @ralph 外键约束是否验证?
|
||||
const fakeCategoryId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
await expect(
|
||||
DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Test',
|
||||
category_id: fakeCategoryId,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject non-existent user', async () => {
|
||||
// @ralph 用户验证是否正确?
|
||||
const fakeUserId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
await expect(
|
||||
DocumentService.create({
|
||||
user_id: fakeUserId,
|
||||
content: 'Test',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find document by id', async () => {
|
||||
// @ralph 查找功能是否正常?
|
||||
const created = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Test content',
|
||||
});
|
||||
|
||||
const found = await DocumentService.findById(created.id, userId);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.content).toBe('Test content');
|
||||
});
|
||||
|
||||
it('should return null for non-existent document', async () => {
|
||||
// @ralph 未找到时是否返回null?
|
||||
const found = await DocumentService.findById('00000000-0000-0000-0000-000000000000', userId);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should not return document from different user', async () => {
|
||||
// @ralph 数据隔离是否正确?
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'otheruser',
|
||||
password_hash: '$2a$10$test',
|
||||
},
|
||||
});
|
||||
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Private document',
|
||||
});
|
||||
|
||||
const found = await DocumentService.findById(document.id, otherUser.id);
|
||||
expect(found).toBeNull();
|
||||
|
||||
await prisma.user.delete({ where: { id: otherUser.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update document content', async () => {
|
||||
// @ralph 更新功能是否正常?
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Original content',
|
||||
});
|
||||
|
||||
const updated = await DocumentService.update(document.id, userId, {
|
||||
content: 'Updated content',
|
||||
});
|
||||
|
||||
expect(updated.content).toBe('Updated content');
|
||||
expect(updated.id).toBe(document.id);
|
||||
});
|
||||
|
||||
it('should update title', async () => {
|
||||
// @ralph 部分更新是否支持?
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Content',
|
||||
});
|
||||
|
||||
const updated = await DocumentService.update(document.id, userId, {
|
||||
title: 'New Title',
|
||||
});
|
||||
|
||||
expect(updated.title).toBe('New Title');
|
||||
expect(updated.content).toBe('Content');
|
||||
});
|
||||
|
||||
it('should update category', async () => {
|
||||
// @ralph 分类更新是否正确?
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Content',
|
||||
});
|
||||
|
||||
const updated = await DocumentService.update(document.id, userId, {
|
||||
category_id: categoryId,
|
||||
});
|
||||
|
||||
expect(updated.category_id).toBe(categoryId);
|
||||
});
|
||||
|
||||
it('should reject update for non-existent document', async () => {
|
||||
// @ralph 错误处理是否正确?
|
||||
await expect(
|
||||
DocumentService.update('00000000-0000-0000-0000-000000000000', userId, {
|
||||
content: 'Updated',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject update from different user', async () => {
|
||||
// @ralph 权限控制是否正确?
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'otheruser',
|
||||
password_hash: '$2a$10$test',
|
||||
},
|
||||
});
|
||||
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Content',
|
||||
});
|
||||
|
||||
await expect(
|
||||
DocumentService.update(document.id, otherUser.id, {
|
||||
content: 'Hacked',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
await prisma.user.delete({ where: { id: otherUser.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete document', async () => {
|
||||
// @ralph 删除功能是否正常?
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Content',
|
||||
});
|
||||
|
||||
await DocumentService.delete(document.id, userId);
|
||||
|
||||
const found = await DocumentService.findById(document.id, userId);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject delete for non-existent document', async () => {
|
||||
// @ralph 错误处理是否正确?
|
||||
await expect(
|
||||
DocumentService.delete('00000000-0000-0000-0000-000000000000', userId)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject delete from different user', async () => {
|
||||
// @ralph 权限控制是否正确?
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'otheruser',
|
||||
password_hash: '$2a$10$test',
|
||||
},
|
||||
});
|
||||
|
||||
const document = await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Content',
|
||||
});
|
||||
|
||||
await expect(
|
||||
DocumentService.delete(document.id, otherUser.id)
|
||||
).rejects.toThrow();
|
||||
|
||||
await prisma.user.delete({ where: { id: otherUser.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUser', () => {
|
||||
beforeEach(async () => {
|
||||
// Create multiple documents
|
||||
await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Document 1',
|
||||
title: 'First',
|
||||
category_id: categoryId,
|
||||
});
|
||||
await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Document 2',
|
||||
title: 'Second',
|
||||
});
|
||||
await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Document 3',
|
||||
title: 'Third',
|
||||
category_id: categoryId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return all user documents', async () => {
|
||||
// @ralph 列表查询是否正确?
|
||||
const documents = await DocumentService.findByUser(userId);
|
||||
|
||||
expect(documents).toHaveLength(3);
|
||||
expect(documents.every(d => d.user_id === userId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
// @ralph 筛选功能是否正确?
|
||||
const documents = await DocumentService.findByUser(userId, { category_id: categoryId });
|
||||
|
||||
expect(documents).toHaveLength(2);
|
||||
expect(documents.every(d => d.category_id === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
// @ralph 分页功能是否支持?
|
||||
const page1 = await DocumentService.findByUser(userId, { page: 1, limit: 2 });
|
||||
expect(page1).toHaveLength(2);
|
||||
|
||||
const page2 = await DocumentService.findByUser(userId, { page: 2, limit: 2 });
|
||||
expect(page2).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty for user with no documents', async () => {
|
||||
// @ralph 空结果是否正确处理?
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'emptyuser',
|
||||
password_hash: '$2a$10$test',
|
||||
},
|
||||
});
|
||||
|
||||
const documents = await DocumentService.findByUser(otherUser.id);
|
||||
expect(documents).toHaveLength(0);
|
||||
|
||||
await prisma.user.delete({ where: { id: otherUser.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'This is about programming with JavaScript',
|
||||
title: 'Programming Guide',
|
||||
});
|
||||
await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'Cooking recipes for beginners',
|
||||
title: 'Cooking Book',
|
||||
});
|
||||
await DocumentService.create({
|
||||
user_id: userId,
|
||||
content: 'JavaScript best practices',
|
||||
title: 'Advanced Programming',
|
||||
});
|
||||
});
|
||||
|
||||
it('should search in content', async () => {
|
||||
// @ralph 内容搜索是否正常?
|
||||
const results = await DocumentService.search(userId, 'JavaScript');
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every(d => d.content.includes('JavaScript') || d.title?.includes('JavaScript'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should search in title', async () => {
|
||||
// @ralph 标题搜索是否正常?
|
||||
const results = await DocumentService.search(userId, 'Programming');
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty for no matches', async () => {
|
||||
// @ralph 无结果时是否正确?
|
||||
const results = await DocumentService.search(userId, 'NonExistentTerm');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty search term', async () => {
|
||||
// @ralph 空搜索词是否处理?
|
||||
const results = await DocumentService.search(userId, '');
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* OCR Service Unit Tests
|
||||
* TDD: Test-Driven Development
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { OCRService } from '../../../src/services/ocr.service';
|
||||
|
||||
describe('OCRService', () => {
|
||||
describe('shouldCreateDocument', () => {
|
||||
const defaultThreshold = 0.3;
|
||||
|
||||
it('should create document when confidence > threshold', () => {
|
||||
// @ralph 这个测试是否清晰描述了决策逻辑?
|
||||
const result = OCRService.shouldCreateDocument(0.8, defaultThreshold);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not create document when confidence < threshold', () => {
|
||||
// @ralph 边界条件是否正确处理?
|
||||
const result = OCRService.shouldCreateDocument(0.2, defaultThreshold);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle threshold boundary (>=)', () => {
|
||||
// @ralph 边界值处理是否明确?
|
||||
expect(OCRService.shouldCreateDocument(0.3, 0.3)).toBe(true);
|
||||
expect(OCRService.shouldCreateDocument(0.31, 0.3)).toBe(true);
|
||||
expect(OCRService.shouldCreateDocument(0.29, 0.3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle perfect confidence', () => {
|
||||
// @ralph 最佳情况是否考虑?
|
||||
const result = OCRService.shouldCreateDocument(1.0, defaultThreshold);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle zero confidence', () => {
|
||||
// @ralph 最坏情况是否考虑?
|
||||
const result = OCRService.shouldCreateDocument(0.0, defaultThreshold);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle negative confidence', () => {
|
||||
// @ralph 异常值是否处理?
|
||||
const result = OCRService.shouldCreateDocument(-0.1, defaultThreshold);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle confidence > 1 (invalid)', () => {
|
||||
// @ralph 非法值是否处理?
|
||||
const result = OCRService.shouldCreateDocument(1.5, defaultThreshold);
|
||||
expect(result).toBe(false); // Should return false for invalid input
|
||||
});
|
||||
});
|
||||
|
||||
describe('processingStatus', () => {
|
||||
it('should return pending for new upload', () => {
|
||||
// @ralph 初始状态是否正确?
|
||||
const status = OCRService.getInitialStatus();
|
||||
expect(status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should process image with provider', async () => {
|
||||
// @ralph 处理流程是否正确?
|
||||
const mockOCR = jest.fn().mockResolvedValue({ text: 'test', confidence: 0.9 });
|
||||
const result = await OCRService.process('image-id', mockOCR);
|
||||
|
||||
expect(result.text).toBe('test');
|
||||
expect(result.confidence).toBe(0.9);
|
||||
expect(result.shouldCreateDocument).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle OCR provider failure', async () => {
|
||||
// @ralph 失败处理是否完善?
|
||||
const mockOCR = jest.fn().mockRejectedValue(new Error('OCR failed'));
|
||||
|
||||
await expect(OCRService.process('image-id', mockOCR)).rejects.toThrow('OCR failed');
|
||||
});
|
||||
|
||||
it('should handle timeout', async () => {
|
||||
// @ralph 超时是否处理?
|
||||
const mockOCR = jest.fn().mockImplementation(() =>
|
||||
new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
);
|
||||
|
||||
await expect(
|
||||
OCRService.process('image-id', mockOCR, { timeout: 100 })
|
||||
).rejects.toThrow('timeout');
|
||||
});
|
||||
|
||||
it('should handle empty result', async () => {
|
||||
// @ralph 空结果是否处理?
|
||||
const mockOCR = jest.fn().mockResolvedValue({ text: '', confidence: 0 });
|
||||
|
||||
const result = await OCRService.process('image-id', mockOCR);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.shouldCreateDocument).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidence validation', () => {
|
||||
it('should validate confidence range', () => {
|
||||
// @ralph 范围检查是否完整?
|
||||
expect(OCRService.isValidConfidence(0.5)).toBe(true);
|
||||
expect(OCRService.isValidConfidence(0)).toBe(true);
|
||||
expect(OCRService.isValidConfidence(1)).toBe(true);
|
||||
expect(OCRService.isValidConfidence(-0.1)).toBe(false);
|
||||
expect(OCRService.isValidConfidence(1.1)).toBe(false);
|
||||
expect(OCRService.isValidConfidence(NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry logic', () => {
|
||||
it('should retry on transient failure', async () => {
|
||||
// @ralph 重试逻辑是否合理?
|
||||
const mockOCR = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('network error'))
|
||||
.mockResolvedValueOnce({ text: 'retry success', confidence: 0.8 });
|
||||
|
||||
const result = await OCRService.process('image-id', mockOCR, { retries: 1 });
|
||||
|
||||
expect(mockOCR).toHaveBeenCalledTimes(2);
|
||||
expect(result.text).toBe('retry success');
|
||||
});
|
||||
|
||||
it('should not retry on permanent failure', async () => {
|
||||
// @ralph 错误类型是否区分?
|
||||
const mockOCR = jest.fn()
|
||||
.mockRejectedValue(new Error('invalid image format'));
|
||||
|
||||
await expect(
|
||||
OCRService.process('image-id', mockOCR, { retries: 2 })
|
||||
).rejects.toThrow('invalid image format');
|
||||
expect(mockOCR).toHaveBeenCalledTimes(1); // No retry
|
||||
});
|
||||
|
||||
it('should respect max retry limit', async () => {
|
||||
// @ralph 重试次数是否限制?
|
||||
const mockOCR = jest.fn()
|
||||
.mockRejectedValue(new Error('network error'));
|
||||
|
||||
await expect(
|
||||
OCRService.process('image-id', mockOCR, { retries: 2 })
|
||||
).rejects.toThrow('network error');
|
||||
expect(mockOCR).toHaveBeenCalledTimes(3); // initial + 2 retries
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Password Service Unit Tests
|
||||
* TDD: Test-Driven Development
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { PasswordService } from '../../../src/services/password.service';
|
||||
|
||||
describe('PasswordService', () => {
|
||||
describe('hash', () => {
|
||||
it('should hash password with bcrypt', async () => {
|
||||
// @ralph 这个测试是否清晰描述了期望行为?
|
||||
const plainPassword = 'MySecurePassword123!';
|
||||
const hash = await PasswordService.hash(plainPassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash).not.toBe(plainPassword);
|
||||
expect(hash.length).toBe(60); // bcrypt hash length
|
||||
});
|
||||
|
||||
it('should generate different hashes for same password (salt)', async () => {
|
||||
// @ralph 这是否验证了salt的正确性?
|
||||
const password = 'test123';
|
||||
const hash1 = await PasswordService.hash(password);
|
||||
const hash2 = await PasswordService.hash(password);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should handle empty string', async () => {
|
||||
// @ralph 边界条件是否考虑充分?
|
||||
const hash = await PasswordService.hash('');
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
|
||||
it('should handle special characters', async () => {
|
||||
// @ralph 特殊字符是否正确处理?
|
||||
const password = '!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`';
|
||||
const hash = await PasswordService.hash(password);
|
||||
expect(hash).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle very long passwords', async () => {
|
||||
// @ralph 是否考虑了长度限制?
|
||||
const password = 'a'.repeat(1000);
|
||||
const hash = await PasswordService.hash(password);
|
||||
expect(hash).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
it('should verify correct password', async () => {
|
||||
// @ralph 基本功能是否正确?
|
||||
const password = 'test123';
|
||||
const hash = await PasswordService.hash(password);
|
||||
const isValid = await PasswordService.verify(password, hash);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject wrong password', async () => {
|
||||
// @ralph 错误密码是否被正确拒绝?
|
||||
const hash = await PasswordService.hash('test123');
|
||||
const isValid = await PasswordService.verify('wrong', hash);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case sensitive', async () => {
|
||||
// @ralph 大小写敏感性是否正确?
|
||||
const hash = await PasswordService.hash('Password123');
|
||||
const isValid = await PasswordService.verify('password123', hash);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid hash format', async () => {
|
||||
// @ralph 错误处理是否完善?
|
||||
await expect(
|
||||
PasswordService.verify('test', 'invalid-hash')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty hash', async () => {
|
||||
// @ralph 空值处理是否正确?
|
||||
await expect(
|
||||
PasswordService.verify('test', '')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle unicode characters', async () => {
|
||||
// @ralph Unicode是否正确处理?
|
||||
const password = '密码123🔐';
|
||||
const hash = await PasswordService.hash(password);
|
||||
const isValid = await PasswordService.verify(password, hash);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('strength validation', () => {
|
||||
it('should validate strong password', () => {
|
||||
// @ralph 强度规则是否合理?
|
||||
const strong = PasswordService.checkStrength('Str0ng!Pass');
|
||||
expect(strong.isStrong).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject weak password (too short)', () => {
|
||||
// @ralph 弱密码是否被正确识别?
|
||||
const weak = PasswordService.checkStrength('12345');
|
||||
expect(weak.isStrong).toBe(false);
|
||||
expect(weak.reason).toContain('长度');
|
||||
});
|
||||
|
||||
it('should reject weak password (no numbers)', () => {
|
||||
// @ralph 规则是否全面?
|
||||
const weak = PasswordService.checkStrength('abcdefgh');
|
||||
expect(weak.isStrong).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Todo Service Unit Tests
|
||||
* TDD: Test-Driven Development
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import { TodoService } from '../../../src/services/todo.service';
|
||||
import { prisma } from '../../../src/lib/prisma';
|
||||
|
||||
describe('TodoService', () => {
|
||||
// @ralph 我要测试什么?
|
||||
// - 创建待办事项
|
||||
// - 更新待办状态 (pending -> completed -> confirmed)
|
||||
// - 软删除待办
|
||||
// - 按状态筛选
|
||||
// - 优先级排序
|
||||
// - 到期日期处理
|
||||
|
||||
let userId: string;
|
||||
let categoryId: string;
|
||||
let documentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user, category and document
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: `testuser_${Date.now()}`,
|
||||
email: `test_${Date.now()}@example.com`,
|
||||
password_hash: '$2a$10$test',
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
name: 'Work',
|
||||
type: 'todo',
|
||||
},
|
||||
});
|
||||
categoryId = category.id;
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
content: 'Related document',
|
||||
},
|
||||
});
|
||||
documentId = document.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await prisma.todo.deleteMany({ where: { user_id: userId } });
|
||||
await prisma.document.deleteMany({ where: { user_id: userId } });
|
||||
await prisma.category.deleteMany({ where: { user_id: userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new todo with required fields', async () => {
|
||||
// @ralph 正常路径是否覆盖?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Test todo',
|
||||
});
|
||||
|
||||
expect(todo).toBeDefined();
|
||||
expect(todo.id).toBeDefined();
|
||||
expect(todo.title).toBe('Test todo');
|
||||
expect(todo.status).toBe('pending');
|
||||
expect(todo.priority).toBe('medium');
|
||||
expect(todo.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
it('should create todo with all fields', async () => {
|
||||
// @ralph 完整创建是否支持?
|
||||
const dueDate = new Date('2025-12-31');
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Complete task',
|
||||
description: 'Task description',
|
||||
priority: 'high',
|
||||
due_date: dueDate,
|
||||
category_id: categoryId,
|
||||
document_id: documentId,
|
||||
});
|
||||
|
||||
expect(todo.title).toBe('Complete task');
|
||||
expect(todo.description).toBe('Task description');
|
||||
expect(todo.priority).toBe('high');
|
||||
expect(new Date(todo.due_date!)).toEqual(dueDate);
|
||||
expect(todo.category_id).toBe(categoryId);
|
||||
expect(todo.document_id).toBe(documentId);
|
||||
});
|
||||
|
||||
it('should reject empty title', async () => {
|
||||
// @ralph 验证是否正确?
|
||||
await expect(
|
||||
TodoService.create({
|
||||
user_id: userId,
|
||||
title: '',
|
||||
})
|
||||
).rejects.toThrow('title');
|
||||
});
|
||||
|
||||
it('should reject invalid priority', async () => {
|
||||
// @ralph 枚举验证是否正确?
|
||||
await expect(
|
||||
TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Test',
|
||||
priority: 'invalid' as any,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid status', async () => {
|
||||
// @ralph 状态验证是否正确?
|
||||
await expect(
|
||||
TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Test',
|
||||
status: 'invalid' as any,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find todo by id', async () => {
|
||||
// @ralph 查找功能是否正常?
|
||||
const created = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Find me',
|
||||
});
|
||||
|
||||
const found = await TodoService.findById(created.id, userId);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.title).toBe('Find me');
|
||||
});
|
||||
|
||||
it('should return null for non-existent todo', async () => {
|
||||
// @ralph 未找到时是否返回null?
|
||||
const found = await TodoService.findById('00000000-0000-0000-0000-000000000000', userId);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should not return todo from different user', async () => {
|
||||
// @ralph 数据隔离是否正确?
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'otheruser',
|
||||
password_hash: '$2a$10$test',
|
||||
},
|
||||
});
|
||||
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Private todo',
|
||||
});
|
||||
|
||||
const found = await TodoService.findById(todo.id, otherUser.id);
|
||||
expect(found).toBeNull();
|
||||
|
||||
await prisma.user.delete({ where: { id: otherUser.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update todo title', async () => {
|
||||
// @ralph 更新功能是否正常?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Old title',
|
||||
});
|
||||
|
||||
const updated = await TodoService.update(todo.id, userId, {
|
||||
title: 'New title',
|
||||
});
|
||||
|
||||
expect(updated.title).toBe('New title');
|
||||
});
|
||||
|
||||
it('should update todo description', async () => {
|
||||
// @ralph 部分更新是否支持?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Test',
|
||||
});
|
||||
|
||||
const updated = await TodoService.update(todo.id, userId, {
|
||||
description: 'New description',
|
||||
});
|
||||
|
||||
expect(updated.description).toBe('New description');
|
||||
});
|
||||
|
||||
it('should update status and set completed_at', async () => {
|
||||
// @ralph 状态转换是否正确?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Task',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const updated = await TodoService.update(todo.id, userId, {
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(updated.status).toBe('completed');
|
||||
expect(updated.completed_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set confirmed_at when status changes to confirmed', async () => {
|
||||
// @ralph 三态流程是否正确?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Task',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const updated = await TodoService.update(todo.id, userId, {
|
||||
status: 'confirmed',
|
||||
});
|
||||
|
||||
expect(updated.status).toBe('confirmed');
|
||||
expect(updated.confirmed_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('should clear completed_at when reverting to pending', async () => {
|
||||
// @ralph 状态回退是否正确?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Task',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const updated = await TodoService.update(todo.id, userId, {
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(updated.status).toBe('pending');
|
||||
expect(updated.completed_at).toBeNull();
|
||||
});
|
||||
|
||||
it('should update priority', async () => {
|
||||
// @ralph 优先级更新是否正确?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Task',
|
||||
});
|
||||
|
||||
const updated = await TodoService.update(todo.id, userId, {
|
||||
priority: 'urgent',
|
||||
});
|
||||
|
||||
expect(updated.priority).toBe('urgent');
|
||||
});
|
||||
|
||||
it('should update due_date', async () => {
|
||||
// @ralph 到期日期更新是否正确?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Task',
|
||||
});
|
||||
|
||||
const newDate = new Date('2025-12-31');
|
||||
const updated = await TodoService.update(todo.id, userId, {
|
||||
due_date: newDate,
|
||||
});
|
||||
|
||||
expect(new Date(updated.due_date!)).toEqual(newDate);
|
||||
});
|
||||
|
||||
it('should reject update for non-existent todo', async () => {
|
||||
// @ralph 错误处理是否正确?
|
||||
await expect(
|
||||
TodoService.update('00000000-0000-0000-0000-000000000000', userId, {
|
||||
title: 'Updated',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete todo', async () => {
|
||||
// @ralph 删除功能是否正常?
|
||||
const todo = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Delete me',
|
||||
});
|
||||
|
||||
await TodoService.delete(todo.id, userId);
|
||||
|
||||
const found = await TodoService.findById(todo.id, userId);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject delete for non-existent todo', async () => {
|
||||
// @ralph 错误处理是否正确?
|
||||
await expect(
|
||||
TodoService.delete('00000000-0000-0000-0000-000000000000', userId)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUser', () => {
|
||||
beforeEach(async () => {
|
||||
// Create multiple todos
|
||||
await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Pending task 1',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
});
|
||||
await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Completed task',
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
});
|
||||
await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Confirmed task',
|
||||
status: 'confirmed',
|
||||
priority: 'low',
|
||||
});
|
||||
await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Pending task 2',
|
||||
status: 'pending',
|
||||
priority: 'urgent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return all user todos', async () => {
|
||||
// @ralph 列表查询是否正确?
|
||||
const todos = await TodoService.findByUser(userId);
|
||||
|
||||
expect(todos).toHaveLength(4);
|
||||
expect(todos.every(t => t.user_id === userId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
// @ralph 状态筛选是否正确?
|
||||
const pending = await TodoService.findByUser(userId, { status: 'pending' });
|
||||
expect(pending).toHaveLength(2);
|
||||
expect(pending.every(t => t.status === 'pending')).toBe(true);
|
||||
|
||||
const completed = await TodoService.findByUser(userId, { status: 'completed' });
|
||||
expect(completed).toHaveLength(1);
|
||||
|
||||
const confirmed = await TodoService.findByUser(userId, { status: 'confirmed' });
|
||||
expect(confirmed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should filter by priority', async () => {
|
||||
// @ralph 优先级筛选是否正确?
|
||||
const high = await TodoService.findByUser(userId, { priority: 'high' });
|
||||
expect(high).toHaveLength(1);
|
||||
expect(high[0].priority).toBe('high');
|
||||
|
||||
const urgent = await TodoService.findByUser(userId, { priority: 'urgent' });
|
||||
expect(urgent).toHaveLength(1);
|
||||
expect(urgent[0].priority).toBe('urgent');
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
// @ralph 分类筛选是否正确?
|
||||
const todoWithCategory = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Categorized task',
|
||||
category_id: categoryId,
|
||||
});
|
||||
|
||||
const categorized = await TodoService.findByUser(userId, { category_id: categoryId });
|
||||
expect(categorized.length).toBeGreaterThanOrEqual(1);
|
||||
expect(categorized.some(t => t.id === todoWithCategory.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by document', async () => {
|
||||
// @ralph 文档筛选是否正确?
|
||||
const todoWithDocument = await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Document task',
|
||||
document_id: documentId,
|
||||
});
|
||||
|
||||
const withDocument = await TodoService.findByUser(userId, { document_id: documentId });
|
||||
expect(withDocument.length).toBeGreaterThanOrEqual(1);
|
||||
expect(withDocument.some(t => t.id === todoWithDocument.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
// @ralph 分页是否支持?
|
||||
const page1 = await TodoService.findByUser(userId, { page: 1, limit: 2 });
|
||||
expect(page1).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should sort by priority by default', async () => {
|
||||
// @ralph 排序是否正确?
|
||||
const todos = await TodoService.findByUser(userId, { limit: 10 });
|
||||
|
||||
// Check that urgent comes before high, and high before medium
|
||||
const priorities = todos.map(t => {
|
||||
const order = { urgent: 0, high: 1, medium: 2, low: 3 };
|
||||
return order[t.priority as keyof typeof order];
|
||||
});
|
||||
|
||||
for (let i = 1; i < priorities.length; i++) {
|
||||
expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return overdue todos', async () => {
|
||||
// @ralph 过期筛选是否正确?
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 10);
|
||||
|
||||
await TodoService.create({
|
||||
user_id: userId,
|
||||
title: 'Overdue task',
|
||||
due_date: pastDate,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const overdue = await TodoService.findByUser(userId, { overdue: true });
|
||||
expect(overdue.length).toBeGreaterThanOrEqual(1);
|
||||
expect(overdue.every(t => t.due_date && new Date(t.due_date) < new Date())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingTodos', () => {
|
||||
it('should return only pending todos', async () => {
|
||||
// @ralph 待办列表是否正确?
|
||||
await TodoService.create({ user_id: userId, title: 'Pending 1', status: 'pending' });
|
||||
await TodoService.create({ user_id: userId, title: 'Pending 2', status: 'pending' });
|
||||
await TodoService.create({ user_id: userId, title: 'Completed', status: 'completed' });
|
||||
await TodoService.create({ user_id: userId, title: 'Confirmed', status: 'confirmed' });
|
||||
|
||||
const pending = await TodoService.getPendingTodos(userId);
|
||||
expect(pending).toHaveLength(2);
|
||||
expect(pending.every(t => t.status === 'pending')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompletedTodos', () => {
|
||||
it('should return only completed todos', async () => {
|
||||
// @ralph 已完成列表是否正确?
|
||||
await TodoService.create({ user_id: userId, title: 'Pending', status: 'pending' });
|
||||
await TodoService.create({ user_id: userId, title: 'Completed 1', status: 'completed' });
|
||||
await TodoService.create({ user_id: userId, title: 'Completed 2', status: 'completed' });
|
||||
await TodoService.create({ user_id: userId, title: 'Confirmed', status: 'confirmed' });
|
||||
|
||||
const completed = await TodoService.getCompletedTodos(userId);
|
||||
expect(completed).toHaveLength(2);
|
||||
expect(completed.every(t => t.status === 'completed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfirmedTodos', () => {
|
||||
it('should return only confirmed todos', async () => {
|
||||
// @ralph 已确认列表是否正确?
|
||||
await TodoService.create({ user_id: userId, title: 'Pending', status: 'pending' });
|
||||
await TodoService.create({ user_id: userId, title: 'Completed', status: 'completed' });
|
||||
await TodoService.create({ user_id: userId, title: 'Confirmed 1', status: 'confirmed' });
|
||||
await TodoService.create({ user_id: userId, title: 'Confirmed 2', status: 'confirmed' });
|
||||
|
||||
const confirmed = await TodoService.getConfirmedTodos(userId);
|
||||
expect(confirmed).toHaveLength(2);
|
||||
expect(confirmed.every(t => t.status === 'confirmed')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node", "jest"],
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user