rebuild(stage-2): establish runtime and persistence foundations

This commit is contained in:
leefer
2026-07-30 01:02:04 +08:00
parent b3ba840d4e
commit d969d2c092
26 changed files with 865 additions and 33 deletions
+22
View File
@@ -0,0 +1,22 @@
import pytest
from tools.database import main
def test_status_uses_configured_data_directory(tmp_path, monkeypatch, capsys) -> None:
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
monkeypatch.delenv("APP_DATABASE_PATH", raising=False)
assert main(["status"]) == 0
assert capsys.readouterr().out.strip() == "available=true schema_version=0"
assert (tmp_path / "xiaobai.db").exists()
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
with pytest.raises(SystemExit, match="confirm-downgrade"):
main(["downgrade", "--target", "0"])
+67 -14
View File
@@ -1,36 +1,89 @@
from fastapi.testclient import TestClient
import asyncio
import httpx
from fastapi import Query
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from backend.http.errors import AppError
def request(application, path: str) -> httpx.Response:
async def run() -> httpx.Response:
transport = httpx.ASGITransport(app=application, raise_app_exceptions=False)
async with application.router.lifespan_context(application):
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
return await client.get(path)
return asyncio.run(run())
def test_health_reports_runtime_environment(tmp_path) -> None:
application = create_application(
Settings(environment="test", debug=False, data_directory=tmp_path)
)
with TestClient(application) as client:
response = client.get("/api/health")
application = create_application(Settings.for_test(tmp_path))
response = request(application, "/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok", "environment": "test"}
assert response.json() == {
"status": "ok",
"environment": "test",
"components": {"process": "ok", "database": "ok"},
}
assert len(response.headers["X-Request-ID"]) == 32
def test_unknown_failure_uses_safe_error_contract(tmp_path) -> None:
application = create_application(
Settings(environment="test", debug=False, data_directory=tmp_path)
)
application = create_application(Settings.for_test(tmp_path))
@application.get("/api/test/failure")
def fail() -> None:
raise RuntimeError("secret provider details")
with TestClient(application, raise_server_exceptions=False) as client:
response = client.get("/api/test/failure")
response = request(application, "/api/test/failure")
payload = response.json()["error"]
assert response.status_code == 500
assert payload["code"] == "internal_error"
assert payload["message"] == "服务暂时不可用,请稍后重试。"
assert "secret provider details" not in response.text
assert len(payload["correlation_id"]) == 32
assert len(payload["request_id"]) == 32
assert response.headers["X-Request-ID"] == payload["request_id"]
def test_application_error_uses_business_message(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
@application.get("/api/test/locked")
def locked() -> None:
raise AppError("membership_required", "该功能仅对会员开放。", 403)
response = request(application, "/api/test/locked")
assert response.status_code == 403
assert response.json()["error"]["code"] == "membership_required"
assert response.json()["error"]["message"] == "该功能仅对会员开放。"
def test_framework_404_uses_the_same_safe_contract(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
response = request(application, "/api/does-not-exist")
assert response.status_code == 404
assert response.json()["error"]["code"] == "not_found"
assert response.json()["error"]["message"] == "请求的内容不存在。"
def test_validation_error_does_not_expose_framework_details(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
@application.get("/api/test/validated")
def validated(value: int = Query(ge=1)) -> dict[str, int]:
return {"value": value}
response = request(application, "/api/test/validated?value=wrong")
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"
assert "integer" not in response.text.lower()
+33
View File
@@ -0,0 +1,33 @@
import json
import logging
from backend.bootstrap.logging import JsonFormatter, redact
def test_nested_sensitive_values_are_redacted() -> None:
payload = {
"account": "leefer",
"api_key": "private-key",
"nested": {"refresh-token": "private-token", "count": 3},
}
assert redact(payload) == {
"account": "leefer",
"api_key": "[REDACTED]",
"nested": {"refresh-token": "[REDACTED]", "count": 3},
}
def test_json_formatter_keeps_context_without_secret_values() -> None:
record = logging.LogRecord(
"test", logging.INFO, __file__, 1, "saved token=plain-secret", (), None
)
record.request_id = "request-1"
record.context = {"token": "hidden", "feature": "health"}
payload = json.loads(JsonFormatter().format(record))
assert payload["timestamp"].endswith("+08:00")
assert payload["request_id"] == "request-1"
assert payload["message"] == "saved token=[REDACTED]"
assert payload["context"] == {"token": "[REDACTED]", "feature": "health"}
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import sqlite3
import pytest
from backend.database import Database, Migration, MigrationError, MigrationRunner
from backend.database.repositories import DatabaseStatusRepository
def create_example(connection: sqlite3.Connection) -> None:
connection.execute("CREATE TABLE example (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
def drop_example(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE example")
def example_migration(signature: str = "example:v1") -> Migration:
return Migration(1, "create_example", signature, create_example, drop_example)
def table_names(database: Database) -> set[str]:
with database.read() as connection:
return {
str(row["name"])
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
def test_connection_enables_wal_foreign_keys_and_busy_timeout(tmp_path) -> None:
database = Database(tmp_path / "app.db")
with database.read() as connection:
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1
assert connection.execute("PRAGMA busy_timeout").fetchone()[0] == 20_000
def test_migration_can_upgrade_idempotently_and_downgrade(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
migration = example_migration()
assert runner.upgrade((migration,)) == (1,)
assert runner.upgrade((migration,)) == ()
assert "example" in table_names(database)
assert DatabaseStatusRepository(database).get().schema_version == 1
assert runner.downgrade((migration,), target_version=0) == (1,)
assert "example" not in table_names(database)
assert DatabaseStatusRepository(database).get().schema_version == 0
def test_failed_migration_is_atomic_and_not_recorded(tmp_path) -> None:
database = Database(tmp_path / "app.db")
def fail(connection: sqlite3.Connection) -> None:
connection.execute("CREATE TABLE should_rollback (id INTEGER)")
raise RuntimeError("stop")
migration = Migration(1, "failure", "failure:v1", fail, lambda connection: None)
with pytest.raises(MigrationError, match="upgrade failed"):
MigrationRunner(database).upgrade((migration,))
assert "should_rollback" not in table_names(database)
assert DatabaseStatusRepository(database).get().schema_version == 0
def test_applied_migration_checksum_cannot_change(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
runner.upgrade((example_migration(),))
with pytest.raises(MigrationError, match="checksum changed"):
runner.upgrade((example_migration("example:v2"),))
with pytest.raises(MigrationError, match="checksum changed"):
runner.downgrade((example_migration("example:v2"),), target_version=0)
def test_unknown_database_migration_is_rejected(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
runner.upgrade((example_migration(),))
with pytest.raises(MigrationError, match="unknown migrations"):
runner.upgrade(())
def test_non_contiguous_database_history_is_rejected(tmp_path) -> None:
database = Database(tmp_path / "app.db")
first = example_migration()
second = Migration(
2,
"second",
"second:v1",
lambda connection: None,
lambda connection: None,
)
runner = MigrationRunner(database)
runner.upgrade((first, second))
with database.transaction() as connection:
connection.execute("DELETE FROM schema_migrations WHERE version = 1")
with pytest.raises(MigrationError, match="not contiguous"):
runner.upgrade((first, second))
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
import pytest
from backend.bootstrap.settings import PROJECT_ROOT, ConfigurationError, Settings
def test_relative_data_directory_is_anchored_to_project(monkeypatch) -> None:
monkeypatch.setenv("APP_DATA_DIR", "var/test-data")
monkeypatch.delenv("APP_DATABASE_PATH", raising=False)
settings = Settings.from_environment()
assert settings.data_directory == (PROJECT_ROOT / "var/test-data").resolve()
assert settings.database_path == settings.data_directory / "xiaobai.db"
assert settings.timezone == "Asia/Shanghai"
@pytest.mark.parametrize(
("name", "value"),
[("APP_PORT", "70000"), ("APP_PORT", "wrong"), ("APP_TIMEZONE", "UTC")],
)
def test_invalid_runtime_configuration_fails_fast(monkeypatch, name: str, value: str) -> None:
monkeypatch.setenv(name, value)
with pytest.raises(ConfigurationError):
Settings.from_environment()
def test_test_settings_do_not_create_log_files(tmp_path: Path) -> None:
settings = Settings.for_test(tmp_path)
assert settings.log_file is None
assert settings.database_path.parent == tmp_path