use assert_cmd::Command;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"
[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod", "sql"]
[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"
[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
[db]
direction = "contract"
provider = "supabase"
rls_tests = "required"
"#;
fn repo() -> Option<tempfile::TempDir> {
let dir = tempfile::tempdir().ok()?;
fs::write(dir.path().join("pushkin.toml"), MANIFEST).ok()?;
fs::create_dir_all(dir.path().join("generated")).ok()?;
fs::write(
dir.path().join("generated/user.gen.sql"),
"CREATE TABLE users (id uuid PRIMARY KEY, name text NOT NULL);\n",
)
.ok()?;
Some(dir)
}
fn fake_tool(bin: &Path, name: &str, stdout: &str, code: i32) -> Option<()> {
let argv_log = bin.join(format!("{name}.argv"));
let script = format!(
"#!/bin/sh\necho \"$@\" > '{}'\ncat <<'PUSHKIN_EOF'\n{stdout}\nPUSHKIN_EOF\nexit {code}\n",
argv_log.display()
);
let path = bin.join(name);
fs::write(&path, script).ok()?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).ok()?;
Some(())
}
fn run_db(dir: &Path, bin: &Path, args: &[&str]) -> Option<(String, String, i32)> {
let mut all = vec!["db"];
all.extend_from_slice(args);
let output = Command::cargo_bin("pushkin")
.ok()?
.args(all)
.current_dir(dir)
.env("PATH", format!("{}:/usr/bin:/bin", bin.display()))
.env("DATABASE_URL", "postgres://localhost:5432/app")
.output()
.ok()?;
Some((
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code().unwrap_or(-1),
))
}
#[test]
fn db_section_parses_and_check_still_works() {
let dir = repo().unwrap();
let output = Command::cargo_bin("pushkin")
.unwrap()
.arg("check")
.current_dir(dir.path())
.output()
.unwrap();
assert!(
output.status.success(),
"a manifest with [db] must parse and check cleanly: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn drift_without_atlas_fails_loud_with_install_hint() {
let dir = repo().unwrap();
let empty_bin = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_db(dir.path(), empty_bin.path(), &["drift"]).unwrap();
assert_ne!(code, 0, "missing atlas must fail, never silently skip");
assert!(
stderr.contains("atlas") && stderr.contains("brew install ariga/tap/atlas"),
"failure must name the tool and the install command: {stderr}"
);
}
#[test]
fn contract_breaking_migration_fails_atlas_diff() {
let dir = repo().unwrap();
let bin = tempfile::tempdir().unwrap();
fake_tool(
bin.path(),
"atlas",
"-- Planned Changes:\nALTER TABLE users DROP COLUMN name;",
0,
)
.unwrap();
let (stdout, _, code) = run_db(dir.path(), bin.path(), &["drift"]).unwrap();
assert_eq!(code, 2, "schema drift must block (exit 2): {stdout}");
assert!(
stdout.contains("DROP COLUMN"),
"the drift verdict must carry the planned DDL: {stdout}"
);
}
#[test]
fn synced_schema_passes_atlas_diff() {
let dir = repo().unwrap();
let bin = tempfile::tempdir().unwrap();
fake_tool(
bin.path(),
"atlas",
"Schemas are synced, no changes to be made.",
0,
)
.unwrap();
let (stdout, _, code) = run_db(dir.path(), bin.path(), &["drift"]).unwrap();
assert_eq!(code, 0, "synced schemas must pass: {stdout}");
}
#[test]
fn drift_direction_contract_diffs_from_generated_sql() {
let dir = repo().unwrap();
let bin = tempfile::tempdir().unwrap();
fake_tool(bin.path(), "atlas", "Schemas are synced.", 0).unwrap();
run_db(dir.path(), bin.path(), &["drift"]).unwrap();
let argv = fs::read_to_string(bin.path().join("atlas.argv")).unwrap();
assert!(
argv.contains("schema diff"),
"drift must call atlas schema diff: {argv}"
);
assert!(
argv.contains("--to") && argv.contains("generated/user.gen.sql"),
"contract direction: generated SQL must be the desired (--to) side: {argv}"
);
assert!(
argv.contains("--from") && argv.contains("postgres://"),
"contract direction: the live DB must be the current (--from) side: {argv}"
);
}
#[test]
fn rls_regression_fails_pgtap_check() {
let dir = repo().unwrap();
let bin = tempfile::tempdir().unwrap();
fake_tool(
bin.path(),
"supabase",
"failed tests: policy_users_select_own\nFAILED (failures=1)",
1,
)
.unwrap();
let (stdout, _, code) = run_db(dir.path(), bin.path(), &["rls"]).unwrap();
assert_eq!(code, 2, "an RLS regression must block (exit 2): {stdout}");
assert!(
stdout.contains("policy_users_select_own"),
"the verdict must carry the failing pgTAP output: {stdout}"
);
}
#[test]
fn passing_pgtap_suite_exits_zero() {
let dir = repo().unwrap();
let bin = tempfile::tempdir().unwrap();
fake_tool(bin.path(), "supabase", "All tests passed", 0).unwrap();
let (_, _, code) = run_db(dir.path(), bin.path(), &["rls"]).unwrap();
assert_eq!(code, 0, "a green pgTAP suite must pass");
}
#[test]
fn rls_without_supabase_fails_loud_with_install_hint() {
let dir = repo().unwrap();
let empty_bin = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_db(dir.path(), empty_bin.path(), &["rls"]).unwrap();
assert_ne!(code, 0, "missing supabase CLI must fail, never skip");
assert!(
stderr.contains("supabase") && stderr.contains("brew install supabase/tap/supabase"),
"failure must name the tool and the install command: {stderr}"
);
}
#[test]
fn unknown_db_key_rejected_with_candidates() {
let dir = tempfile::tempdir().unwrap();
let bad = MANIFEST.replace("direction = \"contract\"", "dierction = \"contract\"");
fs::write(dir.path().join("pushkin.toml"), bad).unwrap();
let output = Command::cargo_bin("pushkin")
.unwrap()
.arg("check")
.current_dir(dir.path())
.output()
.unwrap();
assert!(!output.status.success(), "typo'd [db] key must be fatal");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("direction"),
"the error must suggest the intended key: {stderr}"
);
}