Files
smart-org/apps/api/tests/test_foundation.py
T

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"