use super::design::*;
fn fixture_value(f: &Field) -> String {
if let Some(first) = f.values.as_ref().and_then(|v| v.first()) {
return format!("\"{first}\"");
}
match f.field_type {
FieldType::String => "\"test-value\"",
FieldType::Integer => "1",
FieldType::Float => "1.0",
FieldType::Boolean => "false",
FieldType::Datetime => "\"2026-01-01T00:00:00Z\"",
FieldType::Uuid => "\"00000000-0000-0000-0000-000000000000\"",
FieldType::Json => "{}",
}
.to_string()
}
fn fk_fixture_value(design: &Design, target: &str) -> &'static str {
match design.target_key_rust_type(target) {
"String" => "\"1\"",
_ => "1",
}
}
fn fixture_json(design: &Design, m: &ModuleDesign, entity: &str) -> String {
let Some(e) = m.entities.iter().find(|e| e.name == entity) else {
return "{}".to_string();
};
let fks = e.belongs_to.iter().map(|b| {
format!(
"\"{}\": {}",
Design::fk_column(&b.entity),
fk_fixture_value(design, &b.entity)
)
});
let cols = e
.fields
.iter()
.map(|f| format!("\"{}\": {}", f.name, fixture_value(f)));
let fields = fks.chain(cols).collect::<Vec<_>>().join(", ");
format!("{{{fields}}}")
}
fn creator(m: &ModuleDesign) -> Option<&Endpoint> {
m.endpoints
.iter()
.find(|ep| ep.method == HttpMethod::POST && ep.path == "/" && ep.request_body.is_some())
}
fn param_count(ep: &Endpoint) -> usize {
ep.path.matches('{').count()
}
fn endpoint_is_credential_gated(ep: &Endpoint) -> bool {
ep.declares_signature_auth()
|| (!ep.is_guarded() && ep.errors.iter().any(|e| e.status == 401 || e.status == 403))
}
struct TestOut {
code: String,
todos: Vec<String>,
count: usize,
auth: bool,
}
fn request_expr(
design: &Design,
unit: &ModuleDesign,
ep: &Endpoint,
path: &str,
guarded_and_auth: bool,
) -> String {
let body = || {
ep.request_body
.as_ref()
.map(|rb| fixture_json(design, unit, &rb.entity))
.unwrap_or_else(|| "{}".to_string())
};
if guarded_and_auth {
let cookie = "&[(\"cookie\", &test_cookie())]";
match ep.method {
HttpMethod::GET => format!("t.get_with(\"{path}\", {cookie}).await"),
HttpMethod::DELETE => format!("t.delete_with(\"{path}\", {cookie}).await"),
HttpMethod::POST => format!(
"t.post_json_with(\"{path}\", &serde_json::json!({}), {cookie}).await",
body()
),
HttpMethod::PUT => format!(
"t.put_json_with(\"{path}\", &serde_json::json!({}), {cookie}).await",
body()
),
HttpMethod::PATCH => format!(
"t.patch_json_with(\"{path}\", &serde_json::json!({}), {cookie}).await",
body()
),
}
} else {
match ep.method {
HttpMethod::GET => format!("t.get(\"{path}\").await"),
HttpMethod::DELETE => format!("t.delete(\"{path}\").await"),
HttpMethod::POST => {
format!(
"t.post_json(\"{path}\", &serde_json::json!({})).await",
body()
)
}
HttpMethod::PUT => {
format!(
"t.put_json(\"{path}\", &serde_json::json!({})).await",
body()
)
}
HttpMethod::PATCH => {
format!(
"t.patch_json(\"{path}\", &serde_json::json!({})).await",
body()
)
}
}
}
}
fn unit_tests(design: &Design, unit: &ModuleDesign, base: &str, out: &mut TestOut) {
let auth = out.auth;
let seed_id = creator(unit)
.and_then(|ep| ep.request_body.as_ref())
.and_then(|rb| unit.entities.iter().find(|e| e.name == rb.entity))
.and_then(|e| e.fields.iter().find(|f| f.name == "id"))
.map(|f| fixture_value(f).trim_matches('"').to_string())
.unwrap_or_else(|| "1".to_string());
let seed = creator(unit).map(|ep| {
let body = fixture_json(
design,
unit,
&ep.request_body.as_ref().expect("creator has body").entity,
);
if auth && ep.is_guarded() {
format!(
" t.post_json_with(\"{base}/\", &serde_json::json!({body}), &[(\"cookie\", &test_cookie())]).await; // seed id 1\n"
)
} else {
format!(" t.post_json(\"{base}/\", &serde_json::json!({body})).await; // seed id 1\n")
}
});
for ep in &unit.endpoints {
let full_path = format!("{}{}", base.trim_end_matches('/'), ep.path);
let fn_base = &ep.operation_id;
let status = ep.success.status;
let guarded = auth && ep.is_guarded();
let gated = endpoint_is_credential_gated(ep);
if gated {
out.todos.push(format!(
"// AGENT TODO: {fn_base} ({:?} {full_path}) authenticates via a credential/signature the generator can't supply — write its success test (with a valid credential) and its 401/403 rejection test in your own test file.",
ep.method
));
} else if param_count(ep) == 0 {
let request = request_expr(design, unit, ep, &full_path, guarded);
let id_echo = (ep.method == HttpMethod::POST)
.then_some(ep.request_body.as_ref())
.flatten()
.filter(|rb| ep.success.entity.as_deref() == Some(rb.entity.as_str()))
.and_then(|rb| unit.entities.iter().find(|e| e.name == rb.entity))
.and_then(|e| e.fields.iter().find(|f| f.name == "id"))
.map(|f| format!(
" let body: serde_json::Value = serde_json::from_str(&res.text()).expect(\"json body\");\n assert_eq!(body[\"id\"], serde_json::json!({}), \"design: created {} echoes its id\");\n",
fixture_value(f), ep.success.entity.as_deref().unwrap_or("entity")
))
.unwrap_or_default();
out.code.push_str(&format!(
"#[tokio::test]\nasync fn {fn_base}_returns_{status}() {{\n let t = app().await;\n let res = {request};\n assert_eq!(res.status().as_u16(), {status}, \"design: {fn_base} -> {status}; body: {{}}\", res.text());\n{id_echo}}}\n\n"
));
out.count += 1;
if guarded {
push_401_test(design, out, unit, ep, &full_path, false);
}
} else if param_count(ep) == 1 && seed.is_some() {
let seeded_path = full_path.replacen(®ex_free_param(&ep.path), &seed_id, 1);
let request = request_expr(design, unit, ep, &seeded_path, guarded);
out.code.push_str(&format!(
"#[tokio::test]\nasync fn {fn_base}_returns_{status}() {{\n let t = app().await;\n{seed} let res = {request};\n assert_eq!(res.status().as_u16(), {status}, \"design: {fn_base} -> {status}; body: {{}}\", res.text());\n}}\n\n",
seed = seed.as_deref().unwrap_or("")
));
out.count += 1;
if guarded {
push_401_test(design, out, unit, ep, &seeded_path, seed.is_some());
}
} else if param_count(ep) >= 1 {
out.todos.push(format!(
"// AGENT TODO: {fn_base} ({:?} {full_path}) needs a creator at \"/\" to seed ids — encode its success case in your own test file.",
ep.method
));
}
for ec in &ep.errors {
if ec.status == 404 && param_count(ep) == 1 && !gated {
let missing_path = full_path.replacen(®ex_free_param(&ep.path), "999999", 1);
let request = request_expr(design, unit, ep, &missing_path, guarded);
out.code.push_str(&format!(
"#[tokio::test]\nasync fn {fn_base}_missing_id_is_404() {{\n let t = app().await;\n let res = {request};\n assert_eq!(res.status().as_u16(), 404, \"design: {fn_base} lists 404 ({when}); body: {{}}\", res.text());\n}}\n\n",
when = ec.when
));
out.count += 1;
} else {
out.todos.push(format!(
"// AGENT TODO: design lists {} ({}) for {fn_base} — encode it in your own test file.",
ec.status, ec.when
));
}
}
}
for sub in &unit.subroutes {
let sub_base = format!("{}{}", base, sub.effective_mount());
unit_tests(design, sub, &sub_base, out);
}
}
fn push_401_test(
design: &Design,
out: &mut TestOut,
unit: &ModuleDesign,
ep: &Endpoint,
path: &str,
_seeded: bool,
) {
let fn_base = &ep.operation_id;
let request = request_expr(design, unit, ep, path, false); out.code.push_str(&format!(
"#[tokio::test]\nasync fn {fn_base}_without_auth_is_401() {{\n let t = app().await;\n let res = {request};\n assert_eq!(res.status().as_u16(), 401, \"design: {fn_base} is guarded — no cookie must 401; body: {{}}\", res.text());\n}}\n\n"
));
out.count += 1;
}
fn regex_free_param(path: &str) -> String {
let start = path.find('{').expect("parameterized path");
let end = path[start..].find('}').expect("balanced braces") + start;
path[start..=end].to_string()
}
const TEST_SECRET: &str = "a-very-long-development-secret-string!!";
fn auth_preamble_login() -> String {
format!(
"fn test_cookie_for(user_id: i64) -> String {{\n let auth = jerrycan::auth::Auth::with_secret(\"{TEST_SECRET}\");\n let token = auth.sessions().encode(&shared::SessionUser {{ id: user_id, role: \"admin\".into() }}).expect(\"encode\");\n format!(\"jerrycan_session={{token}}\")\n}}\n\nfn test_cookie() -> String {{\n test_cookie_for(1)\n}}\n\n"
)
}
fn tenant_module(design: &Design) -> Option<&ModuleDesign> {
let tenancy = design.tenancy.as_ref()?;
design
.modules
.iter()
.find(|m| m.entities.iter().any(|e| e.name == tenancy.entity))
}
fn module_needs_tenant(design: &Design, module: &ModuleDesign) -> bool {
let Some(tenancy) = design.tenancy.as_ref() else {
return false;
};
fn walk(m: &ModuleDesign, tenant: &str) -> bool {
m.entities
.iter()
.any(|e| e.belongs_to.iter().any(|b| b.entity == tenant))
|| m.subroutes.iter().any(|s| walk(s, tenant))
}
walk(module, &tenancy.entity)
}
fn tenant_migration_item(design: &Design, module: &ModuleDesign) -> String {
let Some(t) = tenant_module(design) else {
return String::new();
};
if t.name == module.name || t.entities.is_empty() {
return String::new();
}
let t_snake = t.name.replace('-', "_");
format!(
" jerrycan::db::Migration {{\n name: \"{t_snake}_0001_create_tables\",\n sqlite: include_str!(\"../../{t}/migrations/sqlite/0001_create_tables.sql\"),\n postgres: include_str!(\"../../{t}/migrations/postgres/0001_create_tables.sql\"),\n }},\n",
t = t.name,
)
}
fn tenant_seed(design: &Design, module: &ModuleDesign) -> String {
if !module_needs_tenant(design, module) {
return String::new();
}
let Some(tenancy) = design.tenancy.as_ref() else {
return String::new();
};
let Some(t) = tenant_module(design) else {
return String::new();
};
let Some(entity) = t.entities.iter().find(|e| e.name == tenancy.entity) else {
return String::new();
};
let table = format!("{}s", tenancy.entity.to_lowercase());
let members = format!("{}_members", Design::to_snake(&tenancy.entity));
let fk = Design::fk_column(&tenancy.entity);
let role = tenancy
.member_roles
.first()
.map(String::as_str)
.unwrap_or("owner");
let (cols, vals) = tenant_row_cols_vals(entity, "1", 1);
format!(
" db.conn()\n .execute_unprepared(\"INSERT INTO \\\"{table}\\\" ({cols}) VALUES ({vals})\")\n .await\n .expect(\"seed tenant row\");\n db.conn()\n .execute_unprepared(\"INSERT INTO \\\"{members}\\\" (user_id, {fk}, role) VALUES (1, 1, '{role}')\")\n .await\n .expect(\"seed membership\");\n"
)
}
fn isolation_member_role<'a>(design: &'a Design, module: &'a ModuleDesign) -> &'a str {
module
.endpoints
.iter()
.find(|ep| ep.method == HttpMethod::DELETE && !ep.required_roles.is_empty())
.and_then(|ep| ep.required_roles.first())
.map(String::as_str)
.or_else(|| {
design
.tenancy
.as_ref()
.and_then(|t| t.member_roles.first())
.map(String::as_str)
})
.unwrap_or("owner")
}
fn tenant_row_cols_vals(entity: &Entity, pk: &str, n: u32) -> (String, String) {
let mut cols = vec!["id".to_string()];
let mut vals = vec![pk.to_string()];
for f in entity.fields.iter().filter(|f| f.name != "id") {
cols.push(format!("\\\"{}\\\"", f.name));
vals.push(seed_sql_value_n(f, n));
}
(cols.join(", "), vals.join(", "))
}
fn seed_second_tenant_fn(design: &Design, module: &ModuleDesign) -> String {
if !module_needs_tenant(design, module) {
return String::new();
}
let Some(tenancy) = design.tenancy.as_ref() else {
return String::new();
};
let Some(t) = tenant_module(design) else {
return String::new();
};
let Some(entity) = t.entities.iter().find(|e| e.name == tenancy.entity) else {
return String::new();
};
let table = format!("{}s", tenancy.entity.to_lowercase());
let members = format!("{}_members", Design::to_snake(&tenancy.entity));
let fk = Design::fk_column(&tenancy.entity);
let role = isolation_member_role(design, module);
let (cols, vals) = tenant_row_cols_vals(entity, "2", 2);
format!(
"async fn seed_second_tenant(db: &jerrycan::db::Db) {{\n db.conn()\n .execute_unprepared(\"INSERT INTO \\\"{table}\\\" ({cols}) VALUES ({vals})\")\n .await\n .expect(\"seed tenant 2 row\");\n db.conn()\n .execute_unprepared(\"INSERT INTO \\\"{members}\\\" (user_id, {fk}, role) VALUES (2, 2, '{role}')\")\n .await\n .expect(\"seed tenant 2 membership\");\n}}\n\n"
)
}
fn isolation_test(design: &Design, module: &ModuleDesign) -> String {
let Some(tenancy) = design.tenancy.as_ref() else {
return String::new();
};
let Some(entity) = module
.entities
.iter()
.find(|e| e.belongs_to.iter().any(|b| b.entity == tenancy.entity))
else {
return String::new();
};
let Some(create) = module.endpoints.iter().find(|ep| {
ep.method == HttpMethod::POST
&& ep.path == "/"
&& ep
.request_body
.as_ref()
.is_some_and(|rb| rb.entity == entity.name)
}) else {
return String::new();
};
let base = module.effective_mount();
let base = base.trim_end_matches('/');
let plural = module.name.replace('-', "_");
let body = fixture_json(design, module, &entity.name);
let create_path = format!("{base}/");
let mut t = String::new();
t.push_str(&format!(
"/// SECURITY: a tenant must not reach another tenant's {entity} rows. User 1\n/// creates a row in tenant 1; user 2 (tenant 2) must be denied read/list/delete.\n/// Passes only with the SCOPED repo accessors (get_for/all_for/remove_for).\n#[tokio::test]\nasync fn tenant_a_cannot_read_tenant_b_{plural}() {{\n let t = app().await;\n",
entity = entity.name,
));
t.push_str(&format!(
" let created = t.post_json_with(\"{create_path}\", &serde_json::json!({body}), &[(\"cookie\", &test_cookie_for(1))]).await;\n assert_eq!(created.status().as_u16(), {status}, \"setup: user 1 creates a {entity}; body: {{}}\", created.text());\n let row: serde_json::Value = serde_json::from_str(&created.text()).expect(\"created json\");\n let id = &row[\"id\"];\n let cookie2 = test_cookie_for(2);\n",
status = create.success.status,
entity = entity.name,
));
let get_one = module
.endpoints
.iter()
.find(|ep| ep.method == HttpMethod::GET && param_count(ep) == 1);
let delete_one = module
.endpoints
.iter()
.find(|ep| ep.method == HttpMethod::DELETE && param_count(ep) == 1);
let list = module
.endpoints
.iter()
.find(|ep| ep.method == HttpMethod::GET && param_count(ep) == 0);
if let Some(_get) = get_one {
t.push_str(&format!(
" let foreign = t.get_with(&format!(\"{base}/{{id}}\"), &[(\"cookie\", &cookie2)]).await;\n assert_eq!(foreign.status().as_u16(), 404, \"cross-tenant get must 404 (use get_for, not get); body: {{}}\", foreign.text());\n",
));
}
if list.is_some() {
t.push_str(&format!(
" let listed = t.get_with(\"{base}/\", &[(\"cookie\", &cookie2)]).await;\n assert_eq!(listed.status().as_u16(), 200, \"user 2 lists their own {plural}; body: {{}}\", listed.text());\n let rows: serde_json::Value = serde_json::from_str(&listed.text()).expect(\"list json\");\n let absent = rows.as_array().map(|a| a.iter().all(|r| &r[\"id\"] != id)).unwrap_or(true);\n assert!(absent, \"cross-tenant list must NOT contain tenant 1's row (use all_for); body: {{}}\", listed.text());\n",
));
}
if let Some(_del) = delete_one {
t.push_str(&format!(
" let del = t.delete_with(&format!(\"{base}/{{id}}\"), &[(\"cookie\", &cookie2)]).await;\n assert_eq!(del.status().as_u16(), 404, \"cross-tenant delete must 404 (use remove_for, not remove); body: {{}}\", del.text());\n",
));
if get_one.is_some() {
t.push_str(&format!(
" let survives = t.get_with(&format!(\"{base}/{{id}}\"), &[(\"cookie\", &test_cookie_for(1))]).await;\n assert_eq!(survives.status().as_u16(), 200, \"tenant 1's row must survive a cross-tenant delete; body: {{}}\", survives.text());\n",
));
}
}
t.push_str("}\n\n");
t
}
fn seed_sql_value(f: &Field) -> String {
if let Some(values) = &f.values
&& let Some(first) = values.first()
{
return format!("'{first}'");
}
match f.field_type {
FieldType::String | FieldType::Datetime | FieldType::Uuid => "'test-value'".to_string(),
FieldType::Integer => "1".to_string(),
FieldType::Float => "1.0".to_string(),
FieldType::Boolean => "false".to_string(),
FieldType::Json => "'{}'".to_string(),
}
}
fn seed_sql_value_n(f: &Field, n: u32) -> String {
if n == 1 {
return seed_sql_value(f);
}
if let Some(values) = &f.values
&& let Some(first) = values.first()
{
return format!("'{first}'");
}
match f.field_type {
FieldType::String | FieldType::Datetime | FieldType::Uuid => format!("'test-value-{n}'"),
FieldType::Integer => n.to_string(),
FieldType::Float => format!("{n}.0"),
FieldType::Boolean => "false".to_string(),
FieldType::Json => "'{}'".to_string(),
}
}
fn preamble(design: &Design, module: &ModuleDesign, uses_cookies: bool) -> String {
let mount = module.effective_mount();
let auth_login = if design.wants_auth() && uses_cookies {
auth_preamble_login()
} else {
String::new()
};
let auth_extend = if design.wants_auth() {
format!(".extend(jerrycan::auth::Auth::with_secret(\"{TEST_SECRET}\"))")
} else {
String::new()
};
if design.wants_db() {
let mut migration_items = String::new();
collect_migration_items(module, &mut migration_items);
migration_items.push_str(&tenant_migration_item(design, module));
let seed = tenant_seed(design, module);
let tenant_dep = if module_needs_tenant(design, module) {
".provide_dep(shared::tenant)"
} else {
""
};
let second_seed_fn = seed_second_tenant_fn(design, module);
let second_seed_call = if second_seed_fn.is_empty() {
String::new()
} else {
" seed_second_tenant(&db).await;\n".to_string()
};
let seed_use = if seed.is_empty() {
String::new()
} else {
"use jerrycan::db::sea_orm::ConnectionTrait;\n\n".to_string()
};
format!(
"{seed_use}{auth_login}{second_seed_fn}async fn app() -> TestApp {{\n let db = jerrycan::db::Db::connect(\"sqlite::memory:\").await.expect(\"test db\");\n db.migrate(&[\n{migration_items} ])\n .await\n .expect(\"migrations\");\n{seed}{second_seed_call} App::new(){auth_extend}.extend(db){tenant_dep}.mount(\"{mount}\", module()).into_test()\n}}\n"
)
} else {
format!(
"{auth_login}async fn app() -> TestApp {{\n App::new(){auth_extend}.mount(\"{mount}\", module()).into_test()\n}}\n"
)
}
}
fn collect_migration_items(module: &ModuleDesign, out: &mut String) {
if !module.entities.is_empty() {
out.push_str(&format!(
" jerrycan::db::Migration {{\n name: \"{m}_0001_create_tables\",\n sqlite: include_str!(\"../migrations/sqlite/0001_create_tables.sql\"),\n postgres: include_str!(\"../migrations/postgres/0001_create_tables.sql\"),\n }},\n",
m = module.name.replace('-', "_")
));
}
fn subs(module: &ModuleDesign, out: &mut String) {
for sub in &module.subroutes {
if !sub.entities.is_empty() {
let s = sub.name.replace('-', "_");
out.push_str(&format!(
" jerrycan::db::Migration {{\n name: \"{s}_0001_create_tables\",\n sqlite: include_str!(\"../migrations/sqlite/0001_create_tables_{s}.sql\"),\n postgres: include_str!(\"../migrations/postgres/0001_create_tables_{s}.sql\"),\n }},\n"
));
}
subs(sub, out);
}
}
subs(module, out);
}
pub fn acceptance_rs(design: &Design, module: &ModuleDesign) -> String {
let mut out = TestOut {
code: String::new(),
todos: Vec::new(),
count: 0,
auth: design.wants_auth(),
};
unit_tests(design, module, &module.effective_mount(), &mut out);
let isolation = isolation_test(design, module);
out.count += isolation.matches("#[tokio::test]").count();
out.code.push_str(&isolation);
let todos = if out.todos.is_empty() {
String::new()
} else {
format!("\n{}\n", out.todos.join("\n"))
};
let banner = "//! GENERATED by jerrycan gen-tests — TOOL-OWNED acceptance criteria from design.json.\n//! Regenerated on demand; add your own tests in sibling files, not here.\n";
if !out.code.contains("#[tokio::test]") {
return format!("{banner}{todos}");
}
let uses_cookies = out.code.contains("test_cookie");
format!(
"{banner}use jerrycan::prelude::*;\nuse {ident}::module;\n\n{preamble}\n{code}{todos}",
ident = super::genroute::crate_ident(&module.name),
preamble = preamble(design, module, uses_cookies),
code = out.code,
)
}
pub fn write_acceptance(
root: &std::path::Path,
design: &Design,
module_name: &str,
) -> Result<(String, usize), String> {
let Some(module) = design.modules.iter().find(|m| m.name == module_name) else {
return Err(format!(
"module `{module_name}` not found in design.json (top-level modules only)"
));
};
let content = acceptance_rs(design, module);
let rel = format!("crates/routes/{module_name}/tests/acceptance.rs");
let path = root.join(&rel);
std::fs::create_dir_all(path.parent().expect("parent")).map_err(|e| e.to_string())?;
std::fs::write(&path, &content).map_err(|e| e.to_string())?;
Ok((rel, test_count(&content)))
}
pub fn test_count(generated: &str) -> usize {
generated.matches("#[tokio::test]").count()
}