"""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"