Skip to content

Commit d4bc3a6

Browse files
MKY508claude
andcommitted
feat: 添加 CI/CD 和单元测试
- 添加 GitHub Actions CI 工作流 (.github/workflows/ci.yml) - 后端: pytest 测试框架 + 13 个测试用例 (auth, models, health) - 前端: vitest 测试框架 + 9 个测试用例 (utils, stores) - 添加 ruff 代码检查配置 - 更新架构文档迁移计划 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7091d15 commit d4bc3a6

File tree

15 files changed

+3480
-380
lines changed

15 files changed

+3480
-380
lines changed

.github/workflows/ci.yml

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, develop]
6+
pull_request:
7+
branches: [main, develop]
8+
9+
jobs:
10+
# 后端测试
11+
backend:
12+
name: Backend (Python)
13+
runs-on: ubuntu-latest
14+
defaults:
15+
run:
16+
working-directory: apps/api
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Set up Python
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.11"
25+
cache: "pip"
26+
cache-dependency-path: apps/api/pyproject.toml
27+
28+
- name: Install dependencies
29+
run: |
30+
python -m pip install --upgrade pip
31+
pip install -e ".[dev]"
32+
33+
- name: Lint with Ruff
34+
run: |
35+
ruff check .
36+
ruff format --check .
37+
38+
- name: Type check with mypy
39+
run: mypy app --ignore-missing-imports
40+
continue-on-error: true
41+
42+
- name: Run tests
43+
run: pytest tests/ -v --cov=app --cov-report=xml
44+
env:
45+
DATABASE_URL: sqlite+aiosqlite:///./test.db
46+
JWT_SECRET_KEY: test-secret-key-for-ci
47+
ENCRYPTION_KEY: dGVzdC1lbmNyeXB0aW9uLWtleS0zMmJ5dGVz
48+
49+
- name: Upload coverage
50+
uses: codecov/codecov-action@v4
51+
with:
52+
file: apps/api/coverage.xml
53+
flags: backend
54+
continue-on-error: true
55+
56+
# 前端测试
57+
frontend:
58+
name: Frontend (Next.js)
59+
runs-on: ubuntu-latest
60+
defaults:
61+
run:
62+
working-directory: apps/web
63+
64+
steps:
65+
- uses: actions/checkout@v4
66+
67+
- name: Set up Node.js
68+
uses: actions/setup-node@v4
69+
with:
70+
node-version: "20"
71+
cache: "npm"
72+
cache-dependency-path: apps/web/package-lock.json
73+
74+
- name: Install dependencies
75+
run: npm ci
76+
77+
- name: Lint
78+
run: npm run lint
79+
80+
- name: Type check
81+
run: npm run type-check
82+
83+
- name: Run tests
84+
run: npm run test --if-present
85+
86+
- name: Build
87+
run: npm run build
88+
env:
89+
NEXT_PUBLIC_API_URL: http://localhost:8000
90+
91+
# 所有检查通过
92+
ci-success:
93+
name: CI Success
94+
needs: [backend, frontend]
95+
runs-on: ubuntu-latest
96+
steps:
97+
- name: All checks passed
98+
run: echo "✅ All CI checks passed!"

apps/api/pyproject.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ dependencies = [
4343
"structlog>=24.1.0",
4444
]
4545

46+
[project.optional-dependencies]
47+
dev = [
48+
"pytest>=8.0.0",
49+
"pytest-asyncio>=0.23.0",
50+
"pytest-cov>=4.1.0",
51+
"httpx>=0.27.0",
52+
"aiosqlite>=0.20.0",
53+
"ruff>=0.4.0",
54+
"mypy>=1.10.0",
55+
]
56+
4657
[project.scripts]
4758
querygpt-api = "app.main:run"
4859

@@ -52,3 +63,19 @@ packages = ["app"]
5263
[build-system]
5364
requires = ["hatchling"]
5465
build-backend = "hatchling.build"
66+
67+
[tool.ruff]
68+
target-version = "py311"
69+
line-length = 100
70+
71+
[tool.ruff.lint]
72+
select = ["E", "F", "I", "N", "W", "UP"]
73+
ignore = ["E501"]
74+
75+
[tool.ruff.lint.isort]
76+
known-first-party = ["app"]
77+
78+
[tool.pytest.ini_options]
79+
asyncio_mode = "auto"
80+
testpaths = ["tests"]
81+
addopts = "-v --tb=short"

apps/api/pytest.ini

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[pytest]
2+
asyncio_mode = auto
3+
testpaths = tests
4+
python_files = test_*.py
5+
python_functions = test_*
6+
addopts = -v --tb=short
7+
filterwarnings =
8+
ignore::DeprecationWarning
9+
ignore::UserWarning

