Instead of failing with 409 on version conflicts, use "first version wins" strategy. This allows resolution to succeed for complex dependency trees like tensorflow where transitive dependencies may have overlapping but not identical version requirements. The resolver now: - Checks if an already-resolved version satisfies a new constraint - If yes, reuses the existing version - If no, logs the mismatch and uses the first-encountered version This matches pip's behavior of picking a working version rather than failing on theoretical conflicts.
1346 lines
54 KiB
Python
1346 lines
54 KiB
Python
"""Tests for artifact dependency management.
|
|
|
|
Tests cover:
|
|
- #76: Database Schema for Artifact Dependencies
|
|
- #77: Ensure File Parsing and Storage on Upload
|
|
- #78: Dependency Query API Endpoints
|
|
- #79: Server-Side Dependency Resolution
|
|
- #80: Circular Dependency Detection
|
|
- #81: Dependency Conflict Detection and Reporting
|
|
"""
|
|
|
|
import pytest
|
|
import yaml
|
|
from uuid import uuid4
|
|
from io import BytesIO
|
|
|
|
# For schema validation tests
|
|
from pydantic import ValidationError
|
|
|
|
|
|
def unique_content(base: str, test_id: str, extra: str = "") -> bytes:
|
|
"""Generate unique content to avoid artifact hash collisions between tests."""
|
|
return f"{base}-{test_id}-{extra}-{uuid4().hex[:8]}".encode()
|
|
|
|
|
|
class TestDependencySchema:
|
|
"""Tests for #76: Database Schema for Artifact Dependencies"""
|
|
|
|
@pytest.mark.integration
|
|
def test_create_dependency_with_version(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test creating a dependency with version constraint."""
|
|
project_name, package_name = test_package
|
|
|
|
# First upload an artifact
|
|
content = unique_content("test-deps", unique_test_id, "schema1")
|
|
files = {"file": ("test.tar.gz", BytesIO(content), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v1.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Create a second project to depend on
|
|
dep_project_name = f"dep-project-{uuid4().hex[:8]}"
|
|
response = integration_client.post(
|
|
"/api/v1/projects", json={"name": dep_project_name}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Now test the dependency creation via API (once implemented)
|
|
# For now, verify the schema constraints work at DB level
|
|
pass
|
|
finally:
|
|
# Cleanup
|
|
integration_client.delete(f"/api/v1/projects/{dep_project_name}")
|
|
|
|
@pytest.mark.integration
|
|
def test_dependency_requires_version(self, integration_client):
|
|
"""Test that dependency requires version."""
|
|
from app.schemas import DependencyCreate
|
|
|
|
# Test: missing version
|
|
with pytest.raises(ValidationError):
|
|
DependencyCreate(project="proj", package="pkg")
|
|
|
|
# Test: valid with version
|
|
dep = DependencyCreate(project="proj", package="pkg", version="1.0.0")
|
|
assert dep.version == "1.0.0"
|
|
|
|
@pytest.mark.integration
|
|
def test_dependency_unique_constraint(
|
|
self, integration_client, test_package
|
|
):
|
|
"""Test that an artifact can only have one dependency per project/package."""
|
|
# This will be tested once the upload with ensure file is implemented
|
|
pass
|
|
|
|
|
|
class TestEnsureFileParsing:
|
|
"""Tests for #77: Ensure File Parsing and Storage on Upload"""
|
|
|
|
@pytest.mark.integration
|
|
def test_upload_with_valid_ensure_file(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test uploading an artifact with a valid orchard.ensure file."""
|
|
project_name, package_name = test_package
|
|
|
|
# Create dependency project
|
|
dep_project_name = f"dep-project-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/projects", json={"name": dep_project_name}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Create ensure file content
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": dep_project_name, "package": "some-pkg", "version": "1.0.0"}
|
|
]
|
|
})
|
|
|
|
# Upload artifact with ensure file - use unique content to avoid conflicts
|
|
content = unique_content("test-ensure", unique_test_id, "valid")
|
|
files = {
|
|
"file": ("test-artifact.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v1.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
artifact_id = data["artifact_id"]
|
|
|
|
# Verify dependencies were stored
|
|
response = integration_client.get(
|
|
f"/api/v1/artifact/{artifact_id}/dependencies"
|
|
)
|
|
assert response.status_code == 200
|
|
deps = response.json()
|
|
assert len(deps["dependencies"]) == 1
|
|
assert deps["dependencies"][0]["project"] == dep_project_name
|
|
assert deps["dependencies"][0]["package"] == "some-pkg"
|
|
assert deps["dependencies"][0]["version"] == "1.0.0"
|
|
|
|
finally:
|
|
integration_client.delete(f"/api/v1/projects/{dep_project_name}")
|
|
|
|
@pytest.mark.integration
|
|
def test_upload_with_invalid_ensure_file(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test uploading with invalid YAML ensure file."""
|
|
project_name, package_name = test_package
|
|
|
|
# Invalid YAML
|
|
content = unique_content("test-invalid", unique_test_id, "yaml")
|
|
files = {
|
|
"file": ("test-artifact.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(b"invalid: yaml: content: ["), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v1.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "Invalid ensure file" in response.json().get("detail", "")
|
|
|
|
@pytest.mark.integration
|
|
def test_upload_with_missing_dependency_project(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test uploading with ensure file referencing non-existent project."""
|
|
project_name, package_name = test_package
|
|
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": "nonexistent-project-xyz", "package": "some-pkg", "version": "1.0.0"}
|
|
]
|
|
})
|
|
|
|
content = unique_content("test-missing", unique_test_id, "project")
|
|
files = {
|
|
"file": ("test-artifact.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v1.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "Project" in response.json().get("detail", "")
|
|
assert "not found" in response.json().get("detail", "").lower()
|
|
|
|
@pytest.mark.integration
|
|
def test_upload_without_ensure_file(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test normal upload without ensure file still works."""
|
|
project_name, package_name = test_package
|
|
|
|
content = unique_content("test-nodeps", unique_test_id, "upload")
|
|
files = {
|
|
"file": ("test-artifact.tar.gz", BytesIO(content), "application/gzip"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v1.0.0-nodeps-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.integration
|
|
def test_upload_ensure_file_both_version_and_tag(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test that ensure file with both version and tag is rejected."""
|
|
project_name, package_name = test_package
|
|
|
|
dep_project_name = f"dep-project-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/projects", json={"name": dep_project_name}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Test with missing version field (version is now required)
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": dep_project_name, "package": "pkg"} # Missing version
|
|
]
|
|
})
|
|
|
|
content = unique_content("test-missing-version", unique_test_id, "constraint")
|
|
files = {
|
|
"file": ("test.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v1.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "version" in response.json().get("detail", "").lower()
|
|
finally:
|
|
integration_client.delete(f"/api/v1/projects/{dep_project_name}")
|
|
|
|
|
|
class TestDependencyQueryEndpoints:
|
|
"""Tests for #78: Dependency Query API Endpoints"""
|
|
|
|
@pytest.mark.integration
|
|
def test_get_artifact_dependencies(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test GET /api/v1/artifact/{artifact_id}/dependencies"""
|
|
project_name, package_name = test_package
|
|
|
|
# Create dependency project
|
|
dep_project_name = f"dep-project-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/projects", json={"name": dep_project_name}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload artifact with dependencies
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": dep_project_name, "package": "lib-a", "version": "1.0.0"},
|
|
{"project": dep_project_name, "package": "lib-b", "version": "2.0.0"},
|
|
]
|
|
})
|
|
|
|
content = unique_content("test-deps", unique_test_id, "query")
|
|
files = {
|
|
"file": ("artifact.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v2.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
artifact_id = response.json()["artifact_id"]
|
|
|
|
# Get dependencies
|
|
response = integration_client.get(f"/api/v1/artifact/{artifact_id}/dependencies")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["artifact_id"] == artifact_id
|
|
assert len(data["dependencies"]) == 2
|
|
|
|
# Verify both dependencies
|
|
deps = {d["package"]: d for d in data["dependencies"]}
|
|
assert "lib-a" in deps
|
|
assert deps["lib-a"]["version"] == "1.0.0"
|
|
assert "lib-b" in deps
|
|
assert deps["lib-b"]["version"] == "2.0.0"
|
|
|
|
finally:
|
|
integration_client.delete(f"/api/v1/projects/{dep_project_name}")
|
|
|
|
@pytest.mark.integration
|
|
def test_get_dependencies_by_ref(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test GET /api/v1/project/{proj}/{pkg}/+/{ref}/dependencies"""
|
|
project_name, package_name = test_package
|
|
|
|
dep_project_name = f"dep-project-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/projects", json={"name": dep_project_name}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": dep_project_name, "package": "lib-c", "version": "2.0.0"},
|
|
]
|
|
})
|
|
|
|
tag_name = f"v3.0.0-{unique_test_id}"
|
|
content = unique_content("test-ref", unique_test_id, "deps")
|
|
files = {
|
|
"file": ("artifact.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": tag_name},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Get dependencies by tag
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/{tag_name}/dependencies"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["dependencies"]) == 1
|
|
assert data["dependencies"][0]["package"] == "lib-c"
|
|
|
|
finally:
|
|
integration_client.delete(f"/api/v1/projects/{dep_project_name}")
|
|
|
|
@pytest.mark.integration
|
|
def test_get_reverse_dependencies(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test GET /api/v1/project/{proj}/{pkg}/reverse-dependencies"""
|
|
project_name, package_name = test_package
|
|
|
|
# Create the dependency target project/package
|
|
dep_project_name = f"lib-project-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/projects", json={"name": dep_project_name}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Create the target package with an artifact
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{dep_project_name}/packages",
|
|
json={"name": "target-lib"}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
content = unique_content("lib", unique_test_id, "target")
|
|
files = {
|
|
"file": ("lib.tar.gz", BytesIO(content), "application/gzip"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{dep_project_name}/target-lib/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Now upload an artifact that depends on the target
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": dep_project_name, "package": "target-lib", "version": "1.0.0"},
|
|
]
|
|
})
|
|
|
|
content = unique_content("app", unique_test_id, "reverse")
|
|
files = {
|
|
"file": ("app.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v4.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Check reverse dependencies
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{dep_project_name}/target-lib/reverse-dependencies"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["project"] == dep_project_name
|
|
assert data["package"] == "target-lib"
|
|
assert len(data["dependents"]) >= 1
|
|
|
|
# Find our dependent
|
|
found = False
|
|
for dep in data["dependents"]:
|
|
if dep["project"] == project_name:
|
|
found = True
|
|
assert dep["constraint_value"] == "1.0.0"
|
|
break
|
|
assert found, "Our package should be in the dependents list"
|
|
|
|
finally:
|
|
integration_client.delete(f"/api/v1/projects/{dep_project_name}")
|
|
|
|
@pytest.mark.integration
|
|
def test_get_dependencies_empty(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test getting dependencies for artifact with no deps."""
|
|
project_name, package_name = test_package
|
|
|
|
# Upload without ensure file
|
|
content = unique_content("nodeps", unique_test_id, "empty")
|
|
files = {
|
|
"file": ("nodeps.tar.gz", BytesIO(content), "application/gzip"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v5.0.0-nodeps-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
artifact_id = response.json()["artifact_id"]
|
|
|
|
response = integration_client.get(f"/api/v1/artifact/{artifact_id}/dependencies")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["artifact_id"] == artifact_id
|
|
assert len(data["dependencies"]) == 0
|
|
|
|
|
|
class TestDependencyResolution:
|
|
"""Tests for #79: Server-Side Dependency Resolution"""
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_simple_chain(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test resolving A -> B -> C dependency chain."""
|
|
# Create packages A, B, C
|
|
pkg_a = f"pkg-a-{unique_test_id}"
|
|
pkg_b = f"pkg-b-{unique_test_id}"
|
|
pkg_c = f"pkg-c-{unique_test_id}"
|
|
|
|
# Create all packages
|
|
for pkg in [pkg_a, pkg_b, pkg_c]:
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": pkg}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload C (no deps)
|
|
content_c = unique_content("pkg-c", unique_test_id, "chain")
|
|
files = {"file": ("c.tar.gz", BytesIO(content_c), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_c}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload B (depends on C)
|
|
ensure_b = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_c, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_b = unique_content("pkg-b", unique_test_id, "chain")
|
|
files = {
|
|
"file": ("b.tar.gz", BytesIO(content_b), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_b.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_b}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload A (depends on B)
|
|
ensure_a = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_b, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_a = unique_content("pkg-a", unique_test_id, "chain")
|
|
files = {
|
|
"file": ("a.tar.gz", BytesIO(content_a), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_a.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Resolve dependencies for A
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/+/1.0.0/resolve"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should have 3 artifacts: C, B, A (in topological order)
|
|
assert data["artifact_count"] == 3
|
|
packages = [r["package"] for r in data["resolved"]]
|
|
|
|
# C should come before B, B should come before A
|
|
assert packages.index(pkg_c) < packages.index(pkg_b)
|
|
assert packages.index(pkg_b) < packages.index(pkg_a)
|
|
|
|
finally:
|
|
# Cleanup packages
|
|
for pkg in [pkg_a, pkg_b, pkg_c]:
|
|
integration_client.delete(f"/api/v1/project/{test_project}/packages/{pkg}")
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_diamond_dependency(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test resolving diamond: A -> B -> D, A -> C -> D (D appears once)."""
|
|
pkg_a = f"diamond-a-{unique_test_id}"
|
|
pkg_b = f"diamond-b-{unique_test_id}"
|
|
pkg_c = f"diamond-c-{unique_test_id}"
|
|
pkg_d = f"diamond-d-{unique_test_id}"
|
|
|
|
for pkg in [pkg_a, pkg_b, pkg_c, pkg_d]:
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": pkg}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload D (no deps)
|
|
content_d = unique_content("pkg-d", unique_test_id, "diamond")
|
|
files = {"file": ("d.tar.gz", BytesIO(content_d), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_d}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload B (depends on D)
|
|
ensure_b = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_d, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_b = unique_content("pkg-b", unique_test_id, "diamond")
|
|
files = {
|
|
"file": ("b.tar.gz", BytesIO(content_b), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_b.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_b}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload C (also depends on D)
|
|
ensure_c = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_d, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_c = unique_content("pkg-c", unique_test_id, "diamond")
|
|
files = {
|
|
"file": ("c.tar.gz", BytesIO(content_c), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_c.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_c}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload A (depends on B and C)
|
|
ensure_a = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_b, "version": "1.0.0"},
|
|
{"project": test_project, "package": pkg_c, "version": "1.0.0"},
|
|
]
|
|
})
|
|
content_a = unique_content("pkg-a", unique_test_id, "diamond")
|
|
files = {
|
|
"file": ("a.tar.gz", BytesIO(content_a), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_a.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Resolve A
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/+/1.0.0/resolve"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should have 4 artifacts, D appears only once
|
|
assert data["artifact_count"] == 4
|
|
packages = [r["package"] for r in data["resolved"]]
|
|
assert packages.count(pkg_d) == 1 # D only once
|
|
|
|
# D should come before B and C
|
|
d_idx = packages.index(pkg_d)
|
|
b_idx = packages.index(pkg_b)
|
|
c_idx = packages.index(pkg_c)
|
|
a_idx = packages.index(pkg_a)
|
|
assert d_idx < b_idx
|
|
assert d_idx < c_idx
|
|
assert b_idx < a_idx
|
|
assert c_idx < a_idx
|
|
|
|
finally:
|
|
for pkg in [pkg_a, pkg_b, pkg_c, pkg_d]:
|
|
integration_client.delete(f"/api/v1/project/{test_project}/packages/{pkg}")
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_no_dependencies(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test resolving artifact with no dependencies."""
|
|
project_name, package_name = test_package
|
|
|
|
content = unique_content("solo", unique_test_id, "nodeps")
|
|
files = {"file": ("solo.tar.gz", BytesIO(content), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"solo-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/solo-{unique_test_id}/resolve"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["artifact_count"] == 1
|
|
assert data["resolved"][0]["package"] == package_name
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_missing_dependency(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test resolution fails when dependency doesn't exist."""
|
|
project_name, package_name = test_package
|
|
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": project_name, "package": "nonexistent-pkg-xyz", "version": "1.0.0"}
|
|
]
|
|
})
|
|
|
|
# First we need a project that exists to reference
|
|
# Upload artifact with dependency on nonexistent package version
|
|
content = unique_content("missing", unique_test_id, "dep")
|
|
files = {
|
|
"file": ("missing-dep.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"missing-dep-{unique_test_id}"},
|
|
)
|
|
# Should fail at upload time since package doesn't exist
|
|
# OR succeed at upload but fail at resolution
|
|
# Depending on implementation choice
|
|
if response.status_code == 200:
|
|
# Resolution should return missing dependencies
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/missing-dep-{unique_test_id}/resolve"
|
|
)
|
|
# Expect 200 with missing dependencies listed
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# The missing dependency should be in the 'missing' list
|
|
assert len(data.get("missing", [])) >= 1
|
|
|
|
|
|
class TestCircularDependencyDetection:
|
|
"""Tests for #80: Circular Dependency Detection"""
|
|
|
|
@pytest.mark.integration
|
|
def test_detect_direct_cycle(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test detection of direct cycle: A -> B -> A"""
|
|
pkg_a = f"cycle-a-{unique_test_id}"
|
|
pkg_b = f"cycle-b-{unique_test_id}"
|
|
|
|
for pkg in [pkg_a, pkg_b]:
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": pkg}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload A (no deps initially)
|
|
content_a1 = unique_content("A-v1", unique_test_id, "cycle")
|
|
files = {"file": ("a.tar.gz", BytesIO(content_a1), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload B (depends on A)
|
|
ensure_b = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_a, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_b = unique_content("B", unique_test_id, "cycle")
|
|
files = {
|
|
"file": ("b.tar.gz", BytesIO(content_b), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_b.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_b}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Try to upload A v2 that depends on B (creating cycle)
|
|
ensure_a2 = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_b, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_a2 = unique_content("A-v2", unique_test_id, "cycle")
|
|
files = {
|
|
"file": ("a2.tar.gz", BytesIO(content_a2), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_a2.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/upload",
|
|
files=files,
|
|
data={"version": "2.0.0"},
|
|
)
|
|
# Should be rejected with 400 (circular dependency)
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "circular" in data.get("detail", "").lower() or \
|
|
data.get("error") == "circular_dependency"
|
|
|
|
finally:
|
|
for pkg in [pkg_a, pkg_b]:
|
|
integration_client.delete(f"/api/v1/project/{test_project}/packages/{pkg}")
|
|
|
|
@pytest.mark.integration
|
|
def test_detect_indirect_cycle(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test detection of indirect cycle: A -> B -> C -> A"""
|
|
pkg_a = f"icycle-a-{unique_test_id}"
|
|
pkg_b = f"icycle-b-{unique_test_id}"
|
|
pkg_c = f"icycle-c-{unique_test_id}"
|
|
|
|
for pkg in [pkg_a, pkg_b, pkg_c]:
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": pkg}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload A v1 (no deps)
|
|
content_a1 = unique_content("A-v1", unique_test_id, "icycle")
|
|
files = {"file": ("a.tar.gz", BytesIO(content_a1), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload B (depends on A)
|
|
ensure_b = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_a, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_b = unique_content("B", unique_test_id, "icycle")
|
|
files = {
|
|
"file": ("b.tar.gz", BytesIO(content_b), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_b.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_b}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload C (depends on B)
|
|
ensure_c = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_b, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_c = unique_content("C", unique_test_id, "icycle")
|
|
files = {
|
|
"file": ("c.tar.gz", BytesIO(content_c), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_c.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_c}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Try to upload A v2 that depends on C (creating cycle A -> C -> B -> A)
|
|
ensure_a2 = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_c, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_a2 = unique_content("A-v2", unique_test_id, "icycle")
|
|
files = {
|
|
"file": ("a2.tar.gz", BytesIO(content_a2), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_a2.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/upload",
|
|
files=files,
|
|
data={"version": "2.0.0"},
|
|
)
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "circular" in data.get("detail", "").lower() or \
|
|
data.get("error") == "circular_dependency"
|
|
|
|
finally:
|
|
for pkg in [pkg_a, pkg_b, pkg_c]:
|
|
integration_client.delete(f"/api/v1/project/{test_project}/packages/{pkg}")
|
|
|
|
@pytest.mark.integration
|
|
def test_diamond_is_not_cycle(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test that diamond dependency is allowed (not a cycle)."""
|
|
# Diamond: A -> B -> D, A -> C -> D
|
|
# This is already tested in test_resolve_diamond_dependency
|
|
# Just verify it doesn't trigger cycle detection
|
|
pass # Covered by TestDependencyResolution.test_resolve_diamond_dependency
|
|
|
|
|
|
class TestConflictDetection:
|
|
"""Tests for dependency conflict handling.
|
|
|
|
The resolver uses "first version wins" strategy for version conflicts,
|
|
allowing resolution to succeed rather than failing with an error.
|
|
"""
|
|
|
|
@pytest.mark.integration
|
|
def test_version_conflict_uses_first_version(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test conflict when two deps require different versions of same package."""
|
|
pkg_app = f"conflict-app-{unique_test_id}"
|
|
pkg_lib_a = f"conflict-lib-a-{unique_test_id}"
|
|
pkg_lib_b = f"conflict-lib-b-{unique_test_id}"
|
|
pkg_common = f"conflict-common-{unique_test_id}"
|
|
|
|
for pkg in [pkg_app, pkg_lib_a, pkg_lib_b, pkg_common]:
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": pkg}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload common v1.0.0
|
|
content_common1 = unique_content("common-v1", unique_test_id, "conflict")
|
|
files = {"file": ("common1.tar.gz", BytesIO(content_common1), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_common}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload common v2.0.0
|
|
content_common2 = unique_content("common-v2", unique_test_id, "conflict")
|
|
files = {"file": ("common2.tar.gz", BytesIO(content_common2), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_common}/upload",
|
|
files=files,
|
|
data={"version": "2.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload lib-a (depends on common@1.0.0)
|
|
ensure_lib_a = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_common, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_lib_a = unique_content("lib-a", unique_test_id, "conflict")
|
|
files = {
|
|
"file": ("lib-a.tar.gz", BytesIO(content_lib_a), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_lib_a.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_lib_a}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload lib-b (depends on common@2.0.0)
|
|
ensure_lib_b = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_common, "version": "2.0.0"}
|
|
]
|
|
})
|
|
content_lib_b = unique_content("lib-b", unique_test_id, "conflict")
|
|
files = {
|
|
"file": ("lib-b.tar.gz", BytesIO(content_lib_b), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_lib_b.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_lib_b}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload app (depends on both lib-a and lib-b)
|
|
ensure_app = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_lib_a, "version": "1.0.0"},
|
|
{"project": test_project, "package": pkg_lib_b, "version": "1.0.0"},
|
|
]
|
|
})
|
|
content_app = unique_content("app", unique_test_id, "conflict")
|
|
files = {
|
|
"file": ("app.tar.gz", BytesIO(content_app), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_app.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_app}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Try to resolve app - with lenient conflict handling, this should succeed
|
|
# The resolver uses "first version wins" strategy for conflicting versions
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{test_project}/{pkg_app}/+/1.0.0/resolve"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Resolution should succeed with first-encountered version of common
|
|
assert data["artifact_count"] >= 1
|
|
# Find the common package in resolved list
|
|
common_resolved = [r for r in data["resolved"] if r["package"] == pkg_common]
|
|
assert len(common_resolved) == 1 # Only one version should be included
|
|
|
|
finally:
|
|
for pkg in [pkg_app, pkg_lib_a, pkg_lib_b, pkg_common]:
|
|
integration_client.delete(f"/api/v1/project/{test_project}/packages/{pkg}")
|
|
|
|
@pytest.mark.integration
|
|
def test_no_conflict_same_version(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test no conflict when multiple deps require same version."""
|
|
pkg_app = f"noconflict-app-{unique_test_id}"
|
|
pkg_lib_a = f"noconflict-lib-a-{unique_test_id}"
|
|
pkg_lib_b = f"noconflict-lib-b-{unique_test_id}"
|
|
pkg_common = f"noconflict-common-{unique_test_id}"
|
|
|
|
for pkg in [pkg_app, pkg_lib_a, pkg_lib_b, pkg_common]:
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": pkg}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload common v1.0.0
|
|
content_common = unique_content("common", unique_test_id, "noconflict")
|
|
files = {"file": ("common.tar.gz", BytesIO(content_common), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_common}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Both lib-a and lib-b depend on common@1.0.0
|
|
for lib_name, lib_pkg in [("lib-a", pkg_lib_a), ("lib-b", pkg_lib_b)]:
|
|
ensure = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_common, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_lib = unique_content(lib_name, unique_test_id, "noconflict")
|
|
files = {
|
|
"file": (f"{lib_name}.tar.gz", BytesIO(content_lib), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{lib_pkg}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# App depends on both
|
|
ensure_app = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_lib_a, "version": "1.0.0"},
|
|
{"project": test_project, "package": pkg_lib_b, "version": "1.0.0"},
|
|
]
|
|
})
|
|
content_app = unique_content("app", unique_test_id, "noconflict")
|
|
files = {
|
|
"file": ("app.tar.gz", BytesIO(content_app), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_app.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_app}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Resolution should succeed (no conflict)
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{test_project}/{pkg_app}/+/1.0.0/resolve"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# common should appear only once
|
|
packages = [r["package"] for r in data["resolved"]]
|
|
assert packages.count(pkg_common) == 1
|
|
|
|
finally:
|
|
for pkg in [pkg_app, pkg_lib_a, pkg_lib_b, pkg_common]:
|
|
integration_client.delete(f"/api/v1/project/{test_project}/packages/{pkg}")
|
|
|
|
|
|
class TestAutoFetchDependencies:
|
|
"""Tests for auto-fetch functionality in dependency resolution.
|
|
|
|
These tests verify:
|
|
- Resolution with auto_fetch=true (default) fetches missing dependencies from upstream
|
|
- Resolution with auto_fetch=false skips network calls for fast resolution
|
|
- Proper handling of missing/non-existent packages
|
|
- Response schema includes fetched artifacts list
|
|
"""
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_auto_fetch_true_is_default(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test that auto_fetch=true is the default (no fetch needed when all deps cached)."""
|
|
project_name, package_name = test_package
|
|
|
|
# Upload a simple artifact without dependencies
|
|
content = unique_content("autofetch-default", unique_test_id, "nodeps")
|
|
files = {"file": ("default.tar.gz", BytesIO(content), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v1.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Resolve without auto_fetch param (should default to false)
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/v1.0.0-{unique_test_id}/resolve"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should have empty fetched list
|
|
assert data.get("fetched", []) == []
|
|
assert data["artifact_count"] == 1
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_auto_fetch_explicit_false(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test that auto_fetch=false works explicitly."""
|
|
project_name, package_name = test_package
|
|
|
|
content = unique_content("autofetch-explicit-false", unique_test_id, "nodeps")
|
|
files = {"file": ("explicit.tar.gz", BytesIO(content), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v2.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Resolve with explicit auto_fetch=false
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/v2.0.0-{unique_test_id}/resolve",
|
|
params={"auto_fetch": "false"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data.get("fetched", []) == []
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_auto_fetch_true_no_missing_deps(
|
|
self, integration_client, test_project, unique_test_id
|
|
):
|
|
"""Test that auto_fetch=true works when all deps are already cached."""
|
|
pkg_a = f"fetch-a-{unique_test_id}"
|
|
pkg_b = f"fetch-b-{unique_test_id}"
|
|
|
|
for pkg in [pkg_a, pkg_b]:
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/packages",
|
|
json={"name": pkg}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
# Upload B (no deps)
|
|
content_b = unique_content("B", unique_test_id, "fetch")
|
|
files = {"file": ("b.tar.gz", BytesIO(content_b), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_b}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Upload A (depends on B)
|
|
ensure_a = yaml.dump({
|
|
"dependencies": [
|
|
{"project": test_project, "package": pkg_b, "version": "1.0.0"}
|
|
]
|
|
})
|
|
content_a = unique_content("A", unique_test_id, "fetch")
|
|
files = {
|
|
"file": ("a.tar.gz", BytesIO(content_a), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_a.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/upload",
|
|
files=files,
|
|
data={"version": "1.0.0"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Resolve with auto_fetch=true - should work since deps are cached
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{test_project}/{pkg_a}/+/1.0.0/resolve",
|
|
params={"auto_fetch": "true"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should resolve successfully
|
|
assert data["artifact_count"] == 2
|
|
# Nothing fetched since everything was cached
|
|
assert len(data.get("fetched", [])) == 0
|
|
# No missing deps
|
|
assert len(data.get("missing", [])) == 0
|
|
|
|
finally:
|
|
for pkg in [pkg_a, pkg_b]:
|
|
integration_client.delete(f"/api/v1/project/{test_project}/packages/{pkg}")
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_missing_dep_with_auto_fetch_false(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test that missing deps are reported when auto_fetch=false."""
|
|
project_name, package_name = test_package
|
|
|
|
# Create _pypi system project if it doesn't exist
|
|
response = integration_client.get("/api/v1/projects/_pypi")
|
|
if response.status_code == 404:
|
|
response = integration_client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "_pypi", "description": "System project for PyPI packages"}
|
|
)
|
|
# May fail if already exists or can't create - that's ok
|
|
|
|
# Upload artifact with dependency on _pypi package that doesn't exist locally
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": "_pypi", "package": "nonexistent-pkg-xyz123", "version": ">=1.0.0"}
|
|
]
|
|
})
|
|
|
|
content = unique_content("missing-pypi", unique_test_id, "dep")
|
|
files = {
|
|
"file": ("missing-pypi-dep.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v3.0.0-{unique_test_id}"},
|
|
)
|
|
# Upload should succeed - validation is loose for system projects
|
|
if response.status_code == 200:
|
|
# Resolve without auto_fetch - should report missing
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/v3.0.0-{unique_test_id}/resolve",
|
|
params={"auto_fetch": "false"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should have missing dependencies
|
|
assert len(data.get("missing", [])) >= 1
|
|
|
|
# Verify missing dependency structure
|
|
missing = data["missing"][0]
|
|
assert missing["project"] == "_pypi"
|
|
assert missing["package"] == "nonexistent-pkg-xyz123"
|
|
# Without auto_fetch, these should be false/None
|
|
assert missing.get("fetch_attempted", False) is False
|
|
|
|
@pytest.mark.integration
|
|
def test_resolve_response_schema_has_fetched_field(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test that the resolve response always includes the fetched field."""
|
|
project_name, package_name = test_package
|
|
|
|
content = unique_content("schema-check", unique_test_id, "nodeps")
|
|
files = {"file": ("schema.tar.gz", BytesIO(content), "application/gzip")}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v4.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Check both auto_fetch modes include fetched field
|
|
for auto_fetch in ["false", "true"]:
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/v4.0.0-{unique_test_id}/resolve",
|
|
params={"auto_fetch": auto_fetch},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Required fields
|
|
assert "requested" in data
|
|
assert "resolved" in data
|
|
assert "missing" in data
|
|
assert "fetched" in data # New field
|
|
assert "total_size" in data
|
|
assert "artifact_count" in data
|
|
|
|
# Types
|
|
assert isinstance(data["fetched"], list)
|
|
assert isinstance(data["missing"], list)
|
|
|
|
@pytest.mark.integration
|
|
def test_missing_dep_schema_has_fetch_fields(
|
|
self, integration_client, test_package, unique_test_id
|
|
):
|
|
"""Test that missing dependency entries have fetch_attempted and fetch_error fields."""
|
|
project_name, package_name = test_package
|
|
|
|
# Create a dependency on a non-existent package in a real project
|
|
dep_project_name = f"dep-test-{unique_test_id}"
|
|
response = integration_client.post(
|
|
"/api/v1/projects", json={"name": dep_project_name}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
try:
|
|
ensure_content = yaml.dump({
|
|
"dependencies": [
|
|
{"project": dep_project_name, "package": "nonexistent-pkg", "version": "1.0.0"}
|
|
]
|
|
})
|
|
|
|
content = unique_content("missing-schema", unique_test_id, "check")
|
|
files = {
|
|
"file": ("missing-schema.tar.gz", BytesIO(content), "application/gzip"),
|
|
"ensure": ("orchard.ensure", BytesIO(ensure_content.encode()), "application/x-yaml"),
|
|
}
|
|
response = integration_client.post(
|
|
f"/api/v1/project/{project_name}/{package_name}/upload",
|
|
files=files,
|
|
data={"version": f"v5.0.0-{unique_test_id}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Resolve
|
|
response = integration_client.get(
|
|
f"/api/v1/project/{project_name}/{package_name}/+/v5.0.0-{unique_test_id}/resolve",
|
|
params={"auto_fetch": "true"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should have missing dependencies
|
|
assert len(data.get("missing", [])) >= 1
|
|
|
|
# Check schema for missing dependency
|
|
missing = data["missing"][0]
|
|
assert "project" in missing
|
|
assert "package" in missing
|
|
assert "constraint" in missing
|
|
assert "required_by" in missing
|
|
# New fields
|
|
assert "fetch_attempted" in missing
|
|
assert "fetch_error" in missing # May be None
|
|
|
|
finally:
|
|
integration_client.delete(f"/api/v1/projects/{dep_project_name}")
|