From d73333705c0b7f516fc5a243182ade13540e5d83 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:13:13 +0400 Subject: [PATCH] =?UTF-8?q?test(stages):=20aware-=D0=B4=D0=B0=D1=82=D1=8B?= =?UTF-8?q?=20=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../integration/test_form_phases_api_smoke.py | 43 ++++++ api/tests/unit/test_form_phase_service_tz.py | 130 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 api/tests/unit/test_form_phase_service_tz.py diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py index 9a6938b..adffca8 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -326,3 +326,46 @@ def test_form3_phases_delete(client, admin_tokens): ) assert response.status_code == 200 + + +def _instant(value: str): + from datetime import datetime + + return datetime.fromisoformat(value) + + +def test_form_phases_create_aware_dates_keep_instant(client, admin_tokens): + body = { + "sheet": "AHR", + "phase_code": "tz_aware_phase", + "role": "DFIP", + "column_keys": ["plan.q1"], + "opens_at": "2026-10-26T10:00:00+03:00", + "closes_at": "2026-10-27T10:00:00+03:00", + "direction": None, + } + headers = _auth_headers(admin_tokens) + response = client.post("/api/v1/stages/form/4", json=body, headers=headers) + try: + assert response.status_code == 200, response.text + result = response.json()["result"] + assert _instant(result["opens_at"]) == _instant(body["opens_at"]) + assert _instant(result["closes_at"]) == _instant(body["closes_at"]) + + # GET -> PATCH тем же значением не меняет момент времени + listed = client.get( + "/api/v1/stages/form/4?sheet=AHR&phase_code=tz_aware_phase", headers=headers + ).json()["result"] + assert len(listed) == 1 + patch = client.patch( + "/api/v1/stages/form/4/AHR/tz_aware_phase", + json={"opens_at": listed[0]["opens_at"], "closes_at": listed[0]["closes_at"]}, + headers=headers, + ) + assert patch.status_code == 200, patch.text + patched = patch.json()["result"] + assert patched["opens_at"] == listed[0]["opens_at"] + assert patched["closes_at"] == listed[0]["closes_at"] + assert _instant(patched["opens_at"]) == _instant(body["opens_at"]) + finally: + client.delete("/api/v1/stages/form/4/AHR/tz_aware_phase", headers=headers) diff --git a/api/tests/unit/test_form_phase_service_tz.py b/api/tests/unit/test_form_phase_service_tz.py new file mode 100644 index 0000000..019a58a --- /dev/null +++ b/api/tests/unit/test_form_phase_service_tz.py @@ -0,0 +1,130 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from src.core.timezones import timezone_from_offset, to_org_local_naive +from src.db.models.role import UserRoleEnum +from src.domain.schemas import ( + Form3PhaseCreate, + Form3PhaseUpdate, + FormPhaseCreate, + FormPhaseUpdate, +) +from src.services.form3_phase_service import Form3PhaseService +from src.services.form_phase_service import FormPhaseService + + +@pytest.mark.parametrize( + "value, offset, expected", + [ + (datetime(2026, 10, 26, 7, 0, tzinfo=timezone.utc), "+05", datetime(2026, 10, 26, 12, 0)), + ( + datetime(2026, 10, 26, 10, 0, tzinfo=timezone(timedelta(hours=3))), + "+05", + datetime(2026, 10, 26, 12, 0), + ), + (datetime(2026, 10, 26, 10, 0), "+05", datetime(2026, 10, 26, 10, 0)), + (datetime(2026, 10, 26, 10, 0), None, datetime(2026, 10, 26, 10, 0)), + (datetime(2026, 10, 26, 10, 0, tzinfo=timezone.utc), "-03", datetime(2026, 10, 26, 7, 0)), + ], +) +def test_to_org_local_naive(value, offset, expected): + result = to_org_local_naive(value, offset) + assert result == expected + assert result.tzinfo is None + + +def test_to_org_local_naive_keeps_instant(): + value = datetime(2026, 10, 26, 10, 0, tzinfo=timezone(timedelta(hours=3))) + local = to_org_local_naive(value, "+05") + assert local.replace(tzinfo=timezone_from_offset("+05")) == value + + +def test_to_org_local_naive_aware_without_offset_is_422(): + with pytest.raises(HTTPException) as exc: + to_org_local_naive(datetime(2026, 10, 26, 10, 0, tzinfo=timezone.utc), None) + assert exc.value.status_code == 422 + + +def _admin(): + return SimpleNamespace(role_id=UserRoleEnum.ADMIN) + + +def _form_service(): + service = FormPhaseService(db=AsyncMock()) + service.fp_repo = AsyncMock() + form = SimpleNamespace( + id=1, + form_type=SimpleNamespace(section_list=["plan"], sheet_list_with_directions=None), + ) + return service, form + + +MSK = timezone(timedelta(hours=3)) + + +@pytest.mark.asyncio +async def test_form_phase_create_normalizes_to_org_local(): + service, form = _form_service() + body = FormPhaseCreate( + sheet="AHR", phase_code="p", role="DFIP", column_keys=["plan.q1"], + opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK), + closes_at=datetime(2026, 10, 27, 10, 0), + ) + await service.create(body=body, user=_admin(), budget_form=form, utc_offset="+05") + kwargs = service.fp_repo.create.await_args.kwargs + assert kwargs["opens_at"] == datetime(2026, 10, 26, 12, 0) + assert kwargs["opens_at"].tzinfo is None + assert kwargs["closes_at"] == datetime(2026, 10, 27, 10, 0) + + +@pytest.mark.asyncio +async def test_form_phase_update_normalizes_only_given_fields(): + service, form = _form_service() + body = FormPhaseUpdate(opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK)) + await service.update( + sheet="AHR", direction=None, phase_code="p", body=body, + user=_admin(), budget_form=form, utc_offset="+05", + ) + data = service.fp_repo.update.await_args.kwargs["data"] + assert data == {"opens_at": datetime(2026, 10, 26, 12, 0)} + + +@pytest.mark.asyncio +async def test_form_phase_create_aware_without_offset_is_422(): + service, form = _form_service() + body = FormPhaseCreate( + sheet="AHR", phase_code="p", role="DFIP", column_keys=["plan.q1"], + opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK), + closes_at=datetime(2026, 10, 27, 10, 0), + ) + with pytest.raises(HTTPException) as exc: + await service.create(body=body, user=_admin(), budget_form=form, utc_offset=None) + assert exc.value.status_code == 422 + service.fp_repo.create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_form3_phase_create_and_update_normalize(): + service = Form3PhaseService(db=AsyncMock()) + service.f3p_repo = AsyncMock() + body = Form3PhaseCreate( + report_type="PLAN", year=2026, phase_code="p", role="DFIP", column_keys=[], + opens_at=datetime(2026, 10, 26, 10, 0, tzinfo=MSK), + closes_at=datetime(2026, 10, 27, 10, 0, tzinfo=MSK), + ) + await service.create(report_id=1, body=body, user=_admin(), utc_offset="+05") + kwargs = service.f3p_repo.create.await_args.kwargs + assert kwargs["opens_at"] == datetime(2026, 10, 26, 12, 0) + assert kwargs["closes_at"] == datetime(2026, 10, 27, 12, 0) + + await service.update( + report_id=1, phase_code="p", + body=Form3PhaseUpdate(closes_at=datetime(2026, 10, 27, 10, 0, tzinfo=MSK)), + user=_admin(), utc_offset="+05", + ) + data = service.f3p_repo.update.await_args.kwargs["data"] + assert data == {"closes_at": datetime(2026, 10, 27, 12, 0)}