rebuild(migration): validate local legacy cutover

This commit is contained in:
leefer
2026-07-30 17:26:15 +08:00
parent 37bd9fea85
commit 198806c1bd
23 changed files with 418 additions and 27 deletions
+1
View File
@@ -9,6 +9,7 @@ async function authenticate(page) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -15,6 +15,7 @@ async function authenticate(page, username, password) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -33,6 +33,7 @@ async function authenticate(page, username, password) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -15,6 +15,7 @@ async function authenticate(page, username, password) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -15,6 +15,7 @@ async function authenticate(page) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -16,6 +16,7 @@ async function authenticate(page) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -15,6 +15,7 @@ async function authenticate(page) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -15,6 +15,7 @@ async function authenticate(page) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+1
View File
@@ -15,6 +15,7 @@ async function authenticate(page) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
await expect(page.locator(".sidebar")).toBeVisible();
}
}
+19
View File
@@ -187,6 +187,25 @@ def test_birth_profile_is_encrypted_and_isolated_by_account(tmp_path) -> None:
run_scenario(application, scenario)
def test_incomplete_encrypted_profile_returns_controlled_error(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
response, _session = await register(client, "broken-profile", USER_PASSWORD)
user_id = response.json()["account"]["id"]
encrypted = application.state.container.accounts._cipher.encrypt('{"gender":"male"}')
with sqlite3.connect(application.state.settings.database_path) as connection:
connection.execute(
"INSERT INTO birth_profiles VALUES (?,?,datetime('now'),datetime('now'))",
(user_id, encrypted),
)
result = await client.get("/api/account/profile")
assert result.status_code == 503
assert result.json()["error"]["code"] == "profile_unavailable"
run_scenario(application, scenario)
def test_membership_and_admin_are_independent_dimensions(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
+49 -2
View File
@@ -92,7 +92,9 @@ def _legacy_database(path, key: str) -> None:
"INSERT INTO users VALUES (8,'leefer',?,?,?,?,'admin','auto','active','永久',?,NULL)",
(encoded_salt, encoded_hash, now, now, now),
)
profile = fernet.encrypt(b'{"gender":"male"}').decode()
profile = fernet.encrypt(
b'{"birth_datetime":"1990-03-08T08:30:00","gender":"male"}'
).decode()
connection.execute("INSERT INTO user_birth_profiles VALUES (8,?,?)", (profile, now))
connection.execute(
"INSERT INTO llm_usage VALUES (1,8,'mentor','model','','success',3,?,'','','',0,0)",
@@ -109,12 +111,37 @@ def _legacy_database(path, key: str) -> None:
)
dashboard = json.dumps(
{
"meta": {
"requested_date": "2026-07-30",
"trade_date": "2026-07-29",
"previous_trade_date": "2026-07-28",
},
"overview": {
"up_count": 1,
"down_count": 0,
"flat_count": 0,
"limit_up_count": 1,
"limit_down_count": 0,
"broken_count": 0,
"amount_billion": 12.5,
"seal_rate": 100,
"sentiment_score": 64,
"sentiment_label": "情绪偏强",
"sentiment_phase": "修复",
"sentiment_direction": "升温",
"sentiment_components": {
"breadth": {"label": "市场宽度", "score": 100, "weight": 20}
},
"sentiment_engine_version": 2,
},
"limits": [
{
"ts_code": "000001.SZ",
"code": "000001",
"name": "Ping An Bank",
"reason": "legacy",
"amount_billion": 1.25,
"seal_amount_million": 20,
}
],
"broken": [],
@@ -122,7 +149,7 @@ def _legacy_database(path, key: str) -> None:
}
)
connection.execute(
"INSERT INTO dashboard_snapshots VALUES ('20260729','tushare',?,1,?)",
"INSERT INTO dashboard_snapshots VALUES ('20260730','tushare',?,1,?)",
(dashboard, now),
)
connection.execute(
@@ -347,6 +374,18 @@ def test_legacy_migration_is_idempotent_and_preserves_login(tmp_path) -> None:
password = connection.execute("SELECT password_hash FROM users WHERE id=8").fetchone()[0]
assert PasswordHasher().verify("Password123", password)
assert connection.execute("SELECT count(*) FROM watchlist_entries").fetchone()[0] == 1
profile_payload = json.loads(
Fernet(key.encode()).decrypt(
connection.execute(
"SELECT encrypted_payload FROM birth_profiles WHERE user_id=8"
).fetchone()[0].encode()
)
)
assert profile_payload == {
"birth_date": "1990-03-08",
"birth_time": "08:30",
"gender": "male",
}
assert connection.execute("SELECT count(*) FROM screener_runs").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM strategy_tracks").fetchone()[0] == 1
assert connection.execute("SELECT daily_llm_limit FROM memberships").fetchone()[0] == 61
@@ -363,6 +402,14 @@ def test_legacy_migration_is_idempotent_and_preserves_login(tmp_path) -> None:
).fetchone()[0]
)
assert summary["limits"][0]["identifier"] == "000001.SZ"
assert summary["trade_date"] == "2026-07-29"
assert summary["overview"]["limit_up"] == 1
assert summary["overview"]["amount"] == 1_250_000_000
assert summary["limits"][0]["amount"] == 125_000_000
assert summary["limits"][0]["seal_amount"] == 20_000_000
assert summary["sentiment"]["score"] == 64
assert summary["sentiment"]["phase"] == "修复"
assert summary["sentiment"]["components"][0]["key"] == "breadth"
all_revisions = tuple(
connection.execute(
"""SELECT * FROM market_event_revisions WHERE trade_date='2026-07-29'
+51
View File
@@ -475,6 +475,7 @@ def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
sentiment = calculate_sentiment(snapshot, [])
assert sentiment["score"] <= 15
assert sentiment["phase"] == "冰点"
assert sentiment["stats"]["yesterday_count"] == 0
assert {item["key"]: item["weight"] for item in sentiment["components"]} == {
"breadth": 20,
"limit_ecology": 25,
@@ -551,3 +552,53 @@ def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) ->
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
with database.read() as connection:
assert repository.latest_summary(connection, "2026-07-29") is None
def test_synced_snapshot_keeps_event_lists_separate_from_overview_counts(tmp_path) -> None:
database = Database(tmp_path / "sync-complete.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
with database.transaction() as connection:
repository.replace_calendar(
connection,
(
{"cal_date": "20260728", "is_open": 1, "pretrade_date": "20260727"},
{"cal_date": "20260729", "is_open": 1, "pretrade_date": "20260728"},
),
"tushare",
"2026-07-29T15:00:00+08:00",
)
repository.replace_stocks(
connection,
({
"ts_code": "000001.SZ", "symbol": "000001", "name": "平安银行",
"industry": "银行", "list_status": "L",
},),
"tushare",
"2026-07-29T15:00:00+08:00",
)
provider = TushareProvider("test-token")
stock = {
"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行",
"close": 10, "pct_chg": 5, "amount": 100, "limit_times": 1,
}
provider.snapshot_inputs = lambda *_: {
"daily": calculation_result([stock]),
"limit_up": calculation_result([]),
"limit_down": calculation_result([]),
"broken": calculation_result([stock]),
"previous_limit_up": calculation_result([]),
"price_limits": calculation_result([{"ts_code": "000001.SZ", "up_limit": 11}]),
}
service = MarketSnapshotService(
database,
repository,
DataGateway(database, repository, (provider,), DataSourcePolicy()),
)
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
broken = service.workspace("broken", "2026-07-29")
emotion = service.workspace("emotion", "2026-07-29")
assert len(broken["items"]) == 1
assert broken["items"][0]["identifier"] == "000001.SZ"
assert emotion["overview"]["broken"] == 1
+31
View File
@@ -21,6 +21,7 @@ from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.market.events import MarketEventService, apply_event_revisions
from backend.features.market.snapshot import build_realtime_inputs, build_snapshot
from backend.http.errors import AppError
from backend.jobs.repository import JobRepository
from backend.jobs.service import JobAlreadyRunning, JobService
from tests.support import run_scenario
@@ -108,6 +109,36 @@ def test_job_service_records_success_failure_attempts_and_exclusion(tmp_path) ->
)
def test_job_service_exposes_app_error_message_without_internal_tuple(tmp_path) -> None:
database = Database(tmp_path / "jobs.db")
MigrationRunner(database).upgrade(MIGRATIONS)
jobs = JobService(database, JobRepository())
with pytest.raises(AppError):
jobs.execute(
kind="market.refresh",
run_key="2026-07-30:failed",
requested_date="2026-07-30",
trigger="administrator",
operation=lambda: (_ for _ in ()).throw(
AppError("market_data_unavailable", "收盘行情读取失败,已保留原有快照", 503)
),
stale_after_seconds=120,
)
assert jobs.latest()[0]["error_message"] == "收盘行情读取失败,已保留原有快照"
with database.transaction() as connection:
connection.execute(
"UPDATE job_runs SET error_message = ? WHERE id = ?",
(
"('market_data_unavailable', '收盘行情读取失败,已保留原有快照', 503)",
jobs.latest()[0]["id"],
),
)
assert jobs.latest()[0]["error_message"] == "收盘行情读取失败,已保留原有快照"
class EventGateway:
reason = "银行板块走强"