use jerrycan::platform::design::Design;
use jerrycan::platform::{mounting, scaffold};
use std::fs;
const DESIGN: &str = include_str!("../../../conformance/designs/todo-api.design.json");
fn design() -> Design {
serde_json::from_str(DESIGN).unwrap()
}
#[test]
fn scaffold_creates_the_fractal_workspace() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("todo-api");
let created = scaffold::scaffold(&root, &design()).unwrap();
for expected in [
"Cargo.toml",
"jerrycan.toml",
"design.json",
".gitignore",
"crates/app/Cargo.toml",
"crates/app/src/main.rs",
"crates/shared/Cargo.toml",
"crates/shared/src/lib.rs",
"crates/routes/todos/Cargo.toml",
"crates/routes/todos/src/lib.rs",
"crates/routes/todos/src/handlers.rs",
"crates/routes/todos/src/model.rs",
"crates/routes/todos/src/repo.rs",
"crates/routes/todos/src/deps.rs",
"crates/routes/todos/src/subroutes/comments/mod.rs",
"crates/routes/users/src/lib.rs",
] {
assert!(
root.join(expected).exists(),
"missing {expected}; created={created:?}"
);
}
}
#[test]
fn mounting_is_sorted_and_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("todo-api");
scaffold::scaffold(&root, &design()).unwrap();
let main1 = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
assert!(main1.contains("GENERATED by jerrycan"));
let todos_pos = main1
.find(".mount(\"/todos\", route_todos::module())")
.unwrap();
let users_pos = main1
.find(".mount(\"/users\", route_users::module())")
.unwrap();
assert!(
todos_pos < users_pos,
"mounts must be sorted by module name"
);
let ws1 = fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert!(ws1.contains("\"crates/routes/todos\","));
mounting::regenerate(&root, &design()).unwrap();
let main2 = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
let ws2 = fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert_eq!(main1, main2);
assert_eq!(ws1, ws2);
}
#[test]
fn regenerate_shrinks_cleanly_when_a_module_is_removed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("todo-api");
let mut d = design();
scaffold::scaffold(&root, &d).unwrap();
d.modules.retain(|m| m.name != "users");
mounting::regenerate(&root, &d).unwrap();
let main_rs = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
assert!(!main_rs.contains("route_users"), "stale mount removed");
let ws = fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert!(!ws.contains("crates/routes/users"), "stale member removed");
assert!(ws.contains("# jerrycan:members:begin") && ws.contains("# jerrycan:members:end"));
let app_cargo = fs::read_to_string(root.join("crates/app/Cargo.toml")).unwrap();
assert!(
!app_cargo.contains("route-users"),
"stale route-dep removed"
);
mounting::regenerate(&root, &d).unwrap();
assert_eq!(
main_rs,
fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap()
);
}
#[test]
fn scaffold_refuses_a_nonempty_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("busy");
fs::create_dir_all(root.join("stuff")).unwrap();
let err = scaffold::scaffold(&root, &design()).unwrap_err();
assert!(err.contains("not empty"), "{err}");
}
#[test]
fn expected_main_matches_what_regenerate_writes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("todo-api");
scaffold::scaffold(&root, &design()).unwrap();
let on_disk = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
assert_eq!(on_disk, mounting::expected_main(&design()));
}
#[test]
fn write_only_columns_are_response_hidden_in_the_scaffolded_model() {
const HIDDEN: &str = r#"{
"name": "secrets-api", "contract_version": 1,
"dependencies": ["db"],
"modules": [{ "name": "accounts",
"entities": [{ "name": "Account", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "email", "type": "string" },
{ "name": "api_token", "type": "string", "write_only": true },
{ "name": "password_hash", "type": "string" } ] }],
"endpoints": [
{ "operation_id": "create_account", "method": "POST", "path": "/",
"request_body": { "entity": "Account" },
"success": { "status": 201, "entity": "Account" } },
{ "operation_id": "get_account", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Account" } } ] }] }"#;
let d: Design = serde_json::from_str(HIDDEN).unwrap();
let qs = jerrycan::platform::questions::validate(&d);
assert!(qs.is_empty(), "write_only db design must validate: {qs:?}");
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("secrets-api");
scaffold::scaffold(&root, &d).unwrap();
let model = fs::read_to_string(root.join("crates/routes/accounts/src/model.rs")).unwrap();
assert_eq!(
model.matches("#[serde(skip_serializing)]").count(),
2,
"exactly api_token + password_hash are skip_serializing: {model}"
);
for hidden in ["api_token", "password_hash"] {
assert!(
model.contains(&format!("#[serde(skip_serializing)]\n pub {hidden}")),
"`{hidden}` must carry skip_serializing: {model}"
);
}
for shown in ["id", "email"] {
assert!(
!model.contains(&format!("#[serde(skip_serializing)]\n pub {shown}")),
"`{shown}` must NOT be hidden: {model}"
);
}
assert!(
model.contains("Deserialize"),
"the Model must still deserialize (input path intact): {model}"
);
#[derive(serde::Serialize, serde::Deserialize)]
struct Account {
id: i64,
email: String,
#[serde(skip_serializing)]
api_token: String,
}
let created: Account =
serde_json::from_str(r#"{ "id": 1, "email": "a@b.c", "api_token": "sk-secret" }"#).unwrap();
assert_eq!(
created.api_token, "sk-secret",
"input accepts the write_only field"
);
let body = serde_json::to_string(&created).unwrap();
assert!(
!body.contains("api_token"),
"response hides the write_only field: {body}"
);
assert!(
body.contains("a@b.c"),
"non-hidden fields still serialize: {body}"
);
}