"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose. The mirror test drives cache.refresh_meta_repo against an in-memory content repo holding entries under both the default `rfcs/` and a named collection's `features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id.""" from __future__ import annotations import asyncio import tempfile from pathlib import Path from app import cache, db from app.config import Config def _db() -> Config: cfg = Config( gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse", registry_repo="registry", oauth_client_id="x", oauth_client_secret="x", app_url="x", secret_key="x", database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db", owner_gitea_login="x", webhook_secret="x", ) db.run_migrations(cfg) if db._CONN is not None: db._CONN.close() db._CONN = None db.init(cfg) return cfg class _CorpusGitea: """A content repo modelled as a flat {path: text} map, listing files under a directory prefix and reading them back.""" def __init__(self, tree: dict[str, str]): self._tree = tree async def list_dir(self, org, repo, path, ref="main"): out = [] prefix = (path.rstrip("/") + "/") if path else "" for p in self._tree: if p.startswith(prefix) and "/" not in p[len(prefix):]: out.append({"type": "file", "name": p.split("/")[-1], "path": p}) return out async def read_file(self, org, repo, path, ref="main"): t = self._tree.get(path) return (t, "sha-" + path) if t is not None else None def _entry_md(slug, title): return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n" def _seed_project_with_two_collections(): db.conn().execute( "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) " "VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))") for cid, sub in [("default", ""), ("features", "features")]: db.conn().execute( "INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, " "visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, " "'super-draft','public', datetime('now'), datetime('now'))", (cid, sub)) def test_mirror_keys_entries_by_collection(): cfg = _db() _seed_project_with_two_collections() gitea = _CorpusGitea({ "rfcs/a.md": _entry_md("a", "Default A"), "features/rfcs/b.md": _entry_md("b", "Feature B"), }) asyncio.run(cache.refresh_meta_repo(cfg, gitea)) got = {(r["collection_id"], r["slug"]) for r in db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")} assert got == {("default", "a"), ("features", "b")} # --- collection-scoped serve + propose (full app) ----------------------------- from fastapi.testclient import TestClient # noqa: E402 from test_propose_vertical import ( # noqa: E402,F401 app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, ) def _add_features_collection(content_repo="meta"): """Add a named 'features' collection (subfolder 'features') under the seeded default project, plus a single entry under features/rfcs/ in the db cache.""" db.conn().execute( "INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, " "initial_state, visibility, name, created_at, updated_at) VALUES " "('features','default','document','features','super-draft','public','Features', " "datetime('now'), datetime('now'))") def test_scoped_list_returns_only_that_collection(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _add_features_collection() # Seed one entry under each collection's rfcs dir + mirror them in. fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = { "content": _entry_md("a", "Default A"), "sha": "sa"} fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = { "content": _entry_md("b", "Feature B"), "sha": "sb"} from app import cache as cache_mod, gitea as gitea_mod from app.config import load_config cfg = load_config() asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg))) r = client.get("/api/projects/default/collections/features/rfcs") assert r.status_code == 200, r.text assert [i["slug"] for i in r.json()["items"]] == ["b"] # The default collection still serves only its own entry. r2 = client.get("/api/projects/default/collections/default/rfcs") assert [i["slug"] for i in r2.json()["items"]] == ["a"] def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _add_features_collection() provision_user_row(user_id=3, login="alice", role="contributor") # §22 S3: an explicitly-created collection requires an explicit scope # grant to write (the grandfathered baseline covers only `default`). db.conn().execute( "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " "VALUES ('collection', 'features', 3, 'contributor')") sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") r = client.post( "/api/projects/default/collections/features/rfcs/propose", json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []}) assert r.status_code == 200, r.text # The bot wrote the entry under features/rfcs/, not rfcs/. keys = {(k[1], k[3]) for k in fake.files if k[1] == "meta" and k[3].endswith("newb.md")} assert ("meta", "features/rfcs/newb.md") in keys def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea): app, _ = app_with_fake_gitea with TestClient(app) as client: r = client.get("/api/projects/default/collections/nope/rfcs") assert r.status_code == 404 def test_s2_anonymous_empty_public_collection(app_with_fake_gitea): """C3.6 (@S2): a public collection with no entries; an anonymous visitor lands on its catalog → an empty catalog (200, no items), and the propose action is not available to them (the propose route rejects anonymous).""" app, _ = app_with_fake_gitea with TestClient(app) as client: _add_features_collection() # public, no entries # Anonymous (no session cookie) reads the empty catalog — 200, []. r = client.get("/api/projects/default/collections/features/rfcs") assert r.status_code == 200, r.text assert r.json()["items"] == [] # No propose action for an anonymous visitor. r2 = client.post( "/api/projects/default/collections/features/rfcs/propose", json={"title": "X", "slug": "x", "pitch": "p", "tags": []}) assert r2.status_code == 401