//! 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" ); } /// M6 (§4.5): a `{var:NAME}` placeholder resolves against a var set in the SAME /// apply (in-transaction), so a var and a template referencing it converge in /// one `pic apply`. Before M6 the var (written earlier in the tx) was invisible /// to expansion — the route would only pick it up on a *second* apply. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn route_template_var_resolves_in_same_apply() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("tplv-g"); let a = common::unique_slug("tplv-a"); 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); common::pic_as(&env) .args(["apps", "create", &a, "--group", &group]) .assert() .success(); // The group sets `region` AND declares a template referencing it — one // manifest, one apply. 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""#).unwrap(); fs::write( dir.path().join("picloud.toml"), format!( "[group]\nslug = \"{group}\"\nname = \"G\"\n\n\ [vars]\nregion = \"eu\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\ [[route_templates]]\nname = \"geo\"\nscript = \"shared\"\n\ host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/tv/{{app_slug}}/{{var:region}}\"\n" ), ) .unwrap(); fs::create_dir_all(dir.path().join(&a)).unwrap(); fs::write( dir.path().join(&a).join("picloud.toml"), format!("[app]\nslug = \"{a}\"\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)); // The expanded route resolved `{var:region}` → `eu` in THIS apply. let url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let mut pg = PgClient::connect(&url, NoTls).expect("pg connect"); let want = format!("/tv/{a}/eu"); let rows = pg .query( "SELECT path FROM routes WHERE from_template IS NOT NULL AND path = $1", &[&want], ) .expect("query"); assert_eq!( rows.len(), 1, "`{{var:region}}` must resolve to `eu` in the same apply (expected path {want})" ); } /// Template-expanded routes (`from_template IS NOT NULL`) whose path is one of /// `paths`, as `path` strings — for asserting which descendant apps received an /// expansion regardless of which apply declared them. fn expansion_paths(paths: &[String]) -> Vec { let url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let mut pg = PgClient::connect(&url, NoTls).expect("pg connect"); let rows = pg .query( "SELECT path FROM routes \ WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path", &[&paths], ) .expect("query expansion paths"); rows.iter().map(|r| r.get(0)).collect() } /// `(path, id)` of template-expanded routes among `paths` — for asserting an /// expansion is STABLE (same row id) across a re-apply, i.e. not churned. fn expansion_rows_for(paths: &[String]) -> Vec<(String, String)> { let url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let mut pg = PgClient::connect(&url, NoTls).expect("pg connect"); let rows = pg .query( "SELECT path, id::text FROM routes \ WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path", &[&paths], ) .expect("query expansion rows"); rows.iter().map(|r| (r.get(0), r.get(1))).collect() } /// M7 (§4.5): a route template fans out to EVERY descendant app in the DB /// subtree, not only the app nodes present in the apply. An app created under /// the group out-of-band (absent from the manifest) still receives the /// expansion on the next apply, and removing the template reaps it there too. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn route_template_reaches_out_of_tree_descendant() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("xrepo-g"); let sg = common::unique_slug("xrepo-sg"); // subgroup (depth-2) let a = common::unique_slug("xrepo-a"); let c = common::unique_slug("xrepo-c"); // out-of-tree descendant (depth-1) let d = common::unique_slug("xrepo-d"); // out-of-tree descendant (depth-2) 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); common::pic_as(&env) .args(["apps", "create", &a, "--group", &group]) .assert() .success(); // Apply a tree of just (group ← app a) with an `/x/{app_slug}` template. 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""#).unwrap(); let group_toml = |with_template: bool| { let tmpl = if with_template { "\n[[route_templates]]\nname = \"x\"\nscript = \"shared\"\n\ host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x/{app_slug}\"\n" } else { "" }; format!( "[group]\nslug = \"{group}\"\nname = \"G\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n{tmpl}" ) }; fs::write(dir.path().join("picloud.toml"), group_toml(true)).unwrap(); fs::create_dir_all(dir.path().join(&a)).unwrap(); fs::write( dir.path().join(&a).join("picloud.toml"), format!("[app]\nslug = \"{a}\"\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)); assert_eq!( expansion_paths(&[format!("/x/{a}")]).len(), 1, "in-tree app a gets its expansion" ); // Create app c (direct child) and a nested subgroup ← app d (depth-2), // all OUTSIDE the manifest tree, to exercise the recursive descendant CTE. let _ac = AppGuard::new(&env.url, &env.token, &c); common::pic_as(&env) .args(["apps", "create", &c, "--group", &group]) .assert() .success(); let _sg = GroupGuard::new(&env.url, &env.token, &sg); common::pic_as(&env) .args(["groups", "create", &sg, "--parent", &group]) .assert() .success(); let _ad = AppGuard::new(&env.url, &env.token, &d); common::pic_as(&env) .args(["apps", "create", &d, "--group", &sg]) .assert() .success(); // Re-apply the same tree (still only group + a). M7: c (depth-1) AND d // (depth-2, under the subgroup) both receive the expansion. `--force` // waives the bound token (the out-of-band creates bumped structure version). common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .arg("--force") .assert() .success(); let want = [format!("/x/{c}"), format!("/x/{d}")]; assert_eq!( expansion_paths(&want), want.to_vec(), "out-of-tree descendants at depth 1 AND 2 receive expansions" ); // Idempotent re-apply: descendants must not churn (same row ids — provenance // by `from_template`, not delete+recreate), same as the in-tree path. let before = expansion_rows_for(&want); common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .arg("--force") .assert() .success(); assert_eq!( before, expansion_rows_for(&want), "re-apply must not churn descendant expansions (stable ids)" ); // Remove the template + prune: every descendant's expansion is reaped, // in-tree or not, at any depth. fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap(); common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .args(["--prune", "--yes", "--force"]) .assert() .success(); assert!( expansion_paths(&[format!("/x/{a}"), format!("/x/{c}"), format!("/x/{d}")]).is_empty(), "removing the template reaps expansions on ALL descendants, any depth" ); } #[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") }