//! §7 repo round-trip: `list_with_owner` pairs each group with its owning //! project's slug (`None` when unclaimed), and `ancestors()` surfaces //! `owner_project` for every node — the nearest-claimed fold the ownership //! claim path (M1.3) relies on. //! //! Deterministic; skips cleanly when `DATABASE_URL` is unset. use picloud_manager_core::group_repo::{GroupRepository, PostgresGroupRepository}; use picloud_manager_core::project_repo::{PostgresProjectRepository, ProjectRepository}; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use uuid::Uuid; async fn pool_or_skip() -> Option { let Ok(url) = std::env::var("DATABASE_URL") else { eprintln!("projects_repo: DATABASE_URL unset — skipping"); return None; }; let pool = PgPoolOptions::new() .max_connections(2) .connect(&url) .await .expect("connect to DATABASE_URL"); sqlx::migrate!("./migrations") .run(&pool) .await .expect("apply migrations"); Some(pool) } fn uniq(prefix: &str) -> String { format!("{prefix}-{}", &Uuid::new_v4().to_string()[..8]) } #[tokio::test] async fn list_with_owner_and_ancestors_surface_ownership() { let Some(pool) = pool_or_skip().await else { return; }; let groups = PostgresGroupRepository::new(pool.clone()); let projects = PostgresProjectRepository::new(pool.clone()); // A project + a parent/child group pair; claim the CHILD only (a parent is // deliberately left unclaimed to pin the `None` path). let proj_slug = uniq("proj"); let (proj_id,): (Uuid,) = sqlx::query_as("INSERT INTO projects (slug, name) VALUES ($1, $1) RETURNING id") .bind(&proj_slug) .fetch_one(&pool) .await .expect("insert project"); let parent = groups .create(&uniq("parent"), "Parent", None, None) .await .expect("create parent"); let child_slug = uniq("child"); let child = groups .create(&child_slug, "Child", None, Some(parent.id)) .await .expect("create child"); sqlx::query("UPDATE groups SET owner_project = $1 WHERE id = $2") .bind(proj_id) .bind(child.id.into_inner()) .execute(&pool) .await .expect("claim child"); // list_with_owner: the child pairs with the project slug; the parent is // unclaimed (None). (The list spans the whole instance — filter by slug.) let listed = groups.list_with_owner().await.expect("list_with_owner"); let child_row = listed .iter() .find(|(g, _)| g.slug == child_slug) .expect("child present in list_with_owner"); assert_eq!(child_row.1.as_deref(), Some(proj_slug.as_str())); let parent_row = listed .iter() .find(|(g, _)| g.id == parent.id) .expect("parent present"); assert_eq!(parent_row.1, None, "unclaimed group has no owner slug"); // ancestors(child) is nearest-first and carries owner_project on each node — // the child holds its claim, the parent is None. This is the exact fold // input the nearest-claimed resolution walks. let chain = groups.ancestors(child.id).await.expect("ancestors"); assert_eq!(chain[0].id, child.id, "ancestors is nearest-first"); assert!( chain[0].owner_project.is_some(), "child ancestor row carries its claim" ); let parent_in_chain = chain .iter() .find(|g| g.id == parent.id) .expect("parent in chain"); assert!(parent_in_chain.owner_project.is_none()); // Project reads round-trip by slug and by id. let by_slug = projects.get_by_slug(&proj_slug).await.expect("get_by_slug"); assert_eq!(by_slug.as_ref().map(|p| p.id.into_inner()), Some(proj_id)); let by_id = projects .get_by_id(by_slug.unwrap().id) .await .expect("get_by_id"); assert_eq!(by_id.map(|p| p.slug), Some(proj_slug)); } #[tokio::test] async fn get_environments_by_slug_returns_persisted_policy() { // §3 M3 hermetic gate: the persisted per-env policy round-trips by project // slug. Backs `ApplyService::governing_env_policy` (the `governing` half). let Some(pool) = pool_or_skip().await else { return; }; let projects = PostgresProjectRepository::new(pool.clone()); let proj_slug = uniq("penv"); let (proj_id,): (Uuid,) = sqlx::query_as("INSERT INTO projects (slug, name) VALUES ($1, $1) RETURNING id") .bind(&proj_slug) .fetch_one(&pool) .await .expect("insert project"); for (env, confirm) in [("production", true), ("staging", false)] { sqlx::query( "INSERT INTO project_environments (project_id, env_name, confirm) VALUES ($1, $2, $3)", ) .bind(proj_id) .bind(env) .bind(confirm) .execute(&pool) .await .expect("insert env policy"); } let policy = projects .get_environments_by_slug(&proj_slug) .await .expect("get_environments_by_slug"); assert_eq!(policy.get("production"), Some(&true)); assert_eq!(policy.get("staging"), Some(&false)); assert_eq!(policy.len(), 2); // An unknown slug yields an empty policy (no gate). let empty = projects .get_environments_by_slug("no-such-project") .await .expect("get_environments_by_slug"); assert!(empty.is_empty()); // Audit 2026-07-11 H1: the gate now loads the policy by the SERVER-resolved // project id (from the node's ownership walk), not the client slug. The // by-id read must return the same persisted policy as the by-slug read. let by_id = projects .get_environments_by_id(proj_id.into()) .await .expect("get_environments_by_id"); assert_eq!(by_id.get("production"), Some(&true)); assert_eq!(by_id.get("staging"), Some(&false)); assert_eq!(by_id.len(), 2); }