feat: 实现 SQLite 初始化与 v1 迁移 (#7)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""SQLite 初始化和 v1 数据库结构测试。"""
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.db import DatabaseVersionError, initialize_database, open_database
|
||||
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"pdd_tasks",
|
||||
"task_runs",
|
||||
"outbox_events",
|
||||
"app_settings",
|
||||
}
|
||||
|
||||
EXPECTED_INDEXES = {
|
||||
"idx_pdd_tasks_list",
|
||||
"idx_pdd_tasks_status_type",
|
||||
"idx_pdd_tasks_goods_id",
|
||||
"idx_task_runs_task",
|
||||
"idx_outbox_pending",
|
||||
}
|
||||
|
||||
|
||||
class DatabaseInitializationTests(unittest.TestCase):
|
||||
"""每个测试都使用独立临时数据库,不接触真实 data 目录。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.db_path = (
|
||||
Path(self._temporary_directory.name) / "nested" / "client.db"
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def test_initialize_creates_v1_tables_and_indexes(self) -> None:
|
||||
result_path = initialize_database(self.db_path)
|
||||
|
||||
self.assertEqual(result_path, self.db_path)
|
||||
self.assertTrue(self.db_path.is_file())
|
||||
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
indexes = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index'"
|
||||
)
|
||||
}
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertTrue(EXPECTED_TABLES.issubset(tables))
|
||||
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
|
||||
self.assertEqual(version, 1)
|
||||
|
||||
def test_initialize_can_run_twice_without_losing_data(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO app_settings"
|
||||
" (setting_key, value_json, updated_at) VALUES (?, ?, ?)",
|
||||
("automation.dry_run", "true", "2026-08-06T00:00:00Z"),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
value = connection.execute(
|
||||
"SELECT value_json FROM app_settings WHERE setting_key = ?",
|
||||
("automation.dry_run",),
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(value, "true")
|
||||
|
||||
def test_new_connection_uses_required_pragmas(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
foreign_keys = connection.execute("PRAGMA foreign_keys").fetchone()[0]
|
||||
journal_mode = connection.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
busy_timeout = connection.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(foreign_keys, 1)
|
||||
self.assertEqual(journal_mode.lower(), "wal")
|
||||
self.assertEqual(busy_timeout, 5000)
|
||||
|
||||
def test_pdd_task_check_constraints_are_enforced(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
invalid_values = (
|
||||
("unknown", "claimed", None, None),
|
||||
("collect", "unknown", None, None),
|
||||
("collect", "claimed", -1, None),
|
||||
("purchase", "claimed", None, 0),
|
||||
)
|
||||
for index, values in enumerate(invalid_values):
|
||||
with self.subTest(values=values):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute(
|
||||
"INSERT INTO pdd_tasks"
|
||||
" (remote_task_id, task_type, goods_url, price_cent,"
|
||||
" quantity, status, received_at, created_at, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
f"TASK-{index}",
|
||||
values[0],
|
||||
"https://example.test/goods",
|
||||
values[2],
|
||||
values[3],
|
||||
values[1],
|
||||
"2026-08-06T00:00:00Z",
|
||||
"2026-08-06T00:00:00Z",
|
||||
"2026-08-06T00:00:00Z",
|
||||
),
|
||||
)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_task_run_and_outbox_foreign_keys_are_enforced(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute(
|
||||
"INSERT INTO task_runs"
|
||||
" (task_id, attempt_id, attempt_no, device_address, run_status,"
|
||||
" started_at, created_at, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
999,
|
||||
"attempt-1",
|
||||
1,
|
||||
"device-1",
|
||||
"running",
|
||||
"2026-08-06T00:00:00Z",
|
||||
"2026-08-06T00:00:00Z",
|
||||
"2026-08-06T00:00:00Z",
|
||||
),
|
||||
)
|
||||
connection.rollback()
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute(
|
||||
"INSERT INTO outbox_events"
|
||||
" (task_id, event_type, idempotency_key, payload_json,"
|
||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
999,
|
||||
"collect_result",
|
||||
"TASK-1:attempt-1:result-v1",
|
||||
"{}",
|
||||
"2026-08-06T00:00:00Z",
|
||||
"2026-08-06T00:00:00Z",
|
||||
),
|
||||
)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_newer_database_version_is_rejected(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
connection.execute("PRAGMA user_version = 99")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.assertRaisesRegex(DatabaseVersionError, "数据库版本 99"):
|
||||
initialize_database(self.db_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user