Files
MechaCat02 35345e62de feat(cli): pic pull --group round-trips a group node
Exports a group as a [group] manifest: its scripts, [[routes]]/[[triggers.*]]
TEMPLATES (with the group-only sealed/shared flags), a shared collections
entry, [vars]/[secrets], extension_points, and [suppress]. Groups own no
workflows.

The group trigger/route reports were display-only (no ops, dispatch, retry,
host_kind, cron tz, queue timeout) so a pulled manifest could not re-plan
clean. Enriched TriggerTemplateInfo with the full internally-tagged
TriggerDetails JSON + dispatch_mode + retry_max_attempts, and RouteTemplateInfo
with raw manifest-shaped fields (method/host_kind/host/host_param_name/
path_kind/dispatch_mode); pic routes ls --group now applies the ANY/host munge
client-side, consistent with the app pic routes ls. pull.rs factors the
script-writing + trigger-decoding it shares with the app path.

Pinned by a pull journey: a group with a sealed+shared kv trigger, a sealed
route, a shared collection, and a var round-trips and the exported manifest
re-applies clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:19:59 +02:00

220 lines
7.5 KiB
Rust

//! `pic pull` journey: stand up an app with a script, route, cron trigger,
//! and a secret, then export it and assert the manifest + script file.
//!
//! B3: a second case exports a GROUP node (`pic pull --group`) — its scripts,
//! a `sealed`+`shared` `[[triggers.kv]]` template, a `[[routes]]` template, a
//! shared `collections` entry, and a `[vars]` value — and asserts the exported
//! manifest re-applies clean (the round-trip carries the group-only flags).
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{GroupGuard, ScriptGuard};
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn pull_exports_manifest_and_sources() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// App + script "hello" (deploy derives the name from the file stem).
let (script_id, guard) = common::deploy_fixture(&env, "pull", "hello.rhai");
let app = guard.slug().to_string();
// Route → script.
common::pic_as(&env)
.args([
"routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST",
])
.assert()
.success();
// Cron trigger → script.
common::pic_as(&env)
.args([
"triggers",
"create-cron",
"--app",
&app,
"--script",
&script_id,
"--schedule",
"0 0 * * * *",
])
.assert()
.success();
// Secret (name only ends up in the manifest; value stays server-side).
common::pic_as(&env)
.args(["secrets", "set", "--app", &app, "api_key"])
.write_stdin("xyzzy")
.assert()
.success();
// Pull into a scratch dir.
let out_dir = TempDir::new().expect("pull tempdir");
common::pic_as(&env)
.args(["pull", &app, "--dir"])
.arg(out_dir.path())
.assert()
.success();
// Manifest exists and captures every resource.
let manifest = std::fs::read_to_string(out_dir.path().join("picloud.toml"))
.expect("picloud.toml should be written");
assert!(
manifest.contains(&format!("slug = \"{app}\"")),
"manifest missing app slug:\n{manifest}"
);
assert!(
manifest.contains("name = \"hello\"") && manifest.contains("scripts/hello.rhai"),
"manifest missing script entry:\n{manifest}"
);
assert!(
manifest.contains("[[routes]]") && manifest.contains("path = \"/hook\""),
"manifest missing route:\n{manifest}"
);
assert!(
manifest.contains("[[triggers.cron]]") && manifest.contains("schedule = \"0 0 * * * *\""),
"manifest missing cron trigger:\n{manifest}"
);
assert!(
manifest.contains("api_key"),
"manifest missing secret name:\n{manifest}"
);
// Script source was written out faithfully.
let src = std::fs::read_to_string(out_dir.path().join("scripts/hello.rhai"))
.expect("scripts/hello.rhai should be written");
assert!(
src.contains("hello from pic"),
"exported source mismatch:\n{src}"
);
drop(guard);
}
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--group", group])
.output()
.expect("scripts ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&name))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("group script `{name}` not found:\n{table}"))
}
/// B3: `pic pull --group` round-trips a group node, including the group-only
/// `sealed`/`shared` template flags and a shared `collections` entry.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn pull_group_round_trips_templates_and_collections() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("pullg");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
// A group-owned handler the templates bind.
let dir = TempDir::new().expect("group pull tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/gh.rhai"),
r#"log::info("group handler"); "ok""#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/gh.rhai"))
.args(["--group", &group, "--name", "gh"])
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group, "gh"));
// The group declares a shared collection, a sealed+shared kv trigger
// template, a sealed route template, and a var.
let gmanifest = format!(
"[group]\nslug = \"{group}\"\nname = \"PullG\"\n\n\
collections = [\"catalog\"]\n\n\
[vars]\ntier = \"gold\"\n\n\
[[routes]]\nscript = \"gh\"\npath = \"/gpull\"\npath_kind = \"exact\"\n\
host_kind = \"any\"\nsealed = true\n\n\
[[triggers.kv]]\nscript = \"gh\"\ncollection_glob = \"catalog\"\nops = [\"insert\"]\n\
sealed = true\nshared = true\n"
);
let gpath = dir.path().join("picloud.toml");
fs::write(&gpath, &gmanifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
// Pull the group into a FRESH dir.
let out_dir = TempDir::new().expect("pull out dir");
common::pic_as(&env)
.args(["pull", "--group", &group, "--dir"])
.arg(out_dir.path())
.assert()
.success();
let manifest =
std::fs::read_to_string(out_dir.path().join("picloud.toml")).expect("picloud.toml written");
assert!(
manifest.contains("[group]") && manifest.contains(&format!("slug = \"{group}\"")),
"manifest must be a [group] node:\n{manifest}"
);
assert!(
manifest.contains("catalog"),
"shared collection must round-trip:\n{manifest}"
);
assert!(
manifest.contains("tier = \"gold\"") || manifest.contains("tier = 'gold'"),
"group var must round-trip:\n{manifest}"
);
assert!(
manifest.contains("[[routes]]") && manifest.contains("/gpull"),
"route template must round-trip:\n{manifest}"
);
assert!(
manifest.contains("[[triggers.kv]]") && manifest.contains("collection_glob = \"catalog\""),
"kv trigger template must round-trip:\n{manifest}"
);
// The group-only flags survive the round-trip (the whole point of B3).
assert!(
manifest.matches("sealed = true").count() >= 2,
"both the route and trigger `sealed = true` must round-trip:\n{manifest}"
);
assert!(
manifest.contains("shared = true"),
"the trigger `shared = true` must round-trip:\n{manifest}"
);
// The exported script source landed too.
let src = std::fs::read_to_string(out_dir.path().join("scripts/gh.rhai"))
.expect("scripts/gh.rhai written");
assert!(src.contains("group handler"), "source mismatch:\n{src}");
// The strong round-trip proof: re-applying the PULLED manifest is accepted
// (a faithful export re-plans clean; the server would reject a malformed one).
common::pic_as(&env)
.args(["apply", "--file"])
.arg(out_dir.path().join("picloud.toml"))
.assert()
.success();
}