""" Integration tests for project API endpoints. Tests cover: - Project CRUD operations - Project listing with pagination, search, and sorting - Project stats endpoint - Project-level audit logs - Cascade delete behavior """ import pytest from tests.factories import compute_sha256, upload_test_file class TestProjectCRUD: """Tests for project create, read, update, delete operations.""" @pytest.mark.integration def test_create_project(self, integration_client, unique_test_id): """Test creating a new project.""" project_name = f"test-create-{unique_test_id}" try: response = integration_client.post( "/api/v1/projects", json={ "name": project_name, "description": "Test project", "is_public": True, }, ) assert response.status_code == 200 data = response.json() assert data["name"] == project_name assert data["description"] == "Test project" assert data["is_public"] is True assert "id" in data assert "created_at" in data finally: integration_client.delete(f"/api/v1/projects/{project_name}") @pytest.mark.integration def test_get_project(self, integration_client, test_project): """Test getting a project by name.""" response = integration_client.get(f"/api/v1/projects/{test_project}") assert response.status_code == 200 data = response.json() assert data["name"] == test_project @pytest.mark.integration def test_get_nonexistent_project(self, integration_client): """Test getting a non-existent project returns 404.""" response = integration_client.get("/api/v1/projects/nonexistent-project-xyz") assert response.status_code == 404 @pytest.mark.integration def test_list_projects(self, integration_client, test_project): """Test listing projects includes created project.""" # Search specifically for our test project to avoid pagination issues response = integration_client.get(f"/api/v1/projects?search={test_project}") assert response.status_code == 200 data = response.json() assert "items" in data assert "pagination" in data project_names = [p["name"] for p in data["items"]] assert test_project in project_names @pytest.mark.integration def test_delete_project(self, integration_client, unique_test_id): """Test deleting a project.""" project_name = f"test-delete-{unique_test_id}" # Create project integration_client.post( "/api/v1/projects", json={"name": project_name, "description": "To be deleted"}, ) # Delete project response = integration_client.delete(f"/api/v1/projects/{project_name}") assert response.status_code == 204 # Verify deleted response = integration_client.get(f"/api/v1/projects/{project_name}") assert response.status_code == 404 class TestProjectListingFilters: """Tests for project listing with filters and pagination.""" @pytest.mark.integration def test_projects_pagination(self, integration_client): """Test project listing respects pagination parameters.""" response = integration_client.get("/api/v1/projects?page=1&limit=5") assert response.status_code == 200 data = response.json() assert len(data["items"]) <= 5 assert data["pagination"]["limit"] == 5 assert data["pagination"]["page"] == 1 assert "has_more" in data["pagination"] @pytest.mark.integration def test_projects_search(self, integration_client, test_project): """Test project search by name.""" # Search using the unique portion of our test project name # test_project format is "test-project-test-{uuid[:8]}" unique_part = test_project.split("-")[-1] # Get the UUID portion response = integration_client.get( f"/api/v1/projects?search={unique_part}" ) assert response.status_code == 200 data = response.json() # Our project should be in results project_names = [p["name"] for p in data["items"]] assert test_project in project_names @pytest.mark.integration def test_projects_sort_by_name(self, integration_client): """Test project sorting by name.""" response = integration_client.get("/api/v1/projects?sort=name&order=asc") assert response.status_code == 200 data = response.json() # Filter out system projects (names starting with "_") as they may have # collation-specific sort behavior and aren't part of the test data names = [p["name"] for p in data["items"] if not p["name"].startswith("_")] assert names == sorted(names) class TestProjectStats: """Tests for project statistics endpoint.""" @pytest.mark.integration def test_project_stats_returns_valid_response( self, integration_client, test_project ): """Test project stats endpoint returns expected fields.""" response = integration_client.get(f"/api/v1/projects/{test_project}/stats") assert response.status_code == 200 data = response.json() assert "project_id" in data assert "project_name" in data assert "package_count" in data assert "version_count" in data assert "artifact_count" in data assert "total_size_bytes" in data assert "upload_count" in data assert "deduplicated_uploads" in data assert "storage_saved_bytes" in data assert "deduplication_ratio" in data @pytest.mark.integration def test_project_stats_not_found(self, integration_client): """Test project stats returns 404 for non-existent project.""" response = integration_client.get("/api/v1/projects/nonexistent-project/stats") assert response.status_code == 404 class TestProjectAuditLogs: """Tests for project-level audit logs endpoint.""" @pytest.mark.integration def test_project_audit_logs_returns_200(self, integration_client, test_project): """Test project audit logs endpoint returns 200.""" response = integration_client.get(f"/api/v1/projects/{test_project}/audit-logs") assert response.status_code == 200 data = response.json() assert "items" in data assert "pagination" in data @pytest.mark.integration def test_project_audit_logs_not_found(self, integration_client): """Test non-existent project returns 404.""" response = integration_client.get( "/api/v1/projects/nonexistent-project/audit-logs" ) assert response.status_code == 404 class TestProjectCascadeDelete: """Tests for cascade delete behavior when deleting projects.""" @pytest.mark.integration def test_project_delete_cascades_to_packages( self, integration_client, unique_test_id ): """Test deleting project cascades to packages.""" project_name = f"cascade-proj-{unique_test_id}" package_name = f"cascade-pkg-{unique_test_id}" try: # Create project and package integration_client.post( "/api/v1/projects", json={"name": project_name, "description": "Test", "is_public": True}, ) integration_client.post( f"/api/v1/project/{project_name}/packages", json={"name": package_name, "description": "Test package"}, ) # Verify package exists response = integration_client.get( f"/api/v1/project/{project_name}/packages/{package_name}" ) assert response.status_code == 200 # Delete project integration_client.delete(f"/api/v1/projects/{project_name}") # Verify project is deleted (and package with it) response = integration_client.get(f"/api/v1/projects/{project_name}") assert response.status_code == 404 except Exception: # Cleanup if test fails integration_client.delete(f"/api/v1/projects/{project_name}") raise @pytest.mark.integration def test_ref_count_decrements_on_project_delete( self, integration_client, unique_test_id ): """Test ref_count decrements for all versions when project is deleted. Each package can only have one version per artifact (same content = same version). With 2 packages, ref_count should be 2, and go to 0 when project is deleted. """ 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 to 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, version="1.0.0" ) upload_test_file( integration_client, project_name, package2_name, content, version="1.0.0" ) # Verify ref_count is 2 (1 version in each of 2 packages) response = integration_client.get(f"/api/v1/artifact/{expected_hash}") assert response.json()["ref_count"] == 2 # Delete the project 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 class TestProjectUploads: """Tests for project-level uploads endpoint.""" @pytest.mark.integration def test_project_uploads_returns_200(self, integration_client, test_project): """Test project uploads endpoint returns 200.""" response = integration_client.get(f"/api/v1/project/{test_project}/uploads") assert response.status_code == 200 data = response.json() assert "items" in data assert "pagination" in data @pytest.mark.integration def test_project_uploads_after_upload(self, integration_client, test_package): """Test uploads are recorded in project uploads.""" project_name, package_name = test_package # Upload a file upload_test_file( integration_client, project_name, package_name, b"project uploads test", "project.txt", ) response = integration_client.get(f"/api/v1/project/{project_name}/uploads") assert response.status_code == 200 data = response.json() assert len(data["items"]) >= 1 # Verify project name matches for item in data["items"]: assert item["project_name"] == project_name @pytest.mark.integration def test_project_uploads_not_found(self, integration_client): """Test non-existent project returns 404.""" response = integration_client.get("/api/v1/project/nonexistent/uploads") assert response.status_code == 404