fix(apply): preserve project name on a name-less re-apply

`upsert_project_tx` defaulted a missing `[project].name` to the slug and then
`ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name` wrote that fabricated
value unconditionally — so a re-apply from a clone whose manifest omits `name`
silently clobbered the stored display name back to the slug (visible in
`pic projects ls` / `pic groups ls`).

Bind the raw optional name once and guard both sides on it:
`VALUES ($1, COALESCE($2, $1), $3)` (first apply falls back to the slug for the
NOT NULL column) and `DO UPDATE SET name = COALESCE($2, projects.name)` (a
re-apply updates the name only when the manifest actually declares one). An
omitted optional field now preserves persisted data instead of mutating it.

Pinned by a new `reapply_without_name_preserves_project_name` journey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 07:37:25 +02:00
parent f673922d89
commit 74f7a67be7
2 changed files with 77 additions and 6 deletions

View File

@@ -215,6 +215,73 @@ fn claim_conflict_takeover_and_app_inheritance() {
);
}
/// The `name` cell (index 1: slug, NAME, owned_groups, created_at) for `project`
/// in `pic projects ls`.
fn project_name_cell(env: &common::TestEnv, project: &str) -> String {
let ls = common::pic_as(env)
.args(["projects", "ls"])
.output()
.expect("projects ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&project))
.and_then(|c| c.get(1).map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("project `{project}` not in projects ls:\n{table}"))
}
/// A name-less `[project]` re-apply must PRESERVE the display name set on the
/// first apply — an omitted optional field never clobbers the stored name back
/// to the slug (regression for the `ON CONFLICT DO UPDATE SET name` path).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn reapply_without_name_preserves_project_name() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("name-grp");
let proj = common::unique_slug("name-proj");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
let apply = |m: &str| {
fs::write(dir.path().join("picloud.toml"), m).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
};
// First apply declares a display name.
apply(&format!(
"[project]\nslug = \"{proj}\"\nname = \"Acme Platform\"\n\n\
[group]\nslug = \"{group}\"\nname = \"Grp\"\n"
));
assert_eq!(
project_name_cell(&env, &proj),
"Acme Platform",
"the first apply records the declared name"
);
// A re-apply that OMITS `name` must not overwrite it with the slug.
apply(&format!(
"[project]\nslug = \"{proj}\"\n\n\
[group]\nslug = \"{group}\"\nname = \"Grp\"\n"
));
assert_eq!(
project_name_cell(&env, &proj),
"Acme Platform",
"a name-less re-apply must preserve the stored name, not clobber it to the slug"
);
}
/// §6/§7 M2 — `[project] parent_group` is the ceiling: applies are refused for
/// any node not strictly within the attach point's subtree.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]