Remove tag system, use versions only for artifact references
Tags were mutable aliases that caused confusion alongside the immutable version system. This removes tags entirely, keeping only PackageVersion for artifact references. Changes: - Remove tags and tag_history tables (migration 012) - Remove Tag model, TagRepository, and 6 tag API endpoints - Update cache system to create versions instead of tags - Update frontend to display versions instead of tags - Remove tag-related schemas and types - Update artifact cleanup service for version-based ref_count
This commit is contained in:
@@ -1,403 +0,0 @@
|
||||
"""
|
||||
Integration tests for tag API endpoints.
|
||||
|
||||
Tests cover:
|
||||
- Tag CRUD operations
|
||||
- Tag listing with pagination and search
|
||||
- Tag history tracking
|
||||
- ref_count behavior with tag operations
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from tests.factories import compute_sha256, upload_test_file
|
||||
|
||||
|
||||
class TestTagCRUD:
|
||||
"""Tests for tag create, read, delete operations."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_tag_via_upload(self, integration_client, test_package):
|
||||
"""Test creating a tag via upload endpoint."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
result = upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"tag create test",
|
||||
tag="v1.0.0",
|
||||
)
|
||||
|
||||
assert result["tag"] == "v1.0.0"
|
||||
assert result["artifact_id"]
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_tag_via_post(
|
||||
self, integration_client, test_package, unique_test_id
|
||||
):
|
||||
"""Test creating a tag via POST /tags endpoint."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
# First upload an artifact
|
||||
result = upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"artifact for tag",
|
||||
)
|
||||
artifact_id = result["artifact_id"]
|
||||
|
||||
# Create tag via POST
|
||||
tag_name = f"post-tag-{unique_test_id}"
|
||||
response = integration_client.post(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags",
|
||||
json={"name": tag_name, "artifact_id": artifact_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["name"] == tag_name
|
||||
assert data["artifact_id"] == artifact_id
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_get_tag(self, integration_client, test_package):
|
||||
"""Test getting a tag by name."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"get tag test",
|
||||
tag="get-tag",
|
||||
)
|
||||
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags/get-tag"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["name"] == "get-tag"
|
||||
assert "artifact_id" in data
|
||||
assert "artifact_size" in data
|
||||
assert "artifact_content_type" in data
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_tags(self, integration_client, test_package):
|
||||
"""Test listing tags for a package."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
# Create some tags
|
||||
upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"list tags test",
|
||||
tag="list-v1",
|
||||
)
|
||||
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "pagination" in data
|
||||
|
||||
tag_names = [t["name"] for t in data["items"]]
|
||||
assert "list-v1" in tag_names
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_tag(self, integration_client, test_package):
|
||||
"""Test deleting a tag."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"delete tag test",
|
||||
tag="to-delete",
|
||||
)
|
||||
|
||||
# Delete tag
|
||||
response = integration_client.delete(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags/to-delete"
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify deleted
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags/to-delete"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestTagListingFilters:
|
||||
"""Tests for tag listing with filters and search."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_tags_pagination(self, integration_client, test_package):
|
||||
"""Test tag listing respects pagination."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags?limit=5"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert len(data["items"]) <= 5
|
||||
assert data["pagination"]["limit"] == 5
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_tags_search(self, integration_client, test_package, unique_test_id):
|
||||
"""Test tag search by name."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
tag_name = f"searchable-{unique_test_id}"
|
||||
upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"search test",
|
||||
tag=tag_name,
|
||||
)
|
||||
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags?search=searchable"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
tag_names = [t["name"] for t in data["items"]]
|
||||
assert tag_name in tag_names
|
||||
|
||||
|
||||
class TestTagHistory:
|
||||
"""Tests for tag history tracking."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_tag_history_on_create(self, integration_client, test_package):
|
||||
"""Test tag history is created when tag is created."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"history create test",
|
||||
tag="history-create",
|
||||
)
|
||||
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags/history-create/history"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_tag_history_on_update(
|
||||
self, integration_client, test_package, unique_test_id
|
||||
):
|
||||
"""Test tag history is created when tag is updated."""
|
||||
project_name, package_name = test_package
|
||||
|
||||
tag_name = f"history-update-{unique_test_id}"
|
||||
|
||||
# Create tag with first artifact
|
||||
upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"first content",
|
||||
tag=tag_name,
|
||||
)
|
||||
|
||||
# Update tag with second artifact
|
||||
upload_test_file(
|
||||
integration_client,
|
||||
project_name,
|
||||
package_name,
|
||||
b"second content",
|
||||
tag=tag_name,
|
||||
)
|
||||
|
||||
response = integration_client.get(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags/{tag_name}/history"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
# Should have at least 2 history entries (create + update)
|
||||
assert len(data) >= 2
|
||||
|
||||
|
||||
class TestTagRefCount:
|
||||
"""Tests for ref_count behavior with tag operations."""
|
||||
|
||||
@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_name, package_name = test_package
|
||||
content = b"ref count delete test"
|
||||
expected_hash = compute_sha256(content)
|
||||
|
||||
# Upload with two tags
|
||||
upload_test_file(
|
||||
integration_client, project_name, package_name, content, tag="rc-v1"
|
||||
)
|
||||
upload_test_file(
|
||||
integration_client, project_name, package_name, content, tag="rc-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_name}/{package_name}/tags/rc-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_name, package_name = test_package
|
||||
content = b"orphan test content"
|
||||
expected_hash = compute_sha256(content)
|
||||
|
||||
# Upload with one tag
|
||||
upload_test_file(
|
||||
integration_client, project_name, package_name, content, tag="only-tag"
|
||||
)
|
||||
|
||||
# Delete the tag
|
||||
integration_client.delete(
|
||||
f"/api/v1/project/{project_name}/{package_name}/tags/only-tag"
|
||||
)
|
||||
|
||||
# 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_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 different artifact."""
|
||||
project_name, package_name = 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_name, package_name, 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_name, package_name, content2, tag="stable"
|
||||
)
|
||||
|
||||
# Now update "latest" tag to point to second artifact
|
||||
upload_test_file(
|
||||
integration_client, project_name, package_name, content2, tag="latest"
|
||||
)
|
||||
|
||||
# Verify first artifact ref_count decreased to 0
|
||||
response = integration_client.get(f"/api/v1/artifact/{hash1}")
|
||||
assert response.json()["ref_count"] == 0
|
||||
|
||||
# Verify second artifact ref_count increased to 2
|
||||
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_name, package_name = test_package
|
||||
|
||||
content = f"same artifact {unique_test_id}".encode()
|
||||
expected_hash = compute_sha256(content)
|
||||
|
||||
# Upload with tag
|
||||
upload_test_file(
|
||||
integration_client, project_name, package_name, content, tag="same-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_name, package_name, content, tag="same-v1"
|
||||
)
|
||||
|
||||
# Verify ref_count is still 1
|
||||
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_name, package_name = 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_name, package_name, content, filename="test.bin"
|
||||
)
|
||||
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_name}/{package_name}/tags",
|
||||
json={"name": "post-v1", "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_name}/{package_name}/tags",
|
||||
json={"name": "post-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
|
||||
Reference in New Issue
Block a user