Route/trigger templates declared at a group now fan out to EVERY descendant
app in the DB subtree, not just the app nodes present in the current
`pic apply --dir`. A template change at group N reaches subtree(N) across
repos, and a removed/disabled template reaps its expansions on those
descendants too.
- group_repo: recursive `descendant_app_ids` (+ `_tx`) — inverse of the
ancestor chain CTE, app_id-ordered, depth-bounded.
- apply_service: Phase B2 expands templates into descendants of every in-tree
group not already handled in Phase B. All descendant locks are taken up
front in the single sorted batch (their union is invariant under reparenting
in-tree groups), so there is no out-of-order mid-tx locking / deadlock.
Per-recipient write caps (AppWriteRoute / AppManageTriggers /
AppSecretsRead-for-email) are required AUTHORITATIVELY IN-TX against the
post-reparent, already-locked app — checked set == written set, no TOCTOU.
expand_*_templates_tx are now self-contained (load hand-declared identities
from the tx), so collisions are caught on descendants too. Blast radius now
counts the true DB subtree. Descendant expansion errors are tagged with the
app slug (e.g. an {env} template on a NULL-environment descendant names it).
- apply_api: the pre-tx descendant authz pass is removed in favour of the
in-tx gate (sound via the hierarchy-aware effective_app_role).
- templates journey: out-of-tree descendant at depth 1 AND 2 receives the
expansion and is reaped on template removal.
- doc §4.5: strike the in-apply-only scope limitation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
532 lines
20 KiB
Rust
532 lines
20 KiB
Rust
//! 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/<a>` and `/t/<b>` 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<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 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()
|
|
}
|
|
|
|
/// 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();
|
|
assert_eq!(
|
|
expansion_paths(&[format!("/x/{c}"), format!("/x/{d}")]),
|
|
vec![format!("/x/{c}"), format!("/x/{d}")],
|
|
"out-of-tree descendants at depth 1 AND 2 receive expansions"
|
|
);
|
|
|
|
// 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/<a>` 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<_>>(),
|
|
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")
|
|
}
|