refactor: align backend layout and supabase infra

Consolidate backend modules/tests under the backend package while syncing Supabase compose/env config and related plans.
This commit is contained in:
qzl
2026-02-05 15:13:06 +08:00
parent 3cfcb11240
commit ad06fe7de4
111 changed files with 5540 additions and 1362 deletions
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import json
import socket
import threading
import time
from playwright.sync_api import sync_playwright
import uvicorn
from app import app
from v1.auth.dependencies import get_auth_service
from v1.auth.models import (
AuthTokenResponse,
AuthUser,
LoginRequest,
RefreshRequest,
SignupRequest,
)
from v1.auth.service import AuthService
class FakeE2EAuthService(AuthService):
def __init__(self) -> None:
self._user = AuthUser(id="user-1", email="user@example.com")
async def signup(self, request: SignupRequest) -> AuthTokenResponse:
return AuthTokenResponse(
access_token="access-1",
refresh_token="refresh-1",
expires_in=3600,
token_type="bearer",
user=self._user,
)
async def login(self, request: LoginRequest) -> AuthTokenResponse:
return AuthTokenResponse(
access_token="access-2",
refresh_token="refresh-2",
expires_in=3600,
token_type="bearer",
user=self._user,
)
async def refresh(self, request: RefreshRequest) -> AuthTokenResponse:
return AuthTokenResponse(
access_token="access-3",
refresh_token="refresh-3",
expires_in=3600,
token_type="bearer",
user=self._user,
)
async def logout(self, refresh_token: str | None) -> None:
return None
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex((host, port)) == 0:
return
time.sleep(0.05)
raise RuntimeError("Server did not start in time")
def _start_server(host: str, port: int):
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
_wait_for_port(host, port)
return server, thread
def test_auth_flow_e2e() -> None:
app.dependency_overrides[get_auth_service] = lambda: FakeE2EAuthService()
host = "127.0.0.1"
port = _find_free_port()
server, thread = _start_server(host, port)
try:
with sync_playwright() as playwright:
request_context = playwright.request.new_context(
base_url=f"http://{host}:{port}"
)
try:
signup = request_context.post(
"/api/v1/auth/signup",
data=json.dumps(
{"email": "user@example.com", "password": "secret123"}
),
headers={"Content-Type": "application/json"},
)
assert signup.status == 200
assert signup.json()["access_token"] == "access-1"
login = request_context.post(
"/api/v1/auth/login",
data=json.dumps(
{"email": "user@example.com", "password": "secret123"}
),
headers={"Content-Type": "application/json"},
)
assert login.status == 200
assert login.json()["access_token"] == "access-2"
refresh = request_context.post(
"/api/v1/auth/refresh",
data=json.dumps({"refresh_token": "refresh-2"}),
headers={"Content-Type": "application/json"},
)
assert refresh.status == 200
assert refresh.json()["access_token"] == "access-3"
logout = request_context.post(
"/api/v1/auth/logout",
data=json.dumps({"refresh_token": "refresh-3"}),
headers={"Content-Type": "application/json"},
)
assert logout.status == 204
finally:
request_context.dispose()
finally:
app.dependency_overrides = {}
server.should_exit = True
thread.join(timeout=5)
@@ -0,0 +1,79 @@
from __future__ import annotations
import socket
import threading
import time
from playwright.sync_api import sync_playwright
import uvicorn
from app import app
from v1.infra.dependencies import get_qdrant_service, get_redis_service
class _FakeService:
def __init__(self) -> None:
self._initialized = True
@property
def is_initialized(self) -> bool:
return self._initialized
async def initialize(self) -> bool:
return True
async def health_check(self) -> dict[str, object]:
return {"status": "healthy", "details": {}}
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex((host, port)) == 0:
return
time.sleep(0.05)
raise RuntimeError("Server did not start in time")
def _start_server(host: str, port: int):
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
_wait_for_port(host, port)
return server, thread
def test_infra_health_e2e() -> None:
app.dependency_overrides[get_redis_service] = lambda: _FakeService()
app.dependency_overrides[get_qdrant_service] = lambda: _FakeService()
host = "127.0.0.1"
port = _find_free_port()
server, thread = _start_server(host, port)
try:
with sync_playwright() as playwright:
request_context = playwright.request.new_context(
base_url=f"http://{host}:{port}"
)
try:
response = request_context.get("/api/v1/infra/health")
assert response.status == 200
body = response.json()
assert body["status"] == "healthy"
assert "redis" in body["services"]
assert "qdrant" in body["services"]
finally:
request_context.dispose()
finally:
server.should_exit = True
thread.join(timeout=5)
app.dependency_overrides = {}
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
import socket
import threading
import time
from pathlib import Path
from fastapi import FastAPI
from playwright.sync_api import sync_playwright
import uvicorn
from core.config.settings import Settings
from core.logging.config import configure_logging
from core.logging.middleware import (
RequestContextMiddleware,
register_exception_handlers,
)
def _read_json_lines(path: Path) -> list[dict[str, object]]:
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex((host, port)) == 0:
return
time.sleep(0.05)
raise RuntimeError("Server did not start in time")
def _start_server(app: FastAPI, host: str, port: int):
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
_wait_for_port(host, port)
return server, thread
def test_e2e_error_logging(tmp_path: Path) -> None:
settings = Settings()
runtime = settings.runtime.model_copy(
update={
"log_dir": str(tmp_path),
"log_error_dir": str(tmp_path / "errors"),
"log_rotation": "size",
"log_rotation_max_bytes": 2048,
}
)
configure_logging(settings.model_copy(update={"runtime": runtime}))
app = FastAPI()
app.add_middleware(RequestContextMiddleware) # type: ignore[arg-type]
register_exception_handlers(app)
@app.get("/boom")
async def boom() -> dict[str, str]:
raise RuntimeError("boom")
host = "127.0.0.1"
port = _find_free_port()
server, thread = _start_server(app, host, port)
try:
with sync_playwright() as playwright:
request_context = playwright.request.new_context(
base_url=f"http://{host}:{port}"
)
response = request_context.get(
"/boom",
headers={"X-Request-ID": "e2e-5000"},
)
assert response.status == 500
request_context.dispose()
finally:
server.should_exit = True
thread.join(timeout=5)
error_entries = _read_json_lines(Path(tmp_path) / "errors" / "error.log")
entry = next(
item for item in error_entries if item.get("message") == "Unhandled exception"
)
assert entry["request_id"] == "e2e-5000"
exception = str(entry["exception"])
assert "Traceback" in exception
@@ -0,0 +1,57 @@
from __future__ import annotations
import socket
import threading
import time
from playwright.sync_api import sync_playwright
import uvicorn
from app import app
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex((host, port)) == 0:
return
time.sleep(0.05)
raise RuntimeError("Server did not start in time")
def _start_server(host: str, port: int):
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
_wait_for_port(host, port)
return server, thread
def test_mobile_health_e2e() -> None:
host = "127.0.0.1"
port = _find_free_port()
server, thread = _start_server(host, port)
try:
with sync_playwright() as playwright:
request_context = playwright.request.new_context(
base_url=f"http://{host}:{port}"
)
try:
response = request_context.get("/api/v1/health")
assert response.status == 200
body = response.json()
assert body["status"] == "ok"
finally:
request_context.dispose()
finally:
server.should_exit = True
thread.join(timeout=5)
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import json
import socket
import threading
import time
from uuid import UUID
from playwright.sync_api import sync_playwright
import uvicorn
from app import app
from core.auth.models import CurrentUser
from v1.profile.dependencies import get_current_user, get_profile_service
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
class FakeProfileService:
"""Fake service for E2E testing."""
def __init__(self, profile: ProfileResponse) -> None:
self._profile = profile
async def get_me(self) -> ProfileResponse:
return self._profile
async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse:
return ProfileResponse(
id=self._profile.id,
username=self._profile.username,
display_name=(
update.display_name
if update.display_name is not None
else self._profile.display_name
),
avatar_url=(
update.avatar_url
if update.avatar_url is not None
else self._profile.avatar_url
),
bio=update.bio if update.bio is not None else self._profile.bio,
)
async def get_by_username(self, username: str) -> ProfileResponse:
return self._profile
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex((host, port)) == 0:
return
time.sleep(0.05)
raise RuntimeError("Server did not start in time")
def _start_server(host: str, port: int):
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
_wait_for_port(host, port)
return server, thread
def test_profile_flow_e2e() -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
profile = ProfileResponse(
id=str(user_id),
username="demo",
display_name="Demo User",
avatar_url=None,
bio=None,
)
app.dependency_overrides[get_profile_service] = lambda: FakeProfileService(profile) # type: ignore[return-value]
app.dependency_overrides[get_current_user] = lambda: CurrentUser(id=user_id)
host = "127.0.0.1"
port = _find_free_port()
server, thread = _start_server(host, port)
try:
with sync_playwright() as playwright:
request_context = playwright.request.new_context(
base_url=f"http://{host}:{port}"
)
try:
me = request_context.get("/api/v1/profile/me")
assert me.status == 200
assert me.json()["username"] == "demo"
updated = request_context.patch(
"/api/v1/profile/me",
data=json.dumps({"display_name": "Updated"}),
headers={"Content-Type": "application/json"},
)
assert updated.status == 200
assert updated.json()["display_name"] == "Updated"
public = request_context.get("/api/v1/profile/demo")
assert public.status == 200
assert public.json()["username"] == "demo"
finally:
request_context.dispose()
finally:
app.dependency_overrides = {}
server.should_exit = True
thread.join(timeout=5)