//! Phase 5 project-tree apply, end to end via `pic plan/apply --dir`: //! * a group + a nested app under it apply atomically as one tree; the app //! binds a route to the group's inherited script (created in the SAME //! apply); re-plan is a no-op, //! * one invalid node aborts the whole tree (nothing written), //! * a `pic groups reparent` between plan and apply trips the bound-plan //! (StateMoved) check via the tree's structure version. use std::fs; use predicates::prelude::*; use tempfile::TempDir; use crate::common; use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard}; /// A project tree on disk: root group manifest + a nested app manifest that /// binds `GET /greet` to the group's (inherited) `shared` script. fn tree_dir(group: &str, app: &str, group_script_src: &str, app_extra: &str) -> TempDir { let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("scripts")).unwrap(); fs::create_dir_all(dir.path().join(app).join("scripts")).unwrap(); fs::write(dir.path().join("scripts/shared.rhai"), group_script_src).unwrap(); fs::write( dir.path().join("picloud.toml"), format!( "[group]\nslug = \"{group}\"\nname = \"Tree Group\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\ [vars]\nregion = \"eu\"\n" ), ) .unwrap(); fs::write( dir.path().join(app).join("picloud.toml"), format!( "[app]\nslug = \"{app}\"\nname = \"Tree App\"\n\n\ [[routes]]\nscript = \"shared\"\nmethod = \"GET\"\n\ host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n{app_extra}" ), ) .unwrap(); dir } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn tree_applies_group_and_app_atomically_then_noop() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("tr-grp"); let app = common::unique_slug("tr-app"); // Group + app must pre-exist (the manifest owns content, not tree shape). let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let _a = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &group]) .assert() .success(); let dir = tree_dir(&group, &app, "\"hi from the group\"", ""); // Plan the whole tree: a group script+var create and an app route create. let plan = String::from_utf8( common::pic_as(&env) .args(["plan", "--dir"]) .arg(dir.path()) .output() .unwrap() .stdout, ) .unwrap(); assert!( plan.contains("shared") && plan.contains("region") && plan.contains("/greet"), "tree plan should cover both nodes:\n{plan}" ); // Apply the whole tree in one transaction. common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .assert() .success(); // The group node created its `shared` script (it must drop before its // group at teardown — ON DELETE RESTRICT). let listed = String::from_utf8( common::pic_as(&env) .args(["scripts", "ls", "--group", &group]) .output() .unwrap() .stdout, ) .unwrap(); assert!( listed.contains("shared"), "the group node's script should exist after the tree apply:\n{listed}" ); let gscript_id = group_script_id(&env, &group); let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id); // Re-plan is a clean no-op (idempotent across both nodes — incl. the app's // route bound to the inherited group script). let replan = String::from_utf8( common::pic_as(&env) .args(["plan", "--dir"]) .arg(dir.path()) .output() .unwrap() .stdout, ) .unwrap(); assert!( !replan.contains("create") && !replan.contains("update"), "re-plan of the tree must be a no-op:\n{replan}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn tree_apply_is_atomic_on_an_invalid_node() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("tra-grp"); let app = common::unique_slug("tra-app"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let _a = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &group]) .assert() .success(); // The app node carries an INVALID Rhai script → the whole tree must abort. let dir = TempDir::new().unwrap(); fs::create_dir_all(dir.path().join("scripts")).unwrap(); fs::create_dir_all(dir.path().join(&app).join("scripts")).unwrap(); fs::write(dir.path().join("scripts/shared.rhai"), "\"ok\"").unwrap(); fs::write(dir.path().join(&app).join("scripts/bad.rhai"), "let x = ;").unwrap(); fs::write( dir.path().join("picloud.toml"), format!( "[group]\nslug = \"{group}\"\nname = \"G\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n" ), ) .unwrap(); fs::write( dir.path().join(&app).join("picloud.toml"), format!( "[app]\nslug = \"{app}\"\nname = \"A\"\n\n\ [[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n" ), ) .unwrap(); common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .assert() .failure(); // Atomic: the valid group node's `shared` must NOT have been created. let listed = String::from_utf8( common::pic_as(&env) .args(["scripts", "ls", "--group", &group]) .output() .unwrap() .stdout, ) .unwrap(); assert!( !listed.contains("shared"), "a failed tree apply must leave nothing behind:\n{listed}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn tree_reparent_between_plan_and_apply_trips_state_moved() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let parent = common::unique_slug("trp-par"); let group = common::unique_slug("trp-grp"); let app = common::unique_slug("trp-app"); // parent → group → app. let _p = GroupGuard::new(&env.url, &env.token, &parent); common::pic_as(&env) .args(["groups", "create", &parent]) .assert() .success(); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group, "--parent", &parent]) .assert() .success(); let _a = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &group]) .assert() .success(); let dir = tree_dir(&group, &app, "\"x\"", ""); // Record a bound plan for the whole tree. common::pic_as(&env) .args(["plan", "--dir"]) .arg(dir.path()) .assert() .success(); // Reparent the group to the instance root — bumps its structure version. common::pic_as(&env) .args(["groups", "reparent", &group]) .assert() .success(); // Apply the recorded plan: the structure moved underneath it → refuse. // (The apply aborts before any write, so no group script is created and the // GroupGuards tear down cleanly.) common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .assert() .failure() .stderr( predicate::str::contains("changed") .or(predicate::str::contains("plan")) .or(predicate::str::contains("409")), ); } 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") }