"""M7.2b 因子组合测试:repo CRUD(幂等/去重)+ /api/composites(含方向填充/404/删除)。""" from __future__ import annotations import pytest from app.api import deps from app.domain.entities.composite import CompositeComponent, CompositeDefinition from app.infrastructure.persistence.sqlalchemy.base import Base from app.infrastructure.persistence.sqlalchemy.repositories.composite_impl import ( SqlAlchemyCompositeRepository, ) from app.main import app from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @pytest.fixture() def session(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'c.db'}", future=True) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine, expire_on_commit=False) with Session() as s: yield s def _comp(name="质量动量", **kw) -> CompositeDefinition: base = dict( name=name, description="测试组合", components=[ CompositeComponent(name="momentum_60", weight=0.7, direction="higher_is_better"), CompositeComponent(name="volatility_60", weight=0.3, direction="lower_is_better"), ], ) base.update(kw) return CompositeDefinition(**base) class TestCompositeRepository: def test_save_get_list(self, session) -> None: repo = SqlAlchemyCompositeRepository(session) saved = repo.save(_comp().model_copy(update={"id": "CF-TEST-1"})) session.commit() assert saved.id == "CF-TEST-1" got = repo.get("CF-TEST-1") assert got is not None and got.name == "质量动量" assert len(got.components) == 2 assert repo.list()[0].components[0].direction == "higher_is_better" def test_duplicate_name_rejected(self, session) -> None: repo = SqlAlchemyCompositeRepository(session) repo.save(_comp().model_copy(update={"id": "CF-A"})) session.commit() with pytest.raises(ValueError): repo.save(_comp().model_copy(update={"id": "CF-B"})) # 同名不同 id def test_delete(self, session) -> None: repo = SqlAlchemyCompositeRepository(session) repo.save(_comp().model_copy(update={"id": "CF-X"})) session.commit() assert repo.delete("CF-X") is True session.commit() assert repo.get("CF-X") is None assert repo.delete("CF-X") is False @pytest.fixture() def client(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine, expire_on_commit=False) def _session_override(): with Session() as s: yield s app.dependency_overrides[deps.get_session] = _session_override with TestClient(app) as c: yield c app.dependency_overrides.clear() class TestCompositesApi: def test_crud(self, client) -> None: body = { "name": "质量动量组合", "description": "动量+低波", "components": [ {"name": "momentum_60", "weight": 0.7}, {"name": "volatility_60", "weight": 0.3}, ], } created = client.post("/api/composites", json=body) assert created.status_code == 200 cid = created.json()["id"] assert cid.startswith("CF-") # 方向由注册表自动填充 dirs = {c["name"]: c["direction"] for c in created.json()["components"]} assert dirs == {"momentum_60": "higher_is_better", "volatility_60": "lower_is_better"} rows = client.get("/api/composites").json() assert len(rows) == 1 detail = client.get(f"/api/composites/{cid}").json() assert detail["name"] == "质量动量组合" assert client.delete(f"/api/composites/{cid}").status_code == 200 assert client.get(f"/api/composites/{cid}").status_code == 404 assert client.delete(f"/api/composites/{cid}").status_code == 404 def test_unknown_factor_rejected(self, client) -> None: resp = client.post( "/api/composites", json={ "name": "坏组合", "components": [{"name": "no_such_factor", "weight": 1}], }, ) assert resp.status_code == 400 assert "no_such_factor" in resp.json()["detail"] def test_duplicate_name_400(self, client) -> None: body = {"name": "同名", "components": [{"name": "momentum_60", "weight": 1}]} assert client.post("/api/composites", json=body).status_code == 200 assert client.post("/api/composites", json=body).status_code == 400