diff --git a/src/documents/tests/test_api_schema.py b/src/documents/tests/test_api_schema.py index 78e6e3a6d..c53cbb401 100644 --- a/src/documents/tests/test_api_schema.py +++ b/src/documents/tests/test_api_schema.py @@ -6,6 +6,12 @@ from rest_framework import status from rest_framework.test import APITestCase +@pytest.fixture(scope="session") +def api_schema(): + generator = SchemaGenerator() + return generator.get_schema(request=None, public=True) + + class TestApiSchema(APITestCase): ENDPOINT = "/api/schema/" @@ -70,26 +76,17 @@ class TestApiSchema(APITestCase): self.assertIn(action_method, advertised_methods) -# ---- session-scoped fixture: generate schema once for all TestXxx classes ---- - - -@pytest.fixture(scope="session") -def api_schema(): - generator = SchemaGenerator() - return generator.get_schema(request=None, public=True) - - class TestTasksSummarySchema: """tasks_summary_retrieve: response must be an array of TaskSummarySerializer.""" - def test_summary_response_is_array(self, api_schema): + def test_summary_response_is_array(self, api_schema: SchemaGenerator): op = api_schema["paths"]["/api/tasks/summary/"]["get"] resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"] assert resp_200["type"] == "array", ( "tasks_summary_retrieve response must be type:array" ) - def test_summary_items_have_total_count(self, api_schema): + def test_summary_items_have_total_count(self, api_schema: SchemaGenerator): op = api_schema["paths"]["/api/tasks/summary/"]["get"] resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"] items = resp_200.get("items", {}) @@ -107,14 +104,14 @@ class TestTasksSummarySchema: class TestTasksActiveSchema: """tasks_active_retrieve: response must be an array of TaskSerializerV10.""" - def test_active_response_is_array(self, api_schema): + def test_active_response_is_array(self, api_schema: SchemaGenerator): op = api_schema["paths"]["/api/tasks/active/"]["get"] resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"] assert resp_200["type"] == "array", ( "tasks_active_retrieve response must be type:array" ) - def test_active_items_ref_named_schema(self, api_schema): + def test_active_items_ref_named_schema(self, api_schema: SchemaGenerator): op = api_schema["paths"]["/api/tasks/active/"]["get"] resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"] items = resp_200.get("items", {}) @@ -122,3 +119,129 @@ class TestTasksActiveSchema: component_name = ref.split("/")[-1] if ref else "" assert component_name, "items should be a $ref to a named schema" assert component_name in api_schema["components"]["schemas"] + + +class TestMetadataSchema: + """Metadata component: array fields and optional archive fields.""" + + @pytest.mark.parametrize("field", ["original_metadata", "archive_metadata"]) + def test_metadata_field_is_array(self, api_schema: SchemaGenerator, field: str): + props = api_schema["components"]["schemas"]["Metadata"]["properties"] + assert props[field]["type"] == "array", ( + f"{field} should be type:array, not type:object" + ) + + @pytest.mark.parametrize("field", ["original_metadata", "archive_metadata"]) + def test_metadata_items_have_key_field( + self, + api_schema: SchemaGenerator, + field: str, + ): + props = api_schema["components"]["schemas"]["Metadata"]["properties"] + items = props[field]["items"] + ref = items.get("$ref", "") + component_name = ref.split("/")[-1] if ref else "" + if component_name: + item_props = api_schema["components"]["schemas"][component_name][ + "properties" + ] + else: + item_props = items.get("properties", {}) + assert "key" in item_props + + @pytest.mark.parametrize( + "field", + [ + "archive_checksum", + "archive_media_filename", + "archive_size", + "archive_metadata", + ], + ) + def test_archive_field_not_required(self, api_schema, field): + schema = api_schema["components"]["schemas"]["Metadata"] + required = schema.get("required", []) + assert field not in required + props = schema["properties"] + assert props[field].get("nullable") is True, ( + f"{field} should be nullable (allow_null=True)" + ) + + +class TestStoragePathTestSchema: + """storage_paths_test_create: response must be a string, not a StoragePath object.""" + + def test_test_action_response_is_string(self, api_schema: SchemaGenerator): + op = api_schema["paths"]["/api/storage_paths/test/"]["post"] + resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"] + assert resp_200.get("type") == "string", ( + "storage_paths_test_create 200 response must be type:string" + ) + + def test_test_action_request_uses_storage_path_test_serializer( + self, + api_schema: SchemaGenerator, + ): + op = api_schema["paths"]["/api/storage_paths/test/"]["post"] + content = ( + op.get("requestBody", {}).get("content", {}).get("application/json", {}) + ) + schema_ref = content.get("schema", {}).get("$ref", "") + component_name = schema_ref.split("/")[-1] + # COMPONENT_SPLIT_REQUEST=True causes drf-spectacular to append "Request" + # to request body component names, so StoragePathTestSerializer -> StoragePathTestRequest + assert component_name == "StoragePathTestRequest", ( + f"Request body should reference StoragePathTestRequest, got {component_name!r}" + ) + + +class TestProcessedMailBulkDeleteSchema: + """processed_mail_bulk_delete_create: response must be {result, deleted_mail_ids}.""" + + def _get_props(self, api_schema: SchemaGenerator): + op = api_schema["paths"]["/api/processed_mail/bulk_delete/"]["post"] + resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"] + ref = resp_200.get("$ref", "") + component_name = ref.split("/")[-1] if ref else "" + if component_name: + return api_schema["components"]["schemas"][component_name]["properties"] + return resp_200.get("properties", {}) + + @pytest.mark.parametrize("field", ["result", "deleted_mail_ids"]) + def test_bulk_delete_response_has_field( + self, + api_schema: SchemaGenerator, + field: str, + ): + props = self._get_props(api_schema) + assert field in props, f"bulk_delete 200 response must have a '{field}' field" + + def test_bulk_delete_response_is_not_processed_mail_serializer(self, api_schema): + op = api_schema["paths"]["/api/processed_mail/bulk_delete/"]["post"] + resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"] + ref = resp_200.get("$ref", "") + component_name = ref.split("/")[-1] if ref else "" + assert component_name != "ProcessedMail", ( + "bulk_delete 200 response must not be the full ProcessedMail serializer" + ) + + +class TestShareLinkBundleRebuildSchema: + """share_link_bundles_rebuild_create: 200 returns bundle data; 400 is documented.""" + + def test_rebuild_has_400_response(self, api_schema: SchemaGenerator): + op = api_schema["paths"]["/api/share_link_bundles/{id}/rebuild/"]["post"] + assert "400" in op["responses"], ( + "rebuild must document the 400 response for 'Bundle is already being processed.'" + ) + + def test_rebuild_400_has_detail_field(self, api_schema: SchemaGenerator): + op = api_schema["paths"]["/api/share_link_bundles/{id}/rebuild/"]["post"] + resp_400 = op["responses"]["400"]["content"]["application/json"]["schema"] + ref = resp_400.get("$ref", "") + component_name = ref.split("/")[-1] if ref else "" + if component_name: + props = api_schema["components"]["schemas"][component_name]["properties"] + else: + props = resp_400.get("properties", {}) + assert "detail" in props, "rebuild 400 response must have a 'detail' field" diff --git a/src/documents/views.py b/src/documents/views.py index d13760d38..21fd9a4a8 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -9,6 +9,7 @@ from collections import defaultdict from collections import deque from datetime import datetime from datetime import timedelta +from http import HTTPStatus from pathlib import Path from time import mktime from typing import TYPE_CHECKING @@ -689,18 +690,49 @@ class EmailDocumentDetailSchema(EmailSerializer): "original_mime_type": serializers.CharField(), "media_filename": serializers.CharField(), "has_archive_version": serializers.BooleanField(), - "original_metadata": serializers.DictField(), - "archive_checksum": serializers.CharField(), - "archive_media_filename": serializers.CharField(), + "original_metadata": serializers.ListField( + child=inline_serializer( + name="OriginalMetadataEntry", + fields={ + "namespace": serializers.CharField(), + "prefix": serializers.CharField(), + "key": serializers.CharField(), + "value": serializers.CharField(), + }, + ), + ), + "archive_checksum": serializers.CharField( + allow_null=True, + required=False, + ), + "archive_media_filename": serializers.CharField( + allow_null=True, + required=False, + ), "original_filename": serializers.CharField(), - "archive_size": serializers.IntegerField(), - "archive_metadata": serializers.DictField(), + "archive_size": serializers.IntegerField( + allow_null=True, + required=False, + ), + "archive_metadata": serializers.ListField( + child=inline_serializer( + name="ArchiveMetadataEntry", + fields={ + "namespace": serializers.CharField(), + "prefix": serializers.CharField(), + "key": serializers.CharField(), + "value": serializers.CharField(), + }, + ), + allow_null=True, + required=False, + ), "lang": serializers.CharField(), }, ), - 400: None, - 403: None, - 404: None, + HTTPStatus.BAD_REQUEST: None, + HTTPStatus.FORBIDDEN: None, + HTTPStatus.NOT_FOUND: None, }, ), notes=extend_schema( @@ -3528,7 +3560,17 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]): return response -@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer)) +@extend_schema_view( + **generate_object_with_permissions_schema(StoragePathSerializer), + test=extend_schema( + operation_id="storage_paths_test", + description="Test a storage path template against a document.", + request=StoragePathTestSerializer, + responses={ + (HTTPStatus.OK, "application/json"): OpenApiTypes.STR, + }, + ), +) class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[StoragePath]): model = StoragePath @@ -3985,6 +4027,19 @@ class ShareLinkViewSet( ordering_fields = ("created", "expiration", "document") +@extend_schema_view( + rebuild=extend_schema( + operation_id="share_link_bundles_rebuild", + description="Reset and re-queue a share link bundle for processing.", + responses={ + HTTPStatus.OK: ShareLinkBundleSerializer, + (HTTPStatus.BAD_REQUEST, "application/json"): inline_serializer( + name="RebuildBundleError", + fields={"detail": serializers.CharField()}, + ), + }, + ), +) class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]): model = ShareLinkBundle diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index c4ec2da40..8d6a7fa03 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -1,6 +1,7 @@ import datetime import logging from datetime import timedelta +from http import HTTPStatus from typing import Any from django.http import HttpResponseBadRequest @@ -160,6 +161,31 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]): return Response({"result": "OK"}) +@extend_schema_view( + bulk_delete=extend_schema( + operation_id="processed_mail_bulk_delete", + description="Delete multiple processed mail records by ID.", + request=inline_serializer( + name="BulkDeleteMailRequest", + fields={ + "mail_ids": serializers.ListField(child=serializers.IntegerField()), + }, + ), + responses={ + (HTTPStatus.OK, "application/json"): inline_serializer( + name="BulkDeleteMailResponse", + fields={ + "result": serializers.CharField(), + "deleted_mail_ids": serializers.ListField( + child=serializers.IntegerField(), + ), + }, + ), + HTTPStatus.BAD_REQUEST: None, + HTTPStatus.FORBIDDEN: None, + }, + ), +) class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]): permission_classes = (IsAuthenticated, PaperlessObjectPermissions) serializer_class = ProcessedMailSerializer