rebuild(migration): validate local legacy cutover
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "银行板块走强"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user