use jerrycan::platform::design::Design;
use jerrycan::platform::scaffold;
use std::fs;
const GOLDEN: &str = include_str!("../../../conformance/designs/todo-api.design.json");
fn db_design() -> Design {
let mut v: serde_json::Value = serde_json::from_str(GOLDEN).unwrap();
v["dependencies"] = serde_json::json!(["db", "validate"]);
serde_json::from_value(v).unwrap()
}
fn scaffold_db() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("todo-api");
scaffold::scaffold(&root, &db_design()).unwrap();
(tmp, root)
}
#[test]
fn db_mode_emits_sql_repos_with_di_factories() {
let (_t, root) = scaffold_db();
let repo = fs::read_to_string(root.join("crates/routes/todos/src/repo.rs")).unwrap();
assert!(
repo.contains("pub(crate) async fn todo_repo(db: Dep<Db>)"),
"{repo}"
);
assert!(
repo.contains("use jerrycan::db::sea_orm;"),
"facade alias resolves bare sea_orm:: paths: {repo}"
);
assert!(
repo.contains("todo::Entity::find()") && repo.contains(".all(self.db.conn())"),
"reads go through SeaORM entity finders: {repo}"
);
assert!(
!repo.contains("SELECT ") && !repo.contains("self.db.sql("),
"no raw SQL strings in generated repos: {repo}"
);
assert!(
!repo.contains("build_any_sqlx")
&& !repo.contains("self.db.pool()")
&& !repo.contains("sea_query"),
"repos are SeaORM now, not sea-query/sqlx: {repo}"
);
assert!(
repo.contains("id: sea_orm::ActiveValue::NotSet,"),
"synthetic pk is DB-assigned on insert: {repo}"
);
assert!(
!repo.contains(".last_insert_id()"),
"sqlite must not rely on last_insert_id: {repo}"
);
assert!(
repo.contains("pub async fn update(&self, id: i64, item: Todo)"),
"PUT/PATCH handlers need a persisting update: {repo}"
);
assert!(
repo.contains("title: Set(item.title),") && repo.contains("done: Set(item.done),"),
"update sets every non-pk field via the ActiveModel: {repo}"
);
assert!(repo.contains("map_err(db_error)"), "{repo}");
let lib = fs::read_to_string(root.join("crates/routes/todos/src/lib.rs")).unwrap();
assert!(lib.contains(".provide_dep(repo::todo_repo)"), "{lib}");
assert!(
!lib.contains("TodoRepo::new()"),
"no in-memory provide in db mode: {lib}"
);
}
#[test]
fn db_mode_emits_dual_dialect_migrations_from_entities() {
let (_t, root) = scaffold_db();
let sqlite = fs::read_to_string(
root.join("crates/routes/todos/migrations/sqlite/0001_create_tables.sql"),
)
.unwrap();
let postgres = fs::read_to_string(
root.join("crates/routes/todos/migrations/postgres/0001_create_tables.sql"),
)
.unwrap();
assert!(
sqlite.contains("CREATE TABLE \"todos\"") && sqlite.contains("PRIMARY KEY AUTOINCREMENT"),
"{sqlite}"
);
assert!(sqlite.to_lowercase().contains("\"title\" text not null"));
assert!(
sqlite.to_lowercase().contains("\"done\" boolean")
&& !sqlite.to_lowercase().contains("\"done\" boolean not null"),
"optional bool field is a nullable native boolean: {sqlite}"
);
assert!(postgres.to_lowercase().contains("bigserial"), "{postgres}");
assert!(
postgres.to_lowercase().contains("\"done\" bool")
&& !postgres.to_lowercase().contains("\"done\" bool not null"),
"optional bool field is a nullable native boolean: {postgres}"
);
assert!(root.join("crates/routes/todos/migrations/sqlite").exists());
let users = fs::read_to_string(
root.join("crates/routes/users/migrations/sqlite/0001_create_tables.sql"),
)
.unwrap();
assert!(users.contains("CREATE TABLE \"users\""));
}
#[test]
fn db_mode_wires_main_and_aggregated_migrations() {
let (_t, root) = scaffold_db();
let main_rs = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
assert!(
main_rs.contains("jerrycan::db::Db::from_env().await?"),
"{main_rs}"
);
assert!(
main_rs.contains("db.migrate(migrations::MIGRATIONS).await?"),
"{main_rs}"
);
assert!(main_rs.contains(".extend(db)"), "{main_rs}");
assert!(
main_rs.contains("OpenApi::new(include_str!"),
"validate mode mounts the doc: {main_rs}"
);
let agg = fs::read_to_string(root.join("crates/app/src/migrations.rs")).unwrap();
assert!(agg.contains("pub const MIGRATIONS"), "{agg}");
assert!(
agg.contains("routes/todos/migrations/sqlite/0001_create_tables.sql"),
"{agg}"
);
let ws = fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert!(ws.contains("features = [\"db\", \"validate\"]"), "{ws}");
assert!(root.join("openapi.json").exists());
}
fn rustfmt(root: &std::path::Path, src: &str) -> String {
use std::io::Write as _;
let mut child = std::process::Command::new("rustfmt")
.args(["--edition", "2024", "--emit", "stdout"])
.current_dir(root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("rustfmt must be runnable (pinned toolchain component)");
child
.stdin
.take()
.unwrap()
.write_all(src.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"rustfmt failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
}
const LONG_MOUNTS_AND_CORS: &str = r#"{
"name": "invites-app", "contract_version": 2,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"cors": {
"origins": ["https://app.example", "https://admin.example"],
"methods": ["GET", "POST", "PUT", "DELETE"],
"headers": ["content-type", "authorization"],
"allow_credentials": true
},
"storage": { "buckets": [
{ "name": "organization-documents", "visibility": "public", "max_size": "5MB" }
]},
"modules": [
{ "name": "organization-invitations",
"entities": [{ "name": "Invitation", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "email", "type": "string" } ]}],
"endpoints": [{ "operation_id": "list_invitations", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Invitation", "list": true } }] }
]
}"#;
#[test]
fn tool_owned_main_and_migrations_are_rustfmt_fixpoints() {
for design_src in [
include_str!("../../../conformance/designs/reference-slice.design.json"),
include_str!("../../../conformance/designs/limits-api.design.json"),
GOLDEN,
LONG_MOUNTS_AND_CORS,
] {
let design: Design = serde_json::from_str(design_src).unwrap();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
scaffold::scaffold(&root, &design).unwrap();
for rel in ["crates/app/src/main.rs", "crates/app/src/migrations.rs"] {
let path = root.join(rel);
if !path.exists() {
continue;
}
let emitted = fs::read_to_string(&path).unwrap();
let formatted = rustfmt(&root, &emitted);
assert_eq!(
emitted, formatted,
"{} must be a rustfmt fixpoint for design `{}` — otherwise an \
agent's `cargo fmt` rewrites it and JL0003 fires on a file the \
agent never touched",
rel, design.name
);
}
}
}
#[test]
fn sql_identifiers_are_quoted_so_reserved_words_survive() {
let mut v: serde_json::Value = serde_json::from_str(GOLDEN).unwrap();
v["dependencies"] = serde_json::json!(["db"]);
v["modules"][0]["entities"][0]["fields"][0]["name"] = serde_json::json!("order");
let design: Design = serde_json::from_value(v).unwrap();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("todo-api");
scaffold::scaffold(&root, &design).unwrap();
let sqlite = fs::read_to_string(
root.join("crates/routes/todos/migrations/sqlite/0001_create_tables.sql"),
)
.unwrap();
assert!(
sqlite.to_lowercase().contains("\"order\" text"),
"reserved-word column must be quoted: {sqlite}"
);
}
#[test]
fn tenancy_generates_the_tenant_guard_in_shared() {
let s = include_str!("../../../conformance/designs/reference-slice.design.json");
let d: jerrycan::platform::design::Design = serde_json::from_str(s).unwrap();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
jerrycan::platform::scaffold::scaffold(&root, &d).unwrap();
let shared = std::fs::read_to_string(root.join("crates/shared/src/lib.rs")).unwrap();
assert!(shared.contains("pub struct Tenant"), "{shared}");
assert!(
shared.contains("pub async fn tenant("),
"guard factory: {shared}"
);
assert!(
shared.contains("workspace_members"),
"membership check: {shared}"
);
let main_rs = std::fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
assert!(
main_rs.contains(".provide_dep(shared::tenant)"),
"{main_rs}"
);
}
#[test]
fn reference_slice_design_is_valid_contract_v2() {
let s = include_str!("../../../conformance/designs/reference-slice.design.json");
let d: jerrycan::platform::design::Design = serde_json::from_str(s).unwrap();
assert_eq!(d.contract_version, 2);
let qs = jerrycan::platform::questions::validate(&d);
assert!(qs.is_empty(), "{qs:?}");
assert_eq!(d.tenant_owned().len(), 2); }
#[test]
fn generate_migration_emits_numbered_pair_and_rewires() {
let (_t, root) = scaffold_db();
let created =
jerrycan::platform::genroute::generate_migration(&root, "todos", "add_due_index").unwrap();
assert!(
created
.iter()
.any(|p| p.ends_with("migrations/sqlite/0002_add_due_index.sql")),
"{created:?}"
);
assert!(
created
.iter()
.any(|p| p.ends_with("migrations/postgres/0002_add_due_index.sql")),
"{created:?}"
);
let agg = std::fs::read_to_string(root.join("crates/app/src/migrations.rs")).unwrap();
assert!(agg.contains("0002_add_due_index"), "{agg}");
let again = jerrycan::platform::genroute::generate_migration(&root, "todos", "more").unwrap();
assert!(
again.iter().any(|p| p.ends_with("0003_more.sql")),
"{again:?}"
);
}
#[tokio::test]
async fn schema_verify_flags_staleness_with_jc0520() {
let s = include_str!("../../../conformance/designs/reference-slice.design.json");
let d: jerrycan::platform::design::Design = serde_json::from_str(s).unwrap();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
jerrycan::platform::scaffold::scaffold(&root, &d).unwrap();
let c = jerrycan::platform::schema::derive_schema(&root, &d)
.await
.unwrap();
std::fs::write(
root.join("schema.json"),
jerrycan::platform::schema::render(&c),
)
.unwrap();
assert!(
jerrycan::platform::schema::verify_fresh(&root, &d)
.await
.unwrap()
.is_empty()
);
std::fs::write(root.join("schema.json"), "{}").unwrap();
let diags = jerrycan::platform::schema::verify_fresh(&root, &d)
.await
.unwrap();
assert!(diags.iter().any(|x| x.code == "JC0520"), "{diags:?}");
std::fs::remove_file(root.join("schema.json")).unwrap();
assert!(
!jerrycan::platform::schema::verify_fresh(&root, &d)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn cross_module_fk_lets_per_module_migration_insert_under_fk_enforcement() {
use jerrycan::db::sea_orm::ConnectionTrait;
let s = include_str!("../../../conformance/designs/reference-slice.design.json");
let d: Design = serde_json::from_str(s).unwrap();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
scaffold::scaffold(&root, &d).unwrap();
let all = jerrycan::platform::mounting::collect_migrations(&root).unwrap();
let leads_only: Vec<_> = all
.into_iter()
.filter(|m| m.name.starts_with("leads_"))
.collect();
assert!(
!leads_only.is_empty(),
"leads module must own a migration file"
);
let db = jerrycan::db::Db::connect("sqlite::memory:").await.unwrap();
db.migrate_owned(&leads_only).await.unwrap();
db.conn()
.execute_unprepared(
"INSERT INTO leads (workspace_id, phone, name, status) VALUES (1, '555', 'A', 'new')",
)
.await
.expect("cross-module fk is unenforced: insert with a dangling workspace_id must succeed");
let count = db
.conn()
.query_one(jerrycan::db::sea_orm::Statement::from_string(
jerrycan::db::sea_orm::DatabaseBackend::Sqlite,
"SELECT COUNT(*) AS n FROM leads".to_string(),
))
.await
.unwrap()
.unwrap();
let n: i64 = count
.try_get::<i64>("", "n")
.or_else(|_| count.try_get::<i32>("", "n").map(i64::from))
.unwrap();
assert_eq!(n, 1, "the lead row persisted");
}
#[test]
fn memory_mode_is_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("todo-api");
let design: Design = serde_json::from_str(GOLDEN).unwrap();
scaffold::scaffold(&root, &design).unwrap();
let repo = fs::read_to_string(root.join("crates/routes/todos/src/repo.rs")).unwrap();
assert!(repo.contains("BTreeMap"), "in-memory repo stays: {repo}");
let main_rs = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
assert!(!main_rs.contains("jerrycan::db"));
assert!(!root.join("crates/app/src/migrations.rs").exists());
assert!(
root.join("openapi.json").exists(),
"openapi.json is emitted in every mode"
);
}
#[test]
fn db_mode_emits_composite_unique_index_both_dialects() {
const LIKES: &str = r#"{
"name": "likes-api", "contract_version": 1,
"dependencies": ["db"],
"modules": [{
"name": "engagement",
"entities": [
{ "name": "User", "fields": [{ "name": "email", "type": "string" }] },
{ "name": "Post", "fields": [{ "name": "title", "type": "string" }] },
{ "name": "Like",
"belongs_to": [{ "entity": "User" }, { "entity": "Post" }],
"unique": [["user_id", "post_id"]],
"fields": [{ "name": "reaction", "type": "string" }] }
],
"endpoints": [
{ "operation_id": "create_like", "method": "POST", "path": "/likes",
"request_body": { "entity": "Like" },
"success": { "status": 201, "entity": "Like" } }
]
}]
}"#;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("likes-api");
let design: Design = serde_json::from_str(LIKES).unwrap();
scaffold::scaffold(&root, &design).unwrap();
for dialect in ["sqlite", "postgres"] {
let sql = fs::read_to_string(root.join(format!(
"crates/routes/engagement/migrations/{dialect}/0001_create_tables.sql"
)))
.unwrap();
assert!(
sql.contains(
"CREATE UNIQUE INDEX \"idx_likes_uc0\" ON \"likes\" (\"user_id\", \"post_id\")"
),
"{dialect}: the composite unique index must be emitted verbatim:\n{sql}"
);
assert_eq!(
sql.matches("CREATE UNIQUE INDEX").count(),
1,
"{dialect}: only the composite-unique entity gets an index:\n{sql}"
);
}
}