from __future__ import annotations import json from datetime import datetime, timedelta from zoneinfo import ZoneInfo import httpx import pytest from backend.bootstrap.application import create_application from backend.bootstrap.settings import Settings from backend.data.contracts import ( DataSource, DataUsage, ObservationMetadata, ProviderResult, SnapshotState, ) from backend.data.repository import MarketRepository 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 from tests.test_accounts import ( ADMIN_PASSWORD, USER_PASSWORD, register, use_session, ) SHANGHAI = ZoneInfo("Asia/Shanghai") def test_job_service_records_success_failure_attempts_and_exclusion(tmp_path) -> None: database = Database(tmp_path / "jobs.db") MigrationRunner(database).upgrade(MIGRATIONS) repository = JobRepository() jobs = JobService(database, repository) result = jobs.execute( kind="market.refresh", run_key="2026-07-30:manual", requested_date="2026-07-30", trigger="administrator", operation=lambda: { "coverage": 0.99, "source_set": ["tushare", "local"], "output_version": "market-summary-v1", }, stale_after_seconds=120, ) assert result["coverage"] == 0.99 with pytest.raises(RuntimeError, match="network unavailable"): jobs.execute( kind="market.refresh", run_key="2026-07-30:retry", requested_date="2026-07-30", trigger="after-close", operation=lambda: (_ for _ in ()).throw(RuntimeError("network unavailable")), stale_after_seconds=120, ) latest = jobs.latest() assert [row["status"] for row in latest[:2]] == ["failed", "completed"] assert latest[0]["error_message"] == "network unavailable" assert latest[1]["source_set"] == ["tushare", "local"] failure_finished = datetime.fromisoformat(latest[0]["finished_at"]) assert not jobs.ready_for_schedule( "market.refresh", now=failure_finished + timedelta(seconds=59), completed_after_seconds=8, failed_after_seconds=60, ) assert jobs.ready_for_schedule( "market.refresh", now=failure_finished + timedelta(seconds=60), completed_after_seconds=8, failed_after_seconds=60, ) with database.transaction() as connection: repository.begin( connection, kind="auction.collect", run_key="active", requested_date="2026-07-30", trigger="auction-poll", started_at=datetime.now(SHANGHAI), stale_after_seconds=60, ) with pytest.raises(JobAlreadyRunning): jobs.execute( kind="auction.collect", run_key="second", requested_date="2026-07-30", trigger="auction-poll", operation=lambda: {}, stale_after_seconds=60, ) assert not jobs.ready_for_schedule( "auction.collect", now=datetime.now(SHANGHAI) + timedelta(hours=1), completed_after_seconds=8, failed_after_seconds=60, ) 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 = "银行板块走强" def event_reasons(self, trade_date: str) -> ProviderResult: return ProviderResult( ( { "event_type": "limit_up", "identifier": "000001.SZ", "reason": self.reason, "first_time": "09:35", "last_time": "14:20", "open_times": 1, }, ), metadata(SnapshotState.FINAL), ) def test_admin_event_revision_wins_and_history_is_preserved(tmp_path) -> None: database = Database(tmp_path / "events.db") MigrationRunner(database).upgrade(MIGRATIONS) repository = MarketRepository() payload = { "limits": [{"identifier": "000001.SZ", "code": "000001", "reason": ""}], "broken": [], "down_limits": [], } with database.transaction() as connection: connection.execute( """ INSERT INTO users ( username, username_key, password_hash, is_admin, created_at, updated_at ) VALUES ('admin', 'admin', 'hash', 1, ?, ?) """, ("2026-07-30T09:00:00+08:00", "2026-07-30T09:00:00+08:00"), ) connection.execute( """ INSERT INTO market_summaries ( trade_date, observed_at, state, source, coverage, payload_json, created_at ) VALUES ('2026-07-30', '2026-07-30T15:00:00+08:00', 'final', 'tushare', 1, ?, '2026-07-30T15:05:00+08:00') """, (json.dumps(payload, ensure_ascii=False),), ) gateway = EventGateway() events = MarketEventService(database, repository, gateway) assert events.supplement("2026-07-30")["updated"] == 1 events.revise( trade_date="2026-07-30", identifier="000001.SZ", event_type="limit_up", reason="管理员核验原因", first_time="09:36", last_time="14:21", open_times=2, user_id=1, ) gateway.reason = "后续自动结果" assert events.supplement("2026-07-30")["updated"] == 0 with database.read() as connection: revisions = repository.event_revisions(connection, "2026-07-30") resolved = apply_event_revisions(payload, revisions) assert resolved["limits"][0]["reason"] == "管理员核验原因" assert resolved["limits"][0]["reason_source"] == "admin" history = events.history("2026-07-30", "000001.SZ") assert [row["source"] for row in history] == ["admin", "ifind"] def test_realtime_snapshot_uses_official_limits_and_yuan_amounts() -> None: inputs = { "daily": ProviderResult( ( { "ts_code": "000001.SZ", "open": 10.2, "high": 11, "low": 10.1, "close": 11, "pre_close": 10, "pct_chg": 10, "amount": 100_000_000, "amount_unit": "yuan", }, { "ts_code": "000002.SZ", "open": 10, "high": 11, "low": 9.9, "close": 10.5, "pre_close": 10, "pct_chg": 5, "amount": 200_000_000, "amount_unit": "yuan", }, ), metadata(SnapshotState.REALTIME), ), "price_limits": ProviderResult( ( {"ts_code": "000001.SZ", "up_limit": 11, "down_limit": 9}, {"ts_code": "000002.SZ", "up_limit": 11, "down_limit": 9}, ), metadata(SnapshotState.REALTIME), ), "previous_limit_up": ProviderResult( ({"ts_code": "000001.SZ", "limit_times": 2},), metadata(SnapshotState.FINAL), ), } directory = { "000001.SZ": {"name": "ST样本", "sector": "银行"}, "000002.SZ": {"name": "炸板样本", "sector": "银行"}, } snapshot = build_snapshot( "2026-07-30", "2026-07-29", build_realtime_inputs(inputs, directory), ) assert snapshot["overview"]["amount"] == 300_000_000 assert snapshot["limits"][0]["streak"] == 3 assert snapshot["limits"][0]["amount"] == 100_000_000 assert snapshot["broken"][0]["code"] == "000002" def test_operations_status_and_admin_audit_permissions(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: _, admin = await register(client, "operations-admin", ADMIN_PASSWORD) client.cookies.clear() _, regular = await register(client, "operations-user", USER_PASSWORD) status = await client.get("/api/operations/status") assert status.status_code == 200 assert status.json()["state"] == "degraded" assert (await client.get("/api/admin/operations/jobs")).status_code == 403 use_session(client, admin) jobs = await client.get("/api/admin/operations/jobs") assert jobs.status_code == 200 assert jobs.json() == [] run_scenario(application, scenario) def metadata(state: SnapshotState) -> ObservationMetadata: return ObservationMetadata( source=DataSource.TUSHARE, observed_at=datetime(2026, 7, 30, 10, 0, tzinfo=SHANGHAI), unit="mixed", adjustment="not_applicable", freshness_seconds=0, coverage=1, state=state, usage=DataUsage.CALCULATION, )