Publish curated ITWorx-ModelForge source from 990401737f96c8d46a9f7b0d84994516cadcb6a0
This commit is contained in:
1 parent
7082ab955a
commit
848f0d8f8a
434 files changed
+67298
-4315
No files matched your search
@@ -0,0 +1,617 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from sqlalchemy import create_engine, inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from modelforge_api.api.authorization import AccessBoundary, access_boundary_for_request
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.portfolio import PortfolioReconcileRequest
|
||||
from modelforge_api.main import app
|
||||
from modelforge_api.persistence.models import (
|
||||
AuditEvent,
|
||||
Base,
|
||||
PortfolioReconciliationRun,
|
||||
PortfolioRepositoryObservation,
|
||||
Project,
|
||||
ProjectBinding,
|
||||
)
|
||||
from modelforge_api.services.audit import AuditContext
|
||||
from modelforge_api.services.portfolio_registry import (
|
||||
PortfolioRegistryError,
|
||||
PortfolioRegistryService,
|
||||
)
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATION_PATH = (
|
||||
ROOT / "alembic" / "versions" / "20260923_0036_portfolio_repository_reconciliation.py"
|
||||
)
|
||||
|
||||
|
||||
def _migration() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("portfolio_reconciliation_0036", MIGRATION_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as value:
|
||||
yield value
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def observation(
|
||||
*,
|
||||
repository_id: str = "all-examplerag",
|
||||
project_key: str = "examplerag",
|
||||
classification: str = "gateway",
|
||||
slug: str = "ITWorx/ExampleRAG",
|
||||
head_sha: str = "a" * 40,
|
||||
branch: str = "master",
|
||||
accept_head_update: bool = False,
|
||||
runtime_evidence: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"external_repository_id": repository_id,
|
||||
"project_key": project_key,
|
||||
"slug": slug,
|
||||
"default_branch": branch,
|
||||
"head_sha": head_sha,
|
||||
"classification": classification,
|
||||
"accept_head_update": accept_head_update,
|
||||
"code_evidence": [
|
||||
{
|
||||
"path": "backend/src/client.py",
|
||||
"signal": "capability_client",
|
||||
"capabilities": ["rag.embedding@1"],
|
||||
}
|
||||
],
|
||||
"runtime_evidence": runtime_evidence or [],
|
||||
}
|
||||
|
||||
|
||||
def request(
|
||||
*,
|
||||
snapshot_id: str = "gitea-001",
|
||||
observed_at: datetime | None = None,
|
||||
repositories: list[dict[str, object]] | None = None,
|
||||
complete: bool = False,
|
||||
) -> PortfolioReconcileRequest:
|
||||
return PortfolioReconcileRequest.model_validate(
|
||||
{
|
||||
"source_system": "gitea",
|
||||
"snapshot_id": snapshot_id,
|
||||
"observed_at": (observed_at or datetime(2026, 9, 23, 12, tzinfo=UTC)).isoformat(),
|
||||
"complete": complete,
|
||||
"repositories": [observation()] if repositories is None else repositories,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reconcile(
|
||||
session: Session, item: PortfolioReconcileRequest
|
||||
):
|
||||
return PortfolioRegistryService(session).reconcile(
|
||||
item, AuditContext("operator", "portfolio-test", "portfolio-correlation")
|
||||
)
|
||||
|
||||
|
||||
def test_reconciliation_reuses_project_and_exact_replay_is_a_noop(session: Session) -> None:
|
||||
project = Project(
|
||||
key="examplerag", name="ExampleRAG legacy", description="operator description", active=False
|
||||
)
|
||||
session.add(project)
|
||||
session.commit()
|
||||
item = request()
|
||||
|
||||
first = reconcile(session, item)
|
||||
second = reconcile(session, item)
|
||||
|
||||
assert first.projects_created == 0
|
||||
assert first.repositories_created == 1
|
||||
assert first.replayed is False
|
||||
assert second.replayed is True
|
||||
assert second.snapshot_digest == first.snapshot_digest
|
||||
assert session.query(Project).count() == 1
|
||||
assert session.query(ProjectBinding).count() == 0
|
||||
assert session.query(PortfolioRepositoryObservation).count() == 1
|
||||
session.refresh(project)
|
||||
assert (project.name, project.description, project.active) == (
|
||||
"ExampleRAG legacy",
|
||||
"operator description",
|
||||
False,
|
||||
)
|
||||
assert session.query(PortfolioReconciliationRun).count() == 1
|
||||
assert session.scalar(
|
||||
select(AuditEvent).where(AuditEvent.action == "PORTFOLIO_RECONCILED")
|
||||
) is not None
|
||||
assert session.query(AuditEvent).filter_by(action="PORTFOLIO_RECONCILED").count() == 1
|
||||
|
||||
|
||||
def test_authenticated_portfolio_artifact_creates_only_source_confirmed_projects(
|
||||
session: Session,
|
||||
) -> None:
|
||||
artifact = ROOT.parent / "docs/product/PORTFOLIO_RECONCILE_REQUEST_COMPLETE_2026-09-25.json"
|
||||
item = PortfolioReconcileRequest.model_validate_json(artifact.read_text(encoding="utf-8"))
|
||||
|
||||
first = reconcile(session, item)
|
||||
second = reconcile(session, item)
|
||||
|
||||
assert item.complete is True
|
||||
assert first.repositories_created == 69
|
||||
assert first.projects_created == 12
|
||||
assert second.replayed is True
|
||||
assert second.snapshot_digest == first.snapshot_digest
|
||||
assert session.query(PortfolioRepositoryObservation).count() == 69
|
||||
assert session.query(Project).count() == 12
|
||||
assert session.query(ProjectBinding).count() == 0
|
||||
inventory = PortfolioRegistryService(session).inventory()
|
||||
assert len(inventory.repositories) == 69
|
||||
assert sum(row.project_id is not None for row in inventory.repositories) == 12
|
||||
|
||||
|
||||
def test_non_consumer_repositories_are_recorded_without_creating_projects(
|
||||
session: Session,
|
||||
) -> None:
|
||||
item = request(
|
||||
repositories=[
|
||||
observation(
|
||||
repository_id="all-docs-tool",
|
||||
project_key="docs-tool",
|
||||
classification="non_ai",
|
||||
slug="ITWorx/Docs-Tool",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
reconcile(session, item)
|
||||
|
||||
row = session.scalar(select(PortfolioRepositoryObservation))
|
||||
view = PortfolioRegistryService(session).inventory().repositories[0]
|
||||
assert row and row.project_id is None and row.project_key == "docs-tool"
|
||||
assert view.project_key == "docs-tool" and view.project_id is None and view.project_name is None
|
||||
assert session.query(Project).count() == 0
|
||||
|
||||
|
||||
def test_operator_classification_links_an_existing_observation_only_when_relevant(
|
||||
session: Session,
|
||||
) -> None:
|
||||
reconcile(
|
||||
session,
|
||||
request(
|
||||
repositories=[
|
||||
observation(classification="hosted_model", project_key="examplerag")
|
||||
]
|
||||
),
|
||||
)
|
||||
row = session.scalar(select(PortfolioRepositoryObservation))
|
||||
assert row and row.project_id is None
|
||||
assert session.query(Project).count() == 0
|
||||
|
||||
reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="reviewed-local-consumer",
|
||||
observed_at=datetime(2026, 9, 23, 12, 5, tzinfo=UTC),
|
||||
repositories=[observation(classification="gateway", project_key="examplerag")],
|
||||
),
|
||||
)
|
||||
session.refresh(row)
|
||||
assert row.project_id is not None
|
||||
assert session.query(Project).count() == 1
|
||||
|
||||
|
||||
def test_nonlocal_to_local_head_change_stays_unlinked_until_exact_candidate_is_accepted(
|
||||
session: Session,
|
||||
) -> None:
|
||||
start = datetime(2026, 9, 23, 12, tzinfo=UTC)
|
||||
reconcile(
|
||||
session,
|
||||
request(
|
||||
observed_at=start,
|
||||
repositories=[observation(classification="hosted_model")],
|
||||
),
|
||||
)
|
||||
|
||||
pending = reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="local-candidate",
|
||||
observed_at=start + timedelta(minutes=5),
|
||||
repositories=[
|
||||
observation(classification="gateway", head_sha="b" * 40)
|
||||
],
|
||||
),
|
||||
)
|
||||
row = session.scalar(select(PortfolioRepositoryObservation))
|
||||
view = PortfolioRegistryService(session).inventory().repositories[0]
|
||||
assert pending.head_changes_review_required == 1
|
||||
assert row and row.project_id is None and row.head_sha == "a" * 40
|
||||
assert row.classification == "hosted_model"
|
||||
assert row.pending_head_sha == "b" * 40
|
||||
assert row.pending_classification == "gateway"
|
||||
assert row.pending_project_key == "examplerag"
|
||||
assert view.project_id is None and view.pending_project_key == "examplerag"
|
||||
assert session.query(Project).count() == 0
|
||||
|
||||
with pytest.raises(PortfolioRegistryError) as error:
|
||||
reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="accept-unreviewed-head",
|
||||
observed_at=start + timedelta(minutes=7),
|
||||
repositories=[
|
||||
observation(
|
||||
classification="gateway",
|
||||
head_sha="c" * 40,
|
||||
accept_head_update=True,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
assert error.value.code == "PORTFOLIO_PENDING_HEAD_CONFLICT"
|
||||
session.refresh(row)
|
||||
assert row.project_id is None and row.pending_head_sha == "b" * 40
|
||||
assert session.query(Project).count() == 0
|
||||
|
||||
accepted = reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="accept-local-candidate",
|
||||
observed_at=start + timedelta(minutes=10),
|
||||
repositories=[
|
||||
observation(
|
||||
classification="gateway",
|
||||
head_sha="b" * 40,
|
||||
accept_head_update=True,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
session.refresh(row)
|
||||
assert accepted.head_changes_accepted == 1
|
||||
assert row.project_id is not None and row.head_sha == "b" * 40
|
||||
assert row.classification == "gateway" and row.pending_head_sha is None
|
||||
assert row.pending_project_key is None
|
||||
assert session.query(Project).count() == 1
|
||||
|
||||
|
||||
def test_duplicate_project_key_across_repositories_is_an_explicit_conflict(
|
||||
session: Session,
|
||||
) -> None:
|
||||
documents = observation(
|
||||
repository_id="all-docs-tool",
|
||||
project_key="shared-key",
|
||||
classification="non_ai",
|
||||
slug="ITWorx/Docs-Tool",
|
||||
)
|
||||
dashboard = observation(
|
||||
repository_id="all-dashboard",
|
||||
project_key="shared-key",
|
||||
classification="runtime_observer",
|
||||
slug="ITWorx/Dashboard",
|
||||
)
|
||||
|
||||
with pytest.raises(PortfolioRegistryError) as error:
|
||||
reconcile(session, request(repositories=[documents, dashboard]))
|
||||
|
||||
assert error.value.status_code == 409
|
||||
assert error.value.code == "PORTFOLIO_PROJECT_KEY_CONFLICT"
|
||||
assert session.query(PortfolioRepositoryObservation).count() == 0
|
||||
assert session.query(Project).count() == 0
|
||||
assert session.query(PortfolioReconciliationRun).count() == 0
|
||||
|
||||
|
||||
def test_snapshot_identity_conflict_does_not_change_portfolio(session: Session) -> None:
|
||||
first = request()
|
||||
reconcile(session, first)
|
||||
changed = request(
|
||||
snapshot_id=first.snapshot_id,
|
||||
observed_at=first.observed_at + timedelta(minutes=1),
|
||||
repositories=[observation(head_sha="b" * 40)],
|
||||
)
|
||||
|
||||
with pytest.raises(PortfolioRegistryError) as error:
|
||||
reconcile(session, changed)
|
||||
|
||||
assert error.value.status_code == 409
|
||||
row = session.scalar(select(PortfolioRepositoryObservation))
|
||||
assert row and row.head_sha == "a" * 40
|
||||
assert session.query(PortfolioReconciliationRun).count() == 1
|
||||
|
||||
|
||||
def test_head_change_requires_explicit_operator_snapshot_acceptance(session: Session) -> None:
|
||||
first_time = datetime(2026, 9, 23, 12, tzinfo=UTC)
|
||||
reconcile(session, request(observed_at=first_time, complete=True))
|
||||
|
||||
pending = reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="gitea-002",
|
||||
observed_at=first_time + timedelta(minutes=5),
|
||||
repositories=[observation(head_sha="b" * 40)],
|
||||
),
|
||||
)
|
||||
row = session.scalar(select(PortfolioRepositoryObservation))
|
||||
assert row and row.head_sha == "a" * 40
|
||||
assert row.pending_head_sha == "b" * 40
|
||||
assert pending.head_changes_review_required == 1
|
||||
assert row.source_observed_at.replace(tzinfo=UTC) == first_time
|
||||
|
||||
stale = reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="gitea-old",
|
||||
observed_at=first_time + timedelta(minutes=3),
|
||||
repositories=[observation(head_sha="c" * 40)],
|
||||
),
|
||||
)
|
||||
assert stale.stale_snapshot is False
|
||||
assert stale.stale_observations == 1
|
||||
session.refresh(row)
|
||||
assert row.head_sha == "a" * 40
|
||||
assert row.pending_head_sha == "b" * 40
|
||||
|
||||
accepted = reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="gitea-003",
|
||||
observed_at=first_time + timedelta(minutes=7),
|
||||
repositories=[observation(head_sha="b" * 40, accept_head_update=True)],
|
||||
),
|
||||
)
|
||||
session.refresh(row)
|
||||
assert accepted.head_changes_accepted == 1
|
||||
assert row.head_sha == "b" * 40
|
||||
assert row.pending_head_sha is None
|
||||
|
||||
|
||||
def test_incomplete_snapshot_never_marks_absent_repository_stale(session: Session) -> None:
|
||||
start = datetime(2026, 9, 23, 12, tzinfo=UTC)
|
||||
second = observation(
|
||||
repository_id="all-examplevision", project_key="examplevision", slug="ITWorx/ExampleVision"
|
||||
)
|
||||
reconcile(session, request(observed_at=start, repositories=[observation(), second], complete=True))
|
||||
|
||||
partial = reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="partial",
|
||||
observed_at=start + timedelta(minutes=1),
|
||||
repositories=[observation()],
|
||||
complete=False,
|
||||
),
|
||||
)
|
||||
|
||||
examplevision = session.scalar(
|
||||
select(PortfolioRepositoryObservation).where(
|
||||
PortfolioRepositoryObservation.external_repository_id == "all-examplevision"
|
||||
)
|
||||
)
|
||||
assert partial.repositories_marked_stale == 0
|
||||
assert examplevision and examplevision.stale_at is None
|
||||
|
||||
|
||||
def test_newer_complete_snapshot_marks_only_missing_rows_stale_without_deleting(session: Session) -> None:
|
||||
start = datetime(2026, 9, 23, 12, tzinfo=UTC)
|
||||
second = observation(
|
||||
repository_id="all-examplevision", project_key="examplevision", slug="ITWorx/ExampleVision"
|
||||
)
|
||||
reconcile(session, request(observed_at=start, repositories=[observation(), second], complete=True))
|
||||
|
||||
result = reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="complete-newer",
|
||||
observed_at=start + timedelta(minutes=2),
|
||||
repositories=[observation()],
|
||||
complete=True,
|
||||
),
|
||||
)
|
||||
|
||||
examplevision = session.scalar(
|
||||
select(PortfolioRepositoryObservation).where(
|
||||
PortfolioRepositoryObservation.external_repository_id == "all-examplevision"
|
||||
)
|
||||
)
|
||||
pokevault_project = session.scalar(select(Project).where(Project.key == "examplevision"))
|
||||
assert result.repositories_marked_stale == 1
|
||||
assert examplevision and examplevision.stale_at == (start + timedelta(minutes=2)).replace(tzinfo=None)
|
||||
assert pokevault_project and pokevault_project.active is True
|
||||
assert session.query(PortfolioRepositoryObservation).count() == 2
|
||||
assert session.query(ProjectBinding).count() == 0
|
||||
|
||||
|
||||
def test_runtime_observations_are_separate_and_monotone(session: Session) -> None:
|
||||
start = datetime(2026, 9, 23, 12, tzinfo=UTC)
|
||||
running = {
|
||||
"component": "backend",
|
||||
"state": "running",
|
||||
"observed_at": (start + timedelta(minutes=2)).isoformat(),
|
||||
"observation_source": "container_inventory",
|
||||
"git_revision": "1234567890ab",
|
||||
}
|
||||
reconcile(
|
||||
session,
|
||||
request(observed_at=start, repositories=[observation(runtime_evidence=[running])]),
|
||||
)
|
||||
# A later source-only snapshot must not erase runtime evidence.
|
||||
reconcile(
|
||||
session,
|
||||
request(
|
||||
snapshot_id="source-only",
|
||||
observed_at=start + timedelta(minutes=3),
|
||||
repositories=[observation()],
|
||||
),
|
||||
)
|
||||
row = session.scalar(select(PortfolioRepositoryObservation))
|
||||
assert row and row.classification == "gateway"
|
||||
assert row.runtime_evidence[0]["state"] == "running"
|
||||
from modelforge_api.domain.portfolio import PortfolioRuntimeEvidence
|
||||
|
||||
runtime = PortfolioRuntimeEvidence.model_validate(row.runtime_evidence[0])
|
||||
assert runtime.observed_at == start + timedelta(minutes=2)
|
||||
|
||||
|
||||
def test_payload_contract_rejects_secrets_and_unsafe_evidence_paths() -> None:
|
||||
body = request().model_dump(mode="json")
|
||||
body["api_key"] = "this-value-must-not-be-accepted"
|
||||
with pytest.raises(ValidationError):
|
||||
PortfolioReconcileRequest.model_validate(body)
|
||||
|
||||
unsafe = request().model_dump(mode="json")
|
||||
unsafe["repositories"][0]["code_evidence"][0]["path"] = "../private-key.env"
|
||||
with pytest.raises(ValidationError):
|
||||
PortfolioReconcileRequest.model_validate(unsafe)
|
||||
|
||||
windows = request().model_dump(mode="json")
|
||||
windows["repositories"][0]["code_evidence"][0]["path"] = "C:/private-key.env"
|
||||
with pytest.raises(ValidationError):
|
||||
PortfolioReconcileRequest.model_validate(windows)
|
||||
|
||||
|
||||
def test_inventory_and_reconcile_roles_and_validation_responses_are_safe(
|
||||
session: Session,
|
||||
) -> None:
|
||||
tokens = {role: f"mfop_{role[0] * 43}" for role in ("viewer", "operator", "admin")}
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
env="test",
|
||||
operator_identities=SecretStr(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"subject": role,
|
||||
"role": role,
|
||||
"credential_id": role,
|
||||
"token_sha256": hashlib.sha256(token.encode()).hexdigest(),
|
||||
"expires_at": (datetime.now(UTC) + timedelta(days=1)).isoformat(),
|
||||
}
|
||||
for role, token in tokens.items()
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
assert access_boundary_for_request("GET", "/api/v1/portfolio") is AccessBoundary.CONTROL_PLANE
|
||||
assert client.get("/api/v1/portfolio").status_code == 401
|
||||
assert client.get(
|
||||
"/api/v1/portfolio", headers={"X-ModelForge-Admin-Token": tokens["viewer"]}
|
||||
).status_code == 200
|
||||
|
||||
body = request().model_dump(mode="json")
|
||||
assert client.post("/api/v1/portfolio/reconcile", json=body).status_code == 401
|
||||
assert client.post(
|
||||
"/api/v1/portfolio/reconcile",
|
||||
headers={"X-ModelForge-Admin-Token": tokens["viewer"]},
|
||||
json=body,
|
||||
).status_code == 403
|
||||
|
||||
invalid = dict(body)
|
||||
invalid["clone_url"] = "https://private-user:private-password@example.invalid/repo.git"
|
||||
invalid["api-key-secret-value-must-not-echo"] = "untrusted"
|
||||
rejected = client.post(
|
||||
"/api/v1/portfolio/reconcile",
|
||||
headers={"X-ModelForge-Admin-Token": tokens["operator"]},
|
||||
json=invalid,
|
||||
)
|
||||
assert rejected.status_code == 422
|
||||
assert "private-password" not in rejected.text
|
||||
assert "example.invalid" not in rejected.text
|
||||
assert "api-key-secret-value-must-not-echo" not in rejected.text
|
||||
|
||||
accepted = client.post(
|
||||
"/api/v1/portfolio/reconcile",
|
||||
headers={
|
||||
"X-ModelForge-Admin-Token": tokens["operator"],
|
||||
"X-Correlation-ID": "portfolio-reconcile-test",
|
||||
},
|
||||
json=body,
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
assert accepted.json()["repositories_created"] == 1
|
||||
audit = session.scalar(
|
||||
select(AuditEvent).where(AuditEvent.action == "PORTFOLIO_RECONCILED")
|
||||
)
|
||||
assert audit and audit.actor_id == "operator"
|
||||
assert audit.correlation_id == "portfolio-reconcile-test"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_portfolio_migration_upgrades_and_downgrades_empty_history() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE projects (id VARCHAR(36) PRIMARY KEY)"))
|
||||
migration = _migration()
|
||||
migration.op = Operations(MigrationContext.configure(connection))
|
||||
migration.upgrade()
|
||||
assert inspect(connection).has_table("portfolio_repository_observations")
|
||||
assert inspect(connection).has_table("portfolio_reconciliation_runs")
|
||||
foreign_keys = inspect(connection).get_foreign_keys("portfolio_repository_observations")
|
||||
assert any(item["referred_table"] == "projects" for item in foreign_keys)
|
||||
repository_unique_constraints = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_unique_constraints(
|
||||
"portfolio_repository_observations"
|
||||
)
|
||||
}
|
||||
assert "uq_portfolio_source_project_key" in repository_unique_constraints
|
||||
assert "uq_portfolio_repository_project_id" in repository_unique_constraints
|
||||
|
||||
migration.downgrade()
|
||||
assert not inspect(connection).has_table("portfolio_repository_observations")
|
||||
assert not inspect(connection).has_table("portfolio_reconciliation_runs")
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_portfolio_migration_refuses_downgrade_after_a_snapshot_is_saved() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE projects (id VARCHAR(36) PRIMARY KEY)"))
|
||||
migration = _migration()
|
||||
migration.op = Operations(MigrationContext.configure(connection))
|
||||
migration.upgrade()
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO portfolio_reconciliation_runs "
|
||||
"(id, source_system, snapshot_id, payload_digest, observed_at, complete, "
|
||||
"stale_snapshot, projects_created, repositories_created, repositories_updated, "
|
||||
"stale_observations, repositories_marked_stale, runtime_observations_updated, "
|
||||
"head_changes_review_required, head_changes_accepted) "
|
||||
"VALUES ('00000000-0000-0000-0000-000000000001', 'gitea', 'snapshot-1', :digest, "
|
||||
"'2026-09-23 12:00:00', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)"
|
||||
),
|
||||
{"digest": "a" * 64},
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="portfolio reconciliation history exists"):
|
||||
migration.downgrade()
|
||||
assert inspect(connection).has_table("portfolio_reconciliation_runs")
|
||||
assert inspect(connection).has_table("portfolio_repository_observations")
|
||||
engine.dispose()
|
||||
Reference in new issue
Block a user