Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
261 lines
11 KiB
Python
261 lines
11 KiB
Python
"""v2 foundation: schema invariants, shared repository, contracts and wiring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
import app.application.container as container
|
|
import app.application.container_org as container_org
|
|
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
|
|
from app.application.ports.deploy_target import (
|
|
DeployTargetSpec,
|
|
DeployTargetTester,
|
|
FakeDeployTargetTester,
|
|
)
|
|
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
|
|
from app.application.ports.model_grants import ModelGrantPort, tier_for
|
|
from app.application.ports.repo_workspace import (
|
|
DiffResult,
|
|
FakeRepoWorkspace,
|
|
MergeOutcome,
|
|
RemoteCredentials,
|
|
RepoWorkspacePort,
|
|
)
|
|
from app.application.ports.workforce_bootstrap import (
|
|
NullWorkforceBootstrap,
|
|
WorkforceBootstrapPort,
|
|
)
|
|
from app.domain.base import new_id
|
|
from app.domain.identity.models import PlatformRole
|
|
from app.infrastructure.db.models import (
|
|
AiModelRow,
|
|
AiProviderRow,
|
|
OrganizationRow,
|
|
ProjectRow,
|
|
UserRow,
|
|
)
|
|
from app.infrastructure.repositories.app_profile import AppProfileRepository
|
|
|
|
|
|
async def _org_with_user(session, slug: str) -> tuple[str, str]:
|
|
user = UserRow(
|
|
id=new_id(),
|
|
email=f"{slug}@example.com",
|
|
password_hash="x",
|
|
name="t",
|
|
platform_role=PlatformRole.MANAGER.value,
|
|
)
|
|
session.add(user)
|
|
await session.flush()
|
|
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
|
|
session.add(org)
|
|
await session.flush()
|
|
return org.id, user.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_project_per_organization(session_factory):
|
|
async with session_factory() as session:
|
|
org_id, _ = await _org_with_user(session, "solo")
|
|
session.add(ProjectRow(id=new_id(), organization_id=org_id, name="a", slug="a"))
|
|
await session.commit()
|
|
session.add(ProjectRow(id=new_id(), organization_id=org_id, name="b", slug="b"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_app_profile_repository_is_tenant_scoped(session_factory):
|
|
async with session_factory() as session:
|
|
org_a, _ = await _org_with_user(session, "org-a")
|
|
org_b, _ = await _org_with_user(session, "org-b")
|
|
project = ProjectRow(id=new_id(), organization_id=org_a, name="a", slug="a")
|
|
session.add(project)
|
|
await session.commit()
|
|
|
|
repo = AppProfileRepository(session)
|
|
profile = await repo.get_for_org(org_a)
|
|
assert profile is not None
|
|
assert profile.healthcheck_path == "/"
|
|
assert profile.git_source == "platform"
|
|
assert profile.wizard_completed is False
|
|
|
|
# Another org can neither read nor update this project's profile.
|
|
assert await repo.get_for_org(org_b) is None
|
|
assert await repo.get(organization_id=org_b, project_id=project.id) is None
|
|
assert (
|
|
await repo.update_profile(
|
|
organization_id=org_b, project_id=project.id, values={"language": "go"}
|
|
)
|
|
is None
|
|
)
|
|
|
|
updated = await repo.update_profile(
|
|
organization_id=org_a,
|
|
project_id=project.id,
|
|
values={"language": "python", "framework": "fastapi", "app_port": 8000},
|
|
)
|
|
assert updated is not None
|
|
assert (updated.language, updated.framework, updated.app_port) == (
|
|
"python",
|
|
"fastapi",
|
|
8000,
|
|
)
|
|
|
|
with pytest.raises(ValueError):
|
|
await repo.update_profile(
|
|
organization_id=org_a, project_id=project.id, values={"organization_id": "x"}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_granted_models_carry_tiers_and_need_an_owner_grant(session_factory):
|
|
"""The real grant service (Phase 3) replaced the "all active models" stub.
|
|
|
|
The foundation version asserted the placeholder wiring (every active model
|
|
usable by every organization). The behaviour the plan changes on purpose is
|
|
exactly that: an organization now sees a model only after the owner granted
|
|
it. The assertions below are strictly stronger — they still pin the tier
|
|
labels, and additionally pin the two switches (the model must be active
|
|
*and* granted) plus the per-organization scope.
|
|
"""
|
|
async with session_factory() as session:
|
|
provider = AiProviderRow(id=new_id(), name="agy-test")
|
|
session.add(provider)
|
|
await session.flush()
|
|
session.add_all(
|
|
[
|
|
AiModelRow(
|
|
id="m-strong", provider_id=provider.id, internal_name="s",
|
|
display_name_for_admin="S", capabilities=["text", "reasoning"],
|
|
output_price_per_1m=10.0,
|
|
),
|
|
AiModelRow(
|
|
id="m-fast", provider_id=provider.id, internal_name="f",
|
|
display_name_for_admin="F", capabilities=["text"], output_price_per_1m=0.5,
|
|
),
|
|
AiModelRow(
|
|
id="m-off", provider_id=provider.id, internal_name="o",
|
|
display_name_for_admin="O", capabilities=["text"], is_active=False,
|
|
),
|
|
]
|
|
)
|
|
await session.commit()
|
|
|
|
grants = container.build_model_grants(session)
|
|
assert isinstance(grants, ModelGrantPort)
|
|
org_id, _ = await _org_with_user(session, "granted")
|
|
other_id, _ = await _org_with_user(session, "untouched")
|
|
# Nothing is usable until the owner grants it.
|
|
assert await grants.granted_models(organization_id=org_id) == []
|
|
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
|
|
|
|
# ``container.py`` is locked and re-exports only ``build_model_grants``;
|
|
# the typed builder lives in the workstream's own container.
|
|
service = container_org.build_model_grant_service(session)
|
|
await service.grant_model(organization_id=org_id, ai_model_id="m-strong")
|
|
await service.grant_model(organization_id=org_id, ai_model_id="m-fast")
|
|
|
|
granted = {
|
|
g.ai_model_id: g.tier
|
|
for g in await grants.granted_models(organization_id=org_id)
|
|
}
|
|
assert granted == {"m-strong": "strong", "m-fast": "fast"}
|
|
assert await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
|
|
# Inactive in the catalogue: never granted, whatever the grant table says.
|
|
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-off")
|
|
# Another organization is unaffected by this one's grants.
|
|
assert await grants.granted_models(organization_id=other_id) == []
|
|
|
|
|
|
def test_tier_for_never_uses_names():
|
|
assert tier_for(capabilities=["reasoning"], output_price_per_1m=99) == "strong"
|
|
assert tier_for(capabilities=["text"], output_price_per_1m=1) == "fast"
|
|
assert tier_for(capabilities=["text"], output_price_per_1m=5) == "balanced"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_contract_fakes_satisfy_their_protocols():
|
|
assert isinstance(FakeRepoWorkspace(), RepoWorkspacePort)
|
|
assert isinstance(NullDeliveryHooks(), DeliveryHooks)
|
|
assert isinstance(FakeDeployTargetTester(), DeployTargetTester)
|
|
assert isinstance(NullMemory(), MemoryPort)
|
|
assert isinstance(NullWorkforceBootstrap(), WorkforceBootstrapPort)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fake_repo_workspace_full_flow():
|
|
repo = FakeRepoWorkspace()
|
|
ws = await repo.create_task_worktree(project_id="p", task_id="t1")
|
|
assert ws.branch == "task/t1" and ws.sandbox_path.endswith("/t1")
|
|
# An empty diff commits nothing.
|
|
assert await repo.commit(worktree=ws, message="m", author="a") is None
|
|
repo.diffs["t1"] = DiffResult(text="+x", changed_files=["a.py"])
|
|
sha = await repo.commit(worktree=ws, message="m", author="a")
|
|
assert sha
|
|
merged = await repo.merge_into(project_id="p", branch=ws.branch)
|
|
assert merged.ok and merged.sha == sha
|
|
assert (await repo.push(project_id="p", branch="dev")).ok
|
|
assert await repo.head(project_id="p", branch="dev") == sha
|
|
repo.merge_outcomes.append(MergeOutcome(ok=False, conflict_files=["a.py"]))
|
|
assert (await repo.merge_into(project_id="p", branch="x")).conflict_files == ["a.py"]
|
|
check = await repo.test_remote(url="https://x/y.git", credentials=RemoteCredentials())
|
|
assert check.ok
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fake_deploy_tester_and_hooks_and_memory_record_calls():
|
|
tester = FakeDeployTargetTester()
|
|
ok = await tester.test_connection(
|
|
target=DeployTargetSpec(kind="ssh", host="h.example.com", port=22, username="u")
|
|
)
|
|
bad = await tester.test_connection(
|
|
target=DeployTargetSpec(kind="ftp", host="fail.example.com", port=21, username="u")
|
|
)
|
|
assert ok.ok and ok.fingerprint and not bad.ok
|
|
|
|
hooks = NullDeliveryHooks()
|
|
await hooks.on_app_created(organization_id="o", project_id="p")
|
|
await hooks.on_branch_pushed(
|
|
organization_id="o", project_id="p", branch="dev", sha="abc", source="engine"
|
|
)
|
|
assert len(hooks.created) == 1 and hooks.pushed[0]["sha"] == "abc"
|
|
|
|
memory = NullMemory()
|
|
assert await memory.build_context(organization_id="o", role="developer", query="q") == ""
|
|
await memory.on_task_finished(
|
|
organization_id="o", task=TaskOutcome(task_id="t", title="x", status="completed")
|
|
)
|
|
assert memory.finished[0].task_id == "t"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_container_exposes_all_v2_builders(session_factory):
|
|
async with session_factory() as session:
|
|
assert isinstance(container.build_repo_workspace(session), RepoWorkspacePort)
|
|
assert isinstance(container.build_delivery_hooks(session), DeliveryHooks)
|
|
assert isinstance(container.build_deploy_target_tester(session), DeployTargetTester)
|
|
assert isinstance(container.build_memory(session), MemoryPort)
|
|
assert isinstance(container.build_workforce_bootstrap(session), WorkforceBootstrapPort)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_app_builds_with_v2_routers_and_delivery_worker_runs(
|
|
client, redirect_error_log
|
|
):
|
|
# The full handler app imports every v2 router; /health proves it assembled.
|
|
assert (await client.get("/health")).status_code == 200
|
|
|
|
from app.workers.__main__ import _build_parser, _pass_function
|
|
from app.workers.delivery import DeliveryWorker
|
|
|
|
# `redirect_error_log` points the workers (and the error recorder) at the
|
|
# in-memory test database. The delivery worker is no longer a no-op: it now
|
|
# reads the pipeline queue, so without the redirect it would open the
|
|
# developer's real `agy_web.db`.
|
|
assert (await DeliveryWorker().run_pass()).failed == 0
|
|
# ...and it is selectable from the CLI.
|
|
one_pass = _pass_function(_build_parser().parse_args(["delivery"]))
|
|
assert (await one_pass()).name == "delivery"
|