apps/api/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""QueryGPT API Tests"""

apps/api/tests/conftest.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Pytest fixtures for API tests"""
2+
import asyncio
3+
from collections.abc import AsyncGenerator, Generator
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
from httpx import ASGITransport, AsyncClient
8+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
9+
from sqlalchemy.orm import sessionmaker
10+
from sqlalchemy.pool import StaticPool
11+
12+
from app.db.tables import Base
13+
from app.main import app
14+
from app.db import get_db
15+
from app.core.security import create_access_token
16+
17+
# 测试数据库 URL
18+
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
19+
20+
21+
@pytest.fixture(scope="session")
22+
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
23+
"""Create event loop for async tests"""
24+
loop = asyncio.get_event_loop_policy().new_event_loop()
25+
yield loop
26+
loop.close()
27+
28+
29+
@pytest.fixture(scope="function")
30+
async def async_engine():
31+
"""Create async engine for each test"""
32+
engine = create_async_engine(
33+
TEST_DATABASE_URL,
34+
connect_args={"check_same_thread": False},
35+
poolclass=StaticPool,
36+
)
37+
async with engine.begin() as conn:
38+
await conn.run_sync(Base.metadata.create_all)
39+
yield engine
40+
async with engine.begin() as conn:
41+
await conn.run_sync(Base.metadata.drop_all)
42+
await engine.dispose()
43+
44+
45+
@pytest.fixture(scope="function")
46+
async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
47+
"""Create database session for each test"""
48+
async_session = sessionmaker(
49+
async_engine, class_=AsyncSession, expire_on_commit=False
50+
)
51+
async with async_session() as session:
52+
yield session
53+
54+
55+
@pytest.fixture(scope="function")
56+
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
57+
"""Create test client with overridden database"""
58+
async def override_get_db():
59+
yield db_session
60+
61+
app.dependency_overrides[get_db] = override_get_db
62+
63+
async with AsyncClient(
64+
transport=ASGITransport(app=app),
65+
base_url="http://test"
66+
) as ac:
67+
yield ac
68+
69+
app.dependency_overrides.clear()
70+
71+
72+
@pytest.fixture
73+
def sync_client() -> Generator[TestClient, None, None]:
74+
"""Sync test client for simple tests"""
75+
with TestClient(app) as c:
76+
yield c
77+
78+
79+
@pytest.fixture
80+
def auth_headers() -> dict[str, str]:
81+
"""Generate auth headers with test token"""
82+
token = create_access_token(data={"sub": "test@example.com", "user_id": "test-user-id"})
83+
return {"Authorization": f"Bearer {token}"}

apps/api/tests/test_auth.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Authentication API tests"""
2+
import pytest
3+
from httpx import AsyncClient
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_register_user(client: AsyncClient):
8+
"""Test user registration"""
9+
response = await client.post(
10+
"/api/v1/auth/register",
11+
json={
12+
"email": "test@example.com",
13+
"password": "testpassword123",
14+
"display_name": "Test User",
15+
},
16+
)
17+
assert response.status_code == 200
18+
data = response.json()
19+
assert data["code"] == 0
20+
assert data["data"]["email"] == "test@example.com"
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_register_duplicate_email(client: AsyncClient):
25+
"""Test registration with duplicate email fails"""
26+
# First registration
27+
await client.post(
28+
"/api/v1/auth/register",
29+
json={
30+
"email": "duplicate@example.com",
31+
"password": "testpassword123",
32+
},
33+
)
34+
# Second registration with same email
35+
response = await client.post(
36+
"/api/v1/auth/register",
37+
json={
38+
"email": "duplicate@example.com",
39+
"password": "testpassword123",
40+
},
41+
)
42+
assert response.status_code == 400
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_login_success(client: AsyncClient):
47+
"""Test successful login"""
48+
# Register first
49+
await client.post(
50+
"/api/v1/auth/register",
51+
json={
52+
"email": "login@example.com",
53+
"password": "testpassword123",
54+
},
55+
)
56+
# Login
57+
response = await client.post(
58+
"/api/v1/auth/login",
59+
data={
60+
"username": "login@example.com",
61+
"password": "testpassword123",
62+
},
63+
)
64+
assert response.status_code == 200
65+
data = response.json()
66+
assert "access_token" in data
67+
assert data["token_type"] == "bearer"
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_login_wrong_password(client: AsyncClient):
72+
"""Test login with wrong password fails"""
73+
# Register first
74+
await client.post(
75+
"/api/v1/auth/register",
76+
json={
77+
"email": "wrongpw@example.com",
78+
"password": "correctpassword",
79+
},
80+
)
81+
# Login with wrong password
82+
response = await client.post(
83+
"/api/v1/auth/login",
84+
data={
85+
"username": "wrongpw@example.com",
86+
"password": "wrongpassword",
87+
},
88+
)
89+
assert response.status_code == 401
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_get_current_user(client: AsyncClient):
94+
"""Test getting current user info"""
95+
# Register and login
96+
await client.post(
97+
"/api/v1/auth/register",
98+
json={
99+
"email": "me@example.com",
100+
"password": "testpassword123",
101+
"display_name": "Me User",
102+
},
103+
)
104+
login_response = await client.post(
105+
"/api/v1/auth/login",
106+
data={
107+
"username": "me@example.com",
108+
"password": "testpassword123",
109+
},
110+
)
111+
token = login_response.json()["access_token"]
112+
113+
# Get current user
114+
response = await client.get(
115+
"/api/v1/auth/me",
116+
headers={"Authorization": f"Bearer {token}"},
117+
)
118+
assert response.status_code == 200
119+
data = response.json()
120+
assert data["data"]["email"] == "me@example.com"
121+
assert data["data"]["display_name"] == "Me User"
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_unauthorized_access(client: AsyncClient):
126+
"""Test accessing protected endpoint without token"""
127+
response = await client.get("/api/v1/auth/me")
128+
assert response.status_code == 401

apps/api/tests/test_health.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Health check endpoint tests"""
2+
import pytest
3+
from httpx import AsyncClient
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_health_check(client: AsyncClient):
8+
"""Test health check endpoint returns OK"""
9+
response = await client.get("/api/health")
10+
assert response.status_code == 200
11+
data = response.json()
12+
assert data["status"] == "ok"
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_root_redirect(client: AsyncClient):
17+
"""Test root redirects to docs"""
18+
response = await client.get("/", follow_redirects=False)
19+
assert response.status_code in [200, 307, 302]

0 commit comments

Comments
 (0)