Files
tick-stock-panel/backend/tests/test_factor_delete_fail_closed.py
kevin9327 a49c8b3b15 fix(factors): 删除因子被拒时定义已被删掉
DELETE /api/factors/custom/{id} 的 404 守卫把 store.delete_one 当成存在性
探测, 但它会真的 unlink 文件并返回 True。当定义在盘上却没进注册表时
(load_into_registry 对注册失败的定义只告警跳过, 如复合因子的成员已被强制
删除), get_factor 为 None, 短路求值会走到 delete_one, 定义随即被删除。

随后的引用检查再返回 409「该因子仍有引用, 拒绝删除」—— 接口声称拒绝, 定义
却已经没了; 用户改带 force=true 重试只会得到 404, 定义无法恢复。

把 fail-closed 的引用检查移到存在性判定之前, 保证任何拒绝路径都不改磁盘。
2026-09-09 07:20:16 +09:00

81 lines
2.6 KiB
Python

"""删除自定义因子: 有引用时必须 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