fix(stages): FormPhaseService.create отклоняет дубль этапа на листе и направлении (AURORA-1879)
Интеграционные тесты одноимённых этапов, PATCH/DELETE одного этапа, дубль без направления, direction в аудите. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e15fad226
commit
92f6049a73
@ -106,10 +106,21 @@ class FormPhaseService:
|
||||
form_phase=body,
|
||||
budget_form=budget_form,
|
||||
)
|
||||
budget_form_id = budget_form_id or budget_form.id
|
||||
opens_at = to_org_local_naive(body.opens_at, utc_offset)
|
||||
closes_at = to_org_local_naive(body.closes_at, utc_offset)
|
||||
existing = await self.fp_repo.get(
|
||||
budget_form_id=budget_form_id,
|
||||
sheet=body.sheet,
|
||||
direction=body.direction,
|
||||
phase_code=body.phase_code,
|
||||
)
|
||||
if existing:
|
||||
raise ValidationsError(
|
||||
errors={"phase_code": ["Этап с таким названием уже есть на этом листе и направлении"]}
|
||||
)
|
||||
return await self.fp_repo.create(
|
||||
budget_form_id=budget_form_id or budget_form.id,
|
||||
budget_form_id=budget_form_id,
|
||||
sheet=body.sheet,
|
||||
direction=body.direction,
|
||||
phase_code=body.phase_code,
|
||||
|
||||
@ -421,3 +421,122 @@ def test_form_phases_create_aware_dates_keep_instant(client, admin_tokens):
|
||||
assert _instant(patched["opens_at"]) == _instant(body["opens_at"])
|
||||
finally:
|
||||
client.delete("/api/v1/stages/form/4/AHR/tz_aware_phase", headers=headers)
|
||||
|
||||
|
||||
# AURORA-1879: одноимённые этапы Support/Development и дубли на листе без направления.
|
||||
def _phase_body(phase_code, role, direction, opens="2026-01-01T00:00:00", closes="2026-12-31T00:00:00", sheet="AHR"):
|
||||
return {
|
||||
"sheet": sheet,
|
||||
"phase_code": phase_code,
|
||||
"role": role,
|
||||
"column_keys": ["plan.q1"],
|
||||
"opens_at": opens,
|
||||
"closes_at": closes,
|
||||
"direction": direction,
|
||||
}
|
||||
|
||||
|
||||
def _create_same_name_phases(client, headers):
|
||||
sup = _phase_body("T_same", "DFIP", "Support")
|
||||
dev = _phase_body("T_same", "EXECUTOR_RF", "Development", "2026-02-01T00:00:00", "2026-11-30T00:00:00")
|
||||
assert client.post("/api/v1/stages/form/1", json=sup, headers=headers).status_code == 200
|
||||
assert client.post("/api/v1/stages/form/1", json=dev, headers=headers).status_code == 200
|
||||
|
||||
|
||||
def _list_same(client, headers, query=""):
|
||||
response = client.get(f"/api/v1/stages/form/1?phase_code=T_same{query}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
return response.json()["result"]
|
||||
|
||||
|
||||
def test_form_phases_same_name_both_directions_listed(client, admin_tokens):
|
||||
headers = _auth_headers(admin_tokens)
|
||||
_create_same_name_phases(client, headers)
|
||||
|
||||
result = _list_same(client, headers)
|
||||
by_direction = {p["direction"]: p for p in result}
|
||||
assert len(result) == 2
|
||||
assert by_direction["Support"]["role"] == "DFIP"
|
||||
assert by_direction["Development"]["role"] == "EXECUTOR_RF"
|
||||
assert by_direction["Support"]["opens_at"][:10] == "2026-01-01"
|
||||
assert by_direction["Development"]["opens_at"][:10] == "2026-02-01"
|
||||
|
||||
only_dev = _list_same(client, headers, "&sheet=AHR&direction=Development")
|
||||
assert len(only_dev) == 1
|
||||
assert only_dev[0]["direction"] == "Development"
|
||||
|
||||
|
||||
def test_form_phases_patch_touches_only_one_direction(client, admin_tokens):
|
||||
headers = _auth_headers(admin_tokens)
|
||||
_create_same_name_phases(client, headers)
|
||||
|
||||
response = client.patch(
|
||||
"/api/v1/stages/form/1/AHR/T_same?direction=Development",
|
||||
json={"role": "DFIP"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
by_direction = {p["direction"]: p for p in _list_same(client, headers)}
|
||||
assert by_direction["Development"]["role"] == "DFIP"
|
||||
assert by_direction["Support"]["role"] == "DFIP"
|
||||
assert by_direction["Support"]["opens_at"][:10] == "2026-01-01"
|
||||
assert by_direction["Support"]["closes_at"][:10] == "2026-12-31"
|
||||
assert by_direction["Development"]["closes_at"][:10] == "2026-11-30"
|
||||
|
||||
|
||||
def test_form_phases_delete_removes_only_named_phase(client, admin_tokens):
|
||||
headers = _auth_headers(admin_tokens)
|
||||
_create_same_name_phases(client, headers)
|
||||
other = _phase_body("T_other", "DFIP", "Support")
|
||||
assert client.post("/api/v1/stages/form/1", json=other, headers=headers).status_code == 200
|
||||
|
||||
response = client.delete("/api/v1/stages/form/1/AHR/T_same?direction=Support", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = client.get("/api/v1/stages/form/1?sheet=AHR", headers=headers).json()["result"]
|
||||
left = {(p["phase_code"], p["direction"]) for p in result if p["phase_code"] in ("T_same", "T_other")}
|
||||
assert left == {("T_other", "Support"), ("T_same", "Development")}
|
||||
|
||||
|
||||
def test_form_phases_delete_unknown_phase_code_404(client, admin_tokens):
|
||||
headers = _auth_headers(admin_tokens)
|
||||
# в фикстуре на форме 1, лист AHR, Support один этап "test"
|
||||
before = client.get("/api/v1/stages/form/1?sheet=AHR&direction=Support", headers=headers).json()["result"]
|
||||
response = client.delete("/api/v1/stages/form/1/AHR/no_such_phase?direction=Support", headers=headers)
|
||||
assert response.status_code == 404
|
||||
after = client.get("/api/v1/stages/form/1?sheet=AHR&direction=Support", headers=headers).json()["result"]
|
||||
assert [p["phase_code"] for p in after] == [p["phase_code"] for p in before]
|
||||
assert any(p["phase_code"] == "test" for p in after)
|
||||
|
||||
|
||||
def test_form_phases_duplicate_without_direction_rejected(client, admin_tokens):
|
||||
headers = _auth_headers(admin_tokens)
|
||||
body = _phase_body("T_dup", "DFIP", None, sheet="OPER")
|
||||
first = client.post("/api/v1/stages/form/1", json=body, headers=headers)
|
||||
assert first.status_code == 200
|
||||
second = client.post("/api/v1/stages/form/1", json=body, headers=headers)
|
||||
assert 400 <= second.status_code < 500
|
||||
assert "phase_code" in second.text
|
||||
|
||||
result = client.get("/api/v1/stages/form/1?sheet=OPER&phase_code=T_dup", headers=headers).json()["result"]
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_form_phases_patch_audit_has_direction(client, admin_tokens, svod2_sql):
|
||||
headers = _auth_headers(admin_tokens)
|
||||
_create_same_name_phases(client, headers)
|
||||
response = client.patch(
|
||||
"/api/v1/stages/form/1/AHR/T_same?direction=Development",
|
||||
json={"role": "DFIP"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
rows = svod2_sql(
|
||||
"SELECT event, event_data->>'direction', event_data->>'phase_id' FROM v3.audit_log "
|
||||
"WHERE event LIKE 'ACCESS_%' ORDER BY id DESC LIMIT 1"
|
||||
)
|
||||
assert rows, "аудит ACCESS_* не записан"
|
||||
event, direction, phase_id = rows[0]
|
||||
assert direction == "Development"
|
||||
assert phase_id == "1:AHR:Development:T_same"
|
||||
|
||||
@ -60,3 +60,36 @@ async def test_get_list_no_role_filter_for_admin(service_with_mock_repo, admin_u
|
||||
phase_code=None,
|
||||
role=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rejects_existing_phase(service_with_mock_repo, admin_user):
|
||||
from src.core.errors import ValidationsError
|
||||
from src.domain.schemas import FormPhaseCreate
|
||||
|
||||
service, mock_repo = service_with_mock_repo
|
||||
mock_repo.get = AsyncMock(return_value=SimpleNamespace(phase_code="T_dup"))
|
||||
mock_repo.create = AsyncMock()
|
||||
body = FormPhaseCreate(
|
||||
sheet="OPER",
|
||||
phase_code="T_dup",
|
||||
role="DFIP",
|
||||
column_keys=["plan.q1"],
|
||||
opens_at="2026-01-01T00:00:00",
|
||||
closes_at="2026-12-31T00:00:00",
|
||||
direction=None,
|
||||
)
|
||||
budget_form = SimpleNamespace(
|
||||
id=1, form_type=SimpleNamespace(sheet_list_with_directions=None, section_list=None)
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationsError) as exc:
|
||||
await service.create(body=body, user=admin_user, budget_form=budget_form)
|
||||
|
||||
assert exc.value.errors == {
|
||||
"phase_code": ["Этап с таким названием уже есть на этом листе и направлении"]
|
||||
}
|
||||
mock_repo.get.assert_awaited_once_with(
|
||||
budget_form_id=1, sheet="OPER", direction=None, phase_code="T_dup"
|
||||
)
|
||||
mock_repo.create.assert_not_awaited()
|
||||
|
||||
@ -56,6 +56,7 @@ def _admin():
|
||||
def _form_service():
|
||||
service = FormPhaseService(db=AsyncMock())
|
||||
service.fp_repo = AsyncMock()
|
||||
service.fp_repo.get.return_value = None # этапа с таким именем ещё нет
|
||||
form = SimpleNamespace(
|
||||
id=1,
|
||||
form_type=SimpleNamespace(section_list=["plan"], sheet_list_with_directions=None),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user