diff --git a/backend/app/api/factors.py b/backend/app/api/factors.py index 0879732..c8ca012 100644 --- a/backend/app/api/factors.py +++ b/backend/app/api/factors.py @@ -381,14 +381,16 @@ def delete_custom_factor(factor_id: str, request: Request, force: bool = Query(d data_dir = _data_dir(request) from app.factors.registry import get_factor - if get_factor(factor_id) is None and not store.delete_one(data_dir, factor_id): - raise HTTPException(status_code=404, detail=f"因子不存在: {factor_id}") + # 引用检查必须排在存在性判定之前: 下面用来探测「盘上是否有定义」的 + # store.delete_one 本身就会删文件, 反过来会出现「拒绝删除」但定义已被删掉。 references = _find_references(data_dir, factor_id) if references and not force: raise HTTPException( status_code=409, detail={"message": "该因子仍有引用, 拒绝删除 (可带 force=true 强制)", "references": references}, ) + if get_factor(factor_id) is None and not store.delete_one(data_dir, factor_id): + raise HTTPException(status_code=404, detail=f"因子不存在: {factor_id}") try: unregister_factor(factor_id) except ValueError as exc: diff --git a/backend/tests/test_factor_delete_fail_closed.py b/backend/tests/test_factor_delete_fail_closed.py new file mode 100644 index 0000000..c11d977 --- /dev/null +++ b/backend/tests/test_factor_delete_fail_closed.py @@ -0,0 +1,80 @@ +"""删除自定义因子: 有引用时必须 fail-closed —— 拒绝的同时定义文件不能已经被删掉。 + +触发路径: 定义在磁盘上但没进注册表 (load_into_registry 对注册失败的定义只告警跳过, +如复合因子的成员已被强制删除)。此时 get_factor() 为 None, 端点的 404 守卫会走到 +第二个分支, 而那个分支本身就会把文件删掉。 +""" +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.factors import router + +FACTOR_ID = "uf_orphan_delete_guard" + + +@pytest.fixture() +def env(tmp_path): + """磁盘上有一个未注册的自定义因子, 且被一个策略引用。""" + factor_dir = tmp_path / "user_data" / "custom_factors" + factor_dir.mkdir(parents=True) + factor_path = factor_dir / f"{FACTOR_ID}.json" + factor_path.write_text( + json.dumps({ + "id": FACTOR_ID, + "kind": "custom", + "label": "孤儿因子", + "formula": "rank(-ts_sum(change_pct, 5))", + "version": 1, + "status": "draft", + }, ensure_ascii=False), + encoding="utf-8", + ) + strategies_dir = tmp_path / "strategies" + strategies_dir.mkdir(parents=True) + (strategies_dir / "s1.json").write_text( + json.dumps({"id": "s1", "factors": [FACTOR_ID]}, ensure_ascii=False), + encoding="utf-8", + ) + + app = FastAPI() + app.include_router(router) + app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=Path(tmp_path))) + return TestClient(app), factor_path + + +def test_delete_with_references_keeps_definition(env): + """409 拒绝删除时定义必须还在盘上, 否则用户既看不到因子也无法恢复。""" + client, factor_path = env + + response = client.delete(f"/api/factors/custom/{FACTOR_ID}") + + assert response.status_code == 409 + assert response.json()["detail"]["references"] == ["strategies/s1.json"] + assert factor_path.exists() + + +def test_delete_with_force_removes_definition(env): + """force=true 仍按原语义强制删除。""" + client, factor_path = env + + response = client.delete(f"/api/factors/custom/{FACTOR_ID}?force=true") + + assert response.status_code == 200 + assert response.json()["removed_references"] == ["strategies/s1.json"] + assert not factor_path.exists() + + +def test_delete_missing_factor_returns_404(env): + """不存在的因子仍返回 404。""" + client, _ = env + + response = client.delete("/api/factors/custom/uf_not_there_at_all") + + assert response.status_code == 404