diff --git a/docs/source/endpoints/index.md b/docs/source/endpoints/index.md index 3d33783b4..8aaefc0b1 100644 --- a/docs/source/endpoints/index.md +++ b/docs/source/endpoints/index.md @@ -42,6 +42,7 @@ portrait principals querystring querystringsearch +recycle-bin registry relations roles diff --git a/docs/source/endpoints/recycle-bin.md b/docs/source/endpoints/recycle-bin.md new file mode 100644 index 000000000..930200208 --- /dev/null +++ b/docs/source/endpoints/recycle-bin.md @@ -0,0 +1,147 @@ +# Recycle Bin + +The Recycle Bin REST API provides endpoints to interact with the Plone Recycle Bin functionality. + +## List recycle bin contents + +To list all items in the recycle bin, send a GET request to the `@recyclebin` endpoint: + +```http-example +GET /@recyclebin HTTP/1.1 +Accept: application/json +``` + +Response: + +```json +{ + "@id": "http://localhost:8080/Plone/@recyclebin", + "items": [ + { + "@id": "http://localhost:8080/Plone/@recyclebin/6d6d626f-8c85-4f22-8747-adb979bbe3b1", + "id": "document-1", + "title": "My Document", + "type": "Document", + "path": "/Plone/folder/document-1", + "parent_path": "/Plone/folder", + "deletion_date": "2025-04-27T10:30:45.123456", + "size": 1024, + "recycle_id": "6d6d626f-8c85-4f22-8747-adb979bbe3b1", + "actions": { + "restore": "http://localhost:8080/Plone/@recyclebin-restore", + "purge": "http://localhost:8080/Plone/@recyclebin-purge" + } + } + ], + "items_total": 1 +} +``` + +## Restore an item from the recycle bin + +To restore an item from the recycle bin, send a POST request to the `@recyclebin-restore` endpoint: + +```http-example +POST /@recyclebin-restore HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "item_id": "6d6d626f-8c85-4f22-8747-adb979bbe3b1" +} +``` + +You can optionally specify a target path to restore to: + +```json +{ + "item_id": "6d6d626f-8c85-4f22-8747-adb979bbe3b1", + "target_path": "/Plone/another-folder" +} +``` + +Response: + +```json +{ + "status": "success", + "message": "Item document-1 restored successfully", + "restored_item": { + "@id": "http://localhost:8080/Plone/document-1", + "id": "document-1", + "title": "My Document", + "type": "Document" + } +} +``` + +## Purge an item from the recycle bin + +To permanently delete an item from the recycle bin, send a POST request to the `@recyclebin-purge` endpoint: + +```http-example +POST /@recyclebin-purge HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "item_id": "6d6d626f-8c85-4f22-8747-adb979bbe3b1" +} +``` + +Response: + +```json +{ + "status": "success", + "message": "Item document-1 purged successfully" +} +``` + +## Purge all items from the recycle bin + +To purge all items from the recycle bin: + +```http-example +POST /@recyclebin-purge HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "purge_all": true +} +``` + +Response: + +```json +{ + "status": "success", + "purged_count": 5, + "message": "Purged 5 items from recycle bin" +} +``` + +## Purge expired items from the recycle bin + +To purge only expired items (based on the retention period): + +```http-example +POST /@recyclebin-purge HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "purge_expired": true +} +``` + +Response: + +```json +{ + "status": "success", + "purged_count": 2, + "message": "Purged 2 expired items from recycle bin" +} +``` diff --git a/news/1919.feature b/news/1919.feature new file mode 100644 index 000000000..4f1a01205 --- /dev/null +++ b/news/1919.feature @@ -0,0 +1 @@ +Add endpoint for managing recycle bin. @rohnsha0 \ No newline at end of file diff --git a/src/plone/restapi/services/configure.zcml b/src/plone/restapi/services/configure.zcml index 0f42573cd..2a37051b6 100644 --- a/src/plone/restapi/services/configure.zcml +++ b/src/plone/restapi/services/configure.zcml @@ -34,6 +34,7 @@ + diff --git a/src/plone/restapi/services/recyclebin/__init__.py b/src/plone/restapi/services/recyclebin/__init__.py new file mode 100644 index 000000000..a000b69d0 --- /dev/null +++ b/src/plone/restapi/services/recyclebin/__init__.py @@ -0,0 +1 @@ +# Empty init file to make the directory a Python package diff --git a/src/plone/restapi/services/recyclebin/configure.zcml b/src/plone/restapi/services/recyclebin/configure.zcml new file mode 100644 index 000000000..13b918171 --- /dev/null +++ b/src/plone/restapi/services/recyclebin/configure.zcml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/src/plone/restapi/services/recyclebin/get.py b/src/plone/restapi/services/recyclebin/get.py new file mode 100644 index 000000000..a7a30685f --- /dev/null +++ b/src/plone/restapi/services/recyclebin/get.py @@ -0,0 +1,50 @@ +from plone.restapi.services import Service +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from zope.component import getUtility + + +class RecycleBinGet(Service): + """GET /@recyclebin - List items in the recycle bin""" + + def reply(self): + recycle_bin = getUtility(IRecycleBin) + + # Check if recycle bin is enabled + if not recycle_bin.is_enabled(): + self.request.response.setStatus(404) + return { + "error": { + "type": "NotFound", + "message": "Recycle bin is disabled", + } + } + + # Get all items from recycle bin + items = recycle_bin.get_items() + + # Format items for response + results = [] + for item in items: + results.append( + { + "@id": f"{self.context.absolute_url()}/@recyclebin/{item['recycle_id']}", + "id": item["id"], + "title": item["title"], + "type": item["type"], + "path": item["path"], + "parent_path": item["parent_path"], + "deletion_date": item["deletion_date"].isoformat(), + "size": item["size"], + "recycle_id": item["recycle_id"], + "actions": { + "restore": f"{self.context.absolute_url()}/@recyclebin-restore", + "purge": f"{self.context.absolute_url()}/@recyclebin-purge", + }, + } + ) + + return { + "@id": f"{self.context.absolute_url()}/@recyclebin", + "items": results, + "items_total": len(results), + } diff --git a/src/plone/restapi/services/recyclebin/purge.py b/src/plone/restapi/services/recyclebin/purge.py new file mode 100644 index 000000000..a62bc74cc --- /dev/null +++ b/src/plone/restapi/services/recyclebin/purge.py @@ -0,0 +1,93 @@ +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from zope.component import getUtility +from zope.interface import alsoProvides + +import plone.protect.interfaces + + +class RecycleBinPurge(Service): + """POST /@recyclebin-purge - Permanently delete an item from the recycle bin""" + + def reply(self): + # Disable CSRF protection for this request + alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection) + + data = json_body(self.request) + item_id = data.get("item_id", None) + purge_all = data.get("purge_all", False) + purge_expired = data.get("purge_expired", False) + + recycle_bin = getUtility(IRecycleBin) + + # Check if recycle bin is enabled + if not recycle_bin.is_enabled(): + self.request.response.setStatus(404) + return { + "error": { + "type": "NotFound", + "message": "Recycle bin is disabled", + } + } + + # Handle purging all items + if purge_all: + purged_count = 0 + for item in recycle_bin.get_items(): + if recycle_bin.purge_item(item["recycle_id"]): + purged_count += 1 + + return { + "status": "success", + "purged_count": purged_count, + "message": f"Purged {purged_count} items from recycle bin", + } + + # Handle purging expired items + if purge_expired: + purged_count = recycle_bin.purge_expired_items() + + return { + "status": "success", + "purged_count": purged_count, + "message": f"Purged {purged_count} expired items from recycle bin", + } + + # Handle purging a specific item + if not item_id: + self.request.response.setStatus(400) + return { + "error": { + "type": "BadRequest", + "message": "Missing required parameter: item_id, purge_all, or purge_expired", + } + } + + # Get the item to purge + item_data = recycle_bin.get_item(item_id) + if not item_data: + self.request.response.setStatus(404) + return { + "error": { + "type": "NotFound", + "message": f"Item with ID {item_id} not found in recycle bin", + } + } + + # Purge the item + success = recycle_bin.purge_item(item_id) + + if not success: + self.request.response.setStatus(500) + return { + "error": { + "type": "InternalServerError", + "message": "Failed to purge item", + } + } + + return { + "status": "success", + "message": f"Item {item_data['id']} purged successfully", + } diff --git a/src/plone/restapi/services/recyclebin/restore.py b/src/plone/restapi/services/recyclebin/restore.py new file mode 100644 index 000000000..0a38314bd --- /dev/null +++ b/src/plone/restapi/services/recyclebin/restore.py @@ -0,0 +1,95 @@ +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from Products.CMFPlone.interfaces.recyclebin import IRecycleBin +from zope.component import getUtility +from zope.interface import alsoProvides + +import plone.protect.interfaces + + +class RecycleBinRestore(Service): + """POST /@recyclebin-restore - Restore an item from the recycle bin""" + + def __init__(self, context, request): + super().__init__(context, request) + self.params = {} + + def reply(self): + # Disable CSRF protection for this request + alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection) + + data = json_body(self.request) + item_id = data.get("item_id", None) + + if not item_id: + self.request.response.setStatus(400) + return { + "error": { + "type": "BadRequest", + "message": "Missing required parameter: item_id", + } + } + + recycle_bin = getUtility(IRecycleBin) + + # Check if recycle bin is enabled + if not recycle_bin.is_enabled(): + self.request.response.setStatus(404) + return { + "error": { + "type": "NotFound", + "message": "Recycle bin is disabled", + } + } + + # Get the item to restore + item_data = recycle_bin.get_item(item_id) + if not item_data: + self.request.response.setStatus(404) + return { + "error": { + "type": "NotFound", + "message": f"Item with ID {item_id} not found in recycle bin", + } + } + + # Get optional target container path + target_path = data.get("target_path", None) + target_container = None + + if target_path: + try: + portal = self.context.portal_url.getPortalObject() + target_container = portal.unrestrictedTraverse(target_path) + except (KeyError, AttributeError): + self.request.response.setStatus(400) + return { + "error": { + "type": "BadRequest", + "message": f"Target path {target_path} not found", + } + } + + # Restore the item + restored_obj = recycle_bin.restore_item(item_id, target_container) + + if not restored_obj: + self.request.response.setStatus(500) + return { + "error": { + "type": "InternalServerError", + "message": "Failed to restore item", + } + } + + self.request.response.setStatus(200) + return { + "status": "success", + "message": f"Item {item_data['id']} restored successfully", + "restored_item": { + "@id": restored_obj.absolute_url(), + "id": restored_obj.getId(), + "title": restored_obj.Title(), + "type": restored_obj.portal_type, + }, + } diff --git a/src/plone/restapi/tests/test_recyclebin.py b/src/plone/restapi/tests/test_recyclebin.py new file mode 100644 index 000000000..056d8933f --- /dev/null +++ b/src/plone/restapi/tests/test_recyclebin.py @@ -0,0 +1,420 @@ +from datetime import datetime +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.restapi.testing import RelativeSession +from unittest import mock + +import plone.restapi.testing +import transaction +import unittest + + +class TestRecycleBin(unittest.TestCase): + layer = plone.restapi.testing.PLONE_RESTAPI_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.api_session = RelativeSession(self.portal.absolute_url()) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + # For POST requests, set Content-Type header + self.api_session.headers.update({"Content-Type": "application/json"}) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + transaction.commit() + + def tearDown(self): + self.api_session.close() + + # GET tests (from TestRecycleBinGet) + def test_recyclebin_get_disabled(self): + """Test GET /@recyclebin when recycle bin is disabled""" + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = False + + with mock.patch( + "plone.restapi.services.recyclebin.get.getUtility", return_value=recycle_bin + ): + response = self.api_session.get("/@recyclebin") + + self.assertEqual(404, response.status_code) + self.assertEqual("NotFound", response.json()["error"]["type"]) + self.assertEqual("Recycle bin is disabled", response.json()["error"]["message"]) + + def test_recyclebin_get_enabled_empty(self): + """Test GET /@recyclebin when recycle bin is enabled but empty""" + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_items.return_value = [] + + with mock.patch( + "plone.restapi.services.recyclebin.get.getUtility", return_value=recycle_bin + ): + response = self.api_session.get("/@recyclebin") + + self.assertEqual(200, response.status_code) + result = response.json() + self.assertEqual(self.portal.absolute_url() + "/@recyclebin", result["@id"]) + self.assertEqual(0, result["items_total"]) + self.assertEqual([], result["items"]) + + def test_recyclebin_get_enabled_with_items(self): + """Test GET /@recyclebin when recycle bin has items""" + sample_date = datetime(2023, 1, 1, 12, 0, 0) + mock_items = [ + { + "id": "document1", + "title": "Test Document", + "type": "Document", + "path": "/plone/document1", + "parent_path": "/plone", + "deletion_date": sample_date, + "size": 1024, + "recycle_id": "123456789", + }, + { + "id": "folder1", + "title": "Test Folder", + "type": "Folder", + "path": "/plone/folder1", + "parent_path": "/plone", + "deletion_date": sample_date, + "size": 512, + "recycle_id": "987654321", + }, + ] + + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_items.return_value = mock_items + + with mock.patch( + "plone.restapi.services.recyclebin.get.getUtility", return_value=recycle_bin + ): + response = self.api_session.get("/@recyclebin") + + self.assertEqual(200, response.status_code) + result = response.json() + self.assertEqual(self.portal.absolute_url() + "/@recyclebin", result["@id"]) + self.assertEqual(2, result["items_total"]) + + # Check first item + item1 = result["items"][0] + self.assertEqual("document1", item1["id"]) + self.assertEqual("Test Document", item1["title"]) + self.assertEqual("Document", item1["type"]) + self.assertEqual("/plone/document1", item1["path"]) + self.assertEqual("/plone", item1["parent_path"]) + self.assertEqual(sample_date.isoformat(), item1["deletion_date"]) + self.assertEqual(1024, item1["size"]) + self.assertEqual("123456789", item1["recycle_id"]) + self.assertEqual( + self.portal.absolute_url() + "/@recyclebin/123456789", item1["@id"] + ) + + # Verify actions + self.assertEqual( + self.portal.absolute_url() + "/@recyclebin-restore", + item1["actions"]["restore"], + ) + self.assertEqual( + self.portal.absolute_url() + "/@recyclebin-purge", item1["actions"]["purge"] + ) + + # RESTORE tests (from TestRecycleBinRestore) + def test_restore_missing_item_id(self): + """Test restore with missing item_id parameter""" + response = self.api_session.post("/@recyclebin-restore", json={}) + + self.assertEqual(400, response.status_code) + self.assertEqual("BadRequest", response.json()["error"]["type"]) + self.assertEqual( + "Missing required parameter: item_id", response.json()["error"]["message"] + ) + + def test_restore_disabled_recyclebin(self): + """Test restore when recyclebin is disabled""" + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = False + + with mock.patch( + "plone.restapi.services.recyclebin.restore.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-restore", json={"item_id": "123456789"} + ) + + self.assertEqual(404, response.status_code) + self.assertEqual("NotFound", response.json()["error"]["type"]) + self.assertEqual("Recycle bin is disabled", response.json()["error"]["message"]) + + def test_restore_nonexistent_item(self): + """Test restore for a non-existent item""" + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_item.return_value = None + + with mock.patch( + "plone.restapi.services.recyclebin.restore.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-restore", json={"item_id": "nonexistent"} + ) + + self.assertEqual(404, response.status_code) + self.assertEqual("NotFound", response.json()["error"]["type"]) + self.assertEqual( + "Item with ID nonexistent not found in recycle bin", + response.json()["error"]["message"], + ) + + def test_restore_invalid_target_path(self): + """Test restore with an invalid target path""" + item_data = { + "id": "document1", + "title": "Test Document", + "recycle_id": "123456789", + } + + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_item.return_value = item_data + + with mock.patch( + "plone.restapi.services.recyclebin.restore.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-restore", + json={"item_id": "123456789", "target_path": "/non/existent/path"}, + ) + + self.assertEqual(400, response.status_code) + self.assertEqual("BadRequest", response.json()["error"]["type"]) + self.assertEqual( + "Target path /non/existent/path not found", + response.json()["error"]["message"], + ) + + def test_restore_failure(self): + """Test when restore operation fails""" + item_data = { + "id": "document1", + "title": "Test Document", + "recycle_id": "123456789", + } + + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_item.return_value = item_data + recycle_bin.restore_item.return_value = None + + with mock.patch( + "plone.restapi.services.recyclebin.restore.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-restore", json={"item_id": "123456789"} + ) + + self.assertEqual(500, response.status_code) + self.assertEqual("InternalServerError", response.json()["error"]["type"]) + self.assertEqual("Failed to restore item", response.json()["error"]["message"]) + + def test_restore_success(self): + """Test successful item restoration""" + item_data = { + "id": "document1", + "title": "Test Document", + "recycle_id": "123456789", + } + + # Mock restored object + mock_obj = mock.Mock() + mock_obj.absolute_url.return_value = "http://localhost:8080/plone/document1" + mock_obj.getId.return_value = "document1" + mock_obj.Title.return_value = "Test Document" + mock_obj.portal_type = "Document" + + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_item.return_value = item_data + recycle_bin.restore_item.return_value = mock_obj + + with mock.patch( + "plone.restapi.services.recyclebin.restore.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-restore", json={"item_id": "123456789"} + ) + + self.assertEqual(200, response.status_code) + result = response.json() + self.assertEqual("success", result["status"]) + self.assertEqual("Item document1 restored successfully", result["message"]) + + # Verify restored item data + restored = result["restored_item"] + self.assertEqual("http://localhost:8080/plone/document1", restored["@id"]) + self.assertEqual("document1", restored["id"]) + self.assertEqual("Test Document", restored["title"]) + self.assertEqual("Document", restored["type"]) + + # PURGE tests (from TestRecycleBinPurge) + def test_purge_missing_parameters(self): + """Test purge with missing parameters""" + response = self.api_session.post("/@recyclebin-purge", json={}) + + self.assertEqual(400, response.status_code) + self.assertEqual("BadRequest", response.json()["error"]["type"]) + self.assertEqual( + "Missing required parameter: item_id, purge_all, or purge_expired", + response.json()["error"]["message"], + ) + + def test_purge_disabled_recyclebin(self): + """Test purge when recyclebin is disabled""" + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = False + + with mock.patch( + "plone.restapi.services.recyclebin.purge.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-purge", json={"item_id": "123456789"} + ) + + self.assertEqual(404, response.status_code) + self.assertEqual("NotFound", response.json()["error"]["type"]) + self.assertEqual("Recycle bin is disabled", response.json()["error"]["message"]) + + def test_purge_nonexistent_item(self): + """Test purge for a non-existent item""" + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_item.return_value = None + + with mock.patch( + "plone.restapi.services.recyclebin.purge.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-purge", json={"item_id": "nonexistent"} + ) + + self.assertEqual(404, response.status_code) + self.assertEqual("NotFound", response.json()["error"]["type"]) + self.assertEqual( + "Item with ID nonexistent not found in recycle bin", + response.json()["error"]["message"], + ) + + def test_purge_failure(self): + """Test when purge operation fails""" + item_data = { + "id": "document1", + "title": "Test Document", + "recycle_id": "123456789", + } + + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_item.return_value = item_data + recycle_bin.purge_item.return_value = False + + with mock.patch( + "plone.restapi.services.recyclebin.purge.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-purge", json={"item_id": "123456789"} + ) + + self.assertEqual(500, response.status_code) + self.assertEqual("InternalServerError", response.json()["error"]["type"]) + self.assertEqual("Failed to purge item", response.json()["error"]["message"]) + + def test_purge_success(self): + """Test successful item purge""" + item_data = { + "id": "document1", + "title": "Test Document", + "recycle_id": "123456789", + } + + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_item.return_value = item_data + recycle_bin.purge_item.return_value = True + + with mock.patch( + "plone.restapi.services.recyclebin.purge.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-purge", json={"item_id": "123456789"} + ) + + self.assertEqual(200, response.status_code) + result = response.json() + self.assertEqual("success", result["status"]) + self.assertEqual("Item document1 purged successfully", result["message"]) + + def test_purge_all(self): + """Test purging all items""" + mock_items = [ + {"id": "document1", "recycle_id": "123"}, + {"id": "document2", "recycle_id": "456"}, + {"id": "document3", "recycle_id": "789"}, + ] + + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + recycle_bin.get_items.return_value = mock_items + # Configure purge_item to return True for successful purge + recycle_bin.purge_item.return_value = True + + with mock.patch( + "plone.restapi.services.recyclebin.purge.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-purge", json={"purge_all": True} + ) + + self.assertEqual(200, response.status_code) + result = response.json() + self.assertEqual("success", result["status"]) + self.assertEqual(3, result["purged_count"]) + self.assertEqual("Purged 3 items from recycle bin", result["message"]) + # Verify that purge_item was called for each item + recycle_bin.purge_item.assert_any_call("123") + recycle_bin.purge_item.assert_any_call("456") + recycle_bin.purge_item.assert_any_call("789") + + def test_purge_expired(self): + """Test purging expired items""" + recycle_bin = mock.Mock() + recycle_bin.is_enabled.return_value = True + # Configure purge_expired_items to return the number of purged items + recycle_bin.purge_expired_items.return_value = 2 + + with mock.patch( + "plone.restapi.services.recyclebin.purge.getUtility", + return_value=recycle_bin, + ): + response = self.api_session.post( + "/@recyclebin-purge", json={"purge_expired": True} + ) + + self.assertEqual(200, response.status_code) + result = response.json() + self.assertEqual("success", result["status"]) + self.assertEqual(2, result["purged_count"]) + self.assertEqual("Purged 2 expired items from recycle bin", result["message"])