test(db): add schema migration tests
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
376
tests/test_schema_migration.py
Normal file
376
tests/test_schema_migration.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for v2 schema DDL and migrate_v1_to_v2 / ensure_schema."""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
from util.constants import SKILL_SLUG # noqa: E402
|
||||
|
||||
|
||||
# Minimal v1-like DDL (no auth_strategy / session_persistent / device_fingerprint /
|
||||
# platforms.default_auth_strategy; credentials without account_id).
|
||||
_V1_DDL = """
|
||||
CREATE TABLE platforms (
|
||||
platform_key TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
provider_code TEXT,
|
||||
default_url TEXT,
|
||||
aliases_json TEXT NOT NULL DEFAULT '[]',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_key TEXT NOT NULL,
|
||||
account_label TEXT NOT NULL,
|
||||
login_id TEXT,
|
||||
login_id_type TEXT NOT NULL DEFAULT 'unknown',
|
||||
tenant_id TEXT,
|
||||
environment TEXT NOT NULL DEFAULT 'production',
|
||||
role TEXT NOT NULL DEFAULT 'default',
|
||||
provider_code TEXT,
|
||||
profile_dir TEXT,
|
||||
url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
session_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_used_at INTEGER,
|
||||
last_login_check_at INTEGER,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_key TEXT NOT NULL,
|
||||
credential_label TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
secret_storage TEXT NOT NULL,
|
||||
secret_ref TEXT,
|
||||
secret_mask TEXT,
|
||||
expires_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
last_used_at INTEGER,
|
||||
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE account_leases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
lease_token TEXT NOT NULL UNIQUE,
|
||||
holder TEXT NOT NULL,
|
||||
purpose TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
heartbeat_at INTEGER,
|
||||
released_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _db_path_for_env(root: str, uid: str) -> str:
|
||||
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
|
||||
|
||||
|
||||
def _prep_logging():
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_migration_test")
|
||||
|
||||
|
||||
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return [row[1] for row in cur.fetchall()]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iso_env(monkeypatch):
|
||||
with tempfile.TemporaryDirectory(prefix="acc_mgr_schema_", ignore_cleanup_errors=True) as tmp:
|
||||
data_root = os.path.join(tmp, "data-root")
|
||||
os.makedirs(data_root)
|
||||
uid = "migration_uid"
|
||||
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
|
||||
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
|
||||
db_path = _db_path_for_env(data_root, uid)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
_prep_logging()
|
||||
yield {"tmp": tmp, "data_root": data_root, "uid": uid, "db_path": db_path}
|
||||
|
||||
|
||||
class TestFreshSchema:
|
||||
def test_fresh_schema_has_v2_columns_and_meta(self, iso_env):
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
if os.path.isfile(iso_env["db_path"]):
|
||||
os.remove(iso_env["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cols_accounts = _column_names(conn, "accounts")
|
||||
assert "auth_strategy" in cols_accounts
|
||||
assert "session_persistent" in cols_accounts
|
||||
assert "device_fingerprint" in cols_accounts
|
||||
cols_plat = _column_names(conn, "platforms")
|
||||
assert "default_auth_strategy" in cols_plat
|
||||
cols_cred = _column_names(conn, "credentials")
|
||||
assert "account_id" in cols_cred
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT value FROM _schema_meta WHERE key = ?",
|
||||
("schema_version",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None and row[0] == "2"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestMigrateFromV1:
|
||||
def test_v1_to_v2_migrates_and_preserves_rows(self, iso_env, capsys):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
now = int(time.time())
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES ('keep_me', 'Keep', 'generic', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('keep_me', 'acc', 'u@test.com', 'email', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "[migration] account-manager v1→v2:" in err
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM accounts WHERE login_id = ?", ("u@test.com",))
|
||||
assert int(cur.fetchone()[0]) == 1
|
||||
cur.execute(
|
||||
"SELECT auth_strategy, session_persistent FROM accounts WHERE login_id = ?",
|
||||
("u@test.com",),
|
||||
)
|
||||
strat, sp = cur.fetchone()
|
||||
assert strat == "password_auto"
|
||||
assert int(sp) == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_migration_idempotent(self, iso_env, capsys):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db
|
||||
|
||||
init_db()
|
||||
capsys.readouterr()
|
||||
init_db()
|
||||
err = capsys.readouterr().err
|
||||
assert "v1→v2" not in err
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cols = _column_names(conn, "accounts")
|
||||
assert cols.count("auth_strategy") == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_platform_default_mapping_douyin(self, iso_env):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
now = int(time.time())
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES ('douyin', 'Douyin', 'content', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT default_auth_strategy FROM platforms WHERE platform_key = ?",
|
||||
("douyin",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "qr_code_manual"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_accounts_auth_strategy_priority(self, iso_env):
|
||||
db_path = iso_env["db_path"]
|
||||
if os.path.isfile(db_path):
|
||||
os.remove(db_path)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
raw = sqlite3.connect(db_path)
|
||||
now = int(time.time())
|
||||
try:
|
||||
raw.executescript(_V1_DDL)
|
||||
for pk, dn in (
|
||||
("maersk", "Maersk"),
|
||||
("unknown_platform", "Unknown"),
|
||||
):
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, 'generic', NULL, '', '[]', '{}', 1, ?, ?)
|
||||
""",
|
||||
(pk, dn, now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('maersk', 'm', NULL, 'unknown', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.execute(
|
||||
"""
|
||||
INSERT INTO accounts (platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES ('unknown_platform', 'u', NULL, 'unknown', NULL, 'production',
|
||||
'booking', NULL, NULL, '', 'active', 'unknown', '{}', ?, ?)
|
||||
""",
|
||||
(now, now),
|
||||
)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
from db.connection import init_db, get_conn
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
|
||||
("maersk",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "qr_code_manual"
|
||||
cur.execute(
|
||||
"SELECT auth_strategy FROM accounts WHERE platform_key = ? ORDER BY id",
|
||||
("unknown_platform",),
|
||||
)
|
||||
assert cur.fetchone()[0] == "password_auto"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestInsertAccountValidation:
|
||||
def test_insert_account_rejects_invalid_strategy(self, iso_env):
|
||||
from db.connection import init_db, get_conn
|
||||
from db.accounts_repo import insert_account
|
||||
from db.auth_strategy import AUTH_STRATEGY_QR_CODE_MANUAL
|
||||
|
||||
if os.path.isfile(iso_env["db_path"]):
|
||||
os.remove(iso_env["db_path"])
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
now = int(time.time())
|
||||
with pytest.raises(ValueError, match="invalid auth_strategy"):
|
||||
insert_account(
|
||||
conn,
|
||||
platform_key="x",
|
||||
account_label="l",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
profile_dir=None,
|
||||
url=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
auth_strategy="not_a_strategy",
|
||||
)
|
||||
aid = insert_account(
|
||||
conn,
|
||||
platform_key="x",
|
||||
account_label="l",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
profile_dir=None,
|
||||
url=None,
|
||||
extra_json="{}",
|
||||
now=now,
|
||||
auth_strategy=AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||
)
|
||||
assert isinstance(aid, int) and aid > 0
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user