diff --git a/plane/__init__.py b/plane/__init__.py index 943947f..91d3c85 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -1,5 +1,6 @@ from .api.agent_runs import AgentRuns from .api.cycles import Cycles +from .api.estimates import Estimates from .api.initiatives import Initiatives from .api.labels import Labels from .api.milestones import Milestones @@ -44,6 +45,7 @@ "Milestones", "Modules", "Cycles", + "Estimates", "Pages", "Workspaces", "PlaneError", diff --git a/plane/api/base_resource.py b/plane/api/base_resource.py index b0a711b..74de0c6 100644 --- a/plane/api/base_resource.py +++ b/plane/api/base_resource.py @@ -36,7 +36,9 @@ def _get(self, endpoint: str, params: Mapping[str, Any] | None = None) -> Any: ) return self._handle_response(response) - def _post(self, endpoint: str, data: Mapping[str, Any] | None = None) -> Any: + def _post( + self, endpoint: str, data: Mapping[str, Any] | list[Any] | None = None + ) -> Any: url = self._build_url(endpoint) response = self.session.post( url, headers=self._headers(), json=data, timeout=self.config.timeout diff --git a/plane/api/estimates.py b/plane/api/estimates.py new file mode 100644 index 0000000..d44fc43 --- /dev/null +++ b/plane/api/estimates.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from typing import Any + +from ..models.estimates import ( + CreateEstimate, + CreateEstimatePoint, + Estimate, + EstimatePoint, + UpdateEstimate, + UpdateEstimatePoint, +) +from .base_resource import BaseResource + + +class Estimates(BaseResource): + """Resource for managing project estimates and estimate points.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # ── Estimate CRUD ──────────────────────────────────────────── + + def create( + self, + workspace_slug: str, + project_id: str, + data: CreateEstimate, + ) -> Estimate: + """Create a new estimate for a project. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + data: Estimate creation data. + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/estimates", + data.model_dump(exclude_none=True), + ) + return Estimate.model_validate(response) + + def link_to_project( + self, + workspace_slug: str, + project_id: str, + estimate_id: str, + ) -> Any: + """Link an estimate to a project so that it becomes the active estimate system. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + estimate_id: UUID of the estimate to link. + """ + from ..models.projects import UpdateProject + from .projects import Projects + + projects_client = Projects(self.config) + return projects_client.update( + workspace_slug, + project_id, + UpdateProject(estimate=estimate_id), + ) + + def retrieve( + self, + workspace_slug: str, + project_id: str, + ) -> Estimate: + """Retrieve the estimate configured for a project. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/estimates", + ) + return Estimate.model_validate(response) + + def update( + self, + workspace_slug: str, + project_id: str, + data: UpdateEstimate, + ) -> Estimate: + """Update the estimate for a project. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + data: Fields to update. + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}/estimates", + data.model_dump(exclude_none=True), + ) + return Estimate.model_validate(response) + + def delete( + self, + workspace_slug: str, + project_id: str, + ) -> None: + """Delete the estimate for a project. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + """ + return self._delete( + f"{workspace_slug}/projects/{project_id}/estimates", + ) + + # ── Estimate Points ────────────────────────────────────────── + + def list_points( + self, + workspace_slug: str, + project_id: str, + estimate_id: str, + ) -> list[EstimatePoint]: + """List all estimate points for a project estimate. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + estimate_id: UUID of the estimate. + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}" + f"/estimates/{estimate_id}/estimate-points", + ) + return [EstimatePoint.model_validate(item) for item in response] + + def create_points( + self, + workspace_slug: str, + project_id: str, + estimate_id: str, + data: list[CreateEstimatePoint], + ) -> list[EstimatePoint]: + """Create estimate points for a project estimate. + + The API accepts a JSON array directly as the request body. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + estimate_id: UUID of the estimate. + data: List of estimate point creation data. + """ + payload = [item.model_dump(exclude_none=True) for item in data] + response = self._post( + f"{workspace_slug}/projects/{project_id}" + f"/estimates/{estimate_id}/estimate-points", + payload, + ) + return [EstimatePoint.model_validate(item) for item in response] + + def update_point( + self, + workspace_slug: str, + project_id: str, + estimate_id: str, + estimate_point_id: str, + data: UpdateEstimatePoint, + ) -> EstimatePoint: + """Update a single estimate point. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + estimate_id: UUID of the estimate. + estimate_point_id: UUID of the estimate point. + data: Fields to update. + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}" + f"/estimates/{estimate_id}/estimate-points/{estimate_point_id}", + data.model_dump(exclude_none=True), + ) + return EstimatePoint.model_validate(response) + + def delete_point( + self, + workspace_slug: str, + project_id: str, + estimate_id: str, + estimate_point_id: str, + ) -> None: + """Delete a single estimate point. + + Args: + workspace_slug: The workspace slug identifier. + project_id: UUID of the project. + estimate_id: UUID of the estimate. + estimate_point_id: UUID of the estimate point. + """ + return self._delete( + f"{workspace_slug}/projects/{project_id}" + f"/estimates/{estimate_id}/estimate-points/{estimate_point_id}", + ) diff --git a/plane/api/projects.py b/plane/api/projects.py index 67c91d5..95ae0e1 100644 --- a/plane/api/projects.py +++ b/plane/api/projects.py @@ -120,3 +120,32 @@ def update_features( f"{workspace_slug}/projects/{project_id}/features", data.model_dump(exclude_none=True) ) return ProjectFeature.model_validate(response) + + def archive(self, workspace_slug: str, project_id: str) -> None: + """Archive a project. + + Move a project to archived status, hiding it from active project lists. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + + Returns: + None (HTTP 204 No Content) + """ + self._post(f"{workspace_slug}/projects/{project_id}/archive", {}) + + def unarchive(self, workspace_slug: str, project_id: str) -> None: + """Unarchive a project. + + Restore an archived project to active status, making it available + in regular workflows. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + + Returns: + None (HTTP 204 No Content) + """ + self._delete(f"{workspace_slug}/projects/{project_id}/archive") diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 2afaf1b..2599ad8 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -236,3 +236,35 @@ def advanced_search( data.model_dump(exclude_none=True), ) return [AdvancedSearchResult.model_validate(item) for item in response] + + def archive(self, workspace_slug: str, project_id: str, work_item_id: str) -> None: + """Archive a work item. + + Only work items in a completed or cancelled state can be archived. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + """ + self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/archive", + {}, + ) + + def unarchive(self, workspace_slug: str, project_id: str, work_item_id: str) -> None: + """Unarchive a work item. + + Restore an archived work item to active status. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + + Returns: + None (HTTP 204 No Content) + """ + self._delete( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive" + ) diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index 581400c..eb10231 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -2,6 +2,7 @@ from ..api.customers import Customers from ..api.cycles import Cycles from ..api.epics import Epics +from ..api.estimates import Estimates from ..api.initiatives import Initiatives from ..api.intake import Intake from ..api.labels import Labels @@ -55,6 +56,7 @@ def __init__( self.milestones = Milestones(self.config) self.modules = Modules(self.config) self.cycles = Cycles(self.config) + self.estimates = Estimates(self.config) self.work_item_types = WorkItemTypes(self.config) self.work_item_properties = WorkItemProperties(self.config) self.customers = Customers(self.config) diff --git a/plane/models/estimates.py b/plane/models/estimates.py new file mode 100644 index 0000000..f5a1e4e --- /dev/null +++ b/plane/models/estimates.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class Estimate(BaseModel): + """Estimate response model. + + Represents the sizing scale configured for a project + (categories, points, or time). + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str + description: str | None = None + type: str | None = None # "categories" | "points" | "time" + last_used: bool | None = None + external_id: str | None = None + external_source: str | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + project: str | None = None + workspace: str | None = None + + +class CreateEstimate(BaseModel): + """Request model for creating an estimate.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str + description: str | None = None + type: str | None = None # "categories" | "points" | "time" + last_used: bool = True + external_id: str | None = None + external_source: str | None = None + + +class UpdateEstimate(BaseModel): + """Request model for updating an estimate. + + Only name, description, external_id, and external_source are updatable. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + description: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class EstimatePoint(BaseModel): + """Estimate point response model. + + Represents an individual value within an estimate scale + (e.g., "1", "2", "3" for story points). + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + estimate: str | None = None + key: int | None = None + value: str + description: str | None = None + external_id: str | None = None + external_source: str | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + project: str | None = None + workspace: str | None = None + + +class CreateEstimatePoint(BaseModel): + """Request model for creating a single estimate point. + + Used inside a list when calling create_points(). + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + value: str = Field(..., max_length=20) + key: int | None = None + description: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateEstimatePoint(BaseModel): + """Request model for updating an estimate point.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + key: int | None = None + value: str | None = Field(default=None, max_length=20) + description: str | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/pyproject.toml b/pyproject.toml index 8fffab7..43e140c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.10" +version = "0.2.11" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/scripts/test_archive.py b/tests/scripts/test_archive.py new file mode 100644 index 0000000..47cad02 --- /dev/null +++ b/tests/scripts/test_archive.py @@ -0,0 +1,120 @@ +"""Test script for verifying Project and WorkItem archive methods.""" +import os +import sys +import time +from datetime import datetime +from pathlib import Path + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from plane.client import PlaneClient +from plane.models.projects import CreateProject +from plane.models.work_items import CreateWorkItem + + +def print_step(step: int, message: str) -> None: + """Print a step message.""" + print(f"\n[{step}] {message}") + + +def print_success(message: str) -> None: + """Print a success message.""" + print(f" ✅ {message}") + + +def print_error(message: str) -> None: + """Print an error message.""" + print(f" ❌ {message}") + + +def main() -> None: + """Main test function.""" + base_url = os.getenv("PLANE_BASE_URL") + api_key = os.getenv("PLANE_API_KEY") + access_token = os.getenv("PLANE_ACCESS_TOKEN") + workspace_slug = os.getenv("WORKSPACE_SLUG", "random") + + print("Starting Comprehensive Archive SDK Test") + print(f"Base URL: {base_url}") + print(f"Workspace: {workspace_slug}") + + try: + print_step(1, "Initializing Plane Client") + client = PlaneClient( + base_url=base_url, + api_key=api_key, + access_token=access_token, + ) + print_success("Client initialized successfully") + + # Create a test project + print_step(2, "Creating a test project") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + project_identifier = f"ARC{timestamp[-6:]}" + + project_data = CreateProject( + name=f"Archive Test {timestamp}", + description="Testing project and work item archiving", + identifier=project_identifier, + ) + + project = client.projects.create(workspace_slug, project_data) + print_success(f"Project created: {project.name} (ID: {project.id})") + + # Find a completed state + states = client.states.list(workspace_slug, project.id) + completed_state = next((s for s in states.results if s.group == "completed"), None) + if not completed_state: + print_error("Could not find a completed state in the project") + sys.exit(1) + + # Create a test work item in a completed state + print_step(3, "Creating a test work item in completed state") + wi_data = CreateWorkItem( + name="Test Work Item for Archiving", + priority="medium", + state=completed_state.id, + ) + work_item = client.work_items.create(workspace_slug, project.id, wi_data) + print_success(f"Work Item created: {work_item.name} (ID: {work_item.id})") + + # Archive work item + print_step(4, "Archiving the work item") + client.work_items.archive(workspace_slug, project.id, work_item.id) + print_success("Work Item successfully archived.") + + # Unarchive work item + print_step(5, "Unarchiving the work item") + client.work_items.unarchive(workspace_slug, project.id, work_item.id) + print_success("Work Item successfully unarchived.") + + # Archive project + print_step(6, "Archiving the project") + client.projects.archive(workspace_slug, project.id) + print_success("Project successfully archived.") + + # Unarchive project + print_step(7, "Unarchiving the project") + client.projects.unarchive(workspace_slug, project.id) + print_success("Project successfully unarchived.") + + # Delete work item + print_step(8, "Cleaning up Work Item") + client.work_items.delete(workspace_slug, project.id, work_item.id) + print_success("Work Item deleted.") + + # Delete project + print_step(9, "Cleaning up Project") + client.projects.delete(workspace_slug, project.id) + print_success("Project deleted.") + + print("\n🎉 All Archive API methods tested successfully!") + + except Exception as e: + print_error(f"Test failed: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/scripts/test_estimates_methods.py b/tests/scripts/test_estimates_methods.py new file mode 100644 index 0000000..e30f1f5 --- /dev/null +++ b/tests/scripts/test_estimates_methods.py @@ -0,0 +1,110 @@ +"""Test script for verifying all Estimate API methods.""" +import os +import sys +import time +from pathlib import Path + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from plane.client import PlaneClient +from plane.models.estimates import ( + CreateEstimate, + CreateEstimatePoint, + UpdateEstimate, + UpdateEstimatePoint, +) +from plane.models.projects import CreateProject + + +def run_test(): + base_url = os.getenv("PLANE_BASE_URL") + api_key = os.getenv("PLANE_API_KEY") + access_token = os.getenv("PLANE_ACCESS_TOKEN") + slug = os.getenv("WORKSPACE_SLUG") + + client = PlaneClient(base_url=base_url, api_key=api_key) + + print("1. Creating Project...") + project = client.projects.create( + slug, + CreateProject( + name="Estimate API Test", + identifier=f"EAT{str(int(time.time()))[-4:]}", + ), + ) + print(f" ✅ Project created: {project.name} (ID: {project.id})") + + print("\n2. Creating Estimate...") + estimate = client.estimates.create( + slug, + project.id, + CreateEstimate( + name="Complexity Points", + type="points", + description="Initial description" + ) + ) + print(f" ✅ Estimate created: {estimate.name} (ID: {estimate.id})") + + print("\n3. Retrieving Estimate...") + retrieved_est = client.estimates.retrieve(slug, project.id) + print(f" ✅ Retrieved estimate: {retrieved_est.name} (ID: {retrieved_est.id})") + + print("\n4. Updating Estimate...") + updated_est = client.estimates.update( + slug, + project.id, + UpdateEstimate(description="Updated description") + ) + print(f" ✅ Estimate updated. New description: '{updated_est.description}'") + + print("\n5. Linking Estimate to Project...") + client.estimates.link_to_project(slug, project.id, estimate.id) + print(" ✅ Estimate linked successfully to project.") + + print("\n6. Creating Estimate Points...") + points = client.estimates.create_points( + slug, + project.id, + estimate.id, + [ + CreateEstimatePoint(value="1", key=0), + CreateEstimatePoint(value="2", key=1), + CreateEstimatePoint(value="3", key=2), + ] + ) + print(f" ✅ Created {len(points)} points:") + for p in points: + print(f" - {p.value} (ID: {p.id})") + + point_to_update = points[0] + point_to_delete = points[1] + + print("\n7. Listing Estimate Points...") + listed_points = client.estimates.list_points(slug, project.id, estimate.id) + print(f" ✅ Listed {len(listed_points)} points.") + + print("\n8. Updating Estimate Point...") + updated_point = client.estimates.update_point( + slug, + project.id, + estimate.id, + point_to_update.id, + UpdateEstimatePoint(value="0.5") + ) + print(f" ✅ Point updated from '{point_to_update.value}' to '{updated_point.value}'.") + + print("\n9. Deleting Estimate Point...") + client.estimates.delete_point(slug, project.id, estimate.id, point_to_delete.id) + print(f" ✅ Point '{point_to_delete.value}' deleted successfully.") + + print("\n10. Deleting Estimate...") + client.estimates.delete(slug, project.id) + print(" ✅ Estimate deleted successfully.") + + print("\n🎉 All Estimate API methods tested successfully!") + + +if __name__ == "__main__": + run_test() diff --git a/tests/unit/test_estimates.py b/tests/unit/test_estimates.py new file mode 100644 index 0000000..e0f6cb2 --- /dev/null +++ b/tests/unit/test_estimates.py @@ -0,0 +1,235 @@ +"""Unit tests for Estimates API resource (smoke tests with real HTTP requests).""" + +import time + +import pytest +from pydantic import ValidationError + +from plane.client import PlaneClient +from plane.models.estimates import ( + CreateEstimate, + CreateEstimatePoint, + Estimate, + EstimatePoint, + UpdateEstimate, + UpdateEstimatePoint, +) +from plane.models.projects import Project + + +class TestEstimatesAPI: + """Test Estimates API basic operations.""" + + def test_create_and_retrieve_estimate( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test creating and retrieving an estimate.""" + data = CreateEstimate( + name=f"Test Estimate {int(time.time())}", + description="Test estimate description", + type="points", + ) + estimate = client.estimates.create(workspace_slug, project.id, data) + assert estimate is not None + assert isinstance(estimate, Estimate) + assert estimate.id is not None + assert estimate.name == data.name + assert estimate.description == data.description + + # Retrieve + retrieved = client.estimates.retrieve(workspace_slug, project.id) + assert retrieved is not None + assert isinstance(retrieved, Estimate) + assert retrieved.id == estimate.id + assert retrieved.name == estimate.name + + # Cleanup + try: + client.estimates.delete(workspace_slug, project.id) + except Exception: + pass + + +class TestEstimatesAPICRUD: + """Test Estimates API CRUD operations with fixture-based lifecycle.""" + + @pytest.fixture + def estimate_data(self) -> CreateEstimate: + """Create test estimate data.""" + return CreateEstimate( + name=f"Test Estimate {int(time.time())}", + description="Estimate for testing", + type="points", + ) + + @pytest.fixture + def estimate( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + estimate_data: CreateEstimate, + ): + """Create a test estimate and yield it, then delete it.""" + estimate = client.estimates.create(workspace_slug, project.id, estimate_data) + yield estimate + try: + client.estimates.delete(workspace_slug, project.id) + except Exception: + pass + + def test_update_estimate( + self, client: PlaneClient, workspace_slug: str, project: Project, estimate + ) -> None: + """Test updating an estimate.""" + update_data = UpdateEstimate(name="Updated Estimate Name") + updated = client.estimates.update(workspace_slug, project.id, update_data) + assert updated is not None + assert isinstance(updated, Estimate) + assert updated.id == estimate.id + assert updated.name == "Updated Estimate Name" + + def test_delete_estimate( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + estimate_data: CreateEstimate, + ) -> None: + """Test deleting an estimate.""" + estimate = client.estimates.create(workspace_slug, project.id, estimate_data) + assert estimate.id is not None + result = client.estimates.delete(workspace_slug, project.id) + assert result is None + + +class TestEstimatePointsAPI: + """Test Estimate Points API operations.""" + + @pytest.fixture + def estimate( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + ): + """Create a test estimate for points testing.""" + data = CreateEstimate( + name=f"Points Test Estimate {int(time.time())}", + type="points", + ) + estimate = client.estimates.create(workspace_slug, project.id, data) + yield estimate + try: + client.estimates.delete(workspace_slug, project.id) + except Exception: + pass + + def test_create_and_list_points( + self, client: PlaneClient, workspace_slug: str, project: Project, estimate + ) -> None: + """Test creating and listing estimate points.""" + points_data = [ + CreateEstimatePoint(value="1", key=0, description="Tiny"), + CreateEstimatePoint(value="2", key=1, description="Small"), + CreateEstimatePoint(value="3", key=2, description="Medium"), + ] + created_points = client.estimates.create_points( + workspace_slug, project.id, estimate.id, points_data + ) + assert created_points is not None + assert isinstance(created_points, list) + assert len(created_points) == 3 + for point in created_points: + assert isinstance(point, EstimatePoint) + assert point.id is not None + assert point.value in ["1", "2", "3"] + + # List points + listed_points = client.estimates.list_points( + workspace_slug, project.id, estimate.id + ) + assert listed_points is not None + assert isinstance(listed_points, list) + assert len(listed_points) >= 3 + + def test_update_point( + self, client: PlaneClient, workspace_slug: str, project: Project, estimate + ) -> None: + """Test updating an estimate point.""" + # Create a point first + points_data = [CreateEstimatePoint(value="5", key=0)] + created = client.estimates.create_points( + workspace_slug, project.id, estimate.id, points_data + ) + assert len(created) == 1 + point_id = created[0].id + + # Update the point + update_data = UpdateEstimatePoint(value="8", description="Updated") + updated = client.estimates.update_point( + workspace_slug, project.id, estimate.id, point_id, update_data + ) + assert updated is not None + assert isinstance(updated, EstimatePoint) + assert updated.id == point_id + assert updated.value == "8" + + def test_delete_point( + self, client: PlaneClient, workspace_slug: str, project: Project, estimate + ) -> None: + """Test deleting an estimate point.""" + # Create a point first + points_data = [CreateEstimatePoint(value="13", key=0)] + created = client.estimates.create_points( + workspace_slug, project.id, estimate.id, points_data + ) + assert len(created) == 1 + point_id = created[0].id + + # Delete it + result = client.estimates.delete_point( + workspace_slug, project.id, estimate.id, point_id + ) + assert result is None + + +class TestEstimateModels: + """Test Pydantic model validation for estimates.""" + + def test_create_estimate_rejects_extra_fields(self) -> None: + """Test that CreateEstimate ignores extra fields.""" + data = CreateEstimate(name="Test", unknown_field="value") + assert not hasattr(data, "unknown_field") + + def test_estimate_allows_extra_fields(self) -> None: + """Test that Estimate response model accepts unknown fields.""" + data = Estimate.model_validate( + {"name": "Test", "some_new_api_field": "future_value"} + ) + assert data.name == "Test" + assert data.some_new_api_field == "future_value" + + def test_create_estimate_point_value_max_length(self) -> None: + """Test that CreateEstimatePoint enforces value max_length=20.""" + with pytest.raises(ValidationError): + CreateEstimatePoint(value="x" * 21) + + def test_create_estimate_point_valid(self) -> None: + """Test creating a valid estimate point model.""" + point = CreateEstimatePoint(value="5", key=2, description="Medium") + assert point.value == "5" + assert point.key == 2 + assert point.description == "Medium" + + def test_update_estimate_all_optional(self) -> None: + """Test that all UpdateEstimate fields are optional.""" + data = UpdateEstimate() + dumped = data.model_dump(exclude_none=True) + assert dumped == {} + + def test_update_estimate_point_all_optional(self) -> None: + """Test that all UpdateEstimatePoint fields are optional.""" + data = UpdateEstimatePoint() + dumped = data.model_dump(exclude_none=True) + assert dumped == {}