fixed # 修复迁移数据库失败的BUG

This commit is contained in:
2026-10-20 15:31:15 +08:00
parent 9e3fa69219
commit 024541bea2
11 changed files with 464 additions and 120 deletions
+4 -63
View File
@@ -1,5 +1,4 @@
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
@@ -20,66 +19,8 @@ class DataDirectoryTests(unittest.TestCase):
self.base = Path(self.temp.name)
def test_default_and_explicit_directory(self):
self.assertEqual(self.module.data_directories(self.base), (
self.base / 'farm_db', self.base / 'farm_preview',
))
custom = self.base / 'custom'
self.assertEqual(self.module.data_directories(self.base, custom), (
custom, None,
))
def test_migrate_preserves_database_log_and_sidecars(self):
old = self.base / 'farm_preview'
old.mkdir()
for name in ('farm-preview.sqlite3', 'farm-preview.sqlite3-wal',
'farm-preview.log'):
(old / name).write_bytes(name.encode())
target = self.base / 'farm_db'
self.assertTrue(self.module.prepare_directory(target, old))
self.assertFalse(old.exists())
for path in target.iterdir():
self.assertEqual(path.read_bytes(), path.name.encode())
self.assertFalse(self.module.prepare_directory(target, old))
def test_conflicting_directory_stops_without_overwrite(self):
old, target = self.base / 'farm_preview', self.base / 'farm_db'
old.mkdir()
target.mkdir()
(old / 'farm-preview.sqlite3').write_bytes(b'old')
(target / 'farm-preview.sqlite3').write_bytes(b'new')
with self.assertRaises(FileExistsError):
self.module.prepare_directory(target, old)
self.assertEqual((old / 'farm-preview.sqlite3').read_bytes(), b'old')
self.assertEqual((target / 'farm-preview.sqlite3').read_bytes(), b'new')
def test_migration_rejects_paths_outside_default_siblings(self):
with self.assertRaises(ValueError):
self.module.prepare_directory(
self.base / 'farm_db', self.base / 'other/farm_preview',
)
@unittest.skipUnless(os.name == 'nt', 'Windows short paths only')
def test_migration_accepts_short_and_long_parent_paths(self):
import ctypes
parent = self.base / 'Farm migration long directory'
parent.mkdir()
buffer = ctypes.create_unicode_buffer(32768)
get_short_path = ctypes.windll.kernel32.GetShortPathNameW
get_short_path.argtypes = (
ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint,
self.assertEqual(
self.module.data_directory(self.base), self.base / 'farm_db',
)
get_short_path.restype = ctypes.c_uint
length = get_short_path(str(parent), buffer, len(buffer))
self.assertTrue(0 < length < len(buffer))
short_parent = Path(buffer.value)
if short_parent == parent.resolve():
self.skipTest('Short path aliases are disabled on this volume')
old = short_parent / 'farm_preview'
old.mkdir()
(old / 'farm-preview.sqlite3').write_bytes(b'preserved')
target = parent.resolve() / 'farm_db'
self.assertTrue(self.module.prepare_directory(target, old))
self.assertEqual((target / 'farm-preview.sqlite3').read_bytes(),
b'preserved')
self.assertFalse(old.exists())
custom = self.base / 'custom'
self.assertEqual(self.module.data_directory(self.base, custom), custom)
+180
View File
@@ -0,0 +1,180 @@
import asyncio
from contextlib import closing
from dataclasses import replace
import json
from pathlib import Path
import sqlite3
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
from farm_core.domain import FarmError
def create_legacy(path):
with closing(sqlite3.connect(path)) as db, db:
db.executescript('''
CREATE TABLE user(uid TEXT PRIMARY KEY, name TEXT, exp INTEGER,
point INTEGER, vipPoint INTEGER, soil INTEGER);
CREATE TABLE userSeed(uid TEXT, seed TEXT, count INTEGER);
CREATE TABLE userPlant(uid TEXT, plant TEXT, count INTEGER,
isLock INTEGER);
CREATE TABLE userSoil(uid TEXT, soilIndex INTEGER, plantName TEXT,
matureTime INTEGER, soilLevel INTEGER, wiltStatus INTEGER,
bugStatus INTEGER, harvestCount INTEGER, isSoilPlanted INTEGER);
CREATE TABLE userItem(uid TEXT, item TEXT, count INTEGER);
INSERT INTO user VALUES ('1', '旧农场', 900, 1234, 56, 4);
INSERT INTO userSeed VALUES ('1', '白萝卜', 12);
INSERT INTO userPlant VALUES ('1', '白萝卜', 23, 1);
INSERT INTO userSoil VALUES
('1', 1, '白萝卜', 2000, 2, 0, 1, 0, 1),
('1', 2, '白萝卜', 1000, 3, 1, 0, 0, 1),
('1', 3, '', 0, 0, 0, 0, 0, 0);
INSERT INTO userItem VALUES ('1', '化肥', 7);
''')
class LegacyMigrationTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.source = self.root / 'farm.db'
create_legacy(self.source)
self.catalogue = Catalogue.bundled()
self.store = SQLiteStore(self.root / 'farm.sqlite3')
await self.store.initialize(self.catalogue)
self.who = Identity('zhenxun-preview', 'bot', 'OneBot V11', '1')
async def asyncTearDown(self):
await self.store.close()
self.temp.cleanup()
async def migrate(self, who=None):
return await self.store.migrate_legacy(
self.source, who or self.who, self.catalogue, 1500,
)
async def assert_empty(self):
with closing(sqlite3.connect(self.store.path)) as db, db:
for table in ('accounts', 'inventory', 'plots', 'operations',
'ledger'):
self.assertEqual(db.execute(
f'SELECT COUNT(*) FROM {table}'
).fetchone()[0], 0)
async def test_assets_plots_archive_and_source_preserved(self):
before = self.source.read_bytes()
self.assertTrue(await self.migrate())
snapshot = await self.store.snapshot(self.who)
self.assertEqual((snapshot.account.name, snapshot.account.point,
snapshot.account.vip_point,
snapshot.account.experience),
('旧农场', 1234, 56, 900))
item = snapshot.inventory[0]
self.assertEqual((item.seeds, item.crops, item.locked), (12, 23, True))
self.assertEqual(len(snapshot.plots), 4)
self.assertEqual(snapshot.plots[0].mature_at, 2000)
self.assertEqual(snapshot.plots[0].pest_count, 1)
self.assertEqual(snapshot.plots[0].yield_bonus, 20)
self.assertTrue(snapshot.plots[1].withered)
self.assertEqual(snapshot.plots[1].experience_bonus, 28)
self.assertIsNone(snapshot.plots[3].plant)
with closing(sqlite3.connect(self.store.path)) as db, db:
result = json.loads(db.execute(
"SELECT result FROM operations WHERE action='legacy_import'"
).fetchone()[0])
self.assertEqual(result['legacy_rows']['userItem'][0]['count'], 7)
self.assertEqual(result['unsupported_tables'], ['userItem'])
self.assertEqual(db.execute(
'SELECT SUM(delta) FROM ledger WHERE asset=?', ('point',),
).fetchone()[0], 1234)
self.assertEqual(db.execute('PRAGMA foreign_key_check').fetchall(),
[])
self.assertEqual(self.source.read_bytes(), before)
async def test_repeated_import_does_not_restore_spent_assets(self):
await self.migrate()
service = FarmService(self.store, self.catalogue, clock=lambda: 1500)
await service.buy(self.who, '白萝卜', 1, 'buy')
before = await self.store.snapshot(self.who)
self.assertFalse(await self.migrate())
self.assertEqual(await self.store.snapshot(self.who), before)
await self.store.close()
self.store = SQLiteStore(self.root / 'farm.sqlite3')
await self.store.initialize(self.catalogue)
self.source.rename(self.root / 'farm.db.saved')
self.assertFalse(await self.migrate())
self.assertEqual(await self.store.snapshot(self.who), before)
async def test_concurrent_identities_bind_only_once(self):
other = Identity('zhenxun-preview', 'other', 'QQ', '1')
results = await asyncio.gather(
self.migrate(), self.migrate(other), return_exceptions=True,
)
self.assertEqual(sum(result is True for result in results), 1)
failures = [r for r in results if isinstance(r, FarmError)]
self.assertEqual([r.code for r in failures], ['legacy_identity_bound'])
with closing(sqlite3.connect(self.store.path)) as db, db:
self.assertEqual(db.execute(
'SELECT COUNT(*) FROM accounts'
).fetchone()[0], 1)
async def test_existing_account_is_not_overwritten(self):
service = FarmService(self.store, self.catalogue, clock=lambda: 1500)
await service.register(self.who, '新农场', 'new')
before = await self.store.snapshot(self.who)
with self.assertRaisesRegex(FarmError, 'legacy_account_conflict'):
await self.migrate()
self.assertEqual(await self.store.snapshot(self.who), before)
async def test_unknown_crop_rolls_back_all_tables(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute("UPDATE userSeed SET seed='不存在的作物'")
with self.assertRaisesRegex(FarmError, 'unknown_plant'):
await self.migrate()
await self.assert_empty()
async def test_negative_balance_is_rejected(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('UPDATE user SET point=-1')
with self.assertRaisesRegex(FarmError, 'invalid_integer'):
await self.migrate()
await self.assert_empty()
async def test_ambiguous_crop_is_not_guessed(self):
plant = self.catalogue.get('白萝卜')
self.catalogue = Catalogue((plant, replace(plant, plant_id=999999)))
with self.assertRaisesRegex(FarmError, 'ambiguous_plant'):
await self.migrate()
await self.assert_empty()
async def test_duplicate_plot_rolls_back_and_can_retry_after_repair(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('INSERT INTO userSoil SELECT * FROM userSoil '
'WHERE soilIndex=1')
with self.assertRaisesRegex(FarmError, 'legacy_data_invalid'):
await self.migrate()
await self.assert_empty()
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('DELETE FROM userSoil WHERE rowid='
'(SELECT MAX(rowid) FROM userSoil)')
self.assertTrue(await self.migrate())
async def test_missing_source_or_user_does_not_create_account(self):
self.assertFalse(await self.migrate(
Identity('zhenxun-preview', 'bot', 'OneBot V11', 'missing'),
))
self.source.unlink()
self.assertFalse(await self.migrate())
await self.assert_empty()
async def test_missing_schema_is_rejected(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('DROP TABLE userSeed')
with self.assertRaisesRegex(FarmError, 'legacy_schema_unsupported'):
await self.migrate()
await self.assert_empty()
+43 -10
View File
@@ -1,4 +1,5 @@
import asyncio
from contextlib import closing
from dataclasses import replace
import importlib
import importlib.util
@@ -216,29 +217,61 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(before, await self.call("inventory"))
async def test_default_directory_migration_keeps_account_and_inventory(self):
old = self.root / 'migration/farm_preview'
async def test_existing_preview_directory_is_ignored(self):
old = self.root / 'directories/farm_preview'
target = old.with_name('farm_db')
previous = self.module.Runtime(old, choose_font())
await previous.start()
try:
await previous.execute('register', '迁移农场', **self.context)
before = await previous.execute('inventory', '', **self.context)
await previous.execute('register', '旧农场', **self.context)
finally:
await previous.close()
current = self.module.Runtime(
target, choose_font(), legacy_data_dir=old,
)
old_database = old / 'farm.sqlite3'
before = old_database.read_bytes()
target.mkdir()
current = self.module.Runtime(target, choose_font())
await current.start()
try:
self.assertFalse(old.exists())
self.assertTrue((target / 'farm-preview.sqlite3').is_file())
self.assertEqual(old_database.read_bytes(), before)
self.assertTrue((target / 'farm.sqlite3').is_file())
self.assertEqual(await current.execute(
'inventory', '', **self.context,
), before)
), '请先发送:开通农场')
finally:
await current.close()
async def test_first_command_imports_legacy_and_binds_identity(self):
from test_legacy_migration import create_legacy
source = self.root / 'data/farm.db'
create_legacy(source)
before = source.read_bytes()
result = await self.call('inventory')
self.assertIn('旧农场', result)
self.assertIn('1234', result)
self.assertTrue((self.root / 'data/farm.sqlite3').is_file())
self.assertFalse((self.root / 'data/farm-preview.sqlite3').exists())
self.assertEqual(await self.call('inventory'), result)
self.assertIn('已绑定其他机器人或平台', await self.call(
'register', bot_id='other',
))
self.assertEqual(source.read_bytes(), before)
async def test_failed_migration_does_not_register_empty_account(self):
from test_legacy_migration import create_legacy
import sqlite3
source = self.root / 'data/farm.db'
create_legacy(source)
with closing(sqlite3.connect(source)) as db, db:
db.execute("UPDATE userSeed SET seed='不存在的作物'")
self.assertIn('无法迁移', await self.call('register'))
identity = self.module.Identity(
'zhenxun-preview', 'bot', 'OneBot V11', '1',
)
with self.assertRaisesRegex(self.module.FarmError, 'not_registered'):
await self.runtime.store.snapshot(identity)
async def test_mock_host_load_registers_hooks_and_original_matchers(self):
hooks = {"start":[], "close":[], "commands":{}}
driver = SimpleNamespace(