Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Test

on:
push:
branches: [main, dev]
pull_request:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Prepare configuration
run: cp config.example.toml config.toml

- name: Run tests
run: pytest -v
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
testpaths = tests
pythonpath = .
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.5
pytest-asyncio==0.25.3
httpx==0.28.1
95 changes: 95 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Shared pytest fixtures for API tests."""

import shutil
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest
from httpx import ASGITransport, AsyncClient

WORKSPACE = Path(__file__).resolve().parents[1]
CONFIG_PATH = WORKSPACE / "config.toml"

if not CONFIG_PATH.exists():
shutil.copy(WORKSPACE / "config.example.toml", CONFIG_PATH)


@pytest.fixture
def mock_captcha_provider():
"""Minimal captcha provider for template rendering tests."""
from src.captcha.base import CaptchaVerificationResult

provider = MagicMock()
provider.provider_name = "hcaptcha"
provider.get_frontend_config.return_value = {
"provider": "hcaptcha",
"site_key": "test-site-key",
}
provider.verify = AsyncMock(
return_value=CaptchaVerificationResult(success=True)
)
return provider


@pytest.fixture
async def client(monkeypatch, mock_captcha_provider):
"""HTTP client against the FastAPI app without a real database."""
monkeypatch.setattr("src.api.main.init_database", AsyncMock())
monkeypatch.setattr("src.api.main.close_database", AsyncMock())
monkeypatch.setattr(
"src.captcha.factory._captcha_provider",
mock_captcha_provider,
)

from src.api.main import app

transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac


def make_verification_session(
*,
token: str = "test-token",
user_id: int = 123456,
chat_id: int = -100123456,
captcha_completed: bool = False,
expired: bool = False,
):
"""Build a VerificationSession instance for route tests."""
from src.database.models import VerificationSession

expires_at = datetime.utcnow() - timedelta(hours=1)
if not expired:
expires_at = datetime.utcnow() + timedelta(hours=1)

return VerificationSession(
token=token,
user_id=user_id,
chat_id=chat_id,
captcha_completed=captcha_completed,
created_time=datetime.utcnow(),
expires_at=expires_at,
)


def make_join_request(
*,
token: str = "test-token",
user_id: int = 123456,
chat_id: int = -100123456,
request_type: str = "telegram",
status: str = "pending",
):
"""Build a JoinRequest instance for route tests."""
from src.database.models import JoinRequest

return JoinRequest(
user_id=user_id,
chat_id=chat_id,
first_name="Test",
verification_token=token,
request_type=request_type,
status=status,
)
12 changes: 12 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Tests for health check endpoints."""

import pytest


@pytest.mark.asyncio
async def test_health_check(client):
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["service"] == "TGuard API"
100 changes: 100 additions & 0 deletions tests/test_static_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for HTML template routes (Mini Web App pages)."""

from unittest.mock import AsyncMock

import pytest

from tests.conftest import make_verification_session


@pytest.mark.asyncio
async def test_index_page_returns_html(client):
response = await client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "TGuard" in response.text


@pytest.mark.asyncio
async def test_success_page_returns_html(client):
response = await client.get("/success")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]


@pytest.mark.asyncio
async def test_verify_page_with_valid_session(client, monkeypatch):
session = make_verification_session()
monkeypatch.setattr(
"src.api.routes.static_files.get_verification_session",
AsyncMock(return_value=session),
)

response = await client.get("/verify", params={"token": "test-token"})

assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "test-token" in response.text
assert "123456" in response.text


@pytest.mark.asyncio
async def test_verify_page_invalid_token_returns_404(client, monkeypatch):
monkeypatch.setattr(
"src.api.routes.static_files.get_verification_session",
AsyncMock(return_value=None),
)

response = await client.get("/verify", params={"token": "invalid"})

assert response.status_code == 404


@pytest.mark.asyncio
async def test_verify_page_expired_session(client, monkeypatch):
session = make_verification_session(expired=True)
monkeypatch.setattr(
"src.api.routes.static_files.get_verification_session",
AsyncMock(return_value=session),
)

response = await client.get("/verify", params={"token": "test-token"})

assert response.status_code == 200
assert "验证链接已过期" in response.text


@pytest.mark.asyncio
async def test_verify_page_completed_session(client, monkeypatch):
session = make_verification_session(captcha_completed=True)
monkeypatch.setattr(
"src.api.routes.static_files.get_verification_session",
AsyncMock(return_value=session),
)

response = await client.get("/verify", params={"token": "test-token"})

assert response.status_code == 200
assert "您已完成验证" in response.text


@pytest.mark.asyncio
async def test_template_response_signature():
"""Guard against Starlette TemplateResponse API regressions."""
from starlette.requests import Request

from fastapi.templating import Jinja2Templates

templates = Jinja2Templates(directory="templates")
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
}
request = Request(scope)

response = templates.TemplateResponse(request, "index.html")

assert response.status_code == 200
assert response.media_type.startswith("text/html")
Loading
Loading