//! M4a route templates (§4.5) via `pic apply --dir`: //! * a group declares ONE route template (`/t/{app_slug}`) bound to its //! inherited script; the apply fans it out into a concrete route on every //! descendant app, with `{app_slug}` resolved per app, //! * re-apply is idempotent (no duplicate expansions — provenance), //! * removing the template + `--prune` reaps the expansions, //! * an expansion colliding with a hand-declared route is a hard error, //! * `pic plan --dir` reports the blast radius. use std::fs; use postgres::{Client as PgClient, NoTls}; use tempfile::TempDir; use crate::common; use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard}; /// Template-expanded routes (`from_template IS NOT NULL`) for the two app slugs /// under test, as `(path, id)` — read straight from Postgres since the route /// admin endpoint only lists app-owned-script routes (a group script isn't /// addressable there). Scoped to `/t/` and `/t/` so parallel tests don't /// interfere. fn expansion_rows(a: &str, b: &str) -> Vec<(String, String)> { let url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let mut pg = PgClient::connect(&url, NoTls).expect("pg connect"); let want = [format!("/t/{a}"), format!("/t/{b}")]; let rows = pg .query( "SELECT path, id::text FROM routes \ WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path", &[&&want[..]], ) .expect("query expansions"); rows.iter().map(|r| (r.get(0), r.get(1))).collect() } /// A tree: a root group declaring a `shared` endpoint + a `greet` route template /// `/t/{app_slug}`, and `extra_app_toml` appended to app `a`'s manifest. Apps /// must pre-exist under the group (created in the test before applying). fn tree_dir(group: &str, a: &str, b: &str, template: &str, extra_app_a: &str) -> TempDir { let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("scripts")).unwrap(); fs::write( dir.path().join("scripts/shared.rhai"), r#""hi from template""#, ) .unwrap(); fs::write( dir.path().join("picloud.toml"), format!( "[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{template}" ), ) .unwrap(); for (slug, extra) in [(a, extra_app_a), (b, "")] { fs::create_dir_all(dir.path().join(slug)).unwrap(); fs::write( dir.path().join(slug).join("picloud.toml"), format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n{extra}"), ) .unwrap(); } dir } const TEMPLATE: &str = "[[route_templates]]\nname = \"greet\"\nscript = \"shared\"\n\ host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/t/{app_slug}\"\n"; #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn route_template_fans_out_per_app_idempotently_then_prunes() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("tpl-g"); let a = common::unique_slug("tpl-a"); let b = common::unique_slug("tpl-b"); // group ← (app a, app b). Guards drop apps before the group (RESTRICT). let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let _aa = AppGuard::new(&env.url, &env.token, &a); let _ab = AppGuard::new(&env.url, &env.token, &b); for slug in [&a, &b] { common::pic_as(&env) .args(["apps", "create", slug, "--group", &group]) .assert() .success(); } // --- Apply: the template fans out to both apps. --- let dir = tree_dir(&group, &a, &b, TEMPLATE, ""); // Plan reports the blast radius (both descendant apps). let plan = common::pic_as(&env) .args(["plan", "--dir"]) .arg(dir.path()) .output() .expect("plan --dir"); let plan_err = String::from_utf8_lossy(&plan.stderr); assert!( plan_err.contains("blast radius") && plan_err.contains("2 app(s)"), "plan should report the 2-app blast radius:\n{plan_err}" ); // --- Apply: the template fans out to both apps, one route each. --- common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .assert() .success(); // The group script must drop before its group at teardown (RESTRICT). let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group)); let first = expansion_rows(&a, &b); let paths: Vec<&str> = first.iter().map(|(p, _)| p.as_str()).collect(); assert_eq!( paths, vec![format!("/t/{a}").as_str(), format!("/t/{b}").as_str()], "each app gets its own `{{app_slug}}`-resolved route" ); // --- Idempotent re-apply: same rows, same ids (no churn — provenance). --- common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .assert() .success(); let second = expansion_rows(&a, &b); assert_eq!( first, second, "re-apply must not churn expansions (stable ids)" ); // --- Remove the template + --prune: expansions are reaped. Rewrite the // SAME repo's group manifest (a fresh dir would be a different project and // conflict on the owned group, §7). --- fs::write( dir.path().join("picloud.toml"), format!( "[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n" ), ) .unwrap(); common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .args(["--prune", "--yes"]) .assert() .success(); assert!( expansion_rows(&a, &b).is_empty(), "removing the template must reap its expansions" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn expansion_colliding_with_hand_declared_route_is_rejected() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("tplc-g"); let a = common::unique_slug("tplc-a"); let b = common::unique_slug("tplc-b"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let _aa = AppGuard::new(&env.url, &env.token, &a); let _ab = AppGuard::new(&env.url, &env.token, &b); for slug in [&a, &b] { common::pic_as(&env) .args(["apps", "create", slug, "--group", &group]) .assert() .success(); } // App `a` hand-declares the EXACT route the template would expand to (the // template path resolves to `/t/` for app a). That collision is a hard // error — neither side silently wins. let collide = format!( "\n[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\n\ path_kind = \"exact\"\npath = \"/t/{a}\"\n" ); let dir = tree_dir(&group, &a, &b, TEMPLATE, &collide); let out = common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .output() .expect("apply --dir"); assert!( !out.status.success(), "an expansion colliding with a hand-declared route must be rejected" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("template") && (err.contains("hand") || err.contains("declare")), "error should name the template/hand-declared collision:\n{err}" ); } /// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`, /// read straight from Postgres (a group-script trigger isn't listable via the /// app trigger API). Scoped to the test's two slugs. fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> { let url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let mut pg = PgClient::connect(&url, NoTls).expect("pg connect"); let want = [format!("{a}-data"), format!("{b}-data")]; let rows = pg .query( "SELECT d.collection_glob, t.id::text FROM triggers t \ JOIN kv_trigger_details d ON d.trigger_id = t.id \ WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \ ORDER BY d.collection_glob", &[&&want[..]], ) .expect("query trigger expansions"); rows.iter().map(|r| (r.get(0), r.get(1))).collect() } const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\ script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\ ops = [\"insert\"]\n"; #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn trigger_template_fans_out_per_app_idempotently_then_prunes() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("ttpl-g"); let a = common::unique_slug("ttpl-a"); let b = common::unique_slug("ttpl-b"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let _aa = AppGuard::new(&env.url, &env.token, &a); let _ab = AppGuard::new(&env.url, &env.token, &b); for slug in [&a, &b] { common::pic_as(&env) .args(["apps", "create", slug, "--group", &group]) .assert() .success(); } // group manifest: a `shared` script + a kv trigger template using {app_slug}. let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("scripts")).unwrap(); fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap(); let write_group = |with_tmpl: bool| { let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" }; fs::write( dir.path().join("picloud.toml"), format!( "[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}" ), ) .unwrap(); }; write_group(true); for slug in [&a, &b] { fs::create_dir_all(dir.path().join(slug)).unwrap(); fs::write( dir.path().join(slug).join("picloud.toml"), format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"), ) .unwrap(); } common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .assert() .success(); let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group)); let first = trigger_rows(&a, &b); assert_eq!( first.iter().map(|(g, _)| g.as_str()).collect::>(), vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()], "each app gets its own {{app_slug}}-resolved kv trigger" ); // Idempotent re-apply: same rows, same ids. common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .assert() .success(); assert_eq!( trigger_rows(&a, &b), first, "re-apply must not churn triggers" ); // Remove the template + --prune: trigger expansions reaped. write_group(false); common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .args(["--prune", "--yes"]) .assert() .success(); assert!( trigger_rows(&a, &b).is_empty(), "removing the trigger template must reap its expansions" ); } fn group_script_id(env: &common::TestEnv, group: &str) -> String { let ls = common::pic_as(env) .args(["scripts", "ls", "--group", group]) .output() .expect("scripts ls --group"); common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap()) .expect("group should have one script") }