2026-09-12 01:24:02 +08:00
|
|
|
"""Preparation contracts across real HTTP, transactions and immutable research inputs."""
|
|
|
|
|
|
|
|
|
|
from argparse import Namespace
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
from app.catalog.contracts import CatalogJobInput, Scope
|
|
|
|
|
from app.catalog.service import Catalog
|
|
|
|
|
from app.models import CatalogBatch, Job, ResearchInputSnapshot
|
|
|
|
|
from tests.catalog_fake import field_records
|
|
|
|
|
from tests.test_catalog import SCOPE, sync
|
|
|
|
|
from tests.test_catalog import catalog as catalog_fixture
|
|
|
|
|
|
|
|
|
|
catalog = catalog_fixture
|
|
|
|
|
BASE = "/api/v1/data-preparations"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reference(field):
|
|
|
|
|
return {k: field[k] for k in ("scope", "dataset_id", "field_id", "source", "collection_version")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def copied(catalog):
|
|
|
|
|
client = catalog[0]
|
|
|
|
|
await sync(catalog)
|
|
|
|
|
job = await sync(catalog, "TEST_FIN")
|
|
|
|
|
response = await client.post(
|
|
|
|
|
BASE + "/from-dataset",
|
|
|
|
|
json={"scope": SCOPE, "dataset_id": "TEST_FIN", "collection_version": job["id"]},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 201, response.text
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_multi_dataset_collection_atomic_changes_and_snapshot_independence(catalog):
|
|
|
|
|
client, runner, state = catalog
|
|
|
|
|
collection = await copied(catalog)
|
|
|
|
|
state["fields"] = field_records("TEST_NEWS", 3)
|
|
|
|
|
await sync(catalog, "TEST_NEWS")
|
|
|
|
|
query = (await client.get("/api/v1/catalog/fields", params={**SCOPE, "dataset_id": "TEST_NEWS"})).json()
|
|
|
|
|
assert query["total"] == 3
|
|
|
|
|
ref = reference(query["items"][0])
|
|
|
|
|
response = await client.patch(
|
|
|
|
|
f"{BASE}/{collection['id']}/fields", json={"version": 1, "fields": [ref, ref]}
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
collection = response.json()
|
|
|
|
|
assert collection["field_count"] == 124 and collection["dataset_count"] == 2
|
|
|
|
|
bad = {**ref, "scope": {**SCOPE, "delay": 0}}
|
|
|
|
|
response = await client.patch(
|
|
|
|
|
f"{BASE}/{collection['id']}/fields",
|
|
|
|
|
json={"version": 2, "fields": [ref, bad], "remove_ids": ["TEST_FIN_001"]},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
assert (await client.get(f"{BASE}/{collection['id']}")).json()["version"] == 2
|
|
|
|
|
snapshot = (
|
|
|
|
|
await client.post(BASE + "/freeze", json={"items": [{"id": collection["id"], "version": 2}]})
|
|
|
|
|
).json()["items"][0]
|
|
|
|
|
assert snapshot["dataset_ids"] == ["TEST_FIN", "TEST_NEWS"]
|
|
|
|
|
assert all("description" in f and f["dataset_id"] for f in snapshot["fields"])
|
|
|
|
|
assert (
|
|
|
|
|
await client.patch(f"{BASE}/{collection['id']}", json={"version": 2, "name": "edited"})
|
|
|
|
|
).status_code == 200
|
|
|
|
|
assert (
|
|
|
|
|
await client.post(BASE + "/freeze", json={"items": [{"id": collection["id"], "version": 2}]})
|
|
|
|
|
).status_code == 409
|
|
|
|
|
assert (await client.delete(f"{BASE}/{collection['id']}?version=3")).status_code == 200
|
|
|
|
|
assert (await client.get(f"{BASE}/{collection['id']}")).status_code == 404
|
|
|
|
|
assert (await client.get(f"/api/v1/research/input-snapshots/{snapshot['id']}")).json() == snapshot
|
|
|
|
|
async with runner.sessions() as db:
|
|
|
|
|
assert await db.get(ResearchInputSnapshot, snapshot["id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"key,value", [("region", "CHN"), ("universe", "TOP1000"), ("delay", 0), ("instrument_type", "FUTURE")]
|
|
|
|
|
)
|
|
|
|
|
async def test_scope_dimensions_rejected_without_creating_collection(catalog, key, value):
|
|
|
|
|
client = catalog[0]
|
|
|
|
|
await copied(catalog)
|
|
|
|
|
field = (await client.get("/api/v1/catalog/fields", params=SCOPE)).json()["items"][0]
|
|
|
|
|
before = (await client.get(BASE)).json()["total"]
|
|
|
|
|
response = await client.post(
|
|
|
|
|
BASE, json={"name": "bad", "scope": {**SCOPE, key: value}, "fields": [reference(field)]}
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
assert (await client.get(BASE)).json()["total"] == before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_online_fields_without_sync_and_local_search_before_pagination(catalog):
|
|
|
|
|
client, _, _ = catalog
|
2026-09-12 02:13:48 +08:00
|
|
|
params = {**SCOPE, "dataset_id": "TEST_FIN", "limit": 100}
|
|
|
|
|
response = await client.get("/api/v1/catalog/worldquant/fields", params=params)
|
2026-09-12 01:24:02 +08:00
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
first = response.json()
|
|
|
|
|
assert len(first["items"]) == 100 and first["has_more"]
|
|
|
|
|
second = (
|
2026-09-12 02:13:48 +08:00
|
|
|
await client.get("/api/v1/catalog/worldquant/fields", params={**params, "offset": 100})
|
2026-09-12 01:24:02 +08:00
|
|
|
).json()
|
|
|
|
|
assert len(second["items"]) > 0
|
|
|
|
|
assert (await client.get("/api/v1/catalog/fields", params=SCOPE)).json()["total"] == 0
|
|
|
|
|
field = reference(second["items"][-1])
|
|
|
|
|
response = await client.post(BASE, json={"name": "online only", "scope": SCOPE, "fields": [field]})
|
|
|
|
|
assert response.status_code == 201, response.text
|
|
|
|
|
assert response.json()["field_count"] == 1
|
|
|
|
|
assert (await client.get("/api/v1/catalog/fields", params=SCOPE)).json()["total"] == 0
|
|
|
|
|
await copied(catalog)
|
|
|
|
|
query = (
|
|
|
|
|
await client.get(
|
|
|
|
|
"/api/v1/catalog/fields", params={**SCOPE, "q": "合成字段说明 11", "limit": 2, "offset": 2}
|
|
|
|
|
)
|
|
|
|
|
).json()
|
|
|
|
|
assert query["total"] == 11 and len(query["items"]) == 2
|
|
|
|
|
response = await client.get(
|
|
|
|
|
"/api/v1/catalog/fields", params={**SCOPE, "coverage_min": 0.9, "coverage_max": 0.5}
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_empty_crud_copy_and_batch_delete_are_version_checked(catalog):
|
|
|
|
|
client = catalog[0]
|
|
|
|
|
first = (await client.post(BASE, json={"name": "empty", "scope": SCOPE})).json()
|
|
|
|
|
assert first["field_count"] == 0
|
|
|
|
|
assert (
|
|
|
|
|
await client.post(BASE + "/freeze", json={"items": [{"id": first["id"], "version": 1}]})
|
|
|
|
|
).status_code == 422
|
|
|
|
|
second = (await client.post(f"{BASE}/{first['id']}/copy", json={"version": 1})).json()
|
|
|
|
|
response = await client.post(
|
|
|
|
|
BASE + "/batch-delete",
|
|
|
|
|
json={"items": [{"id": first["id"], "version": 1}, {"id": second["id"], "version": 99}]},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 409
|
|
|
|
|
assert (await client.get(BASE)).json()["total"] == 2
|
|
|
|
|
assert (
|
|
|
|
|
await client.patch(
|
|
|
|
|
f"{BASE}/{first['id']}", json={"name": "x", "version": 1, "scope": {**SCOPE, "delay": 0}}
|
|
|
|
|
)
|
|
|
|
|
).status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_full_sync_partial_failure_and_resume_publish_per_dataset(catalog):
|
|
|
|
|
client, runner, state = catalog
|
|
|
|
|
await copied(catalog)
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
service = Catalog(db)
|
|
|
|
|
job = await service.create_job(CatalogJobInput(scope=Scope(**SCOPE)), full=True)
|
|
|
|
|
duplicate = await service.create_job(CatalogJobInput(scope=Scope(**SCOPE)), full=True)
|
|
|
|
|
assert duplicate.id == job.id
|
|
|
|
|
# The fixture deliberately returns TEST_FIN ownership for the other datasets.
|
|
|
|
|
await runner.execute(job.id)
|
|
|
|
|
failed = (await client.get(f"/api/v1/sync-jobs/{job.id}")).json()
|
|
|
|
|
assert failed["status"] == "completed_with_errors" and failed["processed"] == 1
|
|
|
|
|
version = (await client.get("/api/v1/catalog/datasets/TEST_FIN", params=SCOPE)).json()[
|
|
|
|
|
"collection_version"
|
|
|
|
|
]
|
|
|
|
|
assert version != job.id
|
|
|
|
|
async with runner.sessions() as db:
|
|
|
|
|
assert (await db.get(CatalogBatch, version)).job_id == job.id
|
|
|
|
|
state["fields"] = field_records("TEST_NEWS", 3)
|
|
|
|
|
await client.post(f"/api/v1/sync-jobs/{job.id}/retry")
|
|
|
|
|
await runner.execute(job.id)
|
|
|
|
|
retried = (await client.get(f"/api/v1/sync-jobs/{job.id}")).json()
|
|
|
|
|
assert retried["processed"] == 2 and retried["failed"] == 1
|
|
|
|
|
assert (await client.get("/api/v1/catalog/datasets/TEST_FIN", params=SCOPE)).json()[
|
|
|
|
|
"collection_version"
|
|
|
|
|
] == version
|
|
|
|
|
state["fields"] = field_records("TEST_UNKNOWN", 0)
|
|
|
|
|
await client.post(f"/api/v1/sync-jobs/{job.id}/retry")
|
|
|
|
|
await runner.execute(job.id)
|
|
|
|
|
done = (await client.get(f"/api/v1/sync-jobs/{job.id}")).json()
|
|
|
|
|
assert done["status"] == "completed" and done["processed"] == 3 and done["failed"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_cli_timeout_reuses_active_job_and_does_not_cancel(catalog, monkeypatch, capsys):
|
|
|
|
|
from app import cli
|
|
|
|
|
|
|
|
|
|
_, runner, _ = catalog
|
|
|
|
|
monkeypatch.setattr(cli, "Settings", lambda: runner.settings)
|
|
|
|
|
args = Namespace(
|
|
|
|
|
region="USA",
|
|
|
|
|
universe="TOP3000",
|
|
|
|
|
delay=1,
|
|
|
|
|
instrument_type="EQUITY",
|
|
|
|
|
resume_job=None,
|
|
|
|
|
wait_timeout=0.01,
|
|
|
|
|
)
|
|
|
|
|
assert await cli.catalog_sync_command(args) == 4
|
|
|
|
|
assert await cli.catalog_sync_command(args) == 4
|
|
|
|
|
async with runner.sessions() as db:
|
|
|
|
|
rows = list(await db.scalars(select(Job).where(Job.kind == "catalog_full_sync")))
|
|
|
|
|
assert len(rows) == 1 and rows[0].status == "queued" and not rows[0].cancel_requested
|
|
|
|
|
assert "等待超时" in capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_collection_version_checked_at_research_submission(catalog):
|
|
|
|
|
client = catalog[0]
|
|
|
|
|
collection = await copied(catalog)
|
|
|
|
|
response = await client.patch(f"{BASE}/{collection['id']}", json={"version": 1, "name": "changed"})
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
body = {
|
|
|
|
|
"inline": {
|
|
|
|
|
"name": "prepared backtest",
|
|
|
|
|
"preparation_refs": [{"id": collection["id"], "version": 1}],
|
|
|
|
|
"candidates": [
|
|
|
|
|
{
|
|
|
|
|
"client_item_id": "one",
|
|
|
|
|
"expression": "rank(TEST_FIN_001)",
|
|
|
|
|
"settings": {k: SCOPE[k] for k in ("region", "universe", "delay")},
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assert (await client.post("/api/v1/backtests/previews", json=body)).status_code == 409
|
|
|
|
|
body["inline"]["preparation_refs"][0]["version"] = 2
|
|
|
|
|
response = await client.post("/api/v1/backtests/previews", json=body)
|
|
|
|
|
assert response.status_code == 201, response.text
|
|
|
|
|
snapshot_id = response.json()["source"]["input_snapshot_ids"][0]
|
|
|
|
|
await client.delete(f"{BASE}/{collection['id']}?version=2")
|
|
|
|
|
assert (await client.get(f"/api/v1/research/input-snapshots/{snapshot_id}")).json()["fields"][1][
|
|
|
|
|
"dataset_id"
|
|
|
|
|
] == "TEST_FIN"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"status,code",
|
|
|
|
|
[
|
|
|
|
|
("completed", 0),
|
|
|
|
|
("completed_with_errors", 1),
|
|
|
|
|
("failed", 1),
|
|
|
|
|
("cancelled", 1),
|
|
|
|
|
("waiting_connection", 3),
|
|
|
|
|
("waiting_auth", 3),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
async def test_cli_terminal_exit_codes(catalog, monkeypatch, status, code):
|
|
|
|
|
from app import cli
|
|
|
|
|
|
|
|
|
|
_, runner, _ = catalog
|
|
|
|
|
monkeypatch.setattr(cli, "Settings", lambda: runner.settings)
|
|
|
|
|
original = Catalog.create_job
|
|
|
|
|
|
|
|
|
|
async def terminal(self, body, **kwargs):
|
|
|
|
|
result = await original(self, body, **kwargs)
|
|
|
|
|
row = await self.db.get(Job, result.id)
|
|
|
|
|
row.status = status
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(Catalog, "create_job", terminal)
|
|
|
|
|
args = Namespace(
|
|
|
|
|
region="USA", universe="TOP3000", delay=1, instrument_type="EQUITY", resume_job=None, wait_timeout=1
|
|
|
|
|
)
|
|
|
|
|
assert await cli.catalog_sync_command(args) == code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_full_sync_restart_keeps_page_and_auth_pauses_all_datasets(catalog, monkeypatch):
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
from app.worldquant import WqError
|
|
|
|
|
|
|
|
|
|
client, runner, _ = catalog
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
job = await Catalog(db).create_job(CatalogJobInput(scope=Scope(**SCOPE)), full=True)
|
|
|
|
|
original = runner.client.catalog_page
|
|
|
|
|
interrupted = False
|
|
|
|
|
|
|
|
|
|
async def pages(scope, dataset_id, offset):
|
|
|
|
|
nonlocal interrupted
|
|
|
|
|
if dataset_id == "TEST_FIN" and offset == 100 and not interrupted:
|
|
|
|
|
interrupted = True
|
|
|
|
|
runner.stopping = True
|
|
|
|
|
raise asyncio.CancelledError()
|
|
|
|
|
if dataset_id == "TEST_NEWS":
|
|
|
|
|
raise WqError("synthetic network interruption", "network_error")
|
|
|
|
|
return await original(scope, dataset_id, offset)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(runner.client, "catalog_page", pages)
|
|
|
|
|
await runner.execute(job.id)
|
|
|
|
|
async with runner.sessions() as db:
|
|
|
|
|
batch = await db.scalar(
|
|
|
|
|
select(CatalogBatch).where(CatalogBatch.job_id == job.id, CatalogBatch.dataset_id == "TEST_FIN")
|
|
|
|
|
)
|
|
|
|
|
assert batch.offset == 100 and not batch.complete
|
|
|
|
|
assert (await db.get(Job, job.id)).status == "queued"
|
|
|
|
|
runner.stopping = False
|
|
|
|
|
await runner.execute(job.id)
|
|
|
|
|
row = (await client.get(f"/api/v1/sync-jobs/{job.id}")).json()
|
|
|
|
|
assert row["status"] == "waiting_connection" and row["processed"] == 1
|
|
|
|
|
assert row["checkpoint"]["dataset_id"] == "TEST_NEWS"
|
|
|
|
|
assert row["checkpoint"]["datasets_completed"] == 1
|
|
|
|
|
async with runner.sessions() as db:
|
|
|
|
|
assert not await db.scalar(
|
|
|
|
|
select(CatalogBatch).where(
|
|
|
|
|
CatalogBatch.job_id == job.id, CatalogBatch.dataset_id == "TEST_UNKNOWN"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await client.post(f"/api/v1/sync-jobs/{job.id}/cancel")
|
|
|
|
|
assert (await client.get(f"/api/v1/sync-jobs/{job.id}")).json()["status"] == "cancelled"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_online_instrument_type_is_verified(catalog):
|
|
|
|
|
client, _, state = catalog
|
|
|
|
|
state["fields"][0]["instrumentType"] = "FUTURE"
|
2026-09-12 02:13:48 +08:00
|
|
|
response = await client.get("/api/v1/catalog/worldquant/fields", params={**SCOPE, "q": "TEST"})
|
|
|
|
|
assert response.status_code == 502
|
2026-09-12 01:24:02 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_mcp_collection_reads_and_versioned_submit_contract(catalog):
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
from app.research_access.contracts import PreparationRead, PreparationSearch, Submit
|
|
|
|
|
from app.research_access.service import ResearchAccess
|
|
|
|
|
|
|
|
|
|
client, runner, _ = catalog
|
|
|
|
|
collection = await copied(catalog)
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
access = ResearchAccess(
|
|
|
|
|
db, SimpleNamespace(token_id="test", admin_id=1), runner.client, "http://testserver"
|
|
|
|
|
)
|
|
|
|
|
result = await access.preparations(PreparationSearch(q=collection["name"]))
|
|
|
|
|
assert result["items"][0]["version"] == 1
|
|
|
|
|
detail = await access.preparation(PreparationRead(id=collection["id"], version=1, limit=1))
|
|
|
|
|
assert detail["fields"]["has_more"] and detail["fields"]["items"][0]["dataset_id"] == "TEST_FIN"
|
|
|
|
|
# Schema accepts versioned references and rejects accidental old dataset input payloads.
|
|
|
|
|
from tests.test_mcp import submission
|
|
|
|
|
|
|
|
|
|
payload = submission()
|
|
|
|
|
payload["preparation_refs"] = [{"id": collection["id"], "version": 1}]
|
|
|
|
|
assert Submit.model_validate(payload).preparation_refs[0].id == collection["id"]
|
|
|
|
|
|
|
|
|
|
async def test_retry_full_job_reuses_another_active_job(catalog):
|
|
|
|
|
from app.business import Business
|
|
|
|
|
_, runner, _ = catalog
|
|
|
|
|
body = CatalogJobInput(scope=Scope(**SCOPE))
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
old = await Catalog(db).create_job(body, full=True)
|
|
|
|
|
(await db.get(Job, old.id)).status = "failed"
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
new = await Catalog(db).create_job(body, full=True)
|
|
|
|
|
assert new.id != old.id
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
result = await Business(db).retry_job(old.id)
|
|
|
|
|
assert result["id"] == new.id
|
|
|
|
|
assert (await db.get(Job, old.id)).status == "failed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_retry_waiting_full_job_requeues_its_checkpoint(catalog):
|
|
|
|
|
from app.business import Business
|
|
|
|
|
_, runner, _ = catalog
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
job = await Catalog(db).create_job(CatalogJobInput(scope=Scope(**SCOPE)), full=True)
|
|
|
|
|
row = await db.get(Job, job.id)
|
|
|
|
|
row.status, row.checkpoint = "waiting_connection", {"offset": 100}
|
|
|
|
|
async with runner.sessions.begin() as db:
|
|
|
|
|
result = await Business(db).retry_job(job.id)
|
|
|
|
|
assert result["status"] == "queued" and result["checkpoint"]["offset"] == 100
|
2026-09-12 02:13:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_online_fields_reject_empty_criteria_before_requesting_platform(catalog):
|
|
|
|
|
client, _, state = catalog
|
|
|
|
|
before = list(state["calls"])
|
|
|
|
|
for params in (SCOPE, {**SCOPE, "q": " ", "dataset_id": ""}):
|
|
|
|
|
response = await client.get("/api/v1/catalog/worldquant/fields", params=params)
|
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
assert state["calls"] == before
|