Files
codex-app-server/cloud/tests/test_db_backend.py
T

350 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""cloud/db.py 接口层单测:Database + 双后端 + 工厂 + server 启动校验。
不删、不改 cloud/tests/test_db.py 的原有测试;本文件只覆盖新增的
接口层。测试里出现的 Postgres 密码一律是 fake/test 占位
("test-placeholder"),不是真实格式的密钥,绝不写真实凭证。
覆盖点(与任务一一对应):
- SQLite 后端原有行为不变:WAL / busy_timeout / Row / 有界重试 /
事务提交回滚,全部经 SQLiteDatabase 实例走一遍。
- Postgres 后端无驱动时明确报错:实例化即抛 RuntimeError 且文案含
psycopg,且不是静默回落(返回值绝不是 SQLiteDatabase)。
- 后端切换不丢接口契约:Database 的抽象方法集合 == 两个后端类
都提供的方法集合,且签名(除 self 外)一致;Postgres 方法体在
骨架阶段抛 NotImplementedError/驱动缺失错误。
- server 启动校验:CLOUD_DB_BACKEND=postgres 时 create_server 在
建表前就报错;默认/sqlite 时仍能正常建 server。
"""
import inspect
import os
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud.db import (
Database,
PostgresDatabase,
PostgresDriverMissingError,
SQLiteDatabase,
create_database,
database_from_env,
)
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.db import (
Database,
PostgresDatabase,
PostgresDriverMissingError,
SQLiteDatabase,
create_database,
database_from_env,
)
from cloud.server import create_server
INTERFACE_METHODS = (
"connect",
"execute_with_retry",
"read_all",
"transaction",
"is_locked_error",
)
TASKS_DDL = (
"CREATE TABLE IF NOT EXISTS tasks("
"id TEXT PRIMARY KEY, project_id TEXT, prompt TEXT, status TEXT)"
)
class _EnvCleanMixin:
def setUp(self):
self._env_backup = {
k: os.environ.get(k) for k in
("CLOUD_DB_BACKEND", "CLOUD_DB_HOST", "CLOUD_DB_PORT",
"CLOUD_DB_NAME", "CLOUD_DB_USER", "CLOUD_DB_PASSWORD")
}
for key in self._env_backup:
os.environ.pop(key, None)
def tearDown(self):
for key, value in self._env_backup.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
class SQLiteBackendPreservedTestCase(_EnvCleanMixin, unittest.TestCase):
def test_sqlite_database_keeps_wal_busy_timeout_row_retry(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "backend.db")
db = SQLiteDatabase(path)
conn = db.connect()
try:
self.assertEqual(conn.row_factory, sqlite3.Row)
self.assertEqual(
conn.execute("PRAGMA journal_mode").fetchone()[0], "wal")
self.assertEqual(
conn.execute("PRAGMA busy_timeout").fetchone()[0], 5000)
conn.execute(TASKS_DDL)
conn.commit()
with db.transaction(conn):
db.execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t1", "p1", "hello"))
rows = db.read_all(
conn, "SELECT * FROM tasks WHERE id = ?", ("t1",))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["prompt"], "hello")
# 事务回滚:重复主键失败后仍只有 1 条。
with self.assertRaises(sqlite3.IntegrityError):
with db.transaction(conn):
db.execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt,"
" status) VALUES(?, ?, ?, 'queued')",
("t1", "p1", "dup"))
self.assertEqual(
len(db.read_all(conn, "SELECT * FROM tasks")), 1)
finally:
conn.close()
# 锁判定仍走原规则:锁错误 True,其它 False。
self.assertTrue(db.is_locked_error(
sqlite3.OperationalError("database is locked")))
self.assertFalse(db.is_locked_error(
sqlite3.OperationalError("no such table: tasks")))
def test_sqlite_database_custom_busy_timeout(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "backend.db")
db = SQLiteDatabase(path, busy_timeout_ms=1234)
conn = db.connect()
try:
self.assertEqual(
conn.execute("PRAGMA busy_timeout").fetchone()[0], 1234)
finally:
conn.close()
def test_sqlite_retry_still_bounded_via_instance(self):
# 用 fake 连接走实例方法:重试耗尽抛原始错误,不无限循环。
class FakeConn:
def __init__(self):
self.calls = 0
def execute(self, sql, params=()):
self.calls += 1
raise sqlite3.OperationalError("database is locked")
with tempfile.TemporaryDirectory() as tmp:
db = SQLiteDatabase(os.path.join(tmp, "x.db"))
fake = FakeConn()
with mock.patch("cloud.db.time.sleep"):
with self.assertRaises(sqlite3.OperationalError):
db.execute_with_retry(fake, "INSERT ...", ())
self.assertEqual(fake.calls, 4) # 1 次初始 + 3 次重试
class PostgresSkeletonTestCase(_EnvCleanMixin, unittest.TestCase):
def test_postgres_init_without_driver_raises_psycopg_error(self):
# 本环境无 psycopg/psycopg2(已实测);若将来装了驱动,
# 本用例应改为跳过——当前断言的是“无驱动时明确报错”。
try:
import psycopg # noqa: F401
has_driver = True
except ImportError:
try:
import psycopg2 # noqa: F401
has_driver = True
except ImportError:
has_driver = False
if has_driver:
self.skipTest("环境已装 psycopg 驱动,无驱动报错路径不适用")
with self.assertRaises(PostgresDriverMissingError) as ctx:
PostgresDatabase(host="test-host", dbname="test-db",
user="test-user",
password="test-placeholder")
self.assertIn("psycopg", str(ctx.exception))
# 绝不静默回落:异常不是 SQLite 实例,工厂同样抛错而非返回。
with self.assertRaises(PostgresDriverMissingError) as ctx2:
create_database(backend="postgres")
self.assertIn("psycopg", str(ctx2.exception))
self.assertIsInstance(ctx2.exception, RuntimeError)
def test_postgres_params_come_from_env_not_hardcoded(self):
# 驱动探测 mock 成已装(不连真实 Postgres),验证参数来源:
# 环境变量进属性、显式参数优先于环境变量、repr 不泄密码。
from cloud.db import POSTGRES_ENV_VARS
self.assertEqual(POSTGRES_ENV_VARS["host"], "CLOUD_DB_HOST")
self.assertEqual(POSTGRES_ENV_VARS["port"], "CLOUD_DB_PORT")
self.assertEqual(POSTGRES_ENV_VARS["dbname"], "CLOUD_DB_NAME")
self.assertEqual(POSTGRES_ENV_VARS["user"], "CLOUD_DB_USER")
self.assertEqual(POSTGRES_ENV_VARS["password"], "CLOUD_DB_PASSWORD")
env = {
"CLOUD_DB_HOST": "env-host",
"CLOUD_DB_PORT": "5433",
"CLOUD_DB_NAME": "env-db",
"CLOUD_DB_USER": "env-user",
"CLOUD_DB_PASSWORD": "test-placeholder",
}
with mock.patch.dict(os.environ, env):
with mock.patch("cloud.db._load_postgres_driver",
return_value=("psycopg", object())):
db = PostgresDatabase()
self.assertEqual(db.host, "env-host")
self.assertEqual(db.port, 5433)
self.assertEqual(db.dbname, "env-db")
self.assertEqual(db.user, "env-user")
self.assertNotIn("test-placeholder", repr(db))
# 显式参数优先于环境变量;未给出的参数不硬编码成默认值。
db2 = PostgresDatabase(host="explicit-host")
self.assertEqual(db2.host, "explicit-host")
self.assertEqual(db2.dbname, "env-db")
def test_postgres_methods_are_skeleton_not_fake_success(self):
# 绕过 __init__(驱动探测)构造骨架实例,验证方法体本身
# 就是明确的 NotImplementedError,而不是假装成功。
skeleton = object.__new__(PostgresDatabase)
for name in ("connect", "read_all", "transaction",
"is_locked_error"):
method = getattr(skeleton, name)
with self.assertRaises(NotImplementedError):
if name in ("connect", "transaction"):
method(None) if name == "transaction" else method()
elif name == "is_locked_error":
method(ValueError("x"))
else:
method(None, "SELECT 1")
with self.assertRaises(NotImplementedError):
skeleton.execute_with_retry(None, "SELECT 1", ())
def test_postgres_repr_never_contains_password(self):
skeleton = object.__new__(PostgresDatabase)
skeleton.host = "test-host"
skeleton.port = None
skeleton.dbname = "test-db"
skeleton.user = "test-user"
skeleton.password = "test-placeholder"
self.assertNotIn("test-placeholder", repr(skeleton))
class InterfaceContractTestCase(_EnvCleanMixin, unittest.TestCase):
def test_database_is_abstract(self):
with self.assertRaises(TypeError):
Database()
def test_both_backends_expose_same_interface_methods(self):
abstract = set(Database.__abstractmethods__)
self.assertEqual(set(INTERFACE_METHODS), abstract)
for cls in (SQLiteDatabase, PostgresDatabase):
for name in INTERFACE_METHODS:
self.assertTrue(
callable(getattr(cls, name, None)),
"%s 缺少接口方法 %s" % (cls.__name__, name))
def test_both_backends_signatures_match_interface(self):
for cls in (SQLiteDatabase, PostgresDatabase):
for name in INTERFACE_METHODS:
expected = inspect.signature(getattr(Database, name))
actual = inspect.signature(getattr(cls, name))
expected_params = [p for p in expected.parameters
if p != "self"]
actual_params = [p for p in actual.parameters
if p != "self"]
self.assertEqual(
expected_params, actual_params,
"%s.%s 签名与 Database 不一致" % (cls.__name__, name))
def test_factory_backend_switching(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "factory.db")
# 默认(环境变量未设)仍是 SQLite。
db = create_database(db_path=path)
self.assertIsInstance(db, SQLiteDatabase)
self.assertIsInstance(db, Database)
# 显式 sqlite。
self.assertIsInstance(
create_database(backend="sqlite", db_path=path),
SQLiteDatabase)
# 环境变量 sqlite。
with mock.patch.dict(os.environ,
{"CLOUD_DB_BACKEND": "sqlite"}):
self.assertIsInstance(
database_from_env(db_path=path), SQLiteDatabase)
# 未知后端:明确 ValueError,不静默当 sqlite。
with self.assertRaises(ValueError):
create_database(backend="mysql", db_path=path)
with mock.patch.dict(os.environ,
{"CLOUD_DB_BACKEND": "mysql"}):
with self.assertRaises(ValueError):
database_from_env(db_path=path)
# 环境变量 postgres:无驱动时抛含 psycopg 的错误,
# 绝不返回一个 SQLite 实例假装切换成功。
with mock.patch.dict(os.environ,
{"CLOUD_DB_BACKEND": "postgres"}):
try:
import psycopg # noqa: F401
has_driver = True
except ImportError:
try:
import psycopg2 # noqa: F401
has_driver = True
except ImportError:
has_driver = False
if has_driver:
self.skipTest("环境已装 psycopg 驱动")
with self.assertRaises(RuntimeError) as ctx:
database_from_env(db_path=path)
self.assertIn("psycopg", str(ctx.exception))
class ServerBackendSelectionTestCase(_EnvCleanMixin, unittest.TestCase):
def test_server_postgres_backend_fails_at_startup(self):
try:
import psycopg # noqa: F401
has_driver = True
except ImportError:
try:
import psycopg2 # noqa: F401
has_driver = True
except ImportError:
has_driver = False
if has_driver:
self.skipTest("环境已装 psycopg 驱动")
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "should-not-be-created.db")
with mock.patch.dict(os.environ,
{"CLOUD_DB_BACKEND": "postgres"}):
with self.assertRaises(RuntimeError) as ctx:
create_server(path, "127.0.0.1", 0)
self.assertIn("psycopg", str(ctx.exception))
# 启动前失败:不应已建出 SQLite 库文件假装可用。
self.assertFalse(os.path.exists(path))
def test_server_default_and_sqlite_still_work(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "ok.db")
server = create_server(path, "127.0.0.1", 0)
try:
self.assertTrue(os.path.exists(path))
finally:
server.server_close()
with mock.patch.dict(os.environ,
{"CLOUD_DB_BACKEND": "sqlite"}):
path2 = os.path.join(tmp, "ok2.db")
server2 = create_server(path2, "127.0.0.1", 0)
try:
self.assertTrue(os.path.exists(path2))
finally:
server2.server_close()
if __name__ == "__main__":
unittest.main()