2026-08-09 21:44:29 +08:00
|
|
|
"""SQLite 初始化和数据库迁移测试。"""
|
2026-08-06 16:10:57 +08:00
|
|
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from src.db import DatabaseVersionError, initialize_database, open_database
|
2026-08-11 11:17:44 +08:00
|
|
|
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3, MIGRATION_4
|
2026-08-06 16:10:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-08-10 17:23:24 +08:00
|
|
|
"idx_pdd_tasks_visible_list",
|
2026-08-06 16:10:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-08-09 21:44:29 +08:00
|
|
|
def test_initialize_creates_latest_tables_and_indexes(self) -> None:
|
2026-08-06 16:10:57 +08:00
|
|
|
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))
|
2026-08-11 11:17:44 +08:00
|
|
|
self.assertEqual(version, 5)
|
2026-08-09 21:44:29 +08:00
|
|
|
|
|
|
|
|
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
with connection:
|
|
|
|
|
for statement in MIGRATION_1:
|
|
|
|
|
connection.execute(statement)
|
|
|
|
|
connection.execute("PRAGMA user_version = 1")
|
|
|
|
|
connection.execute(
|
|
|
|
|
"INSERT INTO pdd_tasks"
|
|
|
|
|
" (remote_task_id, task_type, goods_url, status, received_at,"
|
|
|
|
|
" created_at, updated_at) VALUES"
|
|
|
|
|
" ('TASK-OLD', 'collect', 'https://example.test', 'claimed',"
|
|
|
|
|
" '2026-08-06T00:00:00Z', '2026-08-06T00:00:00Z',"
|
|
|
|
|
" '2026-08-06T00:00:00Z')"
|
|
|
|
|
)
|
|
|
|
|
connection.execute(
|
|
|
|
|
"INSERT INTO task_runs"
|
|
|
|
|
" (task_id, attempt_id, attempt_no, device_address, run_status,"
|
|
|
|
|
" started_at, created_at, updated_at) VALUES"
|
|
|
|
|
" (1, 'ATTEMPT-OLD', 1, 'USB-001', 'succeeded',"
|
|
|
|
|
" '2026-08-06T00:00:00Z', '2026-08-06T00:00:00Z',"
|
|
|
|
|
" '2026-08-06T00:00:00Z')"
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
|
|
|
|
|
initialize_database(self.db_path)
|
|
|
|
|
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
columns = {
|
|
|
|
|
row[1] for row in connection.execute("PRAGMA table_info(task_runs)")
|
|
|
|
|
}
|
|
|
|
|
attempt_id = connection.execute(
|
|
|
|
|
"SELECT attempt_id FROM task_runs"
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
self.assertIn("result_data", columns)
|
|
|
|
|
self.assertEqual(attempt_id, "ATTEMPT-OLD")
|
2026-08-11 11:17:44 +08:00
|
|
|
self.assertEqual(version, 5)
|
2026-08-06 16:10:57 +08:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-08-10 16:35:38 +08:00
|
|
|
def test_v2_database_adds_safe_execution_mode_default(self) -> None:
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
with connection:
|
|
|
|
|
for statement in MIGRATION_1 + MIGRATION_2:
|
|
|
|
|
connection.execute(statement)
|
|
|
|
|
connection.execute("PRAGMA user_version = 2")
|
|
|
|
|
connection.execute(
|
|
|
|
|
"INSERT INTO pdd_tasks"
|
|
|
|
|
" (remote_task_id, task_type, goods_url, status, received_at,"
|
|
|
|
|
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
"PUR-OLD",
|
|
|
|
|
"purchase",
|
|
|
|
|
"https://example.test",
|
|
|
|
|
"claimed",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
|
|
|
|
|
initialize_database(self.db_path)
|
|
|
|
|
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
row = connection.execute(
|
|
|
|
|
"SELECT execution_mode FROM pdd_tasks"
|
|
|
|
|
" WHERE remote_task_id = 'PUR-OLD'"
|
|
|
|
|
).fetchone()
|
|
|
|
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
self.assertEqual(row[0], "dry_run")
|
2026-08-11 11:17:44 +08:00
|
|
|
self.assertEqual(version, 5)
|
2026-08-10 17:23:24 +08:00
|
|
|
|
|
|
|
|
def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None:
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
with connection:
|
|
|
|
|
for statement in MIGRATION_1 + MIGRATION_2 + MIGRATION_3:
|
|
|
|
|
connection.execute(statement)
|
|
|
|
|
connection.execute("PRAGMA user_version = 3")
|
|
|
|
|
connection.execute(
|
|
|
|
|
"INSERT INTO pdd_tasks"
|
|
|
|
|
" (remote_task_id, task_type, goods_url, status, received_at,"
|
|
|
|
|
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
"COL-V3",
|
|
|
|
|
"collect",
|
|
|
|
|
"https://example.test/goods",
|
|
|
|
|
"succeeded",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
|
|
|
|
|
initialize_database(self.db_path)
|
|
|
|
|
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
columns = {
|
|
|
|
|
row[1] for row in connection.execute("PRAGMA table_info(pdd_tasks)")
|
|
|
|
|
}
|
|
|
|
|
row = connection.execute(
|
|
|
|
|
"SELECT remote_task_id, removed_at FROM pdd_tasks"
|
|
|
|
|
" WHERE remote_task_id = 'COL-V3'"
|
|
|
|
|
).fetchone()
|
|
|
|
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
self.assertIn("removed_at", columns)
|
|
|
|
|
self.assertEqual(tuple(row), ("COL-V3", None))
|
2026-08-11 11:17:44 +08:00
|
|
|
self.assertEqual(version, 5)
|
|
|
|
|
|
|
|
|
|
def test_v4_database_backfills_shop_name_from_valid_pdd_data(self) -> None:
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
with connection:
|
|
|
|
|
for statement in MIGRATION_1 + MIGRATION_2 + MIGRATION_3 + MIGRATION_4:
|
|
|
|
|
connection.execute(statement)
|
|
|
|
|
connection.execute("PRAGMA user_version = 4")
|
|
|
|
|
connection.execute(
|
|
|
|
|
"INSERT INTO pdd_tasks"
|
|
|
|
|
" (remote_task_id, task_type, goods_url, status, pdd_data,"
|
|
|
|
|
" received_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
"COL-V4", "collect", "https://example.test/goods",
|
|
|
|
|
"succeeded", '{"shop_name":" 测试店铺 "}',
|
|
|
|
|
"2026-08-10T00:00:00Z", "2026-08-10T00:00:00Z",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
|
|
|
|
|
initialize_database(self.db_path)
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
row = connection.execute(
|
|
|
|
|
"SELECT shop_name FROM pdd_tasks WHERE remote_task_id = 'COL-V4'"
|
|
|
|
|
).fetchone()
|
|
|
|
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
self.assertEqual(row[0], "测试店铺")
|
|
|
|
|
self.assertEqual(version, 5)
|
2026-08-10 16:35:38 +08:00
|
|
|
|
2026-08-06 16:10:57 +08:00
|
|
|
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()
|
|
|
|
|
|
2026-08-10 16:35:38 +08:00
|
|
|
def test_execution_mode_constraint_is_enforced(self) -> None:
|
|
|
|
|
initialize_database(self.db_path)
|
|
|
|
|
connection = open_database(self.db_path)
|
|
|
|
|
try:
|
|
|
|
|
with self.assertRaises(sqlite3.IntegrityError):
|
|
|
|
|
connection.execute(
|
|
|
|
|
"INSERT INTO pdd_tasks"
|
|
|
|
|
" (remote_task_id, task_type, execution_mode, goods_url,"
|
|
|
|
|
" status, received_at, created_at, updated_at)"
|
|
|
|
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
"PUR-INVALID-MODE",
|
|
|
|
|
"purchase",
|
|
|
|
|
"unknown",
|
|
|
|
|
"https://example.test/goods",
|
|
|
|
|
"claimed",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
"2026-08-10T00:00:00Z",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
connection.close()
|
|
|
|
|
|
2026-08-06 16:10:57 +08:00
|
|
|
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()
|