diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index e0e7d592767b..ca0e90c4d0f0 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -73,12 +73,11 @@ class Meta: exclude = ["description_json", "description_stripped"] def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("target_date", None) is not None - and data.get("start_date", None) > data.get("target_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed target date") + if "start_date" in data or "target_date" in data: + start_date = data.get("start_date", getattr(self.instance, "start_date", None)) + target_date = data.get("target_date", getattr(self.instance, "target_date", None)) + if start_date is not None and target_date is not None and start_date > target_date: + raise serializers.ValidationError("Start date cannot exceed target date") try: if data.get("description_html", None) is not None: diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 2e116cd66136..b8dc1ed994f6 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -125,12 +125,11 @@ def validate(self, attrs): allow_triage = self.context.get("allow_triage_state", False) state_manager = State.triage_objects if allow_triage else State.objects - if ( - attrs.get("start_date", None) is not None - and attrs.get("target_date", None) is not None - and attrs.get("start_date", None) > attrs.get("target_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed target date") + if "start_date" in attrs or "target_date" in attrs: + start_date = attrs.get("start_date", getattr(self.instance, "start_date", None)) + target_date = attrs.get("target_date", getattr(self.instance, "target_date", None)) + if start_date is not None and target_date is not None and start_date > target_date: + raise serializers.ValidationError("Start date cannot exceed target date") # Validate description content for security if "description_html" in attrs and attrs["description_html"]: diff --git a/apps/api/plane/tests/contract/api/test_issue_date_validation.py b/apps/api/plane/tests/contract/api/test_issue_date_validation.py new file mode 100644 index 000000000000..e31e24919d09 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_issue_date_validation.py @@ -0,0 +1,89 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from datetime import date +from unittest import mock + +import pytest +from rest_framework import status + +from plane.db.models import Issue, Project, ProjectMember, State + + +@pytest.fixture +def scheduled_issue(db, workspace, create_user): + project = Project.objects.create(name="Test Project", identifier="TP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + state = State.objects.create(name="Todo", project=project, workspace=workspace, group="backlog", default=True) + return Issue.objects.create( + name="Scheduled work item", + project=project, + workspace=workspace, + state=state, + created_by=create_user, + start_date=date(2026, 1, 10), + target_date=date(2026, 1, 20), + ) + + +@pytest.fixture(params=["public-api", "app-api"]) +def endpoint(request, workspace, scheduled_issue): + project_path = f"workspaces/{workspace.slug}/projects/{scheduled_issue.project_id}" + if request.param == "public-api": + client = request.getfixturevalue("api_key_client") + url = f"/api/v1/{project_path}/work-items/{scheduled_issue.id}/" + success_status = status.HTTP_200_OK + else: + client = request.getfixturevalue("api_client") + client.force_login(request.getfixturevalue("create_user")) + url = f"/api/{project_path}/issues/{scheduled_issue.id}/" + success_status = status.HTTP_204_NO_CONTENT + return client, url, success_status + + +@pytest.fixture(autouse=True) +def mock_deferred_tasks(): + """Keep activity/webhook delivery outside these request and persistence tests.""" + with ( + mock.patch("plane.api.views.issue.issue_activity.delay"), + mock.patch("plane.api.views.issue.model_activity.delay"), + mock.patch("plane.app.views.issue.base.issue_description_version_task.delay"), + ): + yield + + +@pytest.mark.contract +@pytest.mark.django_db +class TestIssueDateValidation: + @pytest.mark.parametrize("dates", [{"start_date": "2026-01-21"}, {"target_date": "2026-01-09"}]) + def test_invalid_partial_date_update_does_not_change_work_item(self, endpoint, scheduled_issue, dates): + client, url, _ = endpoint + updated_at = scheduled_issue.updated_at + + response = client.patch(url, {"name": "Should not be saved", **dates}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data == {"non_field_errors": ["Start date cannot exceed target date"]} + scheduled_issue.refresh_from_db() + assert scheduled_issue.name == "Scheduled work item" + assert scheduled_issue.start_date == date(2026, 1, 10) + assert scheduled_issue.target_date == date(2026, 1, 20) + assert scheduled_issue.updated_at == updated_at + + def test_valid_partial_date_update_and_clearing_date_are_persisted(self, endpoint, scheduled_issue): + client, url, success_status = endpoint + + response = client.patch(url, {"start_date": "2026-01-20"}, format="json") + + assert response.status_code == success_status + scheduled_issue.refresh_from_db() + assert scheduled_issue.start_date == date(2026, 1, 20) + assert scheduled_issue.target_date == date(2026, 1, 20) + + response = client.patch(url, {"target_date": None}, format="json") + + assert response.status_code == success_status + scheduled_issue.refresh_from_db() + assert scheduled_issue.start_date == date(2026, 1, 20) + assert scheduled_issue.target_date is None diff --git a/apps/api/plane/tests/unit/serializers/test_issue_date_validation.py b/apps/api/plane/tests/unit/serializers/test_issue_date_validation.py new file mode 100644 index 000000000000..c8dd8d107f72 --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_issue_date_validation.py @@ -0,0 +1,93 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from datetime import date + +import pytest + +from plane.api.serializers.issue import IssueSerializer +from plane.app.serializers.issue import IssueCreateSerializer +from plane.db.models import Issue + + +@pytest.fixture(params=[IssueSerializer, IssueCreateSerializer], ids=["public-api", "app-api"]) +def serializer_class(request): + return request.param + + +@pytest.mark.unit +class TestIssueDateValidation: + @pytest.mark.parametrize( + "payload", + [ + {"start_date": "2026-01-21"}, + {"target_date": "2026-01-09"}, + ], + ) + def test_partial_update_rejects_invalid_range_against_existing_date(self, serializer_class, payload): + issue = Issue(name="Scheduled work item", start_date=date(2026, 1, 10), target_date=date(2026, 1, 20)) + serializer = serializer_class(issue, data=payload, partial=True) + + assert not serializer.is_valid() + assert serializer.errors["non_field_errors"] == ["Start date cannot exceed target date"] + + @pytest.mark.parametrize( + "payload", + [ + {"start_date": "2026-01-15"}, + {"target_date": "2026-01-15"}, + {"start_date": "2026-01-20"}, + {"target_date": "2026-01-10"}, + {"start_date": None}, + {"target_date": None}, + {"start_date": None, "target_date": None}, + {"start_date": "2026-02-01", "target_date": "2026-02-10"}, + ], + ) + def test_partial_update_accepts_valid_ranges_and_clearing_dates(self, serializer_class, payload): + issue = Issue(name="Scheduled work item", start_date=date(2026, 1, 10), target_date=date(2026, 1, 20)) + serializer = serializer_class(issue, data=payload, partial=True) + + assert serializer.is_valid(), serializer.errors + for field, value in payload.items(): + assert serializer.validated_data[field] == (date.fromisoformat(value) if value else None) + + @pytest.mark.parametrize("field", ["start_date", "target_date"]) + def test_partial_update_accepts_date_when_other_date_is_unset(self, serializer_class, field): + issue = Issue(name="Unscheduled work item") + serializer = serializer_class(issue, data={field: "2026-01-10"}, partial=True) + + assert serializer.is_valid(), serializer.errors + + def test_unrelated_update_does_not_revalidate_existing_dates(self, serializer_class): + issue = Issue(name="Legacy work item", start_date=date(2026, 1, 20), target_date=date(2026, 1, 10)) + serializer = serializer_class(issue, data={"name": "Renamed work item"}, partial=True) + + assert serializer.is_valid(), serializer.errors + + @pytest.mark.parametrize("partial", [False, True], ids=["create", "partial-update"]) + def test_rejects_invalid_range_with_both_dates_supplied(self, serializer_class, partial): + serializer = serializer_class( + instance=Issue(name="Existing work item") if partial else None, + data={"name": "Scheduled work item", "start_date": "2026-01-20", "target_date": "2026-01-10"}, + partial=partial, + ) + + assert not serializer.is_valid() + assert serializer.errors["non_field_errors"] == ["Start date cannot exceed target date"] + + @pytest.mark.parametrize( + "dates", + [ + {}, + {"start_date": "2026-01-10"}, + {"target_date": "2026-01-20"}, + {"start_date": "2026-01-10", "target_date": "2026-01-20"}, + {"start_date": "2026-01-10", "target_date": "2026-01-10"}, + ], + ) + def test_create_accepts_valid_dates(self, serializer_class, dates): + serializer = serializer_class(data={"name": "Scheduled work item", **dates}) + + assert serializer.is_valid(), serializer.errors