diff --git a/CHANGELOG.md b/CHANGELOG.md index 531822f..c09996b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added integration tests for all stats endpoints (#35) - Added integration tests for cascade deletion ref_count behavior (package/project delete) (#35) - Added integration tests for tag update ref_count adjustments (#35) +- Added integration tests for garbage collection endpoints (#35) - Added test dependencies to requirements.txt (pytest, pytest-asyncio, pytest-cov, httpx, moto) (#35) ### Fixed - Fixed Helm chart `minio.ingress` conflicting with Bitnami MinIO subchart by renaming to `minioIngress` (#48) diff --git a/backend/tests/test_garbage_collection.py b/backend/tests/test_garbage_collection.py new file mode 100644 index 0000000..698f98b --- /dev/null +++ b/backend/tests/test_garbage_collection.py @@ -0,0 +1,168 @@ +""" +Integration tests for garbage collection functionality. + +Tests cover: +- Listing orphaned artifacts (ref_count=0) +- Garbage collection in dry-run mode +- Garbage collection actual deletion +- Verifying artifacts with refs are not deleted +""" + +import pytest +from tests.conftest import ( + compute_sha256, + upload_test_file, +) + + +class TestOrphanedArtifactsEndpoint: + """Tests for GET /api/v1/admin/orphaned-artifacts endpoint.""" + + @pytest.mark.integration + def test_list_orphaned_artifacts_returns_list(self, integration_client): + """Test orphaned artifacts endpoint returns a list.""" + response = integration_client.get("/api/v1/admin/orphaned-artifacts") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + @pytest.mark.integration + def test_orphaned_artifact_has_required_fields(self, integration_client): + """Test orphaned artifact response has required fields.""" + response = integration_client.get("/api/v1/admin/orphaned-artifacts?limit=1") + assert response.status_code == 200 + + data = response.json() + if len(data) > 0: + artifact = data[0] + assert "id" in artifact + assert "size" in artifact + assert "created_at" in artifact + assert "created_by" in artifact + assert "original_name" in artifact + + @pytest.mark.integration + def test_orphaned_artifacts_respects_limit(self, integration_client): + """Test orphaned artifacts endpoint respects limit parameter.""" + response = integration_client.get("/api/v1/admin/orphaned-artifacts?limit=5") + assert response.status_code == 200 + assert len(response.json()) <= 5 + + @pytest.mark.integration + def test_artifact_becomes_orphaned_when_tag_deleted( + self, integration_client, test_package, unique_test_id + ): + """Test artifact appears in orphaned list after tag is deleted.""" + project, package = test_package + content = f"orphan test {unique_test_id}".encode() + expected_hash = compute_sha256(content) + + # Upload with tag + upload_test_file(integration_client, project, package, content, tag="temp-tag") + + # Verify not in orphaned list (has ref_count=1) + response = integration_client.get("/api/v1/admin/orphaned-artifacts?limit=1000") + orphaned_ids = [a["id"] for a in response.json()] + assert expected_hash not in orphaned_ids + + # Delete the tag + integration_client.delete(f"/api/v1/project/{project}/{package}/tags/temp-tag") + + # Verify now in orphaned list (ref_count=0) + response = integration_client.get("/api/v1/admin/orphaned-artifacts?limit=1000") + orphaned_ids = [a["id"] for a in response.json()] + assert expected_hash in orphaned_ids + + +class TestGarbageCollectionEndpoint: + """Tests for POST /api/v1/admin/garbage-collect endpoint.""" + + @pytest.mark.integration + def test_garbage_collect_dry_run_returns_response(self, integration_client): + """Test garbage collection dry run returns valid response.""" + response = integration_client.post("/api/v1/admin/garbage-collect?dry_run=true") + assert response.status_code == 200 + + data = response.json() + assert "artifacts_deleted" in data + assert "bytes_freed" in data + assert "artifact_ids" in data + assert "dry_run" in data + assert data["dry_run"] is True + + @pytest.mark.integration + def test_garbage_collect_dry_run_doesnt_delete( + self, integration_client, test_package, unique_test_id + ): + """Test garbage collection dry run doesn't actually delete artifacts.""" + project, package = test_package + content = f"dry run test {unique_test_id}".encode() + expected_hash = compute_sha256(content) + + # Upload and delete tag to create orphan + upload_test_file(integration_client, project, package, content, tag="dry-run") + integration_client.delete(f"/api/v1/project/{project}/{package}/tags/dry-run") + + # Verify artifact exists + response = integration_client.get(f"/api/v1/artifact/{expected_hash}") + assert response.status_code == 200 + + # Run garbage collection in dry-run mode + gc_response = integration_client.post( + "/api/v1/admin/garbage-collect?dry_run=true&limit=1000" + ) + assert gc_response.status_code == 200 + assert expected_hash in gc_response.json()["artifact_ids"] + + # Verify artifact STILL exists (dry run didn't delete) + response = integration_client.get(f"/api/v1/artifact/{expected_hash}") + assert response.status_code == 200 + + @pytest.mark.integration + def test_garbage_collect_preserves_referenced_artifacts( + self, integration_client, test_package, unique_test_id + ): + """Test garbage collection doesn't delete artifacts with ref_count > 0.""" + project, package = test_package + content = f"preserve test {unique_test_id}".encode() + expected_hash = compute_sha256(content) + + # Upload with tag (ref_count=1) + upload_test_file(integration_client, project, package, content, tag="keep-this") + + # Verify artifact exists with ref_count=1 + response = integration_client.get(f"/api/v1/artifact/{expected_hash}") + assert response.status_code == 200 + assert response.json()["ref_count"] == 1 + + # Run garbage collection (dry_run to not affect other tests) + gc_response = integration_client.post( + "/api/v1/admin/garbage-collect?dry_run=true&limit=1000" + ) + assert gc_response.status_code == 200 + + # Verify artifact was NOT in delete list (has ref_count > 0) + assert expected_hash not in gc_response.json()["artifact_ids"] + + # Verify artifact still exists + response = integration_client.get(f"/api/v1/artifact/{expected_hash}") + assert response.status_code == 200 + assert response.json()["ref_count"] == 1 + + @pytest.mark.integration + def test_garbage_collect_respects_limit(self, integration_client): + """Test garbage collection respects limit parameter.""" + response = integration_client.post( + "/api/v1/admin/garbage-collect?dry_run=true&limit=5" + ) + assert response.status_code == 200 + assert response.json()["artifacts_deleted"] <= 5 + + @pytest.mark.integration + def test_garbage_collect_returns_bytes_freed(self, integration_client): + """Test garbage collection returns accurate bytes_freed.""" + response = integration_client.post("/api/v1/admin/garbage-collect?dry_run=true") + assert response.status_code == 200 + + data = response.json() + assert data["bytes_freed"] >= 0 + assert isinstance(data["bytes_freed"], int)