298 lines
10 KiB
Python
298 lines
10 KiB
Python
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,
|
|
)
|