diff --git a/src/deltaglider/__init__.py b/src/deltaglider/__init__.py index 8b03dd7..ead78e0 100644 --- a/src/deltaglider/__init__.py +++ b/src/deltaglider/__init__.py @@ -7,14 +7,13 @@ except ImportError: __version__ = "0.0.0+unknown" # Import client API -from .client import ( +from .client import DeltaGliderClient, create_client +from .client_models import ( BucketStats, CompressionEstimate, - DeltaGliderClient, ListObjectsResponse, ObjectInfo, UploadSummary, - create_client, ) from .core import DeltaService, DeltaSpace, ObjectKey diff --git a/src/deltaglider/client.py b/src/deltaglider/client.py index 37b3b36..66e58ee 100644 --- a/src/deltaglider/client.py +++ b/src/deltaglider/client.py @@ -2,111 +2,22 @@ import tempfile from collections.abc import Callable -from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, cast from .adapters.storage_s3 import S3StorageAdapter +from .client_delete_helpers import delete_with_delta_suffix +from .client_models import ( + BucketStats, + CompressionEstimate, + ListObjectsResponse, + ObjectInfo, + UploadSummary, +) from .core import DeltaService, DeltaSpace, ObjectKey from .core.errors import NotFoundError -@dataclass -class UploadSummary: - """User-friendly upload summary.""" - - operation: str - bucket: str - key: str - original_size: int - stored_size: int - is_delta: bool - delta_ratio: float = 0.0 - - @property - def original_size_mb(self) -> float: - """Original size in MB.""" - return self.original_size / (1024 * 1024) - - @property - def stored_size_mb(self) -> float: - """Stored size in MB.""" - return self.stored_size / (1024 * 1024) - - @property - def savings_percent(self) -> float: - """Percentage saved through compression.""" - if self.original_size == 0: - return 0.0 - return ((self.original_size - self.stored_size) / self.original_size) * 100 - - -@dataclass -class CompressionEstimate: - """Compression estimate for a file.""" - - original_size: int - estimated_compressed_size: int - estimated_ratio: float - confidence: float - recommended_reference: str | None = None - should_use_delta: bool = True - - -@dataclass -class ObjectInfo: - """Detailed object information with compression stats.""" - - key: str - size: int - last_modified: str - etag: str | None = None - storage_class: str = "STANDARD" - - # DeltaGlider-specific fields - original_size: int | None = None - compressed_size: int | None = None - compression_ratio: float | None = None - is_delta: bool = False - reference_key: str | None = None - delta_chain_length: int = 0 - - -@dataclass -class ListObjectsResponse: - """Response from list_objects, compatible with boto3.""" - - name: str # Bucket name - prefix: str = "" - delimiter: str = "" - max_keys: int = 1000 - common_prefixes: list[dict[str, str]] = field(default_factory=list) - contents: list[ObjectInfo] = field(default_factory=list) - is_truncated: bool = False - next_continuation_token: str | None = None - continuation_token: str | None = None - key_count: int = 0 - - @property - def objects(self) -> list[ObjectInfo]: - """Alias for contents, for convenience.""" - return self.contents - - -@dataclass -class BucketStats: - """Statistics for a bucket.""" - - bucket: str - object_count: int - total_size: int - compressed_size: int - space_saved: int - average_compression_ratio: float - delta_objects: int - direct_objects: int - - class DeltaGliderClient: """DeltaGlider client with boto3-compatible APIs and advanced features. @@ -434,17 +345,7 @@ class DeltaGliderClient: Returns: Response dict with deletion details """ - # Try to delete with the key as provided - object_key = ObjectKey(bucket=Bucket, key=Key) - try: - delete_result = self.service.delete(object_key) - except NotFoundError: - # Try with .delta suffix if not already present - if not Key.endswith(".delta"): - object_key = ObjectKey(bucket=Bucket, key=Key + ".delta") - delete_result = self.service.delete(object_key) - else: - raise + _, delete_result = delete_with_delta_suffix(self.service, Bucket, Key) response = { "DeleteMarker": False, @@ -496,10 +397,11 @@ class DeltaGliderClient: for obj in Delete.get("Objects", []): key = obj["Key"] try: - object_key = ObjectKey(bucket=Bucket, key=key) - delete_result = self.service.delete(object_key) + actual_key, delete_result = delete_with_delta_suffix(self.service, Bucket, key) deleted_item = {"Key": key} + if actual_key != key: + deleted_item["StoredKey"] = actual_key if delete_result.get("type"): deleted_item["Type"] = delete_result["type"] if delete_result.get("warnings"): @@ -512,11 +414,20 @@ class DeltaGliderClient: delta_info.append( { "Key": key, + "StoredKey": actual_key, "Type": delete_result["type"], "DependentDeltas": delete_result.get("dependent_deltas", 0), } ) + except NotFoundError as e: + errors.append( + { + "Key": key, + "Code": "NoSuchKey", + "Message": str(e), + } + ) except Exception as e: errors.append( { @@ -556,28 +467,112 @@ class DeltaGliderClient: Returns: Response dict with deletion statistics """ - # Use core service's delta-aware recursive delete + single_results: list[dict[str, Any]] = [] + single_errors: list[str] = [] + + # First, attempt to delete the prefix as a direct object (with delta fallback) + if Prefix and not Prefix.endswith("/"): + candidate_keys = [Prefix] + if not Prefix.endswith(".delta"): + candidate_keys.append(f"{Prefix}.delta") + + seen_candidates = set() + for candidate in candidate_keys: + if candidate in seen_candidates: + continue + seen_candidates.add(candidate) + + obj_head = self.service.storage.head(f"{Bucket}/{candidate}") + if not obj_head: + continue + + try: + actual_key, delete_result = delete_with_delta_suffix( + self.service, Bucket, candidate + ) + if delete_result.get("deleted"): + single_results.append( + { + "requested_key": candidate, + "actual_key": actual_key, + "result": delete_result, + } + ) + except Exception as e: + single_errors.append(f"Failed to delete {candidate}: {e}") + + # Use core service's delta-aware recursive delete for remaining objects delete_result = self.service.delete_recursive(Bucket, Prefix) + # Aggregate results + single_deleted_count = len(single_results) + single_counts = {"delta": 0, "reference": 0, "direct": 0, "other": 0} + single_details = [] + single_warnings: list[str] = [] + + for item in single_results: + result = item["result"] + requested_key = item["requested_key"] + actual_key = item["actual_key"] + result_type = result.get("type", "other") + if result_type not in single_counts: + result_type = "other" + single_counts[result_type] += 1 + detail = { + "Key": requested_key, + "Type": result.get("type"), + "DependentDeltas": result.get("dependent_deltas", 0), + "Warnings": result.get("warnings", []), + } + if actual_key != requested_key: + detail["StoredKey"] = actual_key + single_details.append(detail) + warnings = result.get("warnings") + if warnings: + single_warnings.extend(warnings) + + deleted_count = cast(int, delete_result.get("deleted_count", 0)) + single_deleted_count + failed_count = cast(int, delete_result.get("failed_count", 0)) + len(single_errors) + + deltas_deleted = cast(int, delete_result.get("deltas_deleted", 0)) + single_counts["delta"] + references_deleted = ( + cast(int, delete_result.get("references_deleted", 0)) + single_counts["reference"] + ) + direct_deleted = cast(int, delete_result.get("direct_deleted", 0)) + single_counts["direct"] + other_deleted = cast(int, delete_result.get("other_deleted", 0)) + single_counts["other"] + response = { "ResponseMetadata": { "HTTPStatusCode": 200, }, - "DeletedCount": delete_result.get("deleted_count", 0), - "FailedCount": delete_result.get("failed_count", 0), + "DeletedCount": deleted_count, + "FailedCount": failed_count, "DeltaGliderInfo": { - "DeltasDeleted": delete_result.get("deltas_deleted", 0), - "ReferencesDeleted": delete_result.get("references_deleted", 0), - "DirectDeleted": delete_result.get("direct_deleted", 0), - "OtherDeleted": delete_result.get("other_deleted", 0), + "DeltasDeleted": deltas_deleted, + "ReferencesDeleted": references_deleted, + "DirectDeleted": direct_deleted, + "OtherDeleted": other_deleted, }, } - if delete_result.get("errors"): - response["Errors"] = delete_result["errors"] + errors = delete_result.get("errors") + if errors: + response["Errors"] = cast(list[str], errors) - if delete_result.get("warnings"): - response["Warnings"] = delete_result["warnings"] + warnings = delete_result.get("warnings") + if warnings: + response["Warnings"] = cast(list[str], warnings) + + if single_errors: + errors_list = cast(list[str], response.setdefault("Errors", [])) + errors_list.extend(single_errors) + + if single_warnings: + warnings_list = cast(list[str], response.setdefault("Warnings", [])) + warnings_list.extend(single_warnings) + + if single_details: + response["DeltaGliderInfo"]["SingleDeletes"] = single_details # type: ignore[index] return response diff --git a/src/deltaglider/client_delete_helpers.py b/src/deltaglider/client_delete_helpers.py new file mode 100644 index 0000000..39ddec6 --- /dev/null +++ b/src/deltaglider/client_delete_helpers.py @@ -0,0 +1,35 @@ +"""Helper utilities for client delete operations.""" + +from .core import DeltaService, ObjectKey +from .core.errors import NotFoundError + + +def delete_with_delta_suffix( + service: DeltaService, bucket: str, key: str +) -> tuple[str, dict[str, object]]: + """Delete an object, retrying with '.delta' suffix when needed. + + Args: + service: DeltaService-like instance exposing ``delete(ObjectKey)``. + bucket: Target bucket. + key: Requested key (without forcing .delta suffix). + + Returns: + Tuple containing the actual key deleted in storage and the delete result dict. + + Raises: + NotFoundError: Propagated when both the direct and '.delta' keys are missing. + """ + actual_key = key + object_key = ObjectKey(bucket=bucket, key=actual_key) + + try: + delete_result = service.delete(object_key) + except NotFoundError: + if key.endswith(".delta"): + raise + actual_key = f"{key}.delta" + object_key = ObjectKey(bucket=bucket, key=actual_key) + delete_result = service.delete(object_key) + + return actual_key, delete_result diff --git a/src/deltaglider/client_models.py b/src/deltaglider/client_models.py new file mode 100644 index 0000000..e0d99e6 --- /dev/null +++ b/src/deltaglider/client_models.py @@ -0,0 +1,100 @@ +"""Shared data models for the DeltaGlider client.""" + +from dataclasses import dataclass, field + + +@dataclass +class UploadSummary: + """User-friendly upload summary.""" + + operation: str + bucket: str + key: str + original_size: int + stored_size: int + is_delta: bool + delta_ratio: float = 0.0 + + @property + def original_size_mb(self) -> float: + """Original size in MB.""" + return self.original_size / (1024 * 1024) + + @property + def stored_size_mb(self) -> float: + """Stored size in MB.""" + return self.stored_size / (1024 * 1024) + + @property + def savings_percent(self) -> float: + """Percentage saved through compression.""" + if self.original_size == 0: + return 0.0 + return ((self.original_size - self.stored_size) / self.original_size) * 100 + + +@dataclass +class CompressionEstimate: + """Compression estimate for a file.""" + + original_size: int + estimated_compressed_size: int + estimated_ratio: float + confidence: float + recommended_reference: str | None = None + should_use_delta: bool = True + + +@dataclass +class ObjectInfo: + """Detailed object information with compression stats.""" + + key: str + size: int + last_modified: str + etag: str | None = None + storage_class: str = "STANDARD" + + # DeltaGlider-specific fields + original_size: int | None = None + compressed_size: int | None = None + compression_ratio: float | None = None + is_delta: bool = False + reference_key: str | None = None + delta_chain_length: int = 0 + + +@dataclass +class ListObjectsResponse: + """Response from list_objects, compatible with boto3.""" + + name: str # Bucket name + prefix: str = "" + delimiter: str = "" + max_keys: int = 1000 + common_prefixes: list[dict[str, str]] = field(default_factory=list) + contents: list[ObjectInfo] = field(default_factory=list) + is_truncated: bool = False + next_continuation_token: str | None = None + continuation_token: str | None = None + key_count: int = 0 + + @property + def objects(self) -> list[ObjectInfo]: + """Alias for contents, for convenience.""" + return self.contents + + +@dataclass +class BucketStats: + """Statistics for a bucket.""" + + bucket: str + object_count: int + total_size: int + compressed_size: int + space_saved: int + average_compression_ratio: float + delta_objects: int + direct_objects: int + diff --git a/tests/integration/test_delete_objects_recursive.py b/tests/integration/test_delete_objects_recursive.py new file mode 100644 index 0000000..f4c0128 --- /dev/null +++ b/tests/integration/test_delete_objects_recursive.py @@ -0,0 +1,560 @@ +"""Comprehensive tests for DeltaGliderClient.delete_objects_recursive() method.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from deltaglider import create_client +from deltaglider.client import DeltaGliderClient +from deltaglider.core import DeltaService + + +class MockStorage: + """Mock storage for testing.""" + + def __init__(self): + self.objects = {} + self.delete_calls = [] + + def head(self, key): + """Mock head operation.""" + from deltaglider.ports.storage import ObjectHead + + if key in self.objects: + obj = self.objects[key] + return ObjectHead( + key=key, + size=obj["size"], + etag=obj.get("etag", "mock-etag"), + last_modified=obj.get("last_modified", datetime.now(UTC)), + metadata=obj.get("metadata", {}), + ) + return None + + def list(self, prefix): + """Mock list operation for StoragePort interface.""" + for key, _obj in self.objects.items(): + if key.startswith(prefix): + obj_head = self.head(key) + if obj_head is not None: + yield obj_head + + def delete(self, key): + """Mock delete operation.""" + self.delete_calls.append(key) + if key in self.objects: + del self.objects[key] + return True + return False + + def get(self, key): + """Mock get operation.""" + if key in self.objects: + return self.objects[key].get("content", b"mock-content") + return None + + def put(self, key, data, metadata=None): + """Mock put operation.""" + self.objects[key] = { + "size": len(data), + "content": data, + "metadata": metadata or {}, + } + + +@pytest.fixture +def mock_storage(): + """Create mock storage.""" + return MockStorage() + + +@pytest.fixture +def client(tmp_path): + """Create DeltaGliderClient with mock storage.""" + # Use create_client to get a properly configured client + client = create_client(cache_dir=str(tmp_path / "cache")) + + # Replace storage with mock + mock_storage = MockStorage() + client.service.storage = mock_storage + + return client + + +class TestDeleteObjectsRecursiveBasicFunctionality: + """Test basic functionality of delete_objects_recursive.""" + + def test_delete_single_object_with_file_prefix(self, client): + """Test deleting a single object when prefix is a file (no trailing slash).""" + # Setup: Add a regular file + client.service.storage.objects["test-bucket/file.txt"] = {"size": 100} + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.txt" + ) + + # Verify response structure + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert "DeletedCount" in response + assert "FailedCount" in response + assert "DeltaGliderInfo" in response + + # Verify DeltaGliderInfo structure + info = response["DeltaGliderInfo"] + assert "DeltasDeleted" in info + assert "ReferencesDeleted" in info + assert "DirectDeleted" in info + assert "OtherDeleted" in info + + def test_delete_directory_with_trailing_slash(self, client): + """Test deleting all objects under a prefix with trailing slash.""" + # Setup: Add multiple files under a prefix + client.service.storage.objects["test-bucket/dir/file1.txt"] = {"size": 100} + client.service.storage.objects["test-bucket/dir/file2.txt"] = {"size": 200} + client.service.storage.objects["test-bucket/dir/sub/file3.txt"] = {"size": 300} + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="dir/" + ) + + # Verify + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert response["DeletedCount"] >= 0 + assert response["FailedCount"] == 0 + + def test_delete_empty_prefix_returns_zero_counts(self, client): + """Test deleting with empty prefix returns zero counts.""" + # Execute + response = client.delete_objects_recursive(Bucket="test-bucket", Prefix="") + + # Verify + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert response["DeletedCount"] >= 0 + assert response["FailedCount"] == 0 + + +class TestDeleteObjectsRecursiveDeltaSuffixHandling: + """Test delta suffix fallback logic.""" + + def test_delete_file_with_delta_suffix_fallback(self, client): + """Test that delete falls back to .delta suffix if original not found.""" + # Setup: Add file with .delta suffix + client.service.storage.objects["test-bucket/archive.zip.delta"] = { + "size": 500, + "metadata": {"original_name": "archive.zip"}, + } + + # Execute: Delete using original name (without .delta) + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="archive.zip" + ) + + # Verify + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert "test-bucket/archive.zip.delta" not in client.service.storage.objects + + def test_delete_file_already_with_delta_suffix(self, client): + """Test deleting a file that already has .delta suffix.""" + # Setup + client.service.storage.objects["test-bucket/file.zip.delta"] = {"size": 300} + + # Execute: Delete using .delta suffix directly + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.zip.delta" + ) + + # Verify + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + def test_delta_suffix_not_added_for_directory_prefix(self, client): + """Test that .delta suffix is not added when prefix ends with /.""" + # Setup + client.service.storage.objects["test-bucket/dir/file.txt"] = {"size": 100} + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="dir/" + ) + + # Verify - should not attempt to delete "dir/.delta" + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +class TestDeleteObjectsRecursiveStatisticsAggregation: + """Test statistics aggregation from core service.""" + + def test_aggregates_deleted_count_from_service_and_single_deletes(self, client): + """Test that deleted counts are aggregated correctly.""" + # Setup: Mock service.delete_recursive to return specific counts + mock_result = { + "deleted_count": 5, + "failed_count": 0, + "deltas_deleted": 2, + "references_deleted": 1, + "direct_deleted": 2, + "other_deleted": 0, + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="test/" + ) + + # Verify aggregation + assert response["DeletedCount"] == 5 + assert response["FailedCount"] == 0 + assert response["DeltaGliderInfo"]["DeltasDeleted"] == 2 + assert response["DeltaGliderInfo"]["ReferencesDeleted"] == 1 + assert response["DeltaGliderInfo"]["DirectDeleted"] == 2 + assert response["DeltaGliderInfo"]["OtherDeleted"] == 0 + + def test_aggregates_single_delete_counts_with_service_counts(self, client): + """Test that single file deletes are aggregated with service counts.""" + # Setup: Add file to trigger single delete path + client.service.storage.objects["test-bucket/file.txt"] = {"size": 100} + + # Mock service.delete_recursive to return additional counts + mock_result = { + "deleted_count": 3, + "failed_count": 0, + "deltas_deleted": 1, + "references_deleted": 0, + "direct_deleted": 2, + "other_deleted": 0, + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.txt" + ) + + # Verify that counts include both single delete and service delete + assert response["DeletedCount"] >= 3 # At least service count + assert response["DeltaGliderInfo"]["DeltasDeleted"] >= 1 + + +class TestDeleteObjectsRecursiveErrorHandling: + """Test error handling and error aggregation.""" + + def test_single_delete_error_captured_in_errors_list(self, client): + """Test that errors from single deletes are captured.""" + # Setup: Add file + client.service.storage.objects["test-bucket/file.txt"] = {"size": 100} + + # Mock delete_with_delta_suffix to raise exception + with patch("deltaglider.client.delete_with_delta_suffix") as mock_delete: + mock_delete.side_effect = RuntimeError("Simulated delete error") + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.txt" + ) + + # Verify error captured + assert response["FailedCount"] > 0 + assert "Errors" in response + assert any("Simulated delete error" in err for err in response["Errors"]) + + def test_service_errors_propagated_in_response(self, client): + """Test that errors from service.delete_recursive are propagated.""" + # Mock service to return errors + mock_result = { + "deleted_count": 2, + "failed_count": 1, + "deltas_deleted": 2, + "references_deleted": 0, + "direct_deleted": 0, + "other_deleted": 0, + "errors": ["Error deleting object1", "Error deleting object2"], + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="test/" + ) + + # Verify + assert response["FailedCount"] == 1 + assert "Errors" in response + assert "Error deleting object1" in response["Errors"] + assert "Error deleting object2" in response["Errors"] + + def test_combines_single_and_service_errors(self, client): + """Test that errors from both single deletes and service are combined.""" + # Setup + client.service.storage.objects["test-bucket/file.txt"] = {"size": 100} + + # Mock service to also return errors + mock_result = { + "deleted_count": 1, + "failed_count": 1, + "deltas_deleted": 0, + "references_deleted": 0, + "direct_deleted": 0, + "other_deleted": 0, + "errors": ["Service delete error"], + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Mock delete_with_delta_suffix to raise exception + with patch("deltaglider.client.delete_with_delta_suffix") as mock_delete: + mock_delete.side_effect = RuntimeError("Single delete error") + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.txt" + ) + + # Verify both errors present + assert "Errors" in response + errors_str = " ".join(response["Errors"]) + assert "Single delete error" in errors_str + assert "Service delete error" in errors_str + + +class TestDeleteObjectsRecursiveWarningsHandling: + """Test warning aggregation.""" + + def test_service_warnings_propagated_in_response(self, client): + """Test that warnings from service.delete_recursive are propagated.""" + # Mock service to return warnings + mock_result = { + "deleted_count": 3, + "failed_count": 0, + "deltas_deleted": 2, + "references_deleted": 1, + "direct_deleted": 0, + "other_deleted": 0, + "warnings": ["Reference deleted, 2 dependent deltas invalidated"], + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="test/" + ) + + # Verify + assert "Warnings" in response + assert "Reference deleted, 2 dependent deltas invalidated" in response["Warnings"] + + def test_single_delete_warnings_propagated(self, client): + """Test that warnings from single deletes are captured.""" + # Setup + client.service.storage.objects["test-bucket/ref.bin"] = {"size": 100} + + # Mock service + mock_result = { + "deleted_count": 0, + "failed_count": 0, + "deltas_deleted": 0, + "references_deleted": 0, + "direct_deleted": 0, + "other_deleted": 0, + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Mock delete_with_delta_suffix to return warnings + with patch("deltaglider.client.delete_with_delta_suffix") as mock_delete: + mock_delete.return_value = ( + "ref.bin", + { + "deleted": True, + "type": "reference", + "warnings": ["Warning from single delete"], + }, + ) + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="ref.bin" + ) + + # Verify + assert "Warnings" in response + assert "Warning from single delete" in response["Warnings"] + + +class TestDeleteObjectsRecursiveSingleDeleteDetails: + """Test SingleDeletes detail tracking.""" + + def test_single_delete_details_included_for_file_prefix(self, client): + """Test that SingleDeletes details are included when deleting file prefix.""" + # Setup + client.service.storage.objects["test-bucket/file.txt"] = {"size": 100} + + # Mock service + mock_result = { + "deleted_count": 0, + "failed_count": 0, + "deltas_deleted": 0, + "references_deleted": 0, + "direct_deleted": 0, + "other_deleted": 0, + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Mock delete_with_delta_suffix + with patch("deltaglider.client.delete_with_delta_suffix") as mock_delete: + mock_delete.return_value = ( + "file.txt", + { + "deleted": True, + "type": "direct", + "dependent_deltas": 0, + "warnings": [], + }, + ) + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.txt" + ) + + # Verify + assert "SingleDeletes" in response["DeltaGliderInfo"] + single_deletes = response["DeltaGliderInfo"]["SingleDeletes"] + assert len(single_deletes) > 0 + assert single_deletes[0]["Key"] == "file.txt" + assert single_deletes[0]["Type"] == "direct" + assert "DependentDeltas" in single_deletes[0] + assert "Warnings" in single_deletes[0] + + def test_single_delete_includes_stored_key_when_different(self, client): + """Test that StoredKey is included when actual key differs from requested.""" + # Setup + client.service.storage.objects["test-bucket/file.zip.delta"] = {"size": 200} + + # Mock delete_with_delta_suffix to return different key + from deltaglider import client_delete_helpers + + original_delete = client_delete_helpers.delete_with_delta_suffix + + def mock_delete(service, bucket, key): + actual_key = "file.zip.delta" if key == "file.zip" else key + return ( + actual_key, + { + "deleted": True, + "type": "delta", + "dependent_deltas": 0, + "warnings": [], + }, + ) + + client_delete_helpers.delete_with_delta_suffix = mock_delete + + # Mock service + mock_result = { + "deleted_count": 0, + "failed_count": 0, + "deltas_deleted": 0, + "references_deleted": 0, + "direct_deleted": 0, + "other_deleted": 0, + } + client.service.delete_recursive = Mock(return_value=mock_result) + + try: + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.zip" + ) + + # Verify + assert "SingleDeletes" in response["DeltaGliderInfo"] + single_deletes = response["DeltaGliderInfo"]["SingleDeletes"] + if len(single_deletes) > 0: + # If actual key differs, StoredKey should be present + detail = single_deletes[0] + if detail["Key"] != "file.zip.delta": + assert "StoredKey" in detail + finally: + client_delete_helpers.delete_with_delta_suffix = original_delete + + +class TestDeleteObjectsRecursiveEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_nonexistent_prefix_returns_zero_counts(self, client): + """Test deleting nonexistent prefix returns zero counts.""" + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="nonexistent/path/" + ) + + # Verify + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert response["DeletedCount"] >= 0 + assert response["FailedCount"] == 0 + + def test_duplicate_candidates_handled_correctly(self, client): + """Test that duplicate delete candidates are handled correctly.""" + # Setup: This tests the seen_candidates logic + client.service.storage.objects["test-bucket/file.delta"] = {"size": 100} + + # Execute: Should not attempt to delete "file.delta" twice + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.delta" + ) + + # Verify no errors + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + def test_unknown_result_type_categorized_as_other(self, client): + """Test that unknown result types are categorized as 'other'.""" + # Setup + client.service.storage.objects["test-bucket/file.txt"] = {"size": 100} + + # Mock service + mock_result = { + "deleted_count": 0, + "failed_count": 0, + "deltas_deleted": 0, + "references_deleted": 0, + "direct_deleted": 0, + "other_deleted": 0, + } + client.service.delete_recursive = Mock(return_value=mock_result) + + # Mock delete_with_delta_suffix to return unknown type + with patch("deltaglider.client.delete_with_delta_suffix") as mock_delete: + mock_delete.return_value = ( + "file.txt", + { + "deleted": True, + "type": "unknown_type", # Not in single_counts keys + "dependent_deltas": 0, + "warnings": [], + }, + ) + + # Execute + response = client.delete_objects_recursive( + Bucket="test-bucket", Prefix="file.txt" + ) + + # Verify it's categorized as "other" + assert response["DeltaGliderInfo"]["OtherDeleted"] >= 1 + # Also verify the detail shows the unknown type + if "SingleDeletes" in response["DeltaGliderInfo"]: + assert response["DeltaGliderInfo"]["SingleDeletes"][0]["Type"] == "unknown_type" + + def test_kwargs_parameter_accepted(self, client): + """Test that additional kwargs are accepted without error.""" + # Execute with extra parameters + response = client.delete_objects_recursive( + Bucket="test-bucket", + Prefix="test/", + ExtraParam="value", # Should be ignored + AnotherParam=123, + ) + + # Verify no errors + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200