Metadata database tracks all uploads with project, package, tag, and timestamp queryable via API
This commit is contained in:
322
backend/tests/integration/test_projects_api.py
Normal file
322
backend/tests/integration/test_projects_api.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
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."""
|
||||
response = integration_client.get("/api/v1/projects")
|
||||
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 for our test project
|
||||
response = integration_client.get(
|
||||
f"/api/v1/projects?search={test_project[:10]}"
|
||||
)
|
||||
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()
|
||||
names = [p["name"] for p in data["items"]]
|
||||
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 "tag_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 tags 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 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
|
||||
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
|
||||
Reference in New Issue
Block a user