"""§22.12 S6 — per-collection model universe. A collection's `.collection.yaml` may carry an `enabled_models` list that NARROWS its project's universe, which in turn narrows the deployment ENABLED_MODELS. The resolution chain (extending §6.6/§6.7) is: funder ∩ per-entry models ∩ collection universe ∩ project universe (operator providers = the ceiling) These tests prove (1) the manifest parser reads `enabled_models`, (2) the mirror stores it on the collection row, (3) the resolver narrows the base universe by project then collection, with absent = inherit and [] = opt-out, and (4) the collection API surfaces the collection's own narrowing. """ from __future__ import annotations import json from fastapi.testclient import TestClient from app import db, models_resolver, registry from test_propose_vertical import ( # noqa: F401 app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401 def _install_two_providers(app) -> None: app.state.providers.clear() app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B") app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H") def _set_project_models(project_id: str, models) -> None: cfg = {} if models is None else {"enabled_models": models} db.conn().execute( "UPDATE projects SET config_json = ? WHERE id = ?", (json.dumps(cfg), project_id), ) def _set_collection_models(collection_id: str, models) -> None: cfg = None if models is None else json.dumps({"enabled_models": models}) db.conn().execute( "UPDATE collections SET config_json = ? WHERE id = ?", (cfg, collection_id), ) # ── manifest parsing ─────────────────────────────────────────────────────── def test_manifest_parses_enabled_models_into_config(): ce = registry.parse_collection_manifest( "type: bdd\nname: Features\nenabled_models: [claude, gemini]\n" ) assert ce.config.get("enabled_models") == ["claude", "gemini"] def test_manifest_without_enabled_models_has_no_key(): ce = registry.parse_collection_manifest("type: document\nname: Model\n") assert "enabled_models" not in ce.config def test_manifest_rejects_non_list_enabled_models(): import pytest with pytest.raises(registry.RegistryError): registry.parse_collection_manifest("type: bdd\nenabled_models: claude\n") # ── resolver narrowing (the §22.12 chain) ────────────────────────────────── def test_resolver_inherits_operator_universe_when_no_scope_narrowing(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app): seed_active_rfc(fake, slug="ohm", title="OHM", body="x") _install_two_providers(app) # No project/collection narrowing → full operator universe. resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) assert resolved == ["claude", "gemini"] def test_resolver_narrows_by_project_universe(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app): seed_active_rfc(fake, slug="ohm", title="OHM", body="x") _install_two_providers(app) _set_project_models("default", ["gemini"]) resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) assert resolved == ["gemini"] def test_resolver_collection_narrows_within_project(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app): seed_active_rfc(fake, slug="ohm", title="OHM", body="x") _install_two_providers(app) # Project allows both; the collection narrows to claude only. _set_project_models("default", ["claude", "gemini"]) _set_collection_models("default", ["claude"]) resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) assert resolved == ["claude"] def test_resolver_collection_empty_list_opts_out(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app): seed_active_rfc(fake, slug="ohm", title="OHM", body="x") _install_two_providers(app) _set_collection_models("default", []) # opt this collection out of AI resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) assert resolved == [] def test_resolver_collection_cannot_widen_project(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app): seed_active_rfc(fake, slug="ohm", title="OHM", body="x") _install_two_providers(app) # Project restricts to gemini; the collection naming claude+gemini # cannot re-add claude (narrowing only). _set_project_models("default", ["gemini"]) _set_collection_models("default", ["claude", "gemini"]) resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) assert resolved == ["gemini"] # ── API surfacing ────────────────────────────────────────────────────────── def test_collection_api_surfaces_enabled_models(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") _set_collection_models("default", ["claude"]) r = client.get("/api/projects/default/collections/default") assert r.status_code == 200, r.text assert r.json().get("enabled_models") == ["claude"]