""" Unit and integration tests for reference counting behavior. Tests cover: - ref_count is set correctly for new artifacts - ref_count increments on duplicate uploads - ref_count query correctly identifies existing artifacts - Artifact lookup by SHA256 hash works correctly """ import pytest import io from tests.conftest import ( compute_sha256, upload_test_file, TEST_CONTENT_HELLO, TEST_HASH_HELLO, ) class TestRefCountQuery: """Tests for ref_count querying and artifact lookup.""" @pytest.mark.integration def test_artifact_lookup_by_sha256(self, integration_client, test_package): """Test artifact lookup by SHA256 hash (primary key) works correctly.""" project, package = test_package content = b"unique content for lookup test" expected_hash = compute_sha256(content) # Upload a file upload_result = upload_test_file( integration_client, project, package, content, tag="v1" ) assert upload_result["artifact_id"] == expected_hash # Look up artifact by ID (SHA256) response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.status_code == 200 artifact = response.json() assert artifact["id"] == expected_hash assert artifact["sha256"] == expected_hash assert artifact["size"] == len(content) @pytest.mark.integration def test_ref_count_query_identifies_existing_artifact( self, integration_client, test_package ): """Test ref_count query correctly identifies existing artifacts by hash.""" project, package = test_package content = b"content for ref count query test" expected_hash = compute_sha256(content) # Upload a file with a tag upload_result = upload_test_file( integration_client, project, package, content, tag="v1" ) # Query artifact and check ref_count response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.status_code == 200 artifact = response.json() assert artifact["ref_count"] >= 1 # At least 1 from the tag @pytest.mark.integration def test_ref_count_set_to_1_for_new_artifact_with_tag( self, integration_client, test_package, unique_test_id ): """Test ref_count is set to 1 for new artifacts when created with a tag.""" project, package = test_package content = f"brand new content for ref count test {unique_test_id}".encode() expected_hash = compute_sha256(content) # Upload a new file with a tag upload_result = upload_test_file( integration_client, project, package, content, tag="initial" ) assert upload_result["artifact_id"] == expected_hash assert upload_result["ref_count"] == 1 assert upload_result["deduplicated"] is False @pytest.mark.integration def test_ref_count_increments_on_duplicate_upload_with_tag( self, integration_client, test_package, unique_test_id ): """Test ref_count is incremented when duplicate content is uploaded with a new tag.""" project, package = test_package content = f"content that will be uploaded twice {unique_test_id}".encode() expected_hash = compute_sha256(content) # First upload with tag result1 = upload_test_file( integration_client, project, package, content, tag="v1" ) assert result1["ref_count"] == 1 assert result1["deduplicated"] is False # Second upload with different tag (same content) result2 = upload_test_file( integration_client, project, package, content, tag="v2" ) assert result2["artifact_id"] == expected_hash assert result2["ref_count"] == 2 assert result2["deduplicated"] is True @pytest.mark.integration def test_ref_count_after_multiple_tags(self, integration_client, test_package): """Test ref_count correctly reflects number of tags pointing to artifact.""" project, package = test_package content = b"content for multiple tag test" expected_hash = compute_sha256(content) # Upload with multiple tags tags = ["v1", "v2", "v3", "latest"] for i, tag in enumerate(tags): result = upload_test_file( integration_client, project, package, content, tag=tag ) assert result["artifact_id"] == expected_hash assert result["ref_count"] == i + 1 # Verify final ref_count via artifact endpoint response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.status_code == 200 assert response.json()["ref_count"] == len(tags) class TestRefCountWithDeletion: """Tests for ref_count behavior when tags are deleted.""" @pytest.mark.integration def test_ref_count_decrements_on_tag_delete(self, integration_client, test_package): """Test ref_count decrements when a tag is deleted.""" project, package = test_package content = b"content for delete test" expected_hash = compute_sha256(content) # Upload with two tags upload_test_file(integration_client, project, package, content, tag="v1") upload_test_file(integration_client, project, package, content, tag="v2") # Verify ref_count is 2 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 2 # Delete one tag delete_response = integration_client.delete( f"/api/v1/project/{project}/{package}/tags/v1" ) assert delete_response.status_code == 204 # Verify ref_count is now 1 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 1 @pytest.mark.integration def test_ref_count_zero_after_all_tags_deleted( self, integration_client, test_package ): """Test ref_count goes to 0 when all tags are deleted.""" project, package = test_package content = b"content that will be orphaned" expected_hash = compute_sha256(content) # Upload with one tag upload_test_file(integration_client, project, package, content, tag="only-tag") # Delete the tag integration_client.delete(f"/api/v1/project/{project}/{package}/tags/only-tag") # Verify ref_count is 0 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 0 class TestRefCountCascadeDelete: """Tests for ref_count behavior during cascade deletions.""" @pytest.mark.integration def test_ref_count_decrements_on_package_delete( self, integration_client, unique_test_id ): """Test ref_count decrements for all tags when package is deleted.""" # Create a project and package manually (not using fixtures to control cleanup) project_name = f"cascade-pkg-{unique_test_id}" package_name = f"test-pkg-{unique_test_id}" # Create project response = integration_client.post( "/api/v1/projects", json={ "name": project_name, "description": "Test project", "is_public": True, }, ) assert response.status_code == 200 # Create package response = integration_client.post( f"/api/v1/project/{project_name}/packages", json={"name": package_name, "description": "Test package"}, ) assert response.status_code == 200 # Upload content with multiple tags content = f"cascade delete test {unique_test_id}".encode() expected_hash = compute_sha256(content) upload_test_file( integration_client, project_name, package_name, content, tag="v1" ) upload_test_file( integration_client, project_name, package_name, content, tag="v2" ) upload_test_file( integration_client, project_name, package_name, content, tag="v3" ) # Verify ref_count is 3 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 3 # Delete the package (should cascade delete all tags and decrement ref_count) delete_response = integration_client.delete( f"/api/v1/project/{project_name}/packages/{package_name}" ) assert delete_response.status_code == 204 # Verify ref_count is 0 (all tags were deleted) response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 0 # Cleanup: delete the project integration_client.delete(f"/api/v1/projects/{project_name}") @pytest.mark.integration def test_ref_count_decrements_on_project_delete( self, integration_client, unique_test_id ): """Test ref_count decrements for all tags in all packages when project is deleted.""" # Create a project manually (not using fixtures to control cleanup) project_name = f"cascade-proj-{unique_test_id}" package1_name = f"pkg1-{unique_test_id}" package2_name = f"pkg2-{unique_test_id}" # Create project response = integration_client.post( "/api/v1/projects", json={ "name": project_name, "description": "Test project", "is_public": True, }, ) assert response.status_code == 200 # Create two packages for pkg_name in [package1_name, package2_name]: response = integration_client.post( f"/api/v1/project/{project_name}/packages", json={"name": pkg_name, "description": "Test package"}, ) assert response.status_code == 200 # Upload same content with tags in both packages content = f"project cascade test {unique_test_id}".encode() expected_hash = compute_sha256(content) upload_test_file( integration_client, project_name, package1_name, content, tag="v1" ) upload_test_file( integration_client, project_name, package1_name, content, tag="v2" ) upload_test_file( integration_client, project_name, package2_name, content, tag="latest" ) upload_test_file( integration_client, project_name, package2_name, content, tag="stable" ) # Verify ref_count is 4 (2 tags in each of 2 packages) response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 4 # Delete the project (should cascade delete all packages, tags, and decrement ref_count) delete_response = integration_client.delete(f"/api/v1/projects/{project_name}") assert delete_response.status_code == 204 # Verify ref_count is 0 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 0 @pytest.mark.integration def test_shared_artifact_ref_count_partial_decrement( self, integration_client, unique_test_id ): """Test ref_count correctly decrements when artifact is shared across packages.""" # Create project with two packages project_name = f"shared-artifact-{unique_test_id}" package1_name = f"pkg1-{unique_test_id}" package2_name = f"pkg2-{unique_test_id}" # Create project response = integration_client.post( "/api/v1/projects", json={ "name": project_name, "description": "Test project", "is_public": True, }, ) assert response.status_code == 200 # Create two packages for pkg_name in [package1_name, package2_name]: response = integration_client.post( f"/api/v1/project/{project_name}/packages", json={"name": pkg_name, "description": "Test package"}, ) assert response.status_code == 200 # Upload same content to both packages content = f"shared artifact {unique_test_id}".encode() expected_hash = compute_sha256(content) upload_test_file( integration_client, project_name, package1_name, content, tag="v1" ) upload_test_file( integration_client, project_name, package2_name, content, tag="v1" ) # Verify ref_count is 2 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 2 # Delete only package1 (package2 still references the artifact) delete_response = integration_client.delete( f"/api/v1/project/{project_name}/packages/{package1_name}" ) assert delete_response.status_code == 204 # Verify ref_count is 1 (only package2's tag remains) response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 1 # Cleanup integration_client.delete(f"/api/v1/projects/{project_name}") class TestRefCountTagUpdate: """Tests for ref_count behavior when tags are updated to point to different artifacts.""" @pytest.mark.integration def test_ref_count_adjusts_on_tag_update( self, integration_client, test_package, unique_test_id ): """Test ref_count adjusts when a tag is updated to point to a different artifact.""" project, package = test_package # Upload two different artifacts content1 = f"artifact one {unique_test_id}".encode() content2 = f"artifact two {unique_test_id}".encode() hash1 = compute_sha256(content1) hash2 = compute_sha256(content2) # Upload first artifact with tag "latest" upload_test_file(integration_client, project, package, content1, tag="latest") # Verify first artifact has ref_count 1 response = integration_client.get(f"/api/v1/artifact/{hash1}") assert response.json()["ref_count"] == 1 # Upload second artifact with different tag upload_test_file(integration_client, project, package, content2, tag="stable") # Now update "latest" tag to point to second artifact # This is done by uploading the same content with the same tag upload_test_file(integration_client, project, package, content2, tag="latest") # Verify first artifact ref_count decreased to 0 (tag moved away) response = integration_client.get(f"/api/v1/artifact/{hash1}") assert response.json()["ref_count"] == 0 # Verify second artifact ref_count increased to 2 (stable + latest) response = integration_client.get(f"/api/v1/artifact/{hash2}") assert response.json()["ref_count"] == 2 @pytest.mark.integration def test_ref_count_unchanged_when_tag_same_artifact( self, integration_client, test_package, unique_test_id ): """Test ref_count doesn't change when tag is 'updated' to same artifact.""" project, package = test_package content = f"same artifact {unique_test_id}".encode() expected_hash = compute_sha256(content) # Upload with tag upload_test_file(integration_client, project, package, content, tag="v1") # Verify ref_count is 1 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 1 # Upload same content with same tag (no-op) upload_test_file(integration_client, project, package, content, tag="v1") # Verify ref_count is still 1 (no double-counting) response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 1 @pytest.mark.integration def test_tag_via_post_endpoint_increments_ref_count( self, integration_client, test_package, unique_test_id ): """Test creating tag via POST /tags endpoint increments ref_count.""" project, package = test_package content = f"tag endpoint test {unique_test_id}".encode() expected_hash = compute_sha256(content) # Upload artifact without tag result = upload_test_file( integration_client, project, package, content, filename="test.bin", tag=None ) artifact_id = result["artifact_id"] # Verify ref_count is 0 (no tags yet) response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 0 # Create tag via POST endpoint tag_response = integration_client.post( f"/api/v1/project/{project}/{package}/tags", json={"name": "v1.0.0", "artifact_id": artifact_id}, ) assert tag_response.status_code == 200 # Verify ref_count is now 1 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 1 # Create another tag via POST endpoint tag_response = integration_client.post( f"/api/v1/project/{project}/{package}/tags", json={"name": "latest", "artifact_id": artifact_id}, ) assert tag_response.status_code == 200 # Verify ref_count is now 2 response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 2