489 lines
18 KiB
Python
489 lines
18 KiB
Python
"""
|
|
Integration tests for statistics endpoints.
|
|
|
|
Tests cover:
|
|
- Global stats endpoint
|
|
- Deduplication stats endpoint
|
|
- Cross-project deduplication
|
|
- Timeline stats
|
|
- Export and report endpoints
|
|
- Package and artifact stats
|
|
"""
|
|
|
|
import pytest
|
|
from tests.conftest import compute_sha256, upload_test_file
|
|
|
|
|
|
class TestGlobalStats:
|
|
"""Tests for GET /api/v1/stats endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_stats_returns_valid_response(self, integration_client):
|
|
"""Test stats endpoint returns expected fields."""
|
|
response = integration_client.get("/api/v1/stats")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
# Check all required fields exist
|
|
assert "total_artifacts" in data
|
|
assert "total_size_bytes" in data
|
|
assert "unique_artifacts" in data
|
|
assert "orphaned_artifacts" in data
|
|
assert "orphaned_size_bytes" in data
|
|
assert "total_uploads" in data
|
|
assert "deduplicated_uploads" in data
|
|
assert "deduplication_ratio" in data
|
|
assert "storage_saved_bytes" in data
|
|
|
|
@pytest.mark.integration
|
|
def test_stats_values_are_non_negative(self, integration_client):
|
|
"""Test all stat values are non-negative."""
|
|
response = integration_client.get("/api/v1/stats")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["total_artifacts"] >= 0
|
|
assert data["total_size_bytes"] >= 0
|
|
assert data["unique_artifacts"] >= 0
|
|
assert data["orphaned_artifacts"] >= 0
|
|
assert data["total_uploads"] >= 0
|
|
assert data["deduplicated_uploads"] >= 0
|
|
assert data["deduplication_ratio"] >= 0
|
|
assert data["storage_saved_bytes"] >= 0
|
|
|
|
@pytest.mark.integration
|
|
def test_stats_update_after_upload(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test stats update after uploading an artifact."""
|
|
project, package = test_package
|
|
|
|
# Get initial stats
|
|
initial_response = integration_client.get("/api/v1/stats")
|
|
initial_stats = initial_response.json()
|
|
|
|
# Upload a new file
|
|
content = f"stats test content {unique_test_id}".encode()
|
|
upload_test_file(
|
|
integration_client, project, package, content, tag=f"stats-{unique_test_id}"
|
|
)
|
|
|
|
# Get updated stats
|
|
updated_response = integration_client.get("/api/v1/stats")
|
|
updated_stats = updated_response.json()
|
|
|
|
# Verify stats increased
|
|
assert updated_stats["total_uploads"] >= initial_stats["total_uploads"]
|
|
|
|
|
|
class TestDeduplicationStats:
|
|
"""Tests for GET /api/v1/stats/deduplication endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_dedup_stats_returns_valid_response(self, integration_client):
|
|
"""Test deduplication stats returns expected fields."""
|
|
response = integration_client.get("/api/v1/stats/deduplication")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "total_logical_bytes" in data
|
|
assert "total_physical_bytes" in data
|
|
assert "bytes_saved" in data
|
|
assert "savings_percentage" in data
|
|
assert "total_uploads" in data
|
|
assert "unique_artifacts" in data
|
|
assert "duplicate_uploads" in data
|
|
assert "average_ref_count" in data
|
|
assert "max_ref_count" in data
|
|
assert "most_referenced_artifacts" in data
|
|
|
|
@pytest.mark.integration
|
|
def test_most_referenced_artifacts_format(self, integration_client):
|
|
"""Test most_referenced_artifacts has correct structure."""
|
|
response = integration_client.get("/api/v1/stats/deduplication")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
artifacts = data["most_referenced_artifacts"]
|
|
assert isinstance(artifacts, list)
|
|
|
|
if len(artifacts) > 0:
|
|
artifact = artifacts[0]
|
|
assert "artifact_id" in artifact
|
|
assert "ref_count" in artifact
|
|
assert "size" in artifact
|
|
assert "storage_saved" in artifact
|
|
|
|
@pytest.mark.integration
|
|
def test_dedup_stats_with_top_n_param(self, integration_client):
|
|
"""Test deduplication stats respects top_n parameter."""
|
|
response = integration_client.get("/api/v1/stats/deduplication?top_n=3")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert len(data["most_referenced_artifacts"]) <= 3
|
|
|
|
@pytest.mark.integration
|
|
def test_savings_percentage_valid_range(self, integration_client):
|
|
"""Test savings percentage is between 0 and 100."""
|
|
response = integration_client.get("/api/v1/stats/deduplication")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert 0 <= data["savings_percentage"] <= 100
|
|
|
|
|
|
class TestCrossProjectStats:
|
|
"""Tests for GET /api/v1/stats/cross-project endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_cross_project_returns_valid_response(self, integration_client):
|
|
"""Test cross-project stats returns expected fields."""
|
|
response = integration_client.get("/api/v1/stats/cross-project")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "shared_artifacts_count" in data
|
|
assert "total_cross_project_savings" in data
|
|
assert "shared_artifacts" in data
|
|
assert isinstance(data["shared_artifacts"], list)
|
|
|
|
@pytest.mark.integration
|
|
def test_cross_project_respects_limit(self, integration_client):
|
|
"""Test cross-project stats respects limit parameter."""
|
|
response = integration_client.get("/api/v1/stats/cross-project?limit=5")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert len(data["shared_artifacts"]) <= 5
|
|
|
|
@pytest.mark.integration
|
|
def test_cross_project_detects_shared_artifacts(
|
|
self, integration_client, unique_test_id
|
|
):
|
|
"""Test cross-project deduplication is detected."""
|
|
content = f"shared across projects {unique_test_id}".encode()
|
|
|
|
# Create two projects
|
|
proj1 = f"cross-proj-a-{unique_test_id}"
|
|
proj2 = f"cross-proj-b-{unique_test_id}"
|
|
|
|
try:
|
|
# Create projects and packages
|
|
integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": proj1, "description": "Test", "is_public": True},
|
|
)
|
|
integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": proj2, "description": "Test", "is_public": True},
|
|
)
|
|
integration_client.post(
|
|
f"/api/v1/project/{proj1}/packages",
|
|
json={"name": "pkg", "description": "Test"},
|
|
)
|
|
integration_client.post(
|
|
f"/api/v1/project/{proj2}/packages",
|
|
json={"name": "pkg", "description": "Test"},
|
|
)
|
|
|
|
# Upload same content to both projects
|
|
upload_test_file(integration_client, proj1, "pkg", content, tag="v1")
|
|
upload_test_file(integration_client, proj2, "pkg", content, tag="v1")
|
|
|
|
# Check cross-project stats
|
|
response = integration_client.get("/api/v1/stats/cross-project")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["shared_artifacts_count"] >= 1
|
|
|
|
finally:
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/projects/{proj1}")
|
|
integration_client.delete(f"/api/v1/projects/{proj2}")
|
|
|
|
|
|
class TestTimelineStats:
|
|
"""Tests for GET /api/v1/stats/timeline endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_timeline_returns_valid_response(self, integration_client):
|
|
"""Test timeline stats returns expected fields."""
|
|
response = integration_client.get("/api/v1/stats/timeline")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "period" in data
|
|
assert "start_date" in data
|
|
assert "end_date" in data
|
|
assert "data_points" in data
|
|
assert isinstance(data["data_points"], list)
|
|
|
|
@pytest.mark.integration
|
|
def test_timeline_daily_period(self, integration_client):
|
|
"""Test timeline with daily period."""
|
|
response = integration_client.get("/api/v1/stats/timeline?period=daily")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["period"] == "daily"
|
|
|
|
@pytest.mark.integration
|
|
def test_timeline_weekly_period(self, integration_client):
|
|
"""Test timeline with weekly period."""
|
|
response = integration_client.get("/api/v1/stats/timeline?period=weekly")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["period"] == "weekly"
|
|
|
|
@pytest.mark.integration
|
|
def test_timeline_monthly_period(self, integration_client):
|
|
"""Test timeline with monthly period."""
|
|
response = integration_client.get("/api/v1/stats/timeline?period=monthly")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["period"] == "monthly"
|
|
|
|
@pytest.mark.integration
|
|
def test_timeline_invalid_period_rejected(self, integration_client):
|
|
"""Test timeline rejects invalid period."""
|
|
response = integration_client.get("/api/v1/stats/timeline?period=invalid")
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.integration
|
|
def test_timeline_data_point_structure(self, integration_client):
|
|
"""Test timeline data points have correct structure."""
|
|
response = integration_client.get("/api/v1/stats/timeline")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
if len(data["data_points"]) > 0:
|
|
point = data["data_points"][0]
|
|
assert "date" in point
|
|
assert "total_uploads" in point
|
|
assert "unique_artifacts" in point
|
|
assert "duplicated_uploads" in point
|
|
assert "bytes_saved" in point
|
|
|
|
|
|
class TestExportEndpoint:
|
|
"""Tests for GET /api/v1/stats/export endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_export_json_format(self, integration_client):
|
|
"""Test export with JSON format."""
|
|
response = integration_client.get("/api/v1/stats/export?format=json")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "total_artifacts" in data
|
|
assert "generated_at" in data
|
|
|
|
@pytest.mark.integration
|
|
def test_export_csv_format(self, integration_client):
|
|
"""Test export with CSV format."""
|
|
response = integration_client.get("/api/v1/stats/export?format=csv")
|
|
assert response.status_code == 200
|
|
assert "text/csv" in response.headers.get("content-type", "")
|
|
|
|
content = response.text
|
|
assert "Metric,Value" in content
|
|
assert "total_artifacts" in content
|
|
|
|
@pytest.mark.integration
|
|
def test_export_invalid_format_rejected(self, integration_client):
|
|
"""Test export rejects invalid format."""
|
|
response = integration_client.get("/api/v1/stats/export?format=xml")
|
|
assert response.status_code == 422
|
|
|
|
|
|
class TestReportEndpoint:
|
|
"""Tests for GET /api/v1/stats/report endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_report_markdown_format(self, integration_client):
|
|
"""Test report with markdown format."""
|
|
response = integration_client.get("/api/v1/stats/report?format=markdown")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["format"] == "markdown"
|
|
assert "generated_at" in data
|
|
assert "content" in data
|
|
assert "# Orchard Storage Report" in data["content"]
|
|
|
|
@pytest.mark.integration
|
|
def test_report_json_format(self, integration_client):
|
|
"""Test report with JSON format."""
|
|
response = integration_client.get("/api/v1/stats/report?format=json")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["format"] == "json"
|
|
assert "content" in data
|
|
|
|
@pytest.mark.integration
|
|
def test_report_contains_sections(self, integration_client):
|
|
"""Test markdown report contains expected sections."""
|
|
response = integration_client.get("/api/v1/stats/report?format=markdown")
|
|
assert response.status_code == 200
|
|
|
|
content = response.json()["content"]
|
|
assert "## Overview" in content
|
|
assert "## Storage" in content
|
|
assert "## Uploads" in content
|
|
|
|
|
|
class TestProjectStats:
|
|
"""Tests for GET /api/v1/projects/:project/stats endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_project_stats_returns_valid_response(
|
|
self, integration_client, test_project
|
|
):
|
|
"""Test project stats 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 TestPackageStats:
|
|
"""Tests for GET /api/v1/project/:project/packages/:package/stats endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_package_stats_returns_valid_response(
|
|
self, integration_client, test_package
|
|
):
|
|
"""Test package stats returns expected fields."""
|
|
project, package = test_package
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project}/packages/{package}/stats"
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "package_id" in data
|
|
assert "package_name" in data
|
|
assert "project_name" 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_package_stats_not_found(self, integration_client, test_project):
|
|
"""Test package stats returns 404 for non-existent package."""
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{test_project}/packages/nonexistent-package/stats"
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestArtifactStats:
|
|
"""Tests for GET /api/v1/artifact/:id/stats endpoint."""
|
|
|
|
@pytest.mark.integration
|
|
def test_artifact_stats_returns_valid_response(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test artifact stats returns expected fields."""
|
|
project, package = test_package
|
|
content = f"artifact stats test {unique_test_id}".encode()
|
|
expected_hash = compute_sha256(content)
|
|
|
|
# Upload artifact
|
|
upload_test_file(
|
|
integration_client, project, package, content, tag=f"art-{unique_test_id}"
|
|
)
|
|
|
|
# Get artifact stats
|
|
response = integration_client.get(f"/api/v1/artifact/{expected_hash}/stats")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "artifact_id" in data
|
|
assert "sha256" in data
|
|
assert "size" in data
|
|
assert "ref_count" in data
|
|
assert "storage_savings" in data
|
|
assert "tags" in data
|
|
assert "projects" in data
|
|
assert "packages" in data
|
|
|
|
@pytest.mark.integration
|
|
def test_artifact_stats_not_found(self, integration_client):
|
|
"""Test artifact stats returns 404 for non-existent artifact."""
|
|
fake_hash = "0" * 64
|
|
response = integration_client.get(f"/api/v1/artifact/{fake_hash}/stats")
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.integration
|
|
def test_artifact_stats_shows_correct_projects(
|
|
self, integration_client, unique_test_id
|
|
):
|
|
"""Test artifact stats shows all projects using the artifact."""
|
|
content = f"multi-project artifact {unique_test_id}".encode()
|
|
expected_hash = compute_sha256(content)
|
|
|
|
proj1 = f"art-stats-a-{unique_test_id}"
|
|
proj2 = f"art-stats-b-{unique_test_id}"
|
|
|
|
try:
|
|
# Create projects and packages
|
|
integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": proj1, "description": "Test", "is_public": True},
|
|
)
|
|
integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": proj2, "description": "Test", "is_public": True},
|
|
)
|
|
integration_client.post(
|
|
f"/api/v1/project/{proj1}/packages",
|
|
json={"name": "pkg", "description": "Test"},
|
|
)
|
|
integration_client.post(
|
|
f"/api/v1/project/{proj2}/packages",
|
|
json={"name": "pkg", "description": "Test"},
|
|
)
|
|
|
|
# Upload same content to both projects
|
|
upload_test_file(integration_client, proj1, "pkg", content, tag="v1")
|
|
upload_test_file(integration_client, proj2, "pkg", content, tag="v1")
|
|
|
|
# Check artifact stats
|
|
response = integration_client.get(f"/api/v1/artifact/{expected_hash}/stats")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert len(data["projects"]) == 2
|
|
assert proj1 in data["projects"]
|
|
assert proj2 in data["projects"]
|
|
|
|
finally:
|
|
integration_client.delete(f"/api/v1/projects/{proj1}")
|
|
integration_client.delete(f"/api/v1/projects/{proj2}")
|