From 9ae7f20cc24c8fa9121979b1a8befd8f895b1be1 Mon Sep 17 00:00:00 2001 From: MariaPaula Trujillo Date: Tue, 7 Apr 2026 22:00:44 +0200 Subject: [PATCH] Add PUT endpoint for deployments and related tests --- tests/test_update_product_deployment.py | 132 ++++++++++++++++++++++++ webapp/app.py | 5 + webapp/schemas.py | 19 ++++ webapp/views.py | 56 ++++++++++ 4 files changed, 212 insertions(+) create mode 100644 tests/test_update_product_deployment.py diff --git a/tests/test_update_product_deployment.py b/tests/test_update_product_deployment.py new file mode 100644 index 0000000..06c486b --- /dev/null +++ b/tests/test_update_product_deployment.py @@ -0,0 +1,132 @@ +import unittest + +from tests import BaseTestCase + + +class TestUpdateProductDeployment(BaseTestCase): + def test_update_product_deployment_updates_both_fields_and_returns_200(self): + """PUT updates deployment name and artifact_type and keeps slug unchanged.""" + response = self.client.put( + "/products/test-product/test-deployment", + json={ + "name": "Canonical Charmed Ceph Updated", + "artifact_type": "charm", + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["slug"], "test-deployment") + self.assertEqual(payload["parent_product"], "test-product") + self.assertEqual(payload["name"], "Canonical Charmed Ceph Updated") + self.assertEqual(payload["artifact_type"], "charm") + self.assertIn("versions", payload) + + def test_update_product_deployment_updates_name_only(self): + """PUT updates only name when artifact_type is omitted.""" + response = self.client.put( + "/products/test-product/test-deployment", + json={"name": "Updated Deployment Name"}, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["slug"], "test-deployment") + self.assertEqual(payload["name"], "Updated Deployment Name") + self.assertEqual(payload["artifact_type"], "snap") + + def test_update_product_deployment_updates_artifact_type_only(self): + """PUT updates only artifact_type when name is omitted.""" + response = self.client.put( + "/products/test-product/test-deployment", + json={"artifact_type": "rock"}, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["slug"], "test-deployment") + self.assertEqual(payload["name"], "Test Deployment") + self.assertEqual(payload["artifact_type"], "rock") + + def test_update_product_deployment_empty_body_returns_400(self): + """PUT with empty body returns 400 using existing error shape.""" + response = self.client.put( + "/products/test-product/test-deployment", + json={}, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Invalid request.") + self.assertIn("details", payload["error"]) + + def test_update_product_deployment_invalid_artifact_type_returns_400(self): + """PUT with invalid artifact_type returns 400 with field details.""" + response = self.client.put( + "/products/test-product/test-deployment", + json={"artifact_type": "invalid"}, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Invalid request.") + self.assertIn("details", payload["error"]) + self.assertIn("artifact_type", payload["error"]["details"]) + + def test_update_product_deployment_whitespace_name_returns_400(self): + """PUT with whitespace-only name returns 400 with name details.""" + response = self.client.put( + "/products/test-product/test-deployment", + json={"name": " "}, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 400) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Invalid request.") + self.assertIn("details", payload["error"]) + self.assertIn("name", payload["error"]["details"]) + + def test_update_product_deployment_not_found_returns_404(self): + """PUT unknown deployment returns 404 with identifying details.""" + response = self.client.put( + "/products/test-product/does-not-exist", + json={"name": "Updated Deployment Name"}, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 404) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Deployment not found.") + self.assertEqual( + payload["error"]["details"], + { + "product_slug": "test-product", + "deployment_slug": "does-not-exist", + }, + ) + + def test_update_product_deployment_product_not_found_returns_404(self): + """Unknown product returns 404 with identifying details.""" + response = self.client.put( + "/products/does-not-exist/test-deployment", + json={ + "name": "Updated Deployment Name", + "artifact_type": "snap", + }, + ) + payload = response.get_json() + + self.assertEqual(response.status_code, 404) + self.assertIn("error", payload) + self.assertEqual(payload["error"]["message"], "Product not found.") + self.assertEqual( + payload["error"]["details"], + {"product_slug": "does-not-exist"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webapp/app.py b/webapp/app.py index 8b0d716..f4a51ac 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -49,6 +49,11 @@ view_func=views.get_product_deployment, methods=["GET"], ) +app.add_url_rule( + "/products//", + view_func=views.update_product_deployment, + methods=["PUT"], +) @app.errorhandler(422) diff --git a/webapp/schemas.py b/webapp/schemas.py index d6faa8f..22cd974 100644 --- a/webapp/schemas.py +++ b/webapp/schemas.py @@ -143,6 +143,25 @@ class CreateProductDeploymentBodySchema(NormalizeNameMixin, Schema): ) +class UpdateProductDeploymentBodySchema(NormalizeNameMixin, Schema): + """ + Schema for PUT /products// request body. + """ + + name = fields.String(required=False) + artifact_type = fields.String( + required=False, validate=OneOf(ARTIFACT_TYPES) + ) + + @validates_schema + def validate_at_least_one_field(self, data, **kwargs): + if "name" not in data and "artifact_type" not in data: + raise ValidationError( + "At least one of 'name' or 'artifact_type' must be provided.", + field_name="_schema", + ) + + class UpdateProductBodySchema(NormalizeNameMixin, Schema): """Schema for PUT /products/ request body.""" diff --git a/webapp/views.py b/webapp/views.py index 43a058a..d75e6a9 100644 --- a/webapp/views.py +++ b/webapp/views.py @@ -15,6 +15,7 @@ DeploymentSchema, GetProductsQuerySchema, ProductSchema, + UpdateProductDeploymentBodySchema, UpdateProductBodySchema, ) @@ -201,3 +202,58 @@ def update_product(product_slug, name): }, 500 return ProductSchema().dump(product), 200 + + +@use_kwargs(UpdateProductDeploymentBodySchema, location="json") +def update_product_deployment( + product_slug, + deployment_slug, + name=None, + artifact_type=None, +): + product = Product.query.filter_by(slug=product_slug).one_or_none() + if product is None: + return { + "error": { + "message": "Product not found.", + "details": {"product_slug": product_slug}, + } + }, 404 + + deployment = ( + Deployment.query.options(joinedload(Deployment.versions)) + .filter_by( + parent_product=product_slug, + slug=deployment_slug, + ) + .one_or_none() + ) + + if deployment is None: + return { + "error": { + "message": "Deployment not found.", + "details": { + "product_slug": product_slug, + "deployment_slug": deployment_slug, + }, + } + }, 404 + + if name is not None: + deployment.name = name + if artifact_type is not None: + deployment.artifact_type = artifact_type + + try: + db.session.commit() + except Exception: + db.session.remove() + return { + "error": { + "message": "Internal server error.", + "details": {}, + } + }, 500 + + return DeploymentSchema().dump(deployment), 200