Skip to content

Commit fe55a79

Browse files
authored
Merge pull request #78 from SideCloudGroup/cursor/add-api-tests-dev-8585
test: 添加 API 与核心验证流程测试套件
2 parents 9c26aa0 + 90f761c commit fe55a79

8 files changed

Lines changed: 544 additions & 0 deletions

File tree

.github/workflows/test.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main, dev]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.13"
20+
21+
- name: Install dependencies
22+
run: |
23+
python -m pip install --upgrade pip
24+
pip install -r requirements-dev.txt
25+
26+
- name: Prepare configuration
27+
run: cp config.example.toml config.toml
28+
29+
- name: Run tests
30+
run: pytest -v

pytest.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[pytest]
2+
asyncio_mode = auto
3+
asyncio_default_fixture_loop_scope = function
4+
testpaths = tests
5+
pythonpath = .

requirements-dev.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-r requirements.txt
2+
pytest==8.3.5
3+
pytest-asyncio==0.25.3
4+
httpx==0.28.1

tests/conftest.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Shared pytest fixtures for API tests."""
2+
3+
import shutil
4+
from datetime import datetime, timedelta
5+
from pathlib import Path
6+
from unittest.mock import AsyncMock, MagicMock
7+
8+
import pytest
9+
from httpx import ASGITransport, AsyncClient
10+
11+
WORKSPACE = Path(__file__).resolve().parents[1]
12+
CONFIG_PATH = WORKSPACE / "config.toml"
13+
14+
if not CONFIG_PATH.exists():
15+
shutil.copy(WORKSPACE / "config.example.toml", CONFIG_PATH)
16+
17+
18+
@pytest.fixture
19+
def mock_captcha_provider():
20+
"""Minimal captcha provider for template rendering tests."""
21+
from src.captcha.base import CaptchaVerificationResult
22+
23+
provider = MagicMock()
24+
provider.provider_name = "hcaptcha"
25+
provider.get_frontend_config.return_value = {
26+
"provider": "hcaptcha",
27+
"site_key": "test-site-key",
28+
}
29+
provider.verify = AsyncMock(
30+
return_value=CaptchaVerificationResult(success=True)
31+
)
32+
return provider
33+
34+
35+
@pytest.fixture
36+
async def client(monkeypatch, mock_captcha_provider):
37+
"""HTTP client against the FastAPI app without a real database."""
38+
monkeypatch.setattr("src.api.main.init_database", AsyncMock())
39+
monkeypatch.setattr("src.api.main.close_database", AsyncMock())
40+
monkeypatch.setattr(
41+
"src.captcha.factory._captcha_provider",
42+
mock_captcha_provider,
43+
)
44+
45+
from src.api.main import app
46+
47+
transport = ASGITransport(app=app)
48+
async with AsyncClient(transport=transport, base_url="http://test") as ac:
49+
yield ac
50+
51+
52+
def make_verification_session(
53+
*,
54+
token: str = "test-token",
55+
user_id: int = 123456,
56+
chat_id: int = -100123456,
57+
captcha_completed: bool = False,
58+
expired: bool = False,
59+
):
60+
"""Build a VerificationSession instance for route tests."""
61+
from src.database.models import VerificationSession
62+
63+
expires_at = datetime.utcnow() - timedelta(hours=1)
64+
if not expired:
65+
expires_at = datetime.utcnow() + timedelta(hours=1)
66+
67+
return VerificationSession(
68+
token=token,
69+
user_id=user_id,
70+
chat_id=chat_id,
71+
captcha_completed=captcha_completed,
72+
created_time=datetime.utcnow(),
73+
expires_at=expires_at,
74+
)
75+
76+
77+
def make_join_request(
78+
*,
79+
token: str = "test-token",
80+
user_id: int = 123456,
81+
chat_id: int = -100123456,
82+
request_type: str = "telegram",
83+
status: str = "pending",
84+
):
85+
"""Build a JoinRequest instance for route tests."""
86+
from src.database.models import JoinRequest
87+
88+
return JoinRequest(
89+
user_id=user_id,
90+
chat_id=chat_id,
91+
first_name="Test",
92+
verification_token=token,
93+
request_type=request_type,
94+
status=status,
95+
)

tests/test_health.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Tests for health check endpoints."""
2+
3+
import pytest
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_health_check(client):
8+
response = await client.get("/health")
9+
assert response.status_code == 200
10+
data = response.json()
11+
assert data["status"] == "healthy"
12+
assert data["service"] == "TGuard API"

tests/test_static_pages.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Tests for HTML template routes (Mini Web App pages)."""
2+
3+
from unittest.mock import AsyncMock
4+
5+
import pytest
6+
7+
from tests.conftest import make_verification_session
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_index_page_returns_html(client):
12+
response = await client.get("/")
13+
assert response.status_code == 200
14+
assert "text/html" in response.headers["content-type"]
15+
assert "TGuard" in response.text
16+
17+
18+
@pytest.mark.asyncio
19+
async def test_success_page_returns_html(client):
20+
response = await client.get("/success")
21+
assert response.status_code == 200
22+
assert "text/html" in response.headers["content-type"]
23+
24+
25+
@pytest.mark.asyncio
26+
async def test_verify_page_with_valid_session(client, monkeypatch):
27+
session = make_verification_session()
28+
monkeypatch.setattr(
29+
"src.api.routes.static_files.get_verification_session",
30+
AsyncMock(return_value=session),
31+
)
32+
33+
response = await client.get("/verify", params={"token": "test-token"})
34+
35+
assert response.status_code == 200
36+
assert "text/html" in response.headers["content-type"]
37+
assert "test-token" in response.text
38+
assert "123456" in response.text
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_verify_page_invalid_token_returns_404(client, monkeypatch):
43+
monkeypatch.setattr(
44+
"src.api.routes.static_files.get_verification_session",
45+
AsyncMock(return_value=None),
46+
)
47+
48+
response = await client.get("/verify", params={"token": "invalid"})
49+
50+
assert response.status_code == 404
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_verify_page_expired_session(client, monkeypatch):
55+
session = make_verification_session(expired=True)
56+
monkeypatch.setattr(
57+
"src.api.routes.static_files.get_verification_session",
58+
AsyncMock(return_value=session),
59+
)
60+
61+
response = await client.get("/verify", params={"token": "test-token"})
62+
63+
assert response.status_code == 200
64+
assert "验证链接已过期" in response.text
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_verify_page_completed_session(client, monkeypatch):
69+
session = make_verification_session(captcha_completed=True)
70+
monkeypatch.setattr(
71+
"src.api.routes.static_files.get_verification_session",
72+
AsyncMock(return_value=session),
73+
)
74+
75+
response = await client.get("/verify", params={"token": "test-token"})
76+
77+
assert response.status_code == 200
78+
assert "您已完成验证" in response.text
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_template_response_signature():
83+
"""Guard against Starlette TemplateResponse API regressions."""
84+
from starlette.requests import Request
85+
86+
from fastapi.templating import Jinja2Templates
87+
88+
templates = Jinja2Templates(directory="templates")
89+
scope = {
90+
"type": "http",
91+
"method": "GET",
92+
"path": "/",
93+
"headers": [],
94+
}
95+
request = Request(scope)
96+
97+
response = templates.TemplateResponse(request, "index.html")
98+
99+
assert response.status_code == 200
100+
assert response.media_type.startswith("text/html")

0 commit comments

Comments
 (0)