use std::fmt::Write as _;
use std::fs;
use std::io::Read as _;
use std::net::TcpListener;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
fn run_autumn(dir: &Path, args: &[&str]) -> (String, String) {
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(args)
.current_dir(dir)
.output()
.expect("failed to run autumn");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
output.status.success(),
"autumn {args:?} failed (exit={:?})\nstdout: {stdout}\nstderr: {stderr}",
output.status.code(),
);
(stdout, stderr)
}
fn run_autumn_with_env(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String) {
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(args)
.current_dir(dir)
.envs(envs.iter().copied())
.output()
.expect("failed to run autumn");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
output.status.success(),
"autumn {args:?} failed (exit={:?})\nstdout: {stdout}\nstderr: {stderr}",
output.status.code(),
);
(stdout, stderr)
}
fn run_autumn_failing(dir: &Path, args: &[&str]) -> (String, String, Option<i32>) {
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(args)
.current_dir(dir)
.output()
.expect("failed to run autumn");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
(stdout, stderr, output.status.code())
}
fn fresh_project(name: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
run_autumn(tmp.path(), &["new", name]);
let project = tmp.path().join(name);
(tmp, project)
}
fn patch_generated_cargo_toml(project_dir: &Path) {
let cargo_toml_path = project_dir.join("Cargo.toml");
let mut content = fs::read_to_string(&cargo_toml_path).unwrap();
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root");
let autumn_web = workspace_root.join("autumn");
write!(
content,
"\n[patch.crates-io]\nautumn-web = {{ path = \"{}\" }}\n",
autumn_web.display().to_string().replace('\\', "/")
)
.unwrap();
fs::write(&cargo_toml_path, content).unwrap();
}
fn free_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
listener.local_addr().expect("local addr").port()
}
struct ServerGuard(Child);
impl Drop for ServerGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn child_output(child: &mut Child) -> (String, String) {
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(mut pipe) = child.stdout.take() {
let _ = pipe.read_to_string(&mut stdout);
}
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
(stdout, stderr)
}
async fn wait_for_server_ready_async(
mut child: Child,
client: &reqwest::Client,
base: &str,
) -> ServerGuard {
for _ in 0..60 {
if let Some(status) = child.try_wait().expect("server status") {
let (stdout, stderr) = child_output(&mut child);
panic!(
"server exited before becoming ready: {status}\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
if client.get(format!("{base}/health")).send().await.is_ok() {
return ServerGuard(child);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
let _ = child.kill();
let _ = child.wait();
let (stdout, stderr) = child_output(&mut child);
panic!("server failed to become ready within 30 seconds\nstdout:\n{stdout}\nstderr:\n{stderr}");
}
#[test]
fn generate_model_in_fresh_project() {
let (_tmp, project) = fresh_project("model-app");
run_autumn(
&project,
&[
"generate",
"model",
"Post",
"title:String",
"body:Text",
"published:bool",
],
);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(model.contains("#[autumn_web::model]"));
assert!(model.contains("pub struct Post"));
assert!(model.contains("pub title: String,"));
assert!(model.contains("pub body: String,"));
assert!(model.contains("pub published: bool,"));
let mod_rs = fs::read_to_string(project.join("src/models/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod post;"));
let schema = fs::read_to_string(project.join("src/schema.rs")).unwrap();
assert!(schema.contains("posts (id)"));
assert!(schema.contains("title -> Text,"));
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_posts"))
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let dir = migrations[0].path();
let up = fs::read_to_string(dir.join("up.sql")).unwrap();
assert!(up.contains("CREATE TABLE posts"));
assert!(up.contains("title TEXT NOT NULL"));
assert!(up.contains("published BOOLEAN NOT NULL"));
assert!(up.contains("id BIGSERIAL PRIMARY KEY"));
let down = fs::read_to_string(dir.join("down.sql")).unwrap();
assert!(down.contains("DROP TABLE posts"));
}
#[test]
fn generate_model_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("dryrun-app");
let (stdout, _stderr) = run_autumn(
&project,
&["generate", "model", "Post", "title:String", "--dry-run"],
);
assert!(stdout.contains("Dry run"));
assert!(stdout.contains("src/models/post.rs"));
assert!(!project.join("src/models/post.rs").exists());
assert!(!project.join("src/schema.rs").exists());
}
#[test]
fn generate_scaffold_dry_run_api() {
let (_tmp, project) = fresh_project("dryrun-scaffold-api-app");
let (stdout, _stderr) = run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--api",
"--dry-run",
],
);
assert!(stdout.contains("Dry run"));
assert!(stdout.contains("src/models/post.rs"));
assert!(!stdout.contains("src/routes/posts.rs"));
assert!(!stdout.contains("templates"));
}
#[test]
fn generate_model_collision_without_force() {
let (_tmp, project) = fresh_project("collide-app");
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
let (_, stderr, code) =
run_autumn_failing(&project, &["generate", "model", "Post", "title:String"]);
assert_eq!(code, Some(1));
assert!(
stderr.contains("would overwrite") && stderr.contains("post.rs"),
"expected collision message; got stderr: {stderr}"
);
}
#[test]
fn generate_model_force_overwrites() {
let (_tmp, project) = fresh_project("force-app");
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
let model_path = project.join("src/models/post.rs");
let original = fs::read_to_string(&model_path).unwrap();
fs::write(&model_path, "// touched").unwrap();
run_autumn(
&project,
&["generate", "model", "Post", "title:String", "--force"],
);
let regenerated = fs::read_to_string(&model_path).unwrap();
assert_eq!(regenerated, original);
}
#[test]
fn generate_model_invalid_field_lists_supported_set() {
let (_tmp, project) = fresh_project("badtype-app");
let (_, stderr, code) =
run_autumn_failing(&project, &["generate", "model", "Post", "price:Money"]);
assert_eq!(code, Some(1));
assert!(stderr.contains("unsupported type"));
assert!(stderr.contains("Supported:"));
assert!(stderr.contains("String"));
}
#[test]
fn generate_model_references_field_emits_fk_column_constraint_and_index() {
let (_tmp, project) = fresh_project("references-app");
run_autumn(
&project,
&[
"generate",
"model",
"Comment",
"body:Text",
"post:references",
],
);
let model = fs::read_to_string(project.join("src/models/comment.rs")).unwrap();
assert!(model.contains("pub post_id: i64,"));
let schema = fs::read_to_string(project.join("src/schema.rs")).unwrap();
assert!(schema.contains("post_id -> Int8,"));
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_create_comments")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(up.contains("post_id BIGINT NOT NULL REFERENCES posts(id)"));
assert!(up.contains("CREATE INDEX idx_comments_post_id ON comments (post_id);"));
}
#[test]
fn generate_model_references_warns_when_target_model_missing() {
let (_tmp, project) = fresh_project("references-warn-app");
let (_stdout, stderr) = run_autumn(
&project,
&["generate", "model", "Comment", "post:references"],
);
assert!(
stderr.contains("Warning") && stderr.contains("posts"),
"expected a warning about the missing 'post' model; got stderr: {stderr}"
);
}
#[test]
fn generate_model_references_warning_not_printed_on_a_failed_collision_run() {
let (_tmp, project) = fresh_project("references-warn-collision-app");
run_autumn(
&project,
&["generate", "model", "Comment", "post:references"],
);
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "model", "Comment", "post:references"],
);
assert_eq!(code, Some(1));
assert!(stderr.contains("would overwrite"));
assert!(
!stderr.contains("Warning"),
"no warning should print on a failed, no-op run: {stderr}"
);
}
#[test]
fn generate_model_references_no_warning_when_target_model_exists() {
let (_tmp, project) = fresh_project("references-nowarn-app");
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
let (_stdout, stderr) = run_autumn(
&project,
&["generate", "model", "Comment", "post:references"],
);
assert!(
!stderr.contains("Warning"),
"no warning expected once the Post model exists; got stderr: {stderr}"
);
}
#[test]
fn generate_model_references_nullable_form() {
let (_tmp, project) = fresh_project("references-nullable-app");
run_autumn(
&project,
&["generate", "model", "Comment", "post:references?"],
);
let model = fs::read_to_string(project.join("src/models/comment.rs")).unwrap();
assert!(model.contains("pub post_id: Option<i64>,"));
}
#[test]
fn generate_model_help_documents_references_field() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "model", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("references"));
}
#[test]
fn generate_scaffold_help_documents_references_field() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "scaffold", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("references"));
}
#[test]
fn generate_model_help_documents_enum_field() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "model", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("enum{"));
}
#[test]
fn generate_scaffold_help_documents_enum_field() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "scaffold", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("enum{"));
}
#[test]
fn generate_model_with_enum_writes_check_and_enum_type() {
let (_tmp, project) = fresh_project("enum-model-app");
run_autumn(
&project,
&[
"generate",
"model",
"Post",
"title:String",
"status:enum{draft,published,archived}",
],
);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(model.contains("pub enum Status"), "got:\n{model}");
assert!(model.contains("pub status: Status,"), "got:\n{model}");
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with("_create_posts"))
.expect("create_posts migration should exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(
up.contains("status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived'))"),
"got:\n{up}"
);
}
#[test]
fn generate_migration_add_enum_column_emits_check() {
let (_tmp, project) = fresh_project("enum-migration-app");
run_autumn(
&project,
&[
"generate",
"migration",
"AddStatusToPosts",
"status:enum{draft,published}",
],
);
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_add_status_to_posts")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains(
"ALTER TABLE posts ADD COLUMN status TEXT NOT NULL CHECK (status IN ('draft', 'published'));"
),
"got:\n{up}"
);
}
#[test]
fn generate_scaffold_rejects_bad_enum_default() {
let (_tmp, project) = fresh_project("enum-bad-default-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Post",
"status:enum{draft,published,archived}",
"--default",
"status=bogus",
],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("bogus") && stderr.contains("draft") && stderr.contains("archived"),
"expected an enum-default membership error listing the variants; got stderr: {stderr}"
);
assert!(!project.join("src/models/post.rs").exists());
}
#[test]
fn generate_scaffold_rejects_unique_field_with_default() {
let (_tmp, project) = fresh_project("unique-with-default-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"--default",
"email='a@b.com'",
],
);
assert_eq!(code, Some(1));
assert!(stderr.contains("email"), "got stderr: {stderr}");
assert!(stderr.contains("unique"), "got stderr: {stderr}");
assert!(stderr.contains("default"), "got stderr: {stderr}");
assert!(!project.join("src/models/user.rs").exists());
}
#[test]
fn generate_scaffold_rejects_unique_flag_field_with_default() {
let (_tmp, project) = fresh_project("unique-flag-with-default-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"User",
"email:String",
"--unique",
"email",
"--default",
"email='a@b.com'",
],
);
assert_eq!(code, Some(1));
assert!(stderr.contains("email"), "got stderr: {stderr}");
assert!(stderr.contains("unique"), "got stderr: {stderr}");
assert!(stderr.contains("default"), "got stderr: {stderr}");
assert!(!project.join("src/models/user.rs").exists());
}
#[test]
fn generate_scaffold_with_enum_dry_run_lists_files_and_then_collides_without_force() {
let (_tmp, project) = fresh_project("enum-dryrun-app");
let (stdout, _stderr) = run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"status:enum{draft,published,archived}",
"--dry-run",
],
);
assert!(stdout.contains("Dry run"));
assert!(stdout.contains("src/models/post.rs"));
assert!(stdout.contains("src/repositories/post.rs"));
assert!(stdout.contains("src/routes/posts.rs"));
assert!(stdout.contains("tests/post.rs"));
assert!(!project.join("src/models/post.rs").exists());
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"status:enum{draft,published,archived}",
],
);
assert!(project.join("src/models/post.rs").exists());
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Post",
"status:enum{draft,published,archived}",
],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("overwrite"),
"expected a collision error; got stderr: {stderr}"
);
}
#[test]
fn generate_model_dry_run_lists_migration_file_for_references_field() {
let (_tmp, project) = fresh_project("references-dryrun-app");
let (stdout, _stderr) = run_autumn(
&project,
&[
"generate",
"model",
"Comment",
"post:references",
"--dry-run",
],
);
assert!(stdout.contains("Dry run"));
assert!(stdout.contains("migrations/"));
assert!(stdout.contains("create_comments/up.sql"));
assert!(!project.join("src/models/comment.rs").exists());
}
#[test]
fn generate_model_references_errors_when_target_has_uuid_pk() {
let (_tmp, project) = fresh_project("references-uuid-mismatch-app");
run_autumn(
&project,
&["generate", "model", "Post", "--id", "uuid", "title:String"],
);
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "model", "Comment", "post:references"],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("UUID"),
"expected a clear UUID-mismatch error; got stderr: {stderr}"
);
assert!(!project.join("src/models/comment.rs").exists());
}
#[test]
fn generate_migration_add_columns_emits_alter() {
let (_tmp, project) = fresh_project("migrate-app");
run_autumn(
&project,
&["generate", "migration", "AddTitleToPosts", "title:String"],
);
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_add_title_to_posts")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(up.contains("ALTER TABLE posts ADD COLUMN title TEXT NOT NULL"));
}
#[test]
fn generate_migration_add_unique_reference_column_skips_redundant_plain_index() {
let (_tmp, project) = fresh_project("migrate-unique-reference-app");
run_autumn(
&project,
&[
"generate",
"migration",
"AddAuthorToPosts",
"author:references:unique",
],
);
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_add_author_to_posts")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE UNIQUE INDEX idx_posts_author_id_unique ON posts (author_id);"),
"got:\n{up}"
);
assert!(
!up.contains("CREATE INDEX idx_posts_author_id ON posts (author_id);"),
"a references field that is also unique must not get a redundant \
plain index alongside its unique index; got:\n{up}"
);
}
#[test]
fn generate_migration_unknown_pattern_is_empty() {
let (_tmp, project) = fresh_project("empty-mig-app");
run_autumn(&project, &["generate", "migration", "BackfillSomething"]);
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_backfill_something")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(up.is_empty());
}
#[test]
fn generate_task_emits_task_module() {
let (_tmp, project) = fresh_project("task-app");
run_autumn(&project, &["generate", "task", "cleanup_users"]);
let task = fs::read_to_string(project.join("tasks/cleanup_users.rs")).unwrap();
assert!(task.contains("#[autumn_web::task]"));
assert!(task.contains("pub async fn cleanup_users"));
assert!(task.contains("TaskArgs<CleanupUsersArgs>"));
assert!(task.contains("AutumnResult<()>"));
}
#[test]
fn generate_scaffold_full_e2e_post() {
let (_tmp, project) = fresh_project("scaffold-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"published:bool",
"subtitle:Option<String>",
"views:Option<i64>",
"published_at:Option<NaiveDateTime>",
"token:Option<Uuid>",
],
);
assert!(project.join("src/models/post.rs").is_file());
assert!(project.join("src/models/mod.rs").is_file());
assert!(project.join("src/schema.rs").is_file());
let repo = fs::read_to_string(project.join("src/repositories/post.rs")).unwrap();
assert!(repo.contains("#[autumn_web::repository(Post, api = \"/api/posts\")]"));
assert!(repo.contains("pub trait PostRepository"));
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
for needle in [
"#[get(\"/posts\")]",
"#[get(\"/posts/{id}\")]",
"#[get(\"/posts/new\", name = \"new\")]",
"#[post(\"/posts\")]",
"#[get(\"/posts/{id}/edit\", name = \"edit\")]",
"#[post(\"/posts/{id}/update\")]",
"pub async fn index",
"pub async fn show",
"pub async fn new_form(",
"pub async fn update",
"use autumn_web::security::{CsrfFormField, CsrfToken, SubmitFormField, SubmitToken};",
"input type=\"hidden\" name=(csrf_field_name)",
"(csrf_input(csrf.as_ref(), csrf_field.as_ref()))",
"fn submit_token_input(",
"input type=\"hidden\" name=(submit_field_name) value=(submit_token.token());",
"submit_token: Option<SubmitToken>",
] {
assert!(routes.contains(needle), "routes file missing: {needle}");
}
let test = fs::read_to_string(project.join("tests/post.rs")).unwrap();
assert!(test.contains("posts_index_renders_scaffolded_rows"));
assert!(test.contains("autumn_web::test::{TestApp, TestClient, TestDb}"));
assert!(!test.contains("TcpStream"));
assert!(!test.contains("AUTUMN_TEST_BASE_URL"));
assert!(!test.contains("AUTUMN_TEST_SESSION_COOKIE"));
assert!(!test.contains("Cookie: {session_cookie}"));
assert!(test.contains("#[ignore = \"requires Docker"));
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("mod models;"));
assert!(main.contains("mod routes;"));
assert!(main.contains("mod schema;"));
assert!(main.contains("mod repositories;"));
for entry in [
"routes::posts::index",
"routes::posts::show",
"routes::posts::new_form",
"routes::posts::create",
"routes::posts::edit_form",
"routes::posts::update",
"routes::posts::bulk_delete",
"routes::posts::export_csv",
"repositories::post::post_api_list",
"repositories::post::post_api_get",
] {
assert!(
main.contains(entry),
"main.rs missing routes![] entry: {entry}\n{main}"
);
}
for entry in [
"repositories::post::post_api_create",
"repositories::post::post_api_update",
"repositories::post::post_api_delete",
] {
assert!(
!main.contains(entry),
"main.rs should not mount public scaffold write API route: {entry}\n{main}"
);
}
}
fn run_git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("failed to run git");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
output.status.success(),
"git {args:?} failed (exit={:?})\nstdout: {stdout}\nstderr: {stderr}",
output.status.code(),
);
stdout
}
fn snapshot_tree(root: &Path) -> Vec<(String, Vec<u8>)> {
fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, Vec<u8>)>) {
for entry in fs::read_dir(dir).expect("read_dir").filter_map(Result::ok) {
let path = entry.path();
if path.file_name().is_some_and(|n| n == ".git") {
continue;
}
if path.is_dir() {
walk(&path, root, out);
} else {
let rel = path
.strip_prefix(root)
.unwrap()
.display()
.to_string()
.replace('\\', "/");
let contents = fs::read(&path).expect("read file");
out.push((rel, contents));
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
#[test]
fn generate_then_destroy_scaffold_round_trips_git_clean() {
let (_tmp, project) = fresh_project("destroy-scaffold-app");
run_git(&project, &["init"]);
run_git(&project, &["add", "-A"]);
run_git(
&project,
&[
"-c",
"user.email=t@t.com",
"-c",
"user.name=t",
"commit",
"-m",
"base",
],
);
let before = snapshot_tree(&project);
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
assert!(project.join("src/models/post.rs").is_file());
assert!(project.join("src/routes/posts.rs").is_file());
assert!(project.join("src/repositories/post.rs").is_file());
run_autumn(&project, &["destroy", "scaffold", "Post", "title:String"]);
let after = snapshot_tree(&project);
assert_eq!(
before, after,
"working tree must be byte-identical after generate+destroy"
);
let status_stdout = run_git(&project, &["status", "--porcelain"]);
assert!(
status_stdout.trim().is_empty(),
"git status must be clean after generate+destroy, got:\n{status_stdout}"
);
}
#[test]
fn generate_then_destroy_model_round_trips_git_clean() {
let (_tmp, project) = fresh_project("destroy-model-app");
run_git(&project, &["init"]);
run_git(&project, &["add", "-A"]);
run_git(
&project,
&[
"-c",
"user.email=t@t.com",
"-c",
"user.name=t",
"commit",
"-m",
"base",
],
);
let before = snapshot_tree(&project);
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
run_autumn(&project, &["destroy", "model", "Post", "title:String"]);
let after = snapshot_tree(&project);
assert_eq!(before, after);
let status_stdout = run_git(&project, &["status", "--porcelain"]);
assert!(status_stdout.trim().is_empty());
}
#[test]
fn generate_then_destroy_migration_round_trips_git_clean() {
let (_tmp, project) = fresh_project("destroy-migration-app");
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
run_git(&project, &["init"]);
run_git(&project, &["add", "-A"]);
run_git(
&project,
&[
"-c",
"user.email=t@t.com",
"-c",
"user.name=t",
"commit",
"-m",
"base",
],
);
let before = snapshot_tree(&project);
run_autumn(
&project,
&[
"generate",
"migration",
"AddSubtitleToPosts",
"subtitle:String",
],
);
run_autumn(
&project,
&[
"destroy",
"migration",
"AddSubtitleToPosts",
"subtitle:String",
],
);
let after = snapshot_tree(&project);
assert_eq!(before, after);
let status_stdout = run_git(&project, &["status", "--porcelain"]);
assert!(status_stdout.trim().is_empty());
}
#[test]
fn generate_then_destroy_plugin_round_trips_git_clean() {
let (_tmp, project) = fresh_project("destroy-plugin-app");
run_git(&project, &["init"]);
run_git(&project, &["add", "-A"]);
run_git(
&project,
&[
"-c",
"user.email=t@t.com",
"-c",
"user.name=t",
"commit",
"-m",
"base",
],
);
let before = snapshot_tree(&project);
run_autumn(&project, &["generate", "plugin", "Foo"]);
assert!(project.join("autumn-foo-plugin/Cargo.toml").is_file());
run_autumn(&project, &["destroy", "plugin", "Foo"]);
assert!(!project.join("autumn-foo-plugin").exists());
let after = snapshot_tree(&project);
assert_eq!(
before, after,
"working tree must be byte-identical after generate+destroy"
);
let status_stdout = run_git(&project, &["status", "--porcelain"]);
assert!(
status_stdout.trim().is_empty(),
"git status must be clean after generate+destroy, got:\n{status_stdout}"
);
}
#[test]
fn destroy_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("destroy-dry-run-app");
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
let before = snapshot_tree(&project);
let (stdout, _) = run_autumn(
&project,
&["destroy", "scaffold", "Post", "title:String", "--dry-run"],
);
assert!(stdout.contains("Dry run"));
assert!(stdout.contains("Would remove") || stdout.contains("Would revert"));
let after = snapshot_tree(&project);
assert_eq!(before, after, "--dry-run must not touch disk");
}
#[test]
fn destroy_refuses_on_diverged_file_without_force() {
let (_tmp, project) = fresh_project("destroy-diverged-app");
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
let model_path = project.join("src/models/post.rs");
let mut content = fs::read_to_string(&model_path).unwrap();
content.push_str("\n// hand-edited by the user\n");
fs::write(&model_path, &content).unwrap();
let (_, stderr, code) =
run_autumn_failing(&project, &["destroy", "scaffold", "Post", "title:String"]);
assert_eq!(code, Some(1));
assert!(
stderr.contains("diverged") || stderr.contains("Diverged"),
"expected a divergence error, got stderr: {stderr}"
);
assert!(model_path.is_file(), "diverged file must not be deleted");
assert!(
fs::read_to_string(&model_path)
.unwrap()
.contains("hand-edited")
);
run_autumn(
&project,
&["destroy", "scaffold", "Post", "title:String", "--force"],
);
assert!(!model_path.exists());
}
#[test]
fn generate_scaffold_api_only() {
let (_tmp, project) = fresh_project("scaffold-api-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"published:bool",
"--api",
],
);
assert!(project.join("src/models/post.rs").is_file());
assert!(project.join("src/models/mod.rs").is_file());
assert!(project.join("src/schema.rs").is_file());
let repo = fs::read_to_string(project.join("src/repositories/post.rs")).unwrap();
assert!(repo.contains("#[autumn_web::repository(Post, api = \"/api/posts\")]"));
assert!(repo.contains("pub trait PostRepository"));
assert!(repo.contains("Generated by `autumn generate scaffold --api`"));
assert!(repo.contains("allow_unauthorized_repository_api = true"));
assert!(!project.join("src/routes/posts.rs").is_file());
let test = fs::read_to_string(project.join("tests/post.rs")).unwrap();
assert!(test.contains("posts_api_list_paginates_against_a_real_database"));
assert!(test.contains("autumn_web::test::{TestApp, TestClient, TestDb}"));
assert!(!test.contains("TcpStream"));
assert!(!test.contains("AUTUMN_TEST_BASE_URL"));
assert!(test.contains("#[ignore = \"requires Docker"));
assert!(test.contains("/api/posts"));
assert!(
test.contains("PageRequest") && test.contains("Page::new"),
"the --api smoke test must paginate via the Page envelope: {test}"
);
assert!(
test.contains("page 2 must differ from page 1"),
"the --api smoke test must assert pages advance: {test}"
);
assert!(
test.contains("FROM generate_series(1, 25) AS g"),
"required-column --api seed must use generate_series: {test}"
);
assert!(
!test.contains("INSERT INTO posts DEFAULT VALUES"),
"required-column --api seed must not fall back to DEFAULT VALUES: {test}"
);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("mod models;"));
assert!(
!main.contains("mod routes;"),
"main.rs should not declare routes mod if api is set: {main}"
);
assert!(main.contains("mod schema;"));
assert!(main.contains("mod repositories;"));
for entry in [
"repositories::post::post_api_create",
"repositories::post::post_api_update",
"repositories::post::post_api_delete",
"repositories::post::post_api_list",
"repositories::post::post_api_get",
] {
assert!(
main.contains(entry),
"main.rs missing routes![] entry: {entry}\n{main}"
);
}
for entry in [
"routes::posts::index",
"routes::posts::show",
"routes::posts::new_form",
"routes::posts::create",
"routes::posts::edit_form",
"routes::posts::update",
] {
assert!(
!main.contains(entry),
"main.rs should not mount HTML route: {entry}\n{main}"
);
}
}
#[test]
fn generate_scaffold_accepts_metadata_flags() {
let (_tmp, project) = fresh_project("scaffold-metadata-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Bookmark",
"url:String",
"title:String",
"tag:String",
"alive:bool",
"--index",
"url",
"--index",
"tag",
"--validate",
"url=url",
"--validate",
"title=length:min=1,max=200",
"--default",
"alive=true",
"--query",
"find_by_tag:tag",
"--query",
"find_by_alive:alive",
],
);
let model = fs::read_to_string(project.join("src/models/bookmark.rs")).unwrap();
assert!(model.contains("#[indexed]\n #[validate(url)]\n pub url: String,"));
assert!(model.contains("#[validate(length(min = 1, max = 200))]\n pub title: String,"));
assert!(model.contains("#[indexed]\n pub tag: String,"));
assert!(model.contains("#[default]\n pub alive: bool,"));
let repo = fs::read_to_string(project.join("src/repositories/bookmark.rs")).unwrap();
assert!(repo.contains("fn find_by_tag(tag: String) -> Vec<Bookmark>;"));
assert!(repo.contains("fn find_by_alive(alive: bool) -> Vec<Bookmark>;"));
let routes = fs::read_to_string(project.join("src/routes/bookmarks.rs")).unwrap();
assert!(routes.contains("impl autumn_web::form::FormModel for BookmarkForm"));
assert!(routes.contains("<Bookmark as autumn_web::form::FormModel>::form_fields()"));
assert!(routes.contains("autumn_web::form::form_for(changeset, action, \"post\")"));
assert!(!routes.contains("autumn_web::form::required_text_input(&changeset"));
assert_eq!(
routes.matches("\"alive\"").count(),
1,
"`alive` is defaulted: its only quoted mention may be the CSV header:\n{routes}"
);
assert!(routes.contains(r#"&["id", "url", "title", "tag", "alive", "created_at"]"#));
assert!(routes.contains("self.alive.to_string(),"));
assert!(routes.contains("bookmarks::tag.eq(new.tag.clone())"));
assert!(!routes.contains("bookmarks::alive.eq("));
assert!(!routes.contains("new.alive"));
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_create_bookmarks")
})
.expect("create_bookmarks migration should exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(up.contains("alive BOOLEAN NOT NULL DEFAULT TRUE"));
assert!(up.contains("CREATE INDEX idx_bookmarks_url ON bookmarks (url);"));
assert!(up.contains("CREATE INDEX idx_bookmarks_tag ON bookmarks (tag);"));
let cargo_toml = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo_toml.contains("validator ="),
"validation attributes need validator in Cargo.toml:\n{cargo_toml}"
);
}
#[test]
fn generate_scaffold_rejects_query_name_field_mismatch() {
let (_tmp, project) = fresh_project("scaffold-bad-query-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Bookmark",
"tag:String",
"alive:bool",
"--query",
"find_by_alive:tag",
],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("find_by_alive:tag") && stderr.contains("must match field 'tag'"),
"expected query mismatch validation error; got stderr: {stderr}"
);
}
#[test]
fn generate_scaffold_rejects_validator_field_type_mismatch() {
let (_tmp, project) = fresh_project("scaffold-bad-validator-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Bookmark",
"alive:bool",
"--validate",
"alive=url",
],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("alive=url") && stderr.contains("url validation requires String or Text"),
"expected validator type validation error; got stderr: {stderr}"
);
}
#[test]
fn generate_scaffold_rejects_i32_default_outside_sql_integer_range() {
let (_tmp, project) = fresh_project("scaffold-bad-default-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Counter",
"count:i32",
"--default",
"count=9223372036854775807",
],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("count=9223372036854775807")
&& stderr.contains("i32 defaults must fit the SQL INTEGER range"),
"expected i32 default range validation error; got stderr: {stderr}"
);
}
#[test]
#[ignore = "slow: compiles the generated project with cargo check"]
fn destroy_scaffold_round_trip_leaves_cargo_check_green() {
let (_tmp, project) = fresh_project("destroy-scaffold-check-app");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
run_autumn(&project, &["destroy", "scaffold", "Post", "title:String"]);
let check = Command::new("cargo")
.args(["check", "--all-targets"])
.current_dir(&project)
.output()
.expect("failed to run cargo check");
assert!(
check.status.success(),
"cargo check failed on the round-tripped project:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[tokio::test]
#[ignore = "slow: starts Postgres, runs diesel migrations, builds and boots a generated app"]
async fn generated_scaffold_serves_posts_index_and_json_api() {
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
let (_tmp, project) = fresh_project("scaffold-live");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"published:bool",
],
);
let postgres = Postgres::default()
.start()
.await
.expect("failed to start Postgres testcontainer");
let host = postgres.get_host().await.expect("postgres host");
let pg_port = postgres
.get_host_port_ipv4(5432)
.await
.expect("postgres port");
let database_url = format!("postgres://postgres:postgres@{host}:{pg_port}/postgres");
run_autumn_with_env(
&project,
&["migrate"],
&[("AUTUMN_DATABASE__URL", database_url.as_str())],
);
let build = Command::new("cargo")
.args(["build"])
.current_dir(&project)
.output()
.expect("failed to run cargo build");
assert!(
build.status.success(),
"cargo build failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr),
);
let port = free_port();
let child = Command::new("cargo")
.args(["run", "--quiet"])
.current_dir(&project)
.env("AUTUMN_SERVER__PORT", port.to_string())
.env("AUTUMN_DATABASE__URL", &database_url)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn generated server");
let client = reqwest::Client::new();
let base = format!("http://127.0.0.1:{port}");
let _server = wait_for_server_ready_async(child, &client, &base).await;
let response = client
.get(format!("{base}/posts"))
.send()
.await
.expect("GET /posts failed");
assert_eq!(response.status(), 200, "GET /posts status");
let html = response.text().await.expect("GET /posts body");
assert!(
html.contains("<h1>Posts</h1>") && html.contains("New Post"),
"GET /posts did not render the generated index template:\n{html}",
);
let response = client
.get(format!("{base}/api/posts"))
.send()
.await
.expect("GET /api/posts failed");
assert_eq!(response.status(), 200, "GET /api/posts status");
let body = response.text().await.expect("GET /api/posts body");
let envelope: serde_json::Value =
serde_json::from_str(body.trim()).expect("GET /api/posts must return a JSON Page envelope");
let content = envelope["content"]
.as_array()
.expect("Page envelope must carry a content array");
assert!(content.is_empty(), "empty JSON index body: {body}");
assert_eq!(
envelope["total_elements"], 0,
"empty JSON index total_elements"
);
}
#[test]
fn generate_outside_project_root_fails_clearly() {
let tmp = tempfile::tempdir().unwrap();
let (_, stderr, code) =
run_autumn_failing(tmp.path(), &["generate", "model", "Post", "title:String"]);
assert_eq!(code, Some(1));
assert!(stderr.contains("not inside an Autumn project"));
}
#[test]
fn generate_help_documents_field_dsl() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("model"));
assert!(stdout.contains("migration"));
assert!(stdout.contains("scaffold"));
}
#[test]
fn generate_help_documents_decimal_field_type() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("decimal"), "got: {stdout}");
assert!(stdout.contains("NUMERIC"), "got: {stdout}");
}
#[test]
fn generate_model_help_shows_example() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "model", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("autumn generate model Post"));
assert!(stdout.contains("--dry-run"));
assert!(stdout.contains("--force"));
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("scaffold-build");
let cargo_toml_path = project.join("Cargo.toml");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"published:bool",
"archived:Option<bool>",
"subtitle:Option<String>",
"views:Option<i64>",
"rank:i32",
"rating:f64",
"weight:Option<f32>",
"published_at:Option<NaiveDateTime>",
"scheduled_at:DateTime",
"token:Option<Uuid>",
"status:enum{draft,published,archived}",
"mood:Option<enum{happy,sad}>",
"price:decimal{10,2}",
"balance:Option<decimal>",
"payload:Bytea",
"nickname:Option<Bytea>",
"--validate",
"title=length:min=1,max=200",
"--live-validation",
],
);
let routes_bytea = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes_bytea.contains("pub payload: String,"),
"{routes_bytea}"
);
assert!(
routes_bytea.contains("pub nickname: Option<String>,"),
"{routes_bytea}"
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("pub async fn validate_title("),
"expected a validate_title handler:\n{routes}"
);
assert!(
routes.contains("autumn_web::form::required_text_input_htmx(&changeset, \"title\""),
"validate_title must return the full required_text_input_htmx wrapper:\n{routes}"
);
let cargo_toml_after = fs::read_to_string(&cargo_toml_path).unwrap();
for dep in [
"chrono",
"diesel",
"diesel-async",
"maud",
"serde",
"serde_json",
"serde_urlencoded",
"url",
"rust_decimal",
] {
assert!(
cargo_toml_after.contains(&format!("{dep} =")),
"Cargo.toml is missing '{dep}' after `generate scaffold`"
);
}
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_nested_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("nested-scaffold-build");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Comment",
"body:Text",
"post:references",
"--belongs-to",
"Post",
],
);
let child = fs::read_to_string(project.join("src/routes/comments.rs")).unwrap();
assert!(
child.contains("#[get(\"/posts/{post_id}/comments\", name = \"nested_index\")]"),
"missing the nested read route:\n{child}"
);
assert!(
child.contains("#[post(\"/posts/{post_id}/comments\", name = \"nested_create\")]"),
"missing the nested create route:\n{child}"
);
let parent = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
parent.contains("crate::routes::comments::children_section("),
"the parent show must render its children:\n{parent}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on the nested scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
let output = Command::new("cargo")
.args(["test", "--test", "comment", "comments_nested_under_parent"])
.current_dir(&project)
.output()
.unwrap();
assert!(
output.status.success(),
"the generated nested write-path test failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("test result: ok"),
"expected the generated nested test to pass:\n{stdout}"
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_policy_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("policy-scaffold-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"author_id:i64",
],
);
let policy = fs::read_to_string(project.join("src/policies/post.rs")).unwrap();
assert!(
policy.contains("impl Policy<Post> for PostPolicy"),
"policy file must define PostPolicy:\n{policy}"
);
assert!(
policy.contains("Some(post.author_id)"),
"owner check must use author_id:\n{policy}"
);
let main_rs = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(
main_rs.contains(".policy::<crate::models::post::Post, _>"),
"main.rs must register the policy:\n{main_rs}"
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("autumn_web::authorization::authorize::<Post>"),
"routes must authorize mutating handlers:\n{routes}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated policy scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
let cross_user = Command::new("cargo")
.args([
"test",
"--test",
"post",
"posts_cross_user_mutations_are_forbidden",
])
.current_dir(&project)
.output()
.unwrap();
assert!(
cross_user.status.success(),
"generated cross-user 403 test failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&cross_user.stdout),
String::from_utf8_lossy(&cross_user.stderr),
);
assert!(
String::from_utf8_lossy(&cross_user.stdout).contains("test result: ok"),
"expected the cross-user test to pass:\n{}",
String::from_utf8_lossy(&cross_user.stdout)
);
let export_csv = Command::new("cargo")
.args([
"test",
"--test",
"post",
"posts_export_csv_downloads_a_spreadsheet",
])
.current_dir(&project)
.output()
.unwrap();
assert!(
export_csv.status.success(),
"generated CSV export test failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&export_csv.stdout),
String::from_utf8_lossy(&export_csv.stderr),
);
assert!(
String::from_utf8_lossy(&export_csv.stdout).contains("1 passed"),
"expected the CSV export test to run and pass:\n{}",
String::from_utf8_lossy(&export_csv.stdout)
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_nullable_owner_policy_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("policy-nullable-owner-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Note",
"title:String",
"body:Text",
"user:references?",
],
);
let policy = fs::read_to_string(project.join("src/policies/note.rs")).unwrap();
assert!(
policy.contains("ctx.user_id_i64() == note.user_id"),
"nullable owner must compare option-to-option:\n{policy}"
);
assert!(
!policy.contains("Some(note.user_id)"),
"nullable owner must not wrap in Some(...):\n{policy}"
);
assert!(
policy.contains("notes::user_id.eq(owner_id)"),
"scope must filter on the nullable owner column:\n{policy}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated nullable-owner policy scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_live_owner_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("live-owner-scaffold-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"author_id:i64",
"--live",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("autumn_web::authorization::authorize::<Post>"),
"live routes must authorize mutating handlers:\n{routes}"
);
assert!(
routes.contains("let current: Post = repo.find_by_id(*id).await?"),
"live variant must load the current row via the repository:\n{routes}"
);
assert!(
routes.contains("posts::author_id.eq(owner_id)"),
"live index must be owner-scoped:\n{routes}"
);
assert!(
routes.contains("sse-connect=(paths::events())"),
"live owner index must keep the SSE list contract:\n{routes}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated live-owner scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_sharded_owner_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("sharded-owner-scaffold-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"author_id:i64",
"--sharded",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("autumn_web::authorization::authorize::<Post>"),
"sharded routes must authorize mutating handlers:\n{routes}"
);
assert!(
routes.contains(
"let current: Post = PgPostRepository::from_shard(&db).find_by_id(*id).await?"
),
"sharded variant must load the current row via from_shard:\n{routes}"
);
assert!(
routes.contains("posts::author_id.eq(owner_id)"),
"sharded index must be owner-scoped:\n{routes}"
);
assert!(
!routes.contains("mut db: Db"),
"sharded handlers must use ShardedDb, not a bare Db extractor:\n{routes}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated sharded-owner scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_attachment_owner_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("attachment-owner-scaffold-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"author_id:i64",
"avatar:Attachment",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("autumn_web::authorization::authorize_create::<Post>(&*state, &session)"),
"attachment create must authorize_create via the reused &*state wrapper:\n{routes}"
);
assert!(
routes.contains(
"autumn_web::authorization::authorize::<Post>(&*state, &session, \"update\", ¤t)"
),
"attachment update must authorize via &*state:\n{routes}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated attachment-owner scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-builds and runs a fresh project's test suite — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_validated_scaffold_round_trip_test_passes() {
let (_tmp, project) = fresh_project("scaffold-validation-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:String",
"--validate",
"title=length:min=1,max=200",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("pub struct PostForm"));
assert!(routes.contains("into_changeset()"));
assert!(routes.contains("StatusCode::UNPROCESSABLE_ENTITY"));
let test_file = fs::read_to_string(project.join("tests/post.rs")).unwrap();
assert!(test_file.contains("posts_rejects_invalid_title_and_preserves_input"));
let output = Command::new("cargo")
.args([
"test",
"--test",
"post",
"posts_rejects_invalid_title_and_preserves_input",
])
.current_dir(&project)
.output()
.unwrap();
assert!(
output.status.success(),
"the generated changeset round-trip test failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("test result: ok"),
"expected the generated test to pass:\n{stdout}"
);
let write_path = Command::new("cargo")
.args(["test", "--test", "post", "posts_write_path_crud"])
.current_dir(&project)
.output()
.unwrap();
assert!(
write_path.status.success(),
"the generated write-path CRUD test failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&write_path.stdout),
String::from_utf8_lossy(&write_path.stderr),
);
assert!(
String::from_utf8_lossy(&write_path.stdout).contains("test result: ok"),
"expected the generated write-path test to pass:\n{}",
String::from_utf8_lossy(&write_path.stdout)
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_job_cargo_checks() {
let (_tmp, project) = fresh_project("job-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"job",
"SendWelcomeEmail",
"user_id:i64",
"email:String",
"amount:decimal",
],
);
let cargo_toml = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo_toml.contains("serde"),
"Cargo.toml must include serde after generate job"
);
assert!(
cargo_toml.contains("rust_decimal"),
"Cargo.toml must include rust_decimal after generate job with a decimal field"
);
assert!(
project.join("src/jobs/send_welcome_email.rs").exists(),
"src/jobs/send_welcome_email.rs must exist"
);
assert!(
project.join("src/jobs/mod.rs").exists(),
"src/jobs/mod.rs must exist"
);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("mod jobs;"), "main.rs must declare mod jobs");
assert!(
main.contains(".jobs(jobs::registered_jobs())"),
"main.rs must include .jobs() call"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated job failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
fn generate_model_unique_field_emits_unique_index() {
let (_tmp, project) = fresh_project("unique-app");
run_autumn(
&project,
&["generate", "model", "User", "email:String:unique"],
);
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.expect("create_users migration should exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"got:\n{up}"
);
assert!(
!up.contains("CREATE INDEX idx_users_email ON"),
"a unique field must not also emit a plain, non-unique index; got:\n{up}"
);
}
#[test]
fn generate_model_unique_flag_marks_field_unique() {
let (_tmp, project) = fresh_project("unique-flag-app");
run_autumn(
&project,
&[
"generate",
"model",
"User",
"email:String",
"--unique",
"email",
],
);
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.expect("create_users migration should exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"got:\n{up}"
);
}
#[test]
fn generate_model_unique_flag_rejects_unknown_field() {
let (_tmp, project) = fresh_project("unique-flag-unknown-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"model",
"User",
"email:String",
"--unique",
"bogus",
],
);
assert_eq!(code, Some(1));
assert!(stderr.contains("bogus"), "got: {stderr}");
}
#[test]
fn generate_migration_add_unique_column_emits_unique_index() {
let (_tmp, project) = fresh_project("unique-migration-app");
run_autumn(
&project,
&[
"generate",
"migration",
"AddEmailToUsers",
"email:String:unique",
],
);
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_add_email_to_users")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("ALTER TABLE users ADD COLUMN email TEXT NOT NULL;"),
"got:\n{up}"
);
assert!(
up.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"got:\n{up}"
);
}
#[test]
fn generate_migration_unique_flag_emits_unique_index() {
let (_tmp, project) = fresh_project("unique-migration-flag-app");
run_autumn(
&project,
&[
"generate",
"migration",
"AddEmailToUsers",
"email:String",
"--unique",
"email",
],
);
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_add_email_to_users")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"got:\n{up}"
);
}
#[test]
fn generate_migration_remove_unique_column_rollback_restores_unique_index() {
let (_tmp, project) = fresh_project("unique-migration-remove-app");
run_autumn(
&project,
&[
"generate",
"migration",
"RemoveEmailFromUsers",
"email:String:unique",
],
);
let migrations = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_remove_email_from_users")
})
.collect::<Vec<_>>();
assert_eq!(migrations.len(), 1);
let down = fs::read_to_string(migrations[0].path().join("down.sql")).unwrap();
assert!(
down.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"rollback must restore the UNIQUE index, not just the bare column; got:\n{down}"
);
}
#[test]
fn generate_scaffold_unique_field_adds_find_by_query_to_repository() {
let (_tmp, project) = fresh_project("unique-scaffold-repo-app");
run_autumn(
&project,
&["generate", "scaffold", "User", "email:String:unique"],
);
let repo = fs::read_to_string(project.join("src/repositories/user.rs")).unwrap();
assert!(
repo.contains("fn find_by_email(email: String) -> Vec<User>;"),
"a unique field must get a derived find_by_ repository lookup for free; got:\n{repo}"
);
}
#[test]
fn generate_scaffold_unique_flag_adds_find_by_query_to_repository() {
let (_tmp, project) = fresh_project("unique-scaffold-flag-repo-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String",
"--unique",
"email",
],
);
let repo = fs::read_to_string(project.join("src/repositories/user.rs")).unwrap();
assert!(
repo.contains("fn find_by_email(email: String) -> Vec<User>;"),
"got:\n{repo}"
);
}
#[test]
fn generate_scaffold_unique_field_does_not_duplicate_explicit_query() {
let (_tmp, project) = fresh_project("unique-scaffold-explicit-query-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"--query",
"find_by_email:email",
],
);
let repo = fs::read_to_string(project.join("src/repositories/user.rs")).unwrap();
assert_eq!(
repo.matches("fn find_by_email(").count(),
1,
"an explicit --query and the automatic unique-field derivation for the \
same field must not produce two trait methods:\n{repo}"
);
}
#[test]
fn generate_scaffold_unique_field_routes_handle_duplicate_with_inline_error() {
let (_tmp, project) = fresh_project("unique-scaffold-routes-app");
run_autumn(
&project,
&["generate", "scaffold", "User", "email:String:unique"],
);
let routes = fs::read_to_string(project.join("src/routes/users.rs")).unwrap();
assert!(routes.contains("UNIQUE_CONSTRAINTS"), "got:\n{routes}");
assert!(routes.contains("idx_users_email_unique"), "got:\n{routes}");
assert!(
routes.contains("autumn_web::error::unique_violation_field"),
"got:\n{routes}"
);
assert!(
routes.contains("StatusCode::UNPROCESSABLE_ENTITY"),
"got:\n{routes}"
);
assert!(
!routes.contains(
"pub async fn create(flash: Flash, mut db: Db, body: Bytes) -> AutumnResult<Markup>"
),
"a scaffold with a unique field must not use the plain create signature \
that can only ever 500 on a duplicate; got:\n{routes}"
);
}
#[test]
fn generate_scaffold_unique_field_create_violation_form_preserves_submitted_values() {
let (_tmp, project) = fresh_project("unique-create-preserve-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"age:i32",
"active:bool",
"status:enum{draft,published}",
],
);
let routes = fs::read_to_string(project.join("src/routes/users.rs")).unwrap();
let new_form_start = routes
.find("pub async fn new_form")
.expect("new_form handler");
let create_start = routes.find("pub async fn create(").expect("create handler");
let new_form_body = &routes[new_form_start..create_start];
assert!(
!new_form_body.contains("new."),
"the blank new_form must not reference `new`; got:\n{new_form_body}"
);
let create_body = &routes[create_start..];
assert!(
create_body.contains("Changeset::from_errors(changeset.into_inner(), errors)"),
"got:\n{create_body}"
);
assert!(
create_body.contains("user_form_for(&changeset"),
"got:\n{create_body}"
);
assert!(
routes.contains(".override_field(\"status\", autumn_web::form::FieldControl::Select"),
"got:\n{routes}"
);
}
#[test]
fn generate_scaffold_unique_field_update_violation_form_preserves_submitted_values() {
let (_tmp, project) = fresh_project("unique-update-preserve-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"age:i32",
"active:bool",
"status:enum{draft,published}",
],
);
let routes = fs::read_to_string(project.join("src/routes/users.rs")).unwrap();
let edit_form_start = routes
.find("pub async fn edit_form")
.expect("edit_form handler");
let update_start = routes.find("pub async fn update(").expect("update handler");
let edit_form_body = &routes[edit_form_start..update_start];
assert!(
edit_form_body.contains("Changeset::new(UserForm::from(&row))"),
"the edit_form seeds a changeset from the loaded row; got:\n{edit_form_body}"
);
let update_only = &routes[update_start..];
let destroy_off = update_only
.find("pub async fn destroy")
.expect("destroy handler after update");
let update_body = &update_only[..destroy_off];
assert!(
update_body.contains("Changeset::from_errors(changeset.into_inner(), errors)"),
"got:\n{update_body}"
);
assert!(
update_body.contains("user_form_for(&changeset"),
"got:\n{update_body}"
);
assert_eq!(
update_body.matches(".first(&mut *db)").count(),
1,
"the update violation path must not re-fetch the row for its re-render; \
the only load is the authorize preamble; got:\n{update_body}"
);
assert!(
update_body.contains("authorize::<User>(&state, &session, \"update\", ¤t)"),
"the single row load must be the authorize preamble; got:\n{update_body}"
);
}
#[test]
fn generate_scaffold_unique_field_emits_duplicate_rejection_smoke_test() {
let (_tmp, project) = fresh_project("unique-scaffold-smoke-app");
run_autumn(
&project,
&["generate", "scaffold", "User", "email:String:unique"],
);
let test_file = fs::read_to_string(project.join("tests/user.rs")).unwrap();
assert!(
test_file.contains("async fn users_rejects_duplicate_email()"),
"got:\n{test_file}"
);
assert!(
test_file.contains("idx_users_email_unique"),
"got:\n{test_file}"
);
assert!(
test_file.contains("autumn_web::error::unique_violation_field"),
"got:\n{test_file}"
);
assert!(
test_file.contains("must violate the UNIQUE index"),
"got:\n{test_file}"
);
assert!(
test_file.contains(".assert_status(422)") && test_file.contains(".assert_status(200)"),
"got:\n{test_file}"
);
let before_200 = test_file
.split(".assert_status(200)")
.next()
.expect("assert_status(200) must appear in the generated test");
assert!(
before_200
.matches("TRUNCATE users RESTART IDENTITY")
.count()
>= 2,
"the table must be truncated again before the request-boundary POSTs; got:\n{test_file}"
);
}
#[test]
fn generate_scaffold_unique_enum_field_smoke_test_inserts_a_valid_variant() {
let (_tmp, project) = fresh_project("unique-enum-smoke-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"status:enum{draft,published}:unique",
],
);
let test_file = fs::read_to_string(project.join("tests/post.rs")).unwrap();
assert!(
test_file.contains("async fn posts_rejects_duplicate_status()"),
"got:\n{test_file}"
);
assert!(
!test_file.contains("'dup_value'"),
"a unique enum field's smoke test must not insert an invalid variant \
literal; got:\n{test_file}"
);
assert!(
test_file.contains("(status) VALUES ('draft')"),
"the target enum field must be seeded with its first declared \
variant; got:\n{test_file}"
);
}
#[test]
fn generate_scaffold_unique_reference_field_smoke_test_uses_seeded_fk_value() {
let (_tmp, project) = fresh_project("unique-reference-smoke-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Membership",
"profile:references:unique",
],
);
let test_file = fs::read_to_string(project.join("tests/membership.rs")).unwrap();
assert!(
test_file.contains("async fn memberships_rejects_duplicate_profile_id()"),
"got:\n{test_file}"
);
assert!(
!test_file.contains("424242"),
"a unique `references` field's smoke test must not insert an \
arbitrary FK id that doesn't exist in the seeded stub table; \
got:\n{test_file}"
);
assert!(
test_file.contains("(profile_id) VALUES (1)"),
"the target references field must be seeded with the stub table's \
real row id; got:\n{test_file}"
);
}
#[test]
fn generate_scaffold_multiple_unique_fields_smoke_test_isolates_the_target_field() {
let (_tmp, project) = fresh_project("unique-multi-field-smoke-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"username:String:unique",
],
);
let test_file = fs::read_to_string(project.join("tests/user.rs")).unwrap();
let username_test_start = test_file
.find("async fn users_rejects_duplicate_username()")
.expect("users_rejects_duplicate_username test should exist");
let email_test_start = test_file
.find("async fn users_rejects_duplicate_email()")
.expect("users_rejects_duplicate_email test should exist");
let (test_body, other_field_name) = if username_test_start < email_test_start {
(&test_file[username_test_start..], "email")
} else {
(&test_file[email_test_start..], "username")
};
let insert_calls: Vec<&str> = test_body
.lines()
.filter(|l| l.contains("diesel::sql_query(") && l.contains(other_field_name))
.take(2)
.collect();
assert_eq!(
insert_calls.len(),
2,
"expected two inserts referencing the non-target unique field \
{other_field_name}; got:\n{test_body}"
);
assert_ne!(
insert_calls[0], insert_calls[1],
"the first and duplicate inserts must give the non-target unique \
field {other_field_name} different values, or it would collide \
with itself the same way the target field is meant to; got:\n{test_body}"
);
}
#[test]
fn generate_scaffold_multiple_unique_fields_request_boundary_check_isolates_the_target_field() {
let (_tmp, project) = fresh_project("unique-multi-field-boundary-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"username:String:unique",
],
);
let test_file = fs::read_to_string(project.join("tests/user.rs")).unwrap();
for (field, other_field) in [("email", "username"), ("username", "email")] {
let test_start = test_file
.find(&format!("async fn users_rejects_duplicate_{field}()"))
.unwrap_or_else(|| panic!("users_rejects_duplicate_{field} test should exist"));
let next_test_start = test_file[test_start + 1..]
.find("async fn users_rejects_duplicate_")
.map_or(test_file.len(), |i| test_start + 1 + i);
let test_body = &test_file[test_start..next_test_start];
assert!(
test_body
.contains("let existing: i64 = users::table.count().get_result(&mut *db).await?;"),
"the request-boundary handler must branch on the table's row \
count to tell the two calls apart; got:\n{test_body}"
);
let branch_line = test_body
.lines()
.find(|l| l.contains("let insert_sql = if existing == 0"))
.unwrap_or_else(|| panic!("expected an insert_sql branch; got:\n{test_body}"));
let quoted: Vec<&str> = branch_line.split('"').collect();
assert!(
quoted.len() >= 4,
"expected two quoted SQL literals in the branch; got: {branch_line}"
);
let (if_sql, else_sql) = (quoted[1], quoted[3]);
assert_ne!(
if_sql, else_sql,
"the two insert_sql branches must give the non-target unique \
field {other_field} different values, or the second HTTP call \
would collide on it too; got: {branch_line}"
);
}
}
#[test]
fn generate_scaffold_long_unique_field_name_agrees_across_migration_and_routes() {
let (_tmp, project) = fresh_project("unique-longname-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"AVeryLongModelNameForTruncationTesting",
"an_equally_long_field_name_for_good_measure:String:unique",
],
);
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_create_a_very_long_model_name_for_truncation_testings")
})
.expect("create migration for the long-named model should exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
let index_line = up
.lines()
.find(|l| l.starts_with("CREATE UNIQUE INDEX"))
.unwrap_or_else(|| panic!("expected a CREATE UNIQUE INDEX line; got:\n{up}"));
let index_name = index_line
.strip_prefix("CREATE UNIQUE INDEX ")
.and_then(|rest| rest.split(' ').next())
.expect("index name token");
assert!(
index_name.len() <= 63,
"index name must fit Postgres's identifier limit, got {} bytes: {index_name}",
index_name.len()
);
let routes = fs::read_to_string(
project.join("src/routes/a_very_long_model_name_for_truncation_testings.rs"),
)
.unwrap();
assert!(
routes.contains(&format!("\"{index_name}\"")),
"the generated routes' UNIQUE_CONSTRAINTS must reference the exact \
same (possibly truncated) index name as the migration; index_name=\
{index_name}, got:\n{routes}"
);
}
#[test]
fn generate_scaffold_unique_field_avoids_name_collision_with_coincidentally_named_index() {
let (_tmp, project) = fresh_project("unique-collision-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"email_unique:String",
"--index",
"email_unique",
],
);
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.expect("create_users migration should exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE INDEX idx_users_email_unique ON users (email_unique);"),
"got:\n{up}"
);
assert!(
!up.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"the unique index must not collide with the plain index's exact \
name; got:\n{up}"
);
let disambiguated_line = up
.lines()
.find(|l| l.starts_with("CREATE UNIQUE INDEX") && l.contains(" (email);"))
.unwrap_or_else(|| panic!("expected a disambiguated unique index for email; got:\n{up}"));
let index_name = disambiguated_line
.strip_prefix("CREATE UNIQUE INDEX ")
.and_then(|rest| rest.split(' ').next())
.expect("index name token");
assert_ne!(index_name, "idx_users_email_unique");
let routes = fs::read_to_string(project.join("src/routes/users.rs")).unwrap();
assert!(
routes.contains(&format!("\"{index_name}\"")),
"got:\n{routes}"
);
}
#[test]
fn generate_migration_add_unique_field_avoids_collision_with_earlier_migrations_column() {
let (_tmp, project) = fresh_project("unique-cross-migration-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email_unique:String",
"--index",
"email_unique",
],
);
run_autumn(
&project,
&[
"generate",
"migration",
"AddEmailToUsers",
"email:String:unique",
],
);
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_add_email_to_users")
})
.expect("add_email_to_users migration should exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(
!up.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"must not collide with the pre-existing email_unique column's plain \
index (created by the earlier `generate scaffold` call); got:\n{up}"
);
assert!(
up.contains("CREATE UNIQUE INDEX idx_users_email_unique_"),
"the unique index must still exist, under a disambiguated name; \
got:\n{up}"
);
}
#[test]
fn generate_scaffold_unique_attachment_field_is_skipped_in_smoke_test() {
let (_tmp, project) = fresh_project("unique-attachment-smoke-app");
run_autumn(
&project,
&["generate", "scaffold", "Document", "file:Attachment:unique"],
);
let test_file = fs::read_to_string(project.join("tests/document.rs")).unwrap();
assert!(
!test_file.contains("rejects_duplicate_file"),
"got:\n{test_file}"
);
}
#[test]
fn destroy_attachment_scaffold_keeps_features_used_by_handwritten_route() {
let (_tmp, project) = fresh_project("destroy-attachment-features-app");
run_autumn(
&project,
&["generate", "scaffold", "Document", "file:Attachment"],
);
let cargo_before = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo_before.contains("\"multipart\"") && cargo_before.contains("\"storage\""),
"attachment scaffold must enable multipart+storage, got:\n{cargo_before}"
);
fs::write(
project.join("src/routes/manual.rs"),
"use autumn_web::prelude::*;\n\n\
pub async fn manual_upload(\n \
mut multipart: Multipart,\n \
store: autumn_web::storage::BlobStoreState,\n\
) {\n let _ = (&mut multipart, &store);\n}\n",
)
.unwrap();
run_autumn(
&project,
&["destroy", "scaffold", "Document", "file:Attachment"],
);
let cargo_after = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo_after.contains("\"multipart\""),
"multipart must be retained while a hand-written route uses the Multipart extractor, got:\n{cargo_after}"
);
assert!(
cargo_after.contains("\"storage\""),
"storage must be retained while a hand-written route references autumn_web::storage::, got:\n{cargo_after}"
);
}
#[test]
fn generate_scaffold_without_unique_field_omits_unique_constraints() {
let (_tmp, project) = fresh_project("no-unique-scaffold-routes-app");
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(!routes.contains("UNIQUE_CONSTRAINTS"), "got:\n{routes}");
assert!(
routes.contains(
"pub async fn create(flash: Flash, csrf: Option<CsrfToken>, csrf_field: Option<CsrfFormField>, submit_token: Option<SubmitToken>, submit_field: Option<SubmitFormField>, mut db: Db, \n autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\n session: autumn_web::session::Session,\n body: Bytes) -> AutumnResult<autumn_web::reexports::axum::response::Response>"
),
"got:\n{routes}"
);
assert!(routes.contains("form.into_changeset()"), "got:\n{routes}");
}
#[test]
fn generate_model_help_documents_unique_field() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "model", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("unique"), "got:\n{stdout}");
assert!(stdout.contains("--unique"), "got:\n{stdout}");
}
#[test]
fn generate_scaffold_help_documents_unique_field() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "scaffold", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("unique"), "got:\n{stdout}");
assert!(stdout.contains("--unique"), "got:\n{stdout}");
}
#[test]
fn generate_model_help_documents_the_encrypted_modifier() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "model", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("{encrypted}"), "got:\n{stdout}");
assert!(
stdout.contains("{encrypted:deterministic}"),
"got:\n{stdout}"
);
assert!(stdout.contains("autumn credentials edit"), "got:\n{stdout}");
}
#[test]
fn generate_scaffold_help_documents_the_encrypted_modifier() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "scaffold", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("{encrypted}"), "got:\n{stdout}");
assert!(
stdout.contains("{encrypted:deterministic}"),
"got:\n{stdout}"
);
assert!(stdout.contains("autumn credentials edit"), "got:\n{stdout}");
}
#[test]
fn generate_help_encrypted_examples_are_shell_quoted() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
for args in [
vec!["generate", "--help"],
vec!["generate", "model", "--help"],
vec!["generate", "scaffold", "--help"],
] {
let label = args.join(" ");
let output = Command::new(autumn_bin)
.args(&args)
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let mut checked = 0usize;
for (idx, _) in stdout.match_indices("String{encrypted") {
let token_start = stdout[..idx]
.rfind(|c: char| c.is_whitespace())
.map_or(0, |i| i + 1);
let is_example = stdout[..token_start].ends_with("autumn generate ")
|| stdout[..token_start]
.rsplit('\n')
.next()
.is_some_and(|line| line.contains("autumn generate "));
if !is_example {
continue;
}
checked += 1;
assert!(
stdout[token_start..].starts_with('\''),
"`{label}` shows an unquoted example — bash/zsh would \
brace-expand it before `autumn` sees it: {}",
&stdout[token_start
..stdout[token_start..]
.find(char::is_whitespace)
.map_or(stdout.len(), |i| token_start + i)]
);
}
assert!(checked > 0, "`{label}` shows no runnable encrypted example");
}
}
#[test]
fn generate_scaffold_help_documents_unique_is_html_only() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "scaffold", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("HTML-only"), "got:\n{stdout}");
assert!(stdout.contains("--api"), "got:\n{stdout}");
assert!(stdout.contains("#[repository]"), "got:\n{stdout}");
}
#[test]
fn generate_migration_help_documents_unique_field() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "migration", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("unique"), "got:\n{stdout}");
assert!(stdout.contains("--unique"), "got:\n{stdout}");
}
#[test]
fn generate_scaffold_with_unique_dry_run_lists_migration_file() {
let (_tmp, project) = fresh_project("unique-dryrun-app");
let (stdout, _stderr) = run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"--dry-run",
],
);
assert!(stdout.contains("Dry run"));
assert!(stdout.contains("src/models/user.rs"));
assert!(
stdout.contains("_create_users") && stdout.contains("up.sql"),
"the migration file plan must be listed under --dry-run; got:\n{stdout}"
);
assert!(!project.join("src/models/user.rs").exists());
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_unique_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("unique-scaffold-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&["generate", "scaffold", "User", "email:String:unique"],
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated unique scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
#[allow(clippy::literal_string_with_formatting_args)]
fn generated_slug_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("slug-scaffold-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
r"slug:slug{from:title}",
],
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated slug scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
fn generate_scaffold_unique_live_field_wires_repository_save_and_db_refetch() {
let (_tmp, project) = fresh_project("unique-live-scaffold-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"User",
"email:String:unique",
"--live",
],
);
let routes = fs::read_to_string(project.join("src/routes/users.rs")).unwrap();
assert!(
routes.contains("repo.save(&new).await?;"),
"the create failure branch must still route inserts through the \
repository on --live, not a bare diesel call; got:\n{routes}"
);
assert!(
routes.contains("repo.update(*id, &update_changes).await?;"),
"got:\n{routes}"
);
let update_start = routes.find("pub async fn update(").expect("update handler");
let update_body = &routes[update_start..];
assert!(
!update_body.contains("mut db:"),
"the live update handler must not also take a `Db` extractor; \
got:\n{update_body}"
);
assert!(
update_body.contains("Changeset::from_errors(changeset.into_inner(), errors)"),
"the update violation path re-renders the submitted changeset; got:\n{update_body}"
);
assert!(routes.contains("repo: PgUserRepository"), "got:\n{routes}");
assert!(
routes.contains("autumn_web::error::unique_violation_field"),
"got:\n{routes}"
);
}
#[test]
fn generate_channel_creates_all_expected_files() {
let (_tmp, project) = fresh_project("channel-app");
let (stdout, _stderr) = run_autumn(&project, &["generate", "channel", "Chat"]);
assert!(
stdout.contains("chat.rs") || stdout.contains("Created"),
"output should mention created files: {stdout}"
);
assert!(project.join("src/channels/chat.rs").is_file());
let channel = fs::read_to_string(project.join("src/channels/chat.rs")).unwrap();
assert!(channel.contains(r#"#[get("/chat")]"#));
assert!(channel.contains(r#"#[get("/chat/events")]"#));
assert!(channel.contains(r#"#[post("/chat/messages")]"#));
assert!(channel.contains(r#"sse-connect="/chat/events""#));
assert!(channel.contains(r#"sse-swap="message""#));
assert!(channel.contains("autumn_web::sse::stream"));
assert!(project.join("src/channels/mod.rs").is_file());
let mod_rs = fs::read_to_string(project.join("src/channels/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod chat;"));
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("mod channels;"));
assert!(main.contains("channels::chat::chat_page"));
assert!(main.contains("channels::chat::chat_events"));
assert!(main.contains("channels::chat::chat_publish"));
assert!(project.join("tests/chat_channel.rs").is_file());
let test_src = fs::read_to_string(project.join("tests/chat_channel.rs")).unwrap();
assert!(test_src.contains("TestApp"));
assert!(test_src.contains(".subscribe("));
assert!(!test_src.contains("#[ignore"));
let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("\"ws\""),
"Cargo.toml must enable the ws feature: {cargo}"
);
assert!(cargo.contains("maud"));
assert!(cargo.contains("serde"));
}
#[test]
fn generate_channel_ws_emits_ws_handler() {
let (_tmp, project) = fresh_project("channel-ws-app");
run_autumn(&project, &["generate", "channel", "Chat", "--ws"]);
let channel = fs::read_to_string(project.join("src/channels/chat.rs")).unwrap();
assert!(channel.contains(r#"#[ws("/chat/ws")]"#));
assert!(channel.contains("WithShutdown"));
assert!(!channel.contains("sse-connect"));
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("channels::chat::chat_ws"));
assert!(main.contains("channels::chat::chat_publish"));
let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(cargo.contains("\"ws\""));
}
#[test]
fn generate_channel_force_transport_switch_does_not_strand_stale_routes() {
let (_tmp, project) = fresh_project("channel-switch-app");
run_autumn(&project, &["generate", "channel", "Chat"]);
run_autumn(
&project,
&["generate", "channel", "Chat", "--ws", "--force"],
);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(
!main.contains("chat_page"),
"stale SSE page route must be removed: {main}"
);
assert!(
!main.contains("chat_events"),
"stale SSE events route must be removed: {main}"
);
assert!(main.contains("channels::chat::chat_ws"));
assert!(main.contains("channels::chat::chat_publish"));
let channel = fs::read_to_string(project.join("src/channels/chat.rs")).unwrap();
assert!(channel.contains(r#"#[ws("/chat/ws")]"#));
}
#[test]
fn generate_channel_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("channel-dry-app");
let (stdout, _) = run_autumn(&project, &["generate", "channel", "Chat", "--dry-run"]);
assert!(
stdout.contains("Dry run"),
"dry run must print Dry run header: {stdout}"
);
assert!(!project.join("src/channels/chat.rs").exists());
assert!(!project.join("tests/chat_channel.rs").exists());
}
#[test]
fn generate_channel_collision_without_force_fails() {
let (_tmp, project) = fresh_project("channel-collide-app");
run_autumn(&project, &["generate", "channel", "Chat"]);
let (_, stderr, code) = run_autumn_failing(&project, &["generate", "channel", "Chat"]);
assert_eq!(code, Some(1), "second run without --force must exit 1");
assert!(
stderr.contains("would overwrite") || stderr.contains("chat.rs"),
"must report collision: {stderr}"
);
}
#[test]
fn generate_channel_force_overwrites_existing() {
let (_tmp, project) = fresh_project("channel-force-app");
run_autumn(&project, &["generate", "channel", "Chat"]);
let path = project.join("src/channels/chat.rs");
fs::write(&path, "// corrupted").unwrap();
run_autumn(&project, &["generate", "channel", "Chat", "--force"]);
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("TOPIC"),
"--force must regenerate the channel file"
);
}
#[test]
fn generate_channel_sse_ws_conflict_fails() {
let (_tmp, project) = fresh_project("channel-conflict-app");
let (_, stderr, code) =
run_autumn_failing(&project, &["generate", "channel", "Chat", "--sse", "--ws"]);
assert_ne!(code, Some(0), "--sse and --ws together must fail");
assert!(
stderr.contains("cannot be used with") || stderr.contains("conflict"),
"clap should report the conflicting flags: {stderr}"
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_channel_cargo_checks() {
let (_tmp, project) = fresh_project("channel-build");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "channel", "Chat"]);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated channel (SSE) failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_channel_ws_cargo_checks() {
let (_tmp, project) = fresh_project("channel-ws-build");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "channel", "Chat", "--ws"]);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated channel (--ws) failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: builds and runs a fresh project's test suite — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_channel_smoke_test_passes() {
let (_tmp, project) = fresh_project("channel-smoke-build");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "channel", "Chat"]);
let test_run = Command::new("cargo")
.args(["test", "--test", "chat_channel"])
.current_dir(&project)
.output()
.unwrap();
assert!(
test_run.status.success(),
"generated chat_channel smoke test failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&test_run.stdout),
String::from_utf8_lossy(&test_run.stderr),
);
assert!(
String::from_utf8_lossy(&test_run.stdout).contains("test result: ok"),
"expected the smoke test to report success"
);
}
#[test]
fn generate_webhook_creates_all_expected_files() {
let (_tmp, project) = fresh_project("webhook-app");
let (stdout, stderr) = run_autumn(&project, &["generate", "webhook", "stripe", "Payments"]);
assert!(
stdout.contains("Created") && stdout.contains("payments.rs"),
"output should list the created handler: {stdout}"
);
assert!(project.join("src/webhooks/payments.rs").is_file());
assert!(project.join("src/webhooks/mod.rs").is_file());
let handler = fs::read_to_string(project.join("src/webhooks/payments.rs")).unwrap();
assert!(
handler.contains("#[post(\"/webhooks/stripe\")]"),
"handler must own the provider route path:\n{handler}"
);
assert!(
handler.contains("webhook: SignedWebhook"),
"handler must take the shipped extractor:\n{handler}"
);
assert!(
handler.contains("webhook.event_type()")
&& handler.contains("\"payment_intent.succeeded\""),
"handler must dispatch on the event type:\n{handler}"
);
let main_rs = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main_rs.contains("mod webhooks;"), "got:\n{main_rs}");
assert!(
main_rs.contains("webhooks::payments::payments_webhook"),
"the route must be registered in routes![...]:\n{main_rs}"
);
let autumn_toml = fs::read_to_string(project.join("autumn.toml")).unwrap();
assert!(
autumn_toml.contains("[[security.webhooks.endpoints]]"),
"got:\n{autumn_toml}"
);
assert!(
autumn_toml.contains("secret_env = \"STRIPE_WEBHOOK_SECRET\""),
"the endpoint must reference a secret env var, never an inline secret:\n{autumn_toml}"
);
assert!(
autumn_toml.contains("replay_protection = true"),
"replay protection must be on by default:\n{autumn_toml}"
);
assert!(
!autumn_toml.contains("exempt_paths"),
"path exemptions are derived from the endpoint block, not copied:\n{autumn_toml}"
);
assert!(
stdout.contains("Next steps:") && stdout.contains("STRIPE_WEBHOOK_SECRET"),
"the secret env var must be part of the printed next steps:\n{stdout}"
);
assert!(
stdout.contains("autumn webhook sim stripe"),
"the next steps should show how to fire a signed test delivery:\n{stdout}"
);
assert!(
!stderr.contains("Warning:"),
"a clean run must not print warnings:\n{stderr}"
);
}
#[test]
fn generate_webhook_supports_every_provider_preset() {
let (_tmp, project) = fresh_project("webhook-presets");
for (provider, name, snake) in [
("stripe", "Payments", "payments"),
("github", "Repo", "repo"),
("slack", "Events", "events"),
("generic", "Partner", "partner"),
] {
run_autumn(&project, &["generate", "webhook", provider, name]);
let handler = fs::read_to_string(project.join(format!("src/webhooks/{snake}.rs"))).unwrap();
assert!(
handler.contains(&format!("#[post(\"/webhooks/{provider}\")]")),
"{provider}: wrong route path:\n{handler}"
);
}
let autumn_toml = fs::read_to_string(project.join("autumn.toml")).unwrap();
assert_eq!(
autumn_toml
.matches("[[security.webhooks.endpoints]]")
.count(),
4,
"each preset must add its own endpoint:\n{autumn_toml}"
);
}
#[test]
fn generate_webhook_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("webhook-dry-run");
let toml_before = fs::read_to_string(project.join("autumn.toml")).unwrap();
let (stdout, _stderr) = run_autumn(
&project,
&["generate", "webhook", "stripe", "Payments", "--dry-run"],
);
assert!(stdout.contains("Dry run"), "got:\n{stdout}");
assert!(stdout.contains("Would create"), "got:\n{stdout}");
assert!(
!project.join("src/webhooks").exists(),
"--dry-run must not write any file"
);
assert_eq!(
fs::read_to_string(project.join("autumn.toml")).unwrap(),
toml_before,
"--dry-run must not touch autumn.toml"
);
}
#[test]
fn generate_webhook_rejects_an_unknown_provider() {
let (_tmp, project) = fresh_project("webhook-bad-provider");
let (_stdout, stderr, code) =
run_autumn_failing(&project, &["generate", "webhook", "twilio", "Sms"]);
assert_eq!(code, Some(1), "got:\n{stderr}");
assert!(
stderr.contains("twilio") && stderr.contains("generic"),
"got:\n{stderr}"
);
assert!(!project.join("src/webhooks").exists());
}
#[test]
fn generate_webhook_rejects_hostile_path_and_secret_env_overrides() {
let (_tmp, project) = fresh_project("webhook-hostile-input");
let toml_before = fs::read_to_string(project.join("autumn.toml")).unwrap();
let (_stdout, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"webhook",
"stripe",
"Payments",
"--path",
"/a\")]pub fn evil(){}//",
],
);
assert_eq!(code, Some(1), "got:\n{stderr}");
let (_stdout, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"webhook",
"stripe",
"Payments",
"--secret-env",
"X\n\n[[security.webhooks.endpoints]]\nname = \"evil\"\npath = \"/evil\"\nprovider = \"generic\"\nsecret = \"attacker-known\"\nreplay_protection = false\n# ",
],
);
assert_eq!(code, Some(1), "got:\n{stderr}");
assert!(
stderr.contains("secret environment variable"),
"got:\n{stderr}"
);
assert!(!project.join("src/webhooks").exists());
assert_eq!(
fs::read_to_string(project.join("autumn.toml")).unwrap(),
toml_before,
"a rejected invocation must not touch autumn.toml"
);
}
#[test]
fn generate_webhook_rejects_a_second_endpoint_on_the_same_path() {
let (_tmp, project) = fresh_project("webhook-dup-path");
run_autumn(&project, &["generate", "webhook", "stripe", "Payments"]);
let (_stdout, stderr, code) =
run_autumn_failing(&project, &["generate", "webhook", "stripe", "Billing"]);
assert_eq!(code, Some(1), "got:\n{stderr}");
assert!(
stderr.contains("/webhooks/stripe") && stderr.contains("--path"),
"the duplicate-path error must suggest --path:\n{stderr}"
);
run_autumn(
&project,
&[
"generate",
"webhook",
"stripe",
"Billing",
"--path",
"/webhooks/stripe-billing",
],
);
let handler = fs::read_to_string(project.join("src/webhooks/billing.rs")).unwrap();
assert!(
handler.contains("#[post(\"/webhooks/stripe-billing\")]"),
"got:\n{handler}"
);
}
#[test]
fn destroy_webhook_removes_the_generated_files_and_config() {
let (_tmp, project) = fresh_project("webhook-destroy");
let toml_before = fs::read_to_string(project.join("autumn.toml")).unwrap();
let main_before = fs::read_to_string(project.join("src/main.rs")).unwrap();
run_autumn(&project, &["generate", "webhook", "stripe", "Payments"]);
run_autumn(&project, &["destroy", "webhook", "stripe", "Payments"]);
assert!(
!project.join("src/webhooks").exists(),
"the handler module must be gone"
);
assert_eq!(
fs::read_to_string(project.join("autumn.toml")).unwrap(),
toml_before,
"autumn.toml must be restored exactly"
);
assert_eq!(
fs::read_to_string(project.join("src/main.rs")).unwrap(),
main_before,
"src/main.rs must be restored exactly"
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_webhook_cargo_checks() {
let (_tmp, project) = fresh_project("webhook-build");
patch_generated_cargo_toml(&project);
for (provider, name) in [
("stripe", "Payments"),
("github", "Repo"),
("slack", "Events"),
("generic", "Partner"),
] {
run_autumn(&project, &["generate", "webhook", provider, name]);
}
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated webhooks failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: builds and runs a fresh project's test suite — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_webhook_tests_pass() {
let (_tmp, project) = fresh_project("webhook-smoke");
patch_generated_cargo_toml(&project);
for (provider, name) in [
("stripe", "Payments"),
("github", "Repo"),
("slack", "Events"),
("generic", "Partner"),
] {
run_autumn(&project, &["generate", "webhook", provider, name]);
}
let test_run = Command::new("cargo")
.args(["test", "webhooks::"])
.current_dir(&project)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&test_run.stdout);
assert!(
test_run.status.success(),
"generated webhook tests failed:\nstdout:\n{stdout}\nstderr:\n{}",
String::from_utf8_lossy(&test_run.stderr),
);
assert!(
stdout.contains("16 passed"),
"expected all four presets' four cases to pass; got:\n{stdout}"
);
}
#[allow(clippy::too_many_lines)]
#[test]
fn generate_auth_in_fresh_project_creates_expected_files() {
let (_tmp, project) = fresh_project("auth-app");
run_autumn(&project, &["generate", "auth", "User"]);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.collect();
assert_eq!(migrations.len(), 1, "expected one create_users migration");
let mig_dir = migrations[0].path();
let up = fs::read_to_string(mig_dir.join("up.sql")).unwrap();
assert!(
up.contains("CREATE TABLE users"),
"up.sql missing CREATE TABLE"
);
assert!(up.contains("email"), "up.sql missing email column");
assert!(
up.contains("time_zone TEXT NULL"),
"up.sql missing time_zone column"
);
assert!(
up.contains("password_digest"),
"up.sql missing password_digest"
);
assert!(
up.contains("reset_token_digest"),
"up.sql missing reset_token_digest"
);
assert!(up.contains("UNIQUE"), "email must be UNIQUE");
let down = fs::read_to_string(mig_dir.join("down.sql")).unwrap();
assert!(
down.contains("DROP TABLE users"),
"down.sql missing DROP TABLE"
);
let model = fs::read_to_string(project.join("src/models/user.rs")).unwrap();
assert!(model.contains("pub struct User"), "model missing struct");
assert!(
model.contains("pub email: String"),
"model missing email field"
);
assert!(
model.contains("pub time_zone: Option<String>"),
"model missing time_zone field"
);
assert!(
model.contains("pub password_digest: String"),
"model missing password_digest"
);
assert!(
!model.contains("pub password:"),
"raw password must not be stored"
);
let mod_rs = fs::read_to_string(project.join("src/models/mod.rs")).unwrap();
assert!(
mod_rs.contains("pub mod user;"),
"models/mod.rs missing pub mod user"
);
let schema = fs::read_to_string(project.join("src/schema.rs")).unwrap();
assert!(
schema.contains("users (id)"),
"schema.rs missing users table block"
);
assert!(
schema.contains("email -> Text"),
"schema.rs missing email column"
);
assert!(
schema.contains("time_zone -> Nullable<Text>"),
"schema.rs missing time_zone column"
);
assert!(
schema.contains("reset_token_digest -> Nullable<Text>"),
"schema.rs missing nullable reset_token_digest"
);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
for handler in [
"pub async fn signup_form",
"pub async fn signup",
"pub async fn login_form",
"pub async fn login",
"pub async fn logout",
"pub async fn account",
"pub async fn forgot_password_form",
"pub async fn forgot_password",
"pub async fn reset_password_form",
"pub async fn reset_password",
] {
assert!(
routes.contains(handler),
"routes/auth.rs missing: {handler}"
);
}
assert!(
routes.contains("#[secured]"),
"account route must be protected"
);
assert!(
routes.contains("session.destroy"),
"logout must destroy session"
);
assert!(
routes.contains("session.rotate_id"),
"login must rotate session id"
);
assert!(
routes.contains("State(state): State<AppState>"),
"auth routes must receive AppState so sessions use the configured auth key"
);
assert!(
routes.contains("session.insert(state.auth_session_key()"),
"auth routes must populate the configured auth session key"
);
assert_eq!(
routes.matches("session.insert(\"user_id\"").count(),
4,
"User auth routes should only write user_id as the generated account id key \
(login, reset_password, confirm_email, and the remember-me restore path \
establish_remember_login, which must set the same identity keys as a fresh login)"
);
assert!(
routes.contains("email.split_once('@')"),
"signup email validation should use split_once"
);
assert!(
!routes.contains("email.find('@').unwrap()"),
"signup email validation should not search for @ repeatedly"
);
assert!(
!routes.contains("fn layout(title: &str, content: Markup)"),
"the private 2-arg layout stub must be removed"
);
assert!(
routes.contains("crate::layout("),
"auth views must render through crate::layout"
);
assert!(
routes.contains(
"crate::layout(\"Log In\", \"/login\", flash_messages(&flash.consume().await),"
),
"login must render through crate::layout with /login + flash"
);
assert!(
!routes.contains("flash.render().await"),
"auth views must not use the old in-content flash.render() path"
);
let route_mod = fs::read_to_string(project.join("src/routes/mod.rs")).unwrap();
assert!(
route_mod.contains("pub mod auth;"),
"routes/mod.rs missing pub mod auth"
);
let tests = fs::read_to_string(project.join("tests/auth.rs")).unwrap();
for flow in [
"auth_signup_returns_200",
"auth_login_returns_200",
"auth_logout_redirects",
"auth_forgot_password_returns_200",
"auth_reset_password_returns_200",
"auth_account_rejects_anonymous",
] {
assert!(tests.contains(flow), "tests/auth.rs missing: {flow}");
}
assert!(
project.join("docs/guide/authentication.md").exists(),
"docs/guide/authentication.md must be created"
);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
for entry in [
"routes::auth::signup_form",
"routes::auth::login_form",
"routes::auth::logout",
"routes::auth::account",
"routes::auth::forgot_password_form",
"routes::auth::reset_password_form",
] {
assert!(main.contains(entry), "main.rs missing route: {entry}");
}
}
#[test]
fn generate_auth_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("auth-dry-app");
let (stdout, _) = run_autumn(&project, &["generate", "auth", "User", "--dry-run"]);
assert!(
stdout.contains("Dry run"),
"expected dry-run output; got: {stdout}"
);
assert!(
!project.join("src/models/user.rs").exists(),
"dry run must not create model file"
);
assert!(
!project.join("src/routes/auth.rs").exists(),
"dry run must not create routes file"
);
assert!(
!project.join("tests/auth.rs").exists(),
"dry run must not create tests file"
);
}
#[test]
fn generate_auth_collision_without_force_fails() {
let (_tmp, project) = fresh_project("auth-collide-app");
run_autumn(&project, &["generate", "auth", "User"]);
let (_, stderr, code) = run_autumn_failing(&project, &["generate", "auth", "User"]);
assert_eq!(code, Some(1));
assert!(
stderr.contains("would overwrite"),
"expected collision message; got stderr: {stderr}"
);
}
#[test]
fn generate_auth_force_overwrites_existing_files() {
let (_tmp, project) = fresh_project("auth-force-app");
run_autumn(&project, &["generate", "auth", "User"]);
let model_path = project.join("src/models/user.rs");
let original = fs::read_to_string(&model_path).unwrap();
fs::write(&model_path, "// touched").unwrap();
run_autumn(&project, &["generate", "auth", "User", "--force"]);
let regenerated = fs::read_to_string(&model_path).unwrap();
assert_eq!(
regenerated, original,
"--force must restore original content"
);
}
#[test]
fn generate_auth_help_documents_command() {
let tmp = tempfile::tempdir().unwrap();
let autumn_bin = env!("CARGO_BIN_EXE_autumn");
let output = Command::new(autumn_bin)
.args(["generate", "auth", "--help"])
.current_dir(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--dry-run"),
"help should mention --dry-run"
);
assert!(stdout.contains("--force"), "help should mention --force");
assert!(stdout.contains("--totp"), "help should mention --totp");
}
#[allow(clippy::too_many_lines)]
#[test]
fn generate_auth_totp_creates_expected_files() {
let (_tmp, project) = fresh_project("auth-totp-app");
run_autumn(&project, &["generate", "auth", "User", "--totp"]);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.collect();
assert_eq!(migrations.len(), 1, "expected one create_users migration");
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("totp_secret_encrypted"),
"up.sql missing totp_secret_encrypted"
);
assert!(up.contains("totp_enabled"), "up.sql missing totp_enabled");
assert!(
up.contains("CREATE TABLE recovery_codes"),
"up.sql missing recovery_codes table"
);
assert!(up.contains("code_digest"), "up.sql missing code_digest");
assert!(up.contains("used_at"), "up.sql missing used_at");
let down = fs::read_to_string(migrations[0].path().join("down.sql")).unwrap();
assert!(
down.contains("DROP TABLE recovery_codes"),
"down.sql must drop recovery_codes"
);
let model = fs::read_to_string(project.join("src/models/user.rs")).unwrap();
assert!(model.contains("pub totp_secret_encrypted: Option<String>"));
assert!(model.contains("pub totp_enabled: bool"));
assert!(
project.join("src/models/recovery_code.rs").exists(),
"recovery_code model missing"
);
let mod_rs = fs::read_to_string(project.join("src/models/mod.rs")).unwrap();
assert!(
mod_rs.contains("pub mod recovery_code;"),
"models/mod.rs missing recovery_code"
);
let schema = fs::read_to_string(project.join("src/schema.rs")).unwrap();
assert!(schema.contains("totp_secret_encrypted -> Nullable<Text>"));
assert!(schema.contains("totp_enabled -> Bool"));
assert!(schema.contains("recovery_codes (id)"));
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
for needle in [
"pub async fn two_factor_status",
"pub async fn two_factor_enable",
"pub async fn two_factor_confirm",
"pub async fn two_factor_disable",
"pub async fn login_verify",
"otpauth://",
"Aes256Gcm",
"totp_pending",
] {
assert!(routes.contains(needle), "routes/auth.rs missing: {needle}");
}
let tests = fs::read_to_string(project.join("tests/auth_2fa.rs")).unwrap();
for flow in [
"two_factor_enroll_and_confirm",
"login_with_totp_code",
"login_with_recovery_code",
"recovery_code_reuse_rejected",
"two_factor_disable",
] {
assert!(tests.contains(flow), "tests/auth_2fa.rs missing: {flow}");
}
let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(cargo.contains("totp-rs ="), "Cargo.toml missing totp-rs");
assert!(cargo.contains("aes-gcm ="), "Cargo.toml missing aes-gcm");
let docs = fs::read_to_string(project.join("docs/guide/authentication.md")).unwrap();
assert!(
docs.contains("Two-Factor Authentication"),
"docs missing 2FA section"
);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
for entry in [
"routes::auth::two_factor_enable",
"routes::auth::login_verify",
] {
assert!(main.contains(entry), "main.rs missing route: {entry}");
}
}
#[test]
fn generate_auth_without_totp_has_no_totp_artifacts() {
let (_tmp, project) = fresh_project("auth-no-totp-app");
run_autumn(&project, &["generate", "auth", "User"]);
let model = fs::read_to_string(project.join("src/models/user.rs")).unwrap();
assert!(
!model.contains("totp_enabled"),
"default auth must not include totp fields"
);
assert!(!project.join("src/models/recovery_code.rs").exists());
assert!(!project.join("tests/auth_2fa.rs").exists());
}
#[test]
fn generate_auth_passkeys_creates_expected_files() {
let (_tmp, project) = fresh_project("auth-passkeys-app");
run_autumn(&project, &["generate", "auth", "User", "--passkeys"]);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_create_webauthn_credentials")
})
.collect();
assert_eq!(
migrations.len(),
1,
"expected one create_webauthn_credentials migration"
);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE TABLE webauthn_credentials"),
"up.sql missing webauthn_credentials table"
);
assert!(
project.join("src/models/webauthn_credential.rs").exists(),
"webauthn_credential model missing"
);
let routes = fs::read_to_string(project.join("src/routes/passkeys.rs")).unwrap();
for needle in [
"pub async fn passkey_register_page",
"pub async fn passkey_login_page",
"let script_nonce = nonce.map(|n| n.value().to_owned());",
] {
assert!(
routes.contains(needle),
"routes/passkeys.rs missing or incorrect: {needle}"
);
}
assert!(
!routes.contains("nonce.map(|n| n.0.clone())"),
"routes/passkeys.rs must not access private field n.0"
);
}
#[test]
fn generate_auth_without_passkeys_has_no_passkeys_artifacts() {
let (_tmp, project) = fresh_project("auth-no-passkeys-app");
run_autumn(&project, &["generate", "auth", "User"]);
assert!(!project.join("src/models/webauthn_credential.rs").exists());
assert!(!project.join("src/routes/passkeys.rs").exists());
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_auth_passkeys_cargo_checks() {
let (_tmp, project) = fresh_project("auth-passkeys-build");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "auth", "User", "--passkeys"]);
let cargo_after = fs::read_to_string(project.join("Cargo.toml")).unwrap();
for dep in ["webauthn-rs", "uuid", "serde", "diesel", "maud", "chrono"] {
assert!(
cargo_after.contains(&format!("{dep} =")),
"Cargo.toml missing '{dep}' after `generate auth --passkeys`"
);
}
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated --passkeys auth failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
fn generate_auth_magic_link_creates_expected_files() {
let (_tmp, project) = fresh_project("auth-magic-link-app");
run_autumn(&project, &["generate", "auth", "User", "--magic-link"]);
let up = fs::read_to_string(
fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.unwrap()
.path()
.join("up.sql"),
)
.unwrap();
assert!(up.contains("CREATE TABLE magic_link_tokens"));
assert!(up.contains("token_digest TEXT NOT NULL UNIQUE"));
assert!(up.contains("consumed_at TIMESTAMP NULL"));
assert!(project.join("src/models/magic_link_token.rs").exists());
let mod_rs = fs::read_to_string(project.join("src/models/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod magic_link_token;"));
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
for needle in [
"#[get(\"/login/magic\")]",
"#[post(\"/login/magic\")]",
"#[get(\"/login/magic/verify\")]",
"#[throttle(",
"pub async fn magic_link_verify",
"async fn send_magic_link_email",
] {
assert!(routes.contains(needle), "routes/auth.rs missing: {needle}");
}
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("routes::auth::magic_link_verify"));
let docs = fs::read_to_string(project.join("docs/guide/authentication.md")).unwrap();
assert!(docs.contains("Passwordless Magic-Link Login"));
}
#[test]
fn generate_auth_without_magic_link_emits_no_magic_link_artifacts() {
let (_tmp, project) = fresh_project("auth-no-magic-link-app");
run_autumn(&project, &["generate", "auth", "User"]);
assert!(!project.join("src/models/magic_link_token.rs").exists());
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(!routes.contains("/login/magic"));
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_auth_magic_link_cargo_checks() {
let (_tmp, project) = fresh_project("auth-magic-link-build");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "auth", "User", "--magic-link"]);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated --magic-link auth failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_auth_magic_link_with_totp_cargo_checks() {
let (_tmp, project) = fresh_project("auth-magic-totp-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&["generate", "auth", "User", "--totp", "--magic-link"],
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated --totp --magic-link auth failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_auth_totp_cargo_checks() {
let (_tmp, project) = fresh_project("auth-totp-build");
patch_generated_cargo_toml(&project);
run_autumn(&project, &["generate", "auth", "User", "--totp"]);
let cargo_after = fs::read_to_string(project.join("Cargo.toml")).unwrap();
for dep in ["totp-rs", "aes-gcm", "base64", "diesel", "maud", "chrono"] {
assert!(
cargo_after.contains(&format!("{dep} =")),
"Cargo.toml missing '{dep}' after `generate auth --totp`"
);
}
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated --totp auth failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
fn generate_scaffold_from_config_file() {
let (_tmp, project) = fresh_project("scaffold-config-app");
fs::write(
project.join("autumn.generate.toml"),
"[scaffold.Bookmark]\n\
fields = [\"url:String\", \"title:String\", \"tag:String\", \"alive:bool\"]\n\
indexes = [\"url\", \"tag\"]\n\
validations = [\"url=url\", \"title=length:min=1,max=200\"]\n\
defaults = [\"alive=true\"]\n\
queries = [\"find_by_tag:tag\", \"find_by_alive:alive\"]\n",
)
.unwrap();
run_autumn(
&project,
&[
"generate",
"scaffold",
"Bookmark",
"--config",
"autumn.generate.toml",
],
);
let model = fs::read_to_string(project.join("src/models/bookmark.rs")).unwrap();
assert!(
model.contains("#[indexed]\n #[validate(url)]\n pub url: String,"),
"model missing indexed+validated url field:\n{model}"
);
assert!(
model.contains("#[validate(length(min = 1, max = 200))]\n pub title: String,"),
"model missing length-validated title field:\n{model}"
);
assert!(
model.contains("#[indexed]\n pub tag: String,"),
"model missing indexed tag field:\n{model}"
);
assert!(
model.contains("#[default]\n pub alive: bool,"),
"model missing defaulted alive field:\n{model}"
);
let repo = fs::read_to_string(project.join("src/repositories/bookmark.rs")).unwrap();
assert!(
repo.contains("fn find_by_tag(tag: String) -> Vec<Bookmark>;"),
"repo missing find_by_tag query:\n{repo}"
);
assert!(
repo.contains("fn find_by_alive(alive: bool) -> Vec<Bookmark>;"),
"repo missing find_by_alive query:\n{repo}"
);
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_create_bookmarks")
})
.expect("create_bookmarks migration must exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(
up.contains("alive BOOLEAN NOT NULL DEFAULT TRUE"),
"SQL missing default: {up}"
);
assert!(
up.contains("CREATE INDEX idx_bookmarks_url ON bookmarks (url);"),
"SQL missing url index: {up}"
);
assert!(
up.contains("CREATE INDEX idx_bookmarks_tag ON bookmarks (tag);"),
"SQL missing tag index: {up}"
);
let cargo_toml = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo_toml.contains("validator ="),
"Cargo.toml missing validator dep:\n{cargo_toml}"
);
}
#[test]
fn generate_scaffold_cli_overrides_toml_config() {
let (_tmp, project) = fresh_project("scaffold-config-override-app");
fs::write(
project.join("autumn.generate.toml"),
"[scaffold.Post]\nfields = [\"title:String\", \"body:Text\"]\nindexes = [\"title\"]\n",
)
.unwrap();
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"content:String",
"--index",
"content",
"--config",
"autumn.generate.toml",
],
);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub content: String"),
"model must have CLI field 'content': {model}"
);
assert!(
!model.contains("pub title: String"),
"model must not have TOML field 'title': {model}"
);
assert!(
!model.contains("pub body:"),
"model must not have TOML field 'body': {model}"
);
let migration = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with("_create_posts"))
.expect("create_posts migration must exist");
let up = fs::read_to_string(migration.path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE INDEX idx_posts_content ON posts (content);"),
"SQL must have CLI index on 'content': {up}"
);
assert!(
!up.contains("idx_posts_title"),
"SQL must not have TOML index on 'title': {up}"
);
}
#[test]
fn generate_scaffold_rejects_missing_config_file() {
let (_tmp, project) = fresh_project("scaffold-missing-config-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--config",
"nonexistent.toml",
],
);
assert_eq!(code, Some(1), "expected exit code 1; got {code:?}");
assert!(
stderr.contains("nonexistent.toml"),
"error must mention the missing file name; got:\n{stderr}"
);
}
#[test]
fn generate_scaffold_missing_resource_section_uses_defaults() {
let (_tmp, project) = fresh_project("scaffold-missing-section-app");
fs::write(
project.join("autumn.generate.toml"),
"[scaffold.OtherResource]\nfields = [\"name:String\"]\n",
)
.unwrap();
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--config",
"autumn.generate.toml",
],
);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: i64,"),
"missing-section scaffold should default to i64 PK; got:\n{model}"
);
}
#[test]
fn generate_scaffold_explicit_config_missing_section_errors() {
let (_tmp, project) = fresh_project("scaffold-typo-section-app");
fs::write(
project.join("autumn.generate.toml"),
"[scaffold.OtherResource]\nfields = [\"name:String\"]\n",
)
.unwrap();
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Post",
"--config",
"autumn.generate.toml",
],
);
assert_eq!(
code,
Some(1),
"missing section with no CLI fields must fail"
);
assert!(
stderr.contains("no [scaffold.Post] section found"),
"error must name the missing section; got:\n{stderr}"
);
assert!(
!project.join("src/models/post.rs").exists(),
"errored scaffold must not write a model file"
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_scaffold_config_cargo_checks() {
let (_tmp, project) = fresh_project("scaffold-config-build");
patch_generated_cargo_toml(&project);
fs::write(
project.join("autumn.generate.toml"),
"[scaffold.Bookmark]\n\
fields = [\"url:String\", \"title:String\", \"tag:String\", \"alive:bool\"]\n\
indexes = [\"url\", \"tag\"]\n\
validations = [\"url=url\", \"title=length:min=1,max=200\"]\n\
defaults = [\"alive=true\"]\n\
queries = [\"find_by_tag:tag\", \"find_by_alive:alive\"]\n",
)
.unwrap();
run_autumn(
&project,
&[
"generate",
"scaffold",
"Bookmark",
"--config",
"autumn.generate.toml",
],
);
let cargo_toml = fs::read_to_string(project.join("Cargo.toml")).unwrap();
for dep in [
"chrono",
"diesel",
"diesel-async",
"maud",
"serde",
"serde_urlencoded",
"url",
"validator",
] {
assert!(
cargo_toml.contains(&format!("{dep} =")),
"Cargo.toml missing '{dep}' after config-driven scaffold"
);
}
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on config-driven scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
fn generate_scaffold_index_uses_paginated_repo_method() {
let (_tmp, project) = fresh_project("scaffold-paginated-app");
run_autumn(
&project,
&["generate", "scaffold", "Post", "title:String", "body:Text"],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("PageRequest") || routes.contains("page_req"),
"scaffold index must use PageRequest for pagination: {routes}"
);
assert!(
routes.contains("pagination_nav") || routes.contains("pagination"),
"scaffold index must render a pagination nav partial: {routes}"
);
assert!(
routes.contains(".page(") || routes.contains(".list("),
"scaffold index must call a paginated repository method (page()/list()): {routes}"
);
let index = routes
.split_once("pub async fn index(")
.expect("scaffold must emit an index handler")
.1;
let index = index.split("pub async fn ").next().unwrap_or(index);
assert!(
!index.contains(".load(&mut *db)"),
"scaffold index must not load every row without pagination: {index}"
);
assert!(
routes.contains("PostRepository"),
"scaffold routes must import the PostRepository trait (needed to call repo.list()): {routes}"
);
}
#[test]
fn generate_scaffold_index_uses_page_request_extractor() {
let (_tmp, project) = fresh_project("scaffold-paginated-extractor-app");
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("page_req: PageRequest") || routes.contains("PageRequest,"),
"scaffold index must use the PageRequest extractor: {routes}"
);
assert!(
!routes.contains("HashMap"),
"scaffold index must not manually parse query params via HashMap: {routes}"
);
}
#[test]
fn generate_scaffold_repository_exposes_page_method() {
let (_tmp, project) = fresh_project("scaffold-repo-page-app");
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
let repo = fs::read_to_string(project.join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains("#[autumn_web::repository("),
"scaffold repository must use #[autumn_web::repository] (which generates page()): {repo}"
);
assert!(
repo.contains("pub trait PostRepository"),
"scaffold repository must declare a public PostRepository trait: {repo}"
);
}
#[test]
fn generate_mailer_creates_all_expected_files() {
let (_tmp, project) = fresh_project("mailer-app");
let (stdout, _stderr) = run_autumn(&project, &["generate", "mailer", "Welcome"]);
assert!(
stdout.contains("welcome.rs") || stdout.contains("Created"),
"output should mention created files: {stdout}"
);
assert!(project.join("src/mailers/welcome.rs").is_file());
let mailer = fs::read_to_string(project.join("src/mailers/welcome.rs")).unwrap();
assert!(mailer.contains("pub struct WelcomeMailer"));
assert!(mailer.contains("#[mailer]"));
assert!(
!mailer.contains("#[mailer_preview]"),
"#[mailer_preview] must live in previews/, not the mailer file"
);
assert!(mailer.contains("pub fn welcome("));
assert!(mailer.contains("deliver_later"));
assert!(project.join("templates/mailers/_layout.html").is_file());
let layout_html = fs::read_to_string(project.join("templates/mailers/_layout.html")).unwrap();
assert!(
layout_html.contains("<!DOCTYPE html>"),
"_layout.html must be a full document shell"
);
assert!(
layout_html.contains("<table"),
"_layout.html must contain a table-based wrapper"
);
assert!(
layout_html.contains("style="),
"_layout.html must use inline styles"
);
assert!(
layout_html.contains("{{ content }}"),
"_layout.html must contain the content slot"
);
assert!(project.join("templates/mailers/_layout.txt").is_file());
let layout_txt = fs::read_to_string(project.join("templates/mailers/_layout.txt")).unwrap();
assert!(
layout_txt.contains("{{ content }}"),
"_layout.txt must contain the content slot"
);
assert!(project.join("templates/mailers/welcome.html").is_file());
let html = fs::read_to_string(project.join("templates/mailers/welcome.html")).unwrap();
assert!(html.contains("WelcomeMailer"));
assert!(
!html.contains("<!DOCTYPE"),
"per-mailer template must be a body fragment, not a full document"
);
assert!(project.join("templates/mailers/welcome.txt").is_file());
let txt = fs::read_to_string(project.join("templates/mailers/welcome.txt")).unwrap();
assert!(txt.contains("WelcomeMailer"));
assert!(project.join("src/mailers/mod.rs").is_file());
let mod_rs = fs::read_to_string(project.join("src/mailers/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod welcome;"));
assert!(mod_rs.contains("pub mod previews;"));
assert!(!project.join("tests/welcome_mailer.rs").exists());
assert!(mailer.contains("mod welcome_mailer_tests"));
assert!(mailer.contains("welcome_mailer_renders_both_bodies"));
}
#[test]
fn generate_mailer_creates_preview_files_and_wires_main() {
let (_tmp, project) = fresh_project("mailer-preview-files-app");
run_autumn(&project, &["generate", "mailer", "Welcome"]);
assert!(project.join("src/mailers/previews/welcome.rs").is_file());
let preview = fs::read_to_string(project.join("src/mailers/previews/welcome.rs")).unwrap();
assert!(preview.contains("#[mailer_preview]"));
assert!(preview.contains("welcome_preview"));
assert!(project.join("src/mailers/previews/mod.rs").is_file());
let previews_mod = fs::read_to_string(project.join("src/mailers/previews/mod.rs")).unwrap();
assert!(previews_mod.contains("pub mod welcome;"));
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("mod mailers;"));
assert!(main.contains("mail_previews!["));
assert!(main.contains("mailers::welcome::WelcomeMailer"));
let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("\"mail\""),
"Cargo.toml must include the mail feature: {cargo}"
);
}
#[test]
fn generate_mailer_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("mailer-dry-app");
let (stdout, _) = run_autumn(&project, &["generate", "mailer", "Welcome", "--dry-run"]);
assert!(
stdout.contains("Dry run"),
"dry run must print Dry run header: {stdout}"
);
assert!(
!project.join("src/mailers/welcome.rs").exists(),
"dry run must not create the mailer file"
);
assert!(
!project.join("src/mailers/previews/welcome.rs").exists(),
"dry run must not create the preview file"
);
assert!(
!project.join("templates/mailers/welcome.html").exists(),
"dry run must not create html template"
);
assert!(
!project.join("templates/mailers/welcome.txt").exists(),
"dry run must not create txt template"
);
}
#[test]
fn generate_mailer_collision_without_force_fails() {
let (_tmp, project) = fresh_project("mailer-collide-app");
run_autumn(&project, &["generate", "mailer", "Welcome"]);
let (_, stderr, code) = run_autumn_failing(&project, &["generate", "mailer", "Welcome"]);
assert_eq!(code, Some(1), "second run without --force must exit 1");
assert!(
stderr.contains("would overwrite") || stderr.contains("welcome.rs"),
"must report collision: {stderr}"
);
}
#[test]
fn generate_mailer_force_overwrites_existing() {
let (_tmp, project) = fresh_project("mailer-force-app");
run_autumn(&project, &["generate", "mailer", "Welcome"]);
let path = project.join("src/mailers/welcome.rs");
fs::write(&path, "// corrupted").unwrap();
run_autumn(&project, &["generate", "mailer", "Welcome", "--force"]);
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("WelcomeMailer"),
"--force must regenerate the mailer file"
);
assert!(
project.join("src/mailers/previews/welcome.rs").exists(),
"--force must also create the preview file"
);
}
#[test]
fn generate_mailer_preview_registry_wired_into_main() {
let (_tmp, project) = fresh_project("mailer-preview-app");
run_autumn(&project, &["generate", "mailer", "Welcome"]);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
let preview_pos = main
.find("mail_previews![")
.expect("mail_previews![] must be present in main.rs");
let run_pos = main.find(".run()").expect(".run() must still be present");
assert!(
preview_pos < run_pos,
"mail_previews![] must be wired before .run() in the builder chain"
);
assert!(
main.contains("mailers::welcome::WelcomeMailer"),
"preview registry must reference the generated mailer type"
);
}
#[test]
fn generate_teams_emits_organization_membership_invitation_models() {
let (_tmp, project) = fresh_project("teams-app");
let (stdout, _stderr) = run_autumn(&project, &["generate", "teams"]);
assert!(
stdout.contains("Created") || stdout.contains("teams"),
"output should mention created files: {stdout}"
);
assert!(project.join("src/teams/models.rs").is_file());
let models = fs::read_to_string(project.join("src/teams/models.rs")).unwrap();
assert!(models.contains("pub struct Organization"), "{models}");
assert!(models.contains("pub struct Membership"), "{models}");
assert!(models.contains("pub struct Invitation"), "{models}");
assert!(project.join("src/teams/role.rs").is_file());
let role = fs::read_to_string(project.join("src/teams/role.rs")).unwrap();
assert!(role.contains("pub enum Role"), "{role}");
assert!(role.contains("Owner"), "{role}");
assert!(role.contains("Admin"), "{role}");
assert!(role.contains("Member"), "{role}");
assert!(role.contains("pub async fn require_role"), "{role}");
assert!(
role.contains("pub async fn establish_org_session"),
"{role}"
);
assert!(project.join("src/teams/repositories.rs").is_file());
let repos = fs::read_to_string(project.join("src/teams/repositories.rs")).unwrap();
assert!(repos.contains("tenant_scoped"), "{repos}");
assert!(
project
.join("src/teams/mailers/invitation_mailer.rs")
.is_file()
);
let mailer =
fs::read_to_string(project.join("src/teams/mailers/invitation_mailer.rs")).unwrap();
assert!(mailer.contains("pub struct InvitationMailer"), "{mailer}");
assert!(mailer.contains("#[mailer]"), "{mailer}");
assert!(project.join("src/teams/routes/organizations.rs").is_file());
assert!(project.join("src/teams/routes/invitations.rs").is_file());
assert!(project.join("src/teams/routes/members.rs").is_file());
let migrations_root = project.join("migrations");
let teams_migration_dir = fs::read_dir(&migrations_root)
.unwrap()
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().ends_with("_create_teams"))
.expect("a *_create_teams migration must be generated")
.path();
let up = fs::read_to_string(teams_migration_dir.join("up.sql")).unwrap();
assert!(up.contains("CREATE TABLE organizations"), "{up}");
assert!(up.contains("CREATE TABLE memberships"), "{up}");
assert!(up.contains("CREATE TABLE invitations"), "{up}");
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(main.contains("mod teams;"), "{main}");
assert!(
main.contains("teams::routes::organizations::create_organization"),
"{main}"
);
assert!(
main.contains("teams::routes::invitations::accept_invitation"),
"{main}"
);
assert!(
main.contains("teams::routes::members::list_members"),
"{main}"
);
let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("\"mail\""),
"Cargo.toml must include the mail feature: {cargo}"
);
}
#[test]
fn generate_teams_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("teams-dry-app");
let cargo_before = fs::read_to_string(project.join("Cargo.toml")).unwrap();
let (stdout, _) = run_autumn(&project, &["generate", "teams", "--dry-run"]);
assert!(
stdout.contains("Dry run"),
"dry run must print Dry run header: {stdout}"
);
assert!(
!project.join("src/teams").exists(),
"dry run must not create the src/teams directory"
);
let has_teams_migration = fs::read_dir(project.join("migrations")).is_ok_and(|rd| {
rd.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().ends_with("_create_teams"))
});
assert!(
!has_teams_migration,
"dry run must not create a *_create_teams migration"
);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(
!main.contains("mod teams;"),
"dry run must not touch main.rs: {main}"
);
let cargo_after = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert_eq!(
cargo_after, cargo_before,
"dry run must not touch Cargo.toml"
);
}
#[test]
fn generate_teams_invite_accept_routes_use_invite_prefix_not_invitations() {
let (_tmp, project) = fresh_project("teams-invite-prefix-app");
run_autumn(&project, &["generate", "teams"]);
let invitations = fs::read_to_string(project.join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains(r#"#[get("/invite/{token}")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invite/{token}/accept")]"#),
"{invitations}"
);
assert!(
!invitations.contains(r#"#[get("/invitations/{token}")]"#),
"{invitations}"
);
assert!(
!invitations.contains(r#"#[post("/invitations/{token}/accept")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invitations")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invitations/{id}/revoke")]"#),
"{invitations}"
);
assert!(
invitations.contains(r#"#[post("/invitations/{id}/resend")]"#),
"{invitations}"
);
}
#[test]
fn generate_teams_sends_invite_mail_synchronously_not_deliver_later() {
let (_tmp, project) = fresh_project("teams-sync-mail-app");
run_autumn(&project, &["generate", "teams"]);
let invitations = fs::read_to_string(project.join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains(".send_invite("),
"invite mail must be sent synchronously: {invitations}"
);
assert!(
!invitations.contains(".deliver_later_invite("),
"invite mail must not be a fire-and-forget background send: {invitations}"
);
}
#[test]
fn generate_teams_guards_against_admin_self_promotion_to_owner() {
let (_tmp, project) = fresh_project("teams-owner-guard-app");
run_autumn(&project, &["generate", "teams"]);
let invitations = fs::read_to_string(project.join("src/teams/routes/invitations.rs")).unwrap();
assert!(
invitations.contains("role == Role::Owner && caller_role != Role::Owner"),
"{invitations}"
);
assert!(
invitations.contains("Only an owner can invite someone as owner"),
"{invitations}"
);
let members = fs::read_to_string(project.join("src/teams/routes/members.rs")).unwrap();
assert!(
members.contains("new_role == Role::Owner && caller_role != Role::Owner"),
"{members}"
);
assert!(
members.contains("Only an owner can grant the owner role"),
"{members}"
);
}
#[test]
fn generate_auth_confirmation_migration_has_new_columns() {
let (_tmp, project) = fresh_project("auth-confirm-migration");
run_autumn(&project, &["generate", "auth", "User"]);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.collect();
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("email_confirmed_at"),
"up.sql missing email_confirmed_at column"
);
assert!(
up.contains("confirm_token_digest"),
"up.sql missing confirm_token_digest column"
);
assert!(
up.contains("confirm_token_expires_at"),
"up.sql missing confirm_token_expires_at column"
);
}
#[test]
fn generate_auth_confirmation_model_fields_and_signup_not_logged_in() {
let (_tmp, project) = fresh_project("auth-confirm-signup");
run_autumn(&project, &["generate", "auth", "User"]);
let model = fs::read_to_string(project.join("src/models/user.rs")).unwrap();
assert!(
model.contains("pub email_confirmed_at: Option<chrono::NaiveDateTime>"),
"model missing email_confirmed_at field"
);
assert!(
model.contains("pub confirm_token_digest: Option<String>"),
"model missing confirm_token_digest field"
);
assert!(
model.contains("pub confirm_token_expires_at: Option<chrono::NaiveDateTime>"),
"model missing confirm_token_expires_at field"
);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("confirm-email") || routes.contains("check-your-email"),
"signup handler must redirect to a confirmation-pending page, not /account"
);
}
#[test]
fn generate_auth_confirmation_route_marks_confirmed_and_invalidates_token() {
let (_tmp, project) = fresh_project("auth-confirm-route");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("pub async fn confirm_email"),
"routes/auth.rs missing confirm_email handler"
);
assert!(
routes.contains("/auth/confirm/"),
"routes/auth.rs missing /auth/confirm/:token path"
);
assert!(
routes.contains("email_confirmed_at"),
"confirm_email handler must stamp email_confirmed_at"
);
assert!(
routes.contains("confirm_token_digest.eq(None::<String>)"),
"confirm_email handler must invalidate token after use (set digest to NULL)"
);
}
#[test]
fn generate_auth_confirmation_only_digest_stored_in_db() {
let (_tmp, project) = fresh_project("auth-confirm-digest");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
let stores_digest = routes.contains("confirm_token_digest.eq(Some(&confirm_digest")
|| routes.contains("confirm_token_digest.eq(Some(&token_digest");
assert!(
stores_digest,
"confirmation token digest (not raw token) must be stored"
);
}
#[test]
fn generate_auth_confirmation_token_expires_24h() {
let (_tmp, project) = fresh_project("auth-confirm-expiry");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("hours(24)"),
"confirmation token must default to 24-hour expiry"
);
assert!(
routes.contains("confirm_token_expires_at.gt(now)"),
"confirm handler must reject tokens past confirm_token_expires_at"
);
}
#[test]
fn generate_auth_unconfirmed_login_rejected_with_resend_affordance() {
let (_tmp, project) = fresh_project("auth-confirm-login-gate");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("email_confirmed_at"),
"login handler must check email_confirmed_at before granting session"
);
assert!(
routes.contains("resend") || routes.contains("Resend"),
"login form must offer a resend confirmation email affordance"
);
}
#[test]
fn generate_auth_resend_confirmation_invalidates_old_token() {
let (_tmp, project) = fresh_project("auth-confirm-resend");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("pub async fn resend_confirmation"),
"routes/auth.rs missing resend_confirmation handler"
);
assert!(
routes.contains("confirm_token_digest"),
"resend_confirmation must update confirm_token_digest"
);
}
#[test]
fn generate_auth_confirmed_gate_present() {
let (_tmp, project) = fresh_project("auth-confirm-gate");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("email_confirmed_at.is_some()")
|| routes.contains("email_confirmed_at.is_none()")
|| routes.contains("require_confirmed"),
"routes must demonstrate an email-confirmed gate"
);
}
#[test]
fn generate_auth_password_reset_does_not_confirm_email() {
let (_tmp, project) = fresh_project("auth-confirm-reset-independence");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
let reset_start = routes
.find("pub async fn reset_password(")
.expect("reset_password handler must exist");
let rest = &routes[reset_start..];
let reset_body_end = rest[1..]
.find("pub async fn ")
.map_or(rest.len(), |p| p + 1);
let reset_body = &rest[..reset_body_end];
assert!(
!reset_body.contains("email_confirmed_at.eq("),
"reset_password must NOT set email_confirmed_at (confirmation and credential recovery are independent)"
);
}
#[test]
fn generate_auth_confirmation_signup_fails_clearly_when_mail_disabled() {
let (_tmp, project) = fresh_project("auth-confirm-mail-check");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("mailer.is_disabled()"),
"signup must check mailer.is_disabled() and return a clear error message"
);
}
#[test]
fn generate_auth_confirmation_tests_cover_required_flows() {
let (_tmp, project) = fresh_project("auth-confirm-tests");
run_autumn(&project, &["generate", "auth", "User"]);
let tests = fs::read_to_string(project.join("tests/auth.rs")).unwrap();
for flow in [
"signup_without_confirm_cannot_login",
"confirm_with_valid_token_can_login",
"confirm_with_expired_token_fails",
"confirm_with_replayed_token_fails",
"resend_confirmation_rate_limit",
"email_change_confirm_invalid_token_fails",
"password_and_email_change_end_to_end",
] {
assert!(tests.contains(flow), "tests/auth.rs missing test: {flow}");
}
}
#[test]
fn generate_auth_confirmation_docs_section_present() {
let (_tmp, project) = fresh_project("auth-confirm-docs");
run_autumn(&project, &["generate", "auth", "User"]);
let docs = fs::read_to_string(project.join("docs/guide/authentication.md")).unwrap();
assert!(
docs.contains("Email Confirmation") || docs.contains("email confirmation"),
"docs missing Email Confirmation section"
);
assert!(
docs.contains("digest") || docs.contains("SHA-256"),
"docs must describe digest-only token storage rule"
);
assert!(
docs.contains("email_confirmed_at"),
"docs must reference the email_confirmed_at field"
);
}
#[test]
fn generate_auth_confirmation_docs_migration_path_for_existing_apps() {
let (_tmp, project) = fresh_project("auth-confirm-migration-path");
run_autumn(&project, &["generate", "auth", "User"]);
let docs = fs::read_to_string(project.join("docs/guide/authentication.md")).unwrap();
assert!(
docs.contains("email_confirmed_at") && docs.contains("confirm_token_digest"),
"docs migration path must name both new columns"
);
assert!(
docs.contains("ADD COLUMN") || docs.contains("ALTER TABLE"),
"docs must include ALTER TABLE migration SQL for existing apps"
);
}
#[test]
fn generate_auth_confirmation_routes_registered_in_main() {
let (_tmp, project) = fresh_project("auth-confirm-main-rs");
run_autumn(&project, &["generate", "auth", "User"]);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
assert!(
main.contains("routes::auth::confirm_email"),
"main.rs must register confirm_email route"
);
assert!(
main.contains("routes::auth::resend_confirmation"),
"main.rs must register resend_confirmation route"
);
}
#[test]
fn generate_auth_sessions_migration_schema_and_model() {
let (_tmp, project) = fresh_project("auth-sess-app");
run_autumn(&project, &["generate", "auth", "User"]);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_users"))
.collect();
assert_eq!(migrations.len(), 1, "expected one create_users migration");
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("CREATE TABLE user_sessions"),
"up.sql missing CREATE TABLE user_sessions:\n{up}"
);
for column in [
"user_id BIGINT NOT NULL REFERENCES users",
"token_digest TEXT NOT NULL UNIQUE",
"ip TEXT NOT NULL",
"user_agent TEXT NOT NULL",
"ua_family TEXT NOT NULL",
"ua_os TEXT NOT NULL",
"ua_device TEXT NOT NULL",
"label TEXT NULL",
"last_seen_at TIMESTAMP NOT NULL",
] {
assert!(up.contains(column), "up.sql missing column: {column}\n{up}");
}
let down = fs::read_to_string(migrations[0].path().join("down.sql")).unwrap();
assert!(
down.contains("DROP TABLE user_sessions"),
"down.sql must drop user_sessions"
);
assert!(
down.find("DROP TABLE user_sessions").unwrap() < down.find("DROP TABLE users").unwrap(),
"user_sessions must be dropped before users"
);
let schema = fs::read_to_string(project.join("src/schema.rs")).unwrap();
assert!(
schema.contains("user_sessions (id)"),
"schema.rs missing user_sessions block"
);
assert!(
schema.contains("token_digest -> Text"),
"schema.rs missing token_digest column"
);
let model = fs::read_to_string(project.join("src/models/user_session.rs")).unwrap();
assert!(
model.contains("pub struct UserSession"),
"model missing UserSession struct"
);
for needle in [
"pub token_digest: String",
"pub last_seen_at: chrono::NaiveDateTime",
"pub label: Option<String>",
"pub async fn sessions(",
"pub async fn revoke_session(",
"pub async fn revoke_other_sessions(",
"pub async fn revoke_all_sessions(",
] {
assert!(model.contains(needle), "user_session.rs missing: {needle}");
}
assert!(
!model.contains("pub token: String"),
"raw session token must not be stored"
);
let mod_rs = fs::read_to_string(project.join("src/models/mod.rs")).unwrap();
assert!(
mod_rs.contains("pub mod user_session;"),
"models/mod.rs missing pub mod user_session"
);
}
#[test]
fn generate_auth_sessions_routes_and_page() {
let (_tmp, project) = fresh_project("auth-sess-routes");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
assert!(
routes.contains("pub async fn record_login_session"),
"routes missing record_login_session helper"
);
assert!(
routes.contains("pub async fn session_token_digest"),
"routes missing session_token_digest helper"
);
assert!(
routes.contains("autumn_web::user_agent::parse_user_agent"),
"login must parse the User-Agent via autumn_web::user_agent"
);
assert!(
routes.contains("pub async fn require_tracked_session"),
"routes missing require_tracked_session gate"
);
assert!(
routes.contains("last_seen_update_secs"),
"last_seen_at updates must be throttled via config"
);
assert!(
routes.contains("session.destroy()"),
"require_tracked_session must destroy revoked sessions"
);
for needle in [
"pub async fn sessions_page",
"pub async fn sessions_revoke",
"pub async fn sessions_revoke_others",
"pub async fn sessions_label",
"#[get(\"/account/sessions\")]",
"#[post(\"/account/sessions/{id}/revoke\")]",
"#[post(\"/account/sessions/revoke-others\")]",
"#[post(\"/account/sessions/{id}/label\")]",
] {
assert!(routes.contains(needle), "routes/auth.rs missing: {needle}");
}
assert!(
routes.contains("hx-post"),
"sessions page must use htmx for revocation"
);
assert!(
routes.to_lowercase().contains("sign out everywhere else"),
"sessions page must offer one-click bulk revocation"
);
let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
for entry in [
"routes::auth::sessions_page",
"routes::auth::sessions_revoke",
"routes::auth::sessions_revoke_others",
"routes::auth::sessions_label",
] {
assert!(main.contains(entry), "main.rs missing route: {entry}");
}
}
#[test]
fn generate_auth_reset_password_revokes_sessions() {
let (_tmp, project) = fresh_project("auth-sess-reset");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
let reset_body = &routes[routes.find("pub async fn reset_password(").unwrap()..];
let reset_body = &reset_body[..reset_body.find("\n// ──").unwrap_or(reset_body.len())];
assert!(
reset_body.contains("revoke_existing_sessions") || reset_body.contains("user_sessions"),
"reset_password must revoke existing sessions"
);
assert!(
reset_body.contains("revoke_on_credential_change"),
"session revocation on password change must be configurable"
);
assert!(
reset_body.contains("insert_into(user_sessions::table)"),
"reset_password logs the user in and must record the new session in its transaction"
);
}
#[test]
fn generate_auth_totp_changes_revoke_other_sessions() {
let (_tmp, project) = fresh_project("auth-sess-totp");
run_autumn(&project, &["generate", "auth", "User", "--totp"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
for handler in [
"pub async fn two_factor_confirm",
"pub async fn two_factor_disable",
] {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
let body = &body[..body.find("\n/// `").unwrap_or(body.len())];
assert!(
body.contains("token_digest.ne(") || body.contains("revoke_other_sessions"),
"{handler} must revoke other sessions"
);
assert!(
body.contains("revoke_on_credential_change"),
"{handler} revocation must be configurable"
);
}
assert!(
routes.contains("pub async fn login_verify"),
"missing login_verify"
);
let verify = &routes[routes.find("pub async fn login_verify(").unwrap()..];
assert!(
verify.contains("record_login_session"),
"login_verify completes a login and must record the session row"
);
}
#[test]
fn generate_auth_passkeys_changes_revoke_other_sessions() {
let (_tmp, project) = fresh_project("auth-sess-passkeys");
run_autumn(&project, &["generate", "auth", "User", "--passkeys"]);
let routes = fs::read_to_string(project.join("src/routes/passkeys.rs")).unwrap();
for handler in [
"pub async fn passkey_register_finish",
"pub async fn passkey_revoke",
] {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
let body = &body[..body.find("\n/// `").unwrap_or(body.len())];
assert!(
body.contains("token_digest.ne(") || body.contains("revoke_other_sessions"),
"{handler} must revoke other sessions"
);
assert!(
body.contains("revoke_on_credential_change"),
"{handler} revocation must be configurable"
);
}
let login_finish = &routes[routes.find("pub async fn passkey_login_finish(").unwrap()..];
assert!(
login_finish.contains("record_login_session"),
"passkey_login_finish completes a login and must record the session row"
);
}
#[test]
fn generate_auth_sessions_tests_emitted() {
let (_tmp, project) = fresh_project("auth-sess-tests");
run_autumn(&project, &["generate", "auth", "User"]);
let tests = fs::read_to_string(project.join("tests/auth_sessions.rs")).unwrap();
for needle in [
"fn sessions_page_rejects_anonymous",
"fn revoked_session_next_request_401s",
"fn revoke_other_sessions_keeps_current_session_alive",
"/account/sessions",
] {
assert!(
tests.contains(needle),
"tests/auth_sessions.rs missing: {needle}"
);
}
}
#[test]
fn generate_auth_sessions_docs_emitted() {
let (_tmp, project) = fresh_project("auth-sess-docs");
run_autumn(&project, &["generate", "auth", "User"]);
let docs = fs::read_to_string(project.join("docs/guide/session-management.md")).unwrap();
for needle in [
"## Privacy",
"retention",
"parse_user_agent",
"revoke_on_credential_change",
"/account/sessions",
"CREATE TABLE user_sessions",
] {
assert!(
docs.contains(needle),
"session-management.md missing: {needle}"
);
}
}
#[test]
fn generate_auth_sessions_review_hardening() {
let (_tmp, project) = fresh_project("auth-sess-hardening");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
let reauth_start = routes
.find("pub async fn reauth(")
.expect("missing reauth handler");
let reauth = &routes[reauth_start..];
let reauth = &reauth[..reauth.find("\n// ──").unwrap_or(reauth.len())];
let rotate_at = reauth
.find("session.rotate_id()")
.expect("reauth must rotate");
let rebind_at = reauth
.find("rebind_tracked_session")
.expect("reauth must rebind the tracked session row after rotation");
assert!(
rotate_at < rebind_at,
"reauth must rebind AFTER rotating the session id"
);
for handler in [
"pub async fn data_export_form",
"pub async fn delete_account_form",
"pub async fn reauth_form",
] {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
let body = &body[..body.find("\n/// `").unwrap_or(body.len())];
assert!(
body.contains("require_tracked_session"),
"{handler} must validate the tracked session row"
);
}
assert!(
routes.contains("form method=\"post\" action=\"/account/sessions/revoke-others\""),
"bulk revoke must be a real form for non-JS fallback"
);
assert!(
routes.contains("action={ \"/account/sessions/\" (s.id) \"/revoke\" }"),
"per-session revoke must be a real form for non-JS fallback"
);
assert!(
routes.contains("hx-post=\"/account/sessions/revoke-others\""),
"bulk revoke keeps htmx enhancement"
);
}
#[test]
fn generate_auth_passkeys_pages_gated_on_tracked_session() {
let (_tmp, project) = fresh_project("auth-sess-pk-gate");
run_autumn(&project, &["generate", "auth", "User", "--passkeys"]);
let routes = fs::read_to_string(project.join("src/routes/passkeys.rs")).unwrap();
for handler in [
"pub async fn passkey_register_page",
"pub async fn passkey_register_begin",
] {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
let body = &body[..body.find("\n/// `").unwrap_or(body.len())];
assert!(
body.contains("require_tracked_session"),
"{handler} must validate the tracked session row"
);
}
}
#[test]
fn live_validation_emits_hx_post_and_error_slot() {
let (_tmp, project) = fresh_project("lv-hx-post");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:String",
"--validate",
"title=length:min=1,max=200",
"--live-validation",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(
"autumn_web::form::required_text_input_htmx(&changeset, \"title\", \"Title\", &paths::validate_title())"
),
"create form must render the validated title input via required_text_input_htmx with a typed path helper:\n{routes}"
);
assert!(
routes.contains("autumn_web::form::required_text_input(&changeset, \"body\", \"Body\")"),
"unvalidated required body input must use required_text_input:\n{routes}"
);
assert!(
!routes.contains("autumn_web::form::text_input_htmx(&changeset, \"body\""),
"unvalidated body input must not use text_input_htmx:\n{routes}"
);
}
#[test]
fn live_validation_emits_validate_handler_with_real_rules() {
let (_tmp, project) = fresh_project("lv-validate-handler");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"site:String",
"email:String",
"--validate",
"title=length:min=1,max=200",
"--validate",
"site=url",
"--validate",
"email=email",
"--live-validation",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
for field in ["title", "site", "email"] {
assert!(
routes.contains(&format!("pub async fn validate_{field}(")),
"routes must contain a validate_{field} handler:\n{routes}"
);
assert!(
routes.contains(&format!(
"autumn_web::form::required_text_input_htmx(&changeset, \"{field}\""
)),
"validate_{field} must return the full required_text_input_htmx wrapper, \
not a bare error span:\n{routes}"
);
}
assert_eq!(
routes
.matches("let Ok(form) = decode_form(body) else")
.count(),
3,
"every validate_{{field}} handler must decode via the shared decode_form:\n{routes}"
);
assert!(
routes.contains("#[validate(length(min = 1, max = 200))]\n pub title: String"),
"PostForm must carry the length rule for validator::Validate to enforce:\n{routes}"
);
}
#[test]
fn without_live_validation_no_hx_attrs() {
let (_tmp, project) = fresh_project("no-lv");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--validate",
"title=length:min=1,max=200",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("hx-post"),
"routes without --live-validation must not have hx-post:\n{routes}"
);
assert!(
!routes.contains("validate_title"),
"routes without --live-validation must not have validate_title handler:\n{routes}"
);
}
#[test]
fn live_validation_without_live_loads_htmx_script() {
let (_tmp, project) = fresh_project("lv-no-live-htmx");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--validate",
"title=length:min=1,max=200",
"--live-validation",
],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("HTMX_JS_PATH"),
"layout must include htmx script when --live-validation is set:\n{routes}"
);
let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(
cargo.contains("\"htmx\"") || cargo.contains("htmx"),
"Cargo.toml must include autumn-web htmx feature for --live-validation:\n{cargo}"
);
assert!(
cargo.contains("\"maud\"") || cargo.contains("maud"),
"Cargo.toml must include autumn-web maud feature for --live-validation:\n{cargo}"
);
}
#[test]
fn live_scaffold_emits_live_fragment_and_broadcasts() {
let (_tmp, project) = fresh_project("live-frag");
run_autumn(
&project,
&["generate", "scaffold", "Post", "title:String", "--live"],
);
let repo = fs::read_to_string(project.join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains("broadcasts = true"),
"repository must have broadcasts attribute under --live:\n{repo}"
);
assert!(
repo.contains("impl autumn_web::live::LiveFragment for Post"),
"repository must contain LiveFragment impl under --live:\n{repo}"
);
assert!(
repo.contains("fn insert_swap()") && repo.contains("OobMethod::BeforeEnd"),
"LiveFragment impl must override insert_swap() with BeforeEnd targeting the list container:\n{repo}"
);
assert!(
repo.contains("a href=(crate::routes::posts::paths::show(self.id))"),
"render_fragment must include a show link href via the typed path helper:\n{repo}"
);
}
#[test]
fn live_scaffold_index_uses_sse_list_and_stream_route() {
let (_tmp, project) = fresh_project("live-sse");
run_autumn(
&project,
&["generate", "scaffold", "Post", "title:String", "--live"],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("hx-ext=\"sse\""),
"index list must have hx-ext=\"sse\" under --live:\n{routes}"
);
assert!(
routes.contains("sse-connect=(paths::events())"),
"index list must connect to the SSE stream endpoint via the typed path helper:\n{routes}"
);
assert!(
routes.contains("pub async fn events"),
"routes must contain the stream handler:\n{routes}"
);
}
#[test]
fn live_layout_references_idiomorph_and_morph() {
let (_tmp, project) = fresh_project("live-morph");
run_autumn(
&project,
&["generate", "scaffold", "Post", "title:String", "--live"],
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("IDIOMORPH_JS_PATH"),
"layout <head> must include the idiomorph script under --live:\n{routes}"
);
assert!(
routes.contains(r#"body hx-ext="morph""#),
"layout <body> must carry hx-ext=\"morph\" under --live:\n{routes}"
);
assert!(
routes.contains(r#"hx-swap="none""#),
"SSE list container must use hx-swap=\"none\" under --live:\n{routes}"
);
}
#[test]
fn live_api_scaffold_emits_stream_handler_in_repository() {
let (_tmp, project) = fresh_project("live-api");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--api",
"--live",
],
);
assert!(
!project.join("src/routes/posts.rs").is_file(),
"--api scaffold must not create a routes file"
);
let repo = fs::read_to_string(project.join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains("pub async fn stream("),
"repository must contain stream handler under --api --live:\n{repo}"
);
assert!(
repo.contains("GET /posts/stream"),
"repository must document the stream route path:\n{repo}"
);
assert!(
repo.contains("autumn_web::sse::stream(&state, \"posts\")"),
"stream handler must delegate to sse::stream:\n{repo}"
);
assert!(
repo.contains("impl autumn_web::live::LiveFragment for Post"),
"repository must contain LiveFragment impl under --api --live:\n{repo}"
);
assert!(
!repo.contains("a href=(format!(\"/posts/"),
"API LiveFragment must NOT emit a show-page link (no HTML routes):\n{repo}"
);
assert!(
repo.contains("(self.id)"),
"API LiveFragment render_fragment must emit plain id:\n{repo}"
);
}
#[test]
fn generate_auth_sessions_codex_round2() {
let (_tmp, project) = fresh_project("auth-sess-codex2");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
let body_of = |handler: &str| {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
&body[..body.find("\n/// `").unwrap_or(body.len())]
};
let login = body_of("pub async fn login(");
let untrack_at = login
.find("untrack_current_session")
.expect("login must untrack the consumed session row");
let rotate_at = login
.find("session.rotate_id()")
.expect("login must rotate");
assert!(
untrack_at < rotate_at,
"login must untrack BEFORE rotating the session id"
);
for handler in [
"pub async fn confirm_email(",
"pub async fn reset_password(",
] {
assert!(
body_of(handler).contains("untrack_current_session"),
"{handler} rotates while logging in and must untrack the old row"
);
}
assert!(
body_of("pub async fn logout(").contains("untrack_current_session"),
"logout shares the untrack helper"
);
let reset = body_of("pub async fn reset_password(");
assert!(
reset.contains(".transaction"),
"reset_password must commit password + revocation atomically"
);
assert!(
reset.contains("revoke_on_credential_change") && reset.contains("user_sessions"),
"reset_password revocation must stay config-gated and target user_sessions"
);
}
#[test]
fn generate_auth_totp_revocation_is_atomic() {
let (_tmp, project) = fresh_project("auth-sess-totp-atomic");
run_autumn(&project, &["generate", "auth", "User", "--totp"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
let body_of = |handler: &str| {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
&body[..body.find("\n/// `").unwrap_or(body.len())]
};
for handler in [
"pub async fn two_factor_confirm(",
"pub async fn two_factor_disable(",
] {
let body = body_of(handler);
assert!(
body.contains("revoke_on_credential_change"),
"{handler} revocation must stay config-gated"
);
assert!(
body.contains("token_digest.ne("),
"{handler} must revoke other sessions inside its transaction"
);
assert!(
!body.contains("revoke_other_sessions(&mut *db"),
"{handler} must not revoke outside the transaction (a post-commit \
failure would 500 after the credential change)"
);
}
let verify = body_of("pub async fn login_verify(");
assert!(
verify.contains(".transaction"),
"login_verify's deferred password-reset commit must be atomic with revocation"
);
}
#[test]
fn generate_auth_sessions_codex_round3() {
let (_tmp, project) = fresh_project("auth-sess-codex3");
run_autumn(&project, &["generate", "auth", "User"]);
let routes = fs::read_to_string(project.join("src/routes/auth.rs")).unwrap();
let body_of = |handler: &str| {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
&body[..body.find("\n/// `").unwrap_or(body.len())]
};
let signup = body_of("pub async fn signup(");
let rotate_at = signup
.find("session.rotate_id()")
.expect("signup must rotate");
let rebind_at = signup
.find("rebind_tracked_session")
.expect("signup must rebind the tracked row across its rotation");
assert!(
rotate_at < rebind_at,
"signup must rebind AFTER rotating the session id"
);
let reset = body_of("pub async fn reset_password(");
assert!(
reset.contains("insert_into(user_sessions::table)"),
"reset_password must insert the new session row inside its transaction"
);
assert!(
!reset.contains("record_login_session"),
"reset_password must not record the session outside the transaction"
);
assert!(
routes.contains("pub async fn build_session_row"),
"session-row construction must be a shared helper"
);
}
#[test]
fn generate_auth_passkeys_revocation_is_atomic() {
let (_tmp, project) = fresh_project("auth-sess-pk-atomic");
run_autumn(&project, &["generate", "auth", "User", "--passkeys"]);
let routes = fs::read_to_string(project.join("src/routes/passkeys.rs")).unwrap();
let body_of = |handler: &str| {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing {handler}"));
let body = &routes[start..];
&body[..body.find("\n/// `").unwrap_or(body.len())]
};
for handler in [
"pub async fn passkey_register_finish(",
"pub async fn passkey_revoke(",
] {
let body = body_of(handler);
assert!(
body.contains(".transaction"),
"{handler} must commit the credential change and revocation atomically"
);
assert!(
body.contains("token_digest.ne("),
"{handler} must revoke other sessions inside the transaction"
);
assert!(
body.contains("revoke_on_credential_change"),
"{handler} revocation must stay config-gated"
);
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn generate_wizard_creates_expected_files() {
let (_tmp, project) = fresh_project("wizard-app");
run_autumn(
&project,
&[
"generate", "wizard", "checkout", "shipping", "payment", "review",
],
);
let wizard = fs::read_to_string(project.join("src/wizards/checkout.rs")).unwrap();
assert!(
wizard.contains("pub const WIZARD_NAME: &str = \"checkout\";"),
"wizard file missing WIZARD_NAME constant"
);
assert!(
wizard.contains("pub const STEPS: &[&str] = &[\"shipping\", \"payment\", \"review\"];"),
"wizard file missing STEPS constant with all step names"
);
assert!(
wizard.contains("pub fn wizard_context(session: Session) -> WizardContext"),
"wizard file missing wizard_context helper"
);
for (pascal_struct, snake_step) in [
("ShippingForm", "shipping"),
("PaymentForm", "payment"),
("ReviewForm", "review"),
] {
assert!(
wizard.contains(&format!("pub struct {pascal_struct}")),
"wizard file missing step struct: {pascal_struct}"
);
assert!(
wizard.contains("Serialize, Deserialize"),
"step struct for {snake_step} must derive Serialize and Deserialize"
);
}
for step in ["shipping", "payment", "review"] {
assert!(
wizard.contains(&format!("#[get(\"/checkout/{step}\")]")),
"wizard file missing GET route attribute for step: {step}"
);
assert!(
wizard.contains(&format!("pub async fn show_{step}(")),
"wizard file missing show_{step} handler"
);
assert!(
wizard.contains(&format!("#[post(\"/checkout/{step}\")]")),
"wizard file missing POST route attribute for step: {step}"
);
assert!(
wizard.contains(&format!("pub async fn submit_{step}(")),
"wizard file missing submit_{step} handler"
);
}
assert!(
wizard.contains("#[get(\"/checkout/confirm\")]"),
"wizard file missing GET /checkout/confirm route"
);
assert!(
wizard.contains("pub async fn show_confirm("),
"wizard file missing show_confirm handler"
);
assert!(
wizard.contains("#[post(\"/checkout/commit\")]"),
"commit must be POST, not GET"
);
assert!(
wizard.contains("pub async fn commit("),
"wizard file missing commit handler"
);
assert!(
wizard.contains("#[post(\"/checkout/cancel\")]"),
"cancel must be POST, not GET"
);
assert!(
wizard.contains("pub async fn cancel("),
"wizard file missing cancel handler"
);
assert!(
wizard.contains("wizard.guard_step("),
"step handlers must call guard_step"
);
assert!(
wizard.contains("wizard_progress("),
"step handlers must render wizard_progress"
);
assert!(
wizard.contains("csrf: Option<CsrfToken>"),
"GET step handlers must use optional CsrfToken"
);
assert!(
wizard.contains("csrf_field: Option<CsrfFormField>"),
"GET step handlers must use optional CsrfFormField"
);
assert!(
wizard.contains("use autumn_web::form::ChangesetForm;"),
"wizard file must import ChangesetForm"
);
assert!(
wizard.contains("StatusCode::UNPROCESSABLE_ENTITY"),
"submit handlers must return 422 on validation failure"
);
assert_eq!(
wizard.matches("wizard.clear()").count(),
2,
"wizard.clear() must be called in both commit and cancel handlers"
);
let mod_rs = fs::read_to_string(project.join("src/wizards/mod.rs")).unwrap();
assert!(
mod_rs.contains("pub mod checkout;"),
"src/wizards/mod.rs missing pub mod checkout"
);
let test = fs::read_to_string(project.join("tests/checkout_wizard.rs")).unwrap();
assert!(
test.contains("checkout_wizard_happy_path"),
"test file missing checkout_wizard_happy_path test"
);
assert!(
test.contains("checkout_step2_invalid_rerender_with_errors"),
"test file missing step2 invalid-data test"
);
assert!(
test.contains("checkout_cancel_clears_session_state"),
"test file missing cancel test"
);
assert!(
test.contains(".wizard-progress"),
"test file must reference the .wizard-progress CSS selector"
);
assert!(
test.contains("#[ignore"),
"generated tests must be #[ignore] until the user fills them in"
);
}
#[test]
fn generate_wizard_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("wizard-dry-app");
let (stdout, _) = run_autumn(
&project,
&[
"generate",
"wizard",
"checkout",
"shipping",
"payment",
"--dry-run",
],
);
assert!(
stdout.contains("Dry run"),
"expected Dry run header; got: {stdout}"
);
assert!(
!project.join("src/wizards/checkout.rs").exists(),
"dry run must not create the wizard file"
);
assert!(
!project.join("src/wizards/mod.rs").exists(),
"dry run must not create mod.rs"
);
assert!(
!project.join("tests/checkout_wizard.rs").exists(),
"dry run must not create the test file"
);
}
#[test]
fn generate_wizard_collision_without_force_fails() {
let (_tmp, project) = fresh_project("wizard-collide-app");
run_autumn(
&project,
&["generate", "wizard", "checkout", "shipping", "payment"],
);
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "checkout", "shipping", "payment"],
);
assert_eq!(code, Some(1), "re-run without --force must exit 1");
assert!(
stderr.contains("would overwrite") || stderr.contains("checkout.rs"),
"must report collision; got stderr: {stderr}"
);
}
#[test]
fn generate_wizard_force_overwrites() {
let (_tmp, project) = fresh_project("wizard-force-app");
run_autumn(
&project,
&["generate", "wizard", "checkout", "shipping", "payment"],
);
let wizard_path = project.join("src/wizards/checkout.rs");
let original = fs::read_to_string(&wizard_path).unwrap();
fs::write(&wizard_path, "// corrupted").unwrap();
run_autumn(
&project,
&[
"generate", "wizard", "checkout", "shipping", "payment", "--force",
],
);
let regenerated = fs::read_to_string(&wizard_path).unwrap();
assert_eq!(
regenerated, original,
"--force must restore original content"
);
}
#[test]
fn generate_wizard_mod_rs_is_idempotent() {
let (_tmp, project) = fresh_project("wizard-idempotent-app");
run_autumn(
&project,
&[
"generate", "wizard", "checkout", "shipping", "payment", "--force",
],
);
run_autumn(
&project,
&[
"generate", "wizard", "checkout", "shipping", "payment", "--force",
],
);
let mod_rs = fs::read_to_string(project.join("src/wizards/mod.rs")).unwrap();
assert_eq!(
mod_rs.matches("pub mod checkout;").count(),
1,
"mod.rs must not gain duplicate pub mod declarations on re-run"
);
}
#[test]
fn generate_wizard_rejects_fewer_than_two_steps() {
let (_tmp, project) = fresh_project("wizard-toofew-app");
let (_, stderr, code) =
run_autumn_failing(&project, &["generate", "wizard", "checkout", "shipping"]);
assert_eq!(code, Some(1));
assert!(
stderr.contains("at least") || stderr.contains('2'),
"error must mention the minimum step requirement; got: {stderr}"
);
}
#[test]
fn generate_wizard_rejects_reserved_step_names() {
let (_tmp, project) = fresh_project("wizard-reserved-app");
for reserved in ["confirm", "commit", "cancel"] {
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "checkout", reserved, "payment"],
);
assert_eq!(
code,
Some(1),
"reserved step name '{reserved}' must be rejected"
);
assert!(
stderr.contains(reserved) || stderr.contains("reserved"),
"error must mention the reserved name '{reserved}'; got: {stderr}"
);
}
}
#[test]
fn generate_wizard_rejects_step_name_with_hyphen() {
let (_tmp, project) = fresh_project("wizard-hyphen-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "checkout", "ship-ping", "payment"],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("ship-ping") || stderr.contains("only ASCII"),
"error must mention the invalid step name; got: {stderr}"
);
}
#[test]
fn generate_wizard_rejects_duplicate_step_names() {
let (_tmp, project) = fresh_project("wizard-dupe-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "checkout", "shipping", "shipping"],
);
assert_eq!(code, Some(1));
assert!(
stderr.contains("shipping") || stderr.contains("duplicate"),
"error must mention the duplicate step name; got: {stderr}"
);
}
#[test]
fn generate_wizard_rejects_rust_keyword_as_name() {
let (_tmp, project) = fresh_project("wizard-keyword-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "type", "shipping", "payment"],
);
assert_eq!(code, Some(1), "Rust keyword wizard name must be rejected");
assert!(
stderr.contains("keyword") || stderr.contains("type"),
"error must mention the keyword issue; got: {stderr}"
);
}
#[test]
fn generate_wizard_rejects_rust_keyword_as_step_name() {
let (_tmp, project) = fresh_project("wizard-keyword-step-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "checkout", "mod", "payment"],
);
assert_eq!(code, Some(1), "Rust keyword step name must be rejected");
assert!(
stderr.contains("keyword") || stderr.contains("mod"),
"error must mention the keyword issue; got: {stderr}"
);
}
#[test]
fn generate_wizard_rejects_underscore_only_name() {
let (_tmp, project) = fresh_project("wizard-underscore-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "_", "shipping", "payment"],
);
assert_eq!(
code,
Some(1),
"underscore-only wizard name must be rejected"
);
assert!(
stderr.contains('_') || stderr.contains("letter") || stderr.contains("digit"),
"error must mention the invalid name; got: {stderr}"
);
}
#[test]
fn generate_wizard_rejects_gen_keyword() {
let (_tmp, project) = fresh_project("wizard-gen-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "wizard", "gen", "shipping", "payment"],
);
assert_eq!(
code,
Some(1),
"'gen' (Rust 2024 reserved keyword) must be rejected"
);
assert!(
stderr.contains("keyword") || stderr.contains("gen"),
"error must mention the keyword issue; got: {stderr}"
);
}
#[test]
fn generate_sharded_scaffold_in_fresh_project() {
let (_tmp, project) = fresh_project("sharded-scaffold-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Account",
"shard_id:i64",
"name:String",
"--sharded",
],
);
let model = fs::read_to_string(project.join("src/models/account.rs")).unwrap();
assert!(
model.contains("#[shard_key = \"id\"]"),
"model must have #[shard_key = \"id\"] (default when no tenant_id field):\n{model}"
);
assert!(
model.contains("#[autumn_web::model]"),
"model must have #[autumn_web::model] attr:\n{model}"
);
let routes = fs::read_to_string(project.join("src/routes/accounts.rs")).unwrap();
assert!(
routes.contains("use autumn_web::sharding::ShardedDb"),
"routes must import ShardedDb:\n{routes}"
);
assert!(
routes.contains("mut db: ShardedDb"),
"routes must use ShardedDb in handler signatures:\n{routes}"
);
assert!(
routes.contains("from_shard(&db)"),
"routes index must use from_shard(&db):\n{routes}"
);
assert!(
!routes.contains("mut db: Db"),
"routes must not use bare Db extractor when sharded:\n{routes}"
);
let repo = fs::read_to_string(project.join("src/repositories/account.rs")).unwrap();
assert!(
repo.contains("from_shard"),
"repository must mention from_shard in doc comment:\n{repo}"
);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with("_create_accounts")
})
.collect();
assert_eq!(
migrations.len(),
1,
"expected one create_accounts migration"
);
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("autumn migrate --shard"),
"up.sql must mention `autumn migrate --shard`:\n{up}"
);
}
#[test]
fn generate_sharded_scaffold_with_tenant_id_field_uses_tenant_id_as_default_key() {
let (_tmp, project) = fresh_project("sharded-tenant-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Booking",
"tenant_id:i64",
"title:String",
"--sharded",
],
);
let model = fs::read_to_string(project.join("src/models/booking.rs")).unwrap();
assert!(
model.contains("#[shard_key = \"tenant_id\"]"),
"model must default shard_key to tenant_id when that field is present:\n{model}"
);
}
#[test]
fn generate_sharded_scaffold_with_explicit_shard_key() {
let (_tmp, project) = fresh_project("sharded-explicit-app");
run_autumn(
&project,
&[
"generate",
"scaffold",
"Widget",
"org_id:i64",
"label:String",
"--sharded",
"--shard-key",
"org_id",
],
);
let model = fs::read_to_string(project.join("src/models/widget.rs")).unwrap();
assert!(
model.contains("#[shard_key = \"org_id\"]"),
"model must use explicitly supplied --shard-key:\n{model}"
);
}
#[test]
fn generate_sharded_scaffold_rejects_bogus_shard_key() {
let (_tmp, project) = fresh_project("sharded-bogus-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Widget",
"label:String",
"--sharded",
"--shard-key",
"bogus",
],
);
assert_eq!(
code,
Some(1),
"--shard-key with non-existent field must fail"
);
assert!(
stderr.contains("bogus"),
"error must mention the invalid field name; got: {stderr}"
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh sharded project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_sharded_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("sharded-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Account",
"shard_id:i64",
"name:String",
"--sharded",
],
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated sharded scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh searchable project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_searchable_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("searchable-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"--searchable",
"title,body",
],
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated searchable scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh owner-scoped searchable project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_owner_searchable_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("owner-searchable-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"author_id:i64",
"--searchable",
"title,body",
],
);
let repo = fs::read_to_string(project.join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains(", owner = author_id)"),
"owner-scoped searchable repository must carry `owner = author_id`:\n{repo}"
);
let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("repo.search_page_scoped(owner_id, q, &page_req)")
&& routes.contains("repo.list_scoped(owner_id, &list_query, &page_req)"),
"owner-scoped search/index must call the scoped repository methods:\n{routes}"
);
assert!(
!routes.contains("repo.search_page(") && !routes.contains("repo.page("),
"SECURITY: owner-scoped searchable routes must NEVER call the unscoped \
repo.search_page(/repo.page( (cross-user leak):\n{routes}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated owner-scoped searchable scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh nullable-owner searchable project — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_nullable_owner_searchable_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("nullable-owner-searchable-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Note",
"title:String",
"body:Text",
"user:references?",
"--searchable",
"title,body",
],
);
let repo = fs::read_to_string(project.join("src/repositories/note.rs")).unwrap();
assert!(
repo.contains(", owner = user_id)"),
"nullable-owner searchable repository must carry `owner = user_id`:\n{repo}"
);
let routes = fs::read_to_string(project.join("src/routes/notes.rs")).unwrap();
assert!(
!routes.contains("repo.search_page(") && !routes.contains("repo.page("),
"SECURITY: nullable-owner searchable routes must NEVER call the unscoped \
repo.search_page(/repo.page(:\n{routes}"
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated nullable-owner searchable scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh live scaffold — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_live_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("live-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"--live",
],
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated live scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
#[ignore = "slow: cargo-checks a fresh soft-delete scaffold — run with `cargo test -p autumn-cli -- --ignored`"]
fn generated_soft_delete_scaffold_cargo_checks() {
let (_tmp, project) = fresh_project("soft-delete-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"body:Text",
"--soft-delete",
],
);
let check = Command::new("cargo")
.args(["check", "--tests"])
.current_dir(&project)
.output()
.unwrap();
assert!(
check.status.success(),
"cargo check on generated soft-delete scaffold failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&check.stdout),
String::from_utf8_lossy(&check.stderr),
);
}
#[test]
fn generate_model_uuid_id() {
let (_tmp, project) = fresh_project("uuid-model-app");
run_autumn(
&project,
&["generate", "model", "Post", "title:String", "--id", "uuid"],
);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: uuid::Uuid,"),
"model should have `pub id: uuid::Uuid`; got:\n{model}"
);
assert!(
!model.contains("pub id: i64,"),
"model must not have i64 id with --id uuid; got:\n{model}"
);
let schema = fs::read_to_string(project.join("src/schema.rs")).unwrap();
assert!(
schema.contains("id -> Uuid,"),
"schema should have `id -> Uuid`; got:\n{schema}"
);
assert!(
!schema.contains("id -> Int8,"),
"schema must not have Int8 with --id uuid; got:\n{schema}"
);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_posts"))
.collect();
assert_eq!(migrations.len(), 1, "expected 1 migration directory");
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("id UUID PRIMARY KEY DEFAULT gen_random_uuid()"),
"migration should have UUID PK; got:\n{up}"
);
assert!(
!up.contains("BIGSERIAL"),
"migration must not have BIGSERIAL with --id uuid; got:\n{up}"
);
assert!(
up.contains("gen_random_uuid()"),
"migration should include UUID default; got:\n{up}"
);
}
#[test]
fn generate_model_default_id_is_bigserial() {
let (_tmp, project) = fresh_project("default-id-app");
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: i64,"),
"default model should have `pub id: i64`; got:\n{model}"
);
let schema = fs::read_to_string(project.join("src/schema.rs")).unwrap();
assert!(
schema.contains("id -> Int8,"),
"default schema should have `id -> Int8`; got:\n{schema}"
);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_posts"))
.collect();
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("id BIGSERIAL PRIMARY KEY"),
"default migration should have BIGSERIAL; got:\n{up}"
);
assert!(
!up.contains("UUID"),
"default migration must not have UUID; got:\n{up}"
);
}
#[test]
fn generate_scaffold_uuid_id_is_rejected() {
let (_tmp, project) = fresh_project("uuid-scaffold-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--id",
"uuid",
],
);
assert_eq!(code, Some(1), "scaffold --id uuid must fail with exit 1");
assert!(
stderr.contains("not yet supported") && stderr.contains("generate model"),
"error must explain the limitation and point to `generate model`; got:\n{stderr}"
);
assert!(
!project.join("src/models/post.rs").exists(),
"rejected scaffold must not write a model file"
);
assert!(
!project.join("src/repositories/post.rs").exists(),
"rejected scaffold must not write a repository file"
);
}
#[test]
fn generate_model_bad_id_type_errors() {
let (_tmp, project) = fresh_project("bad-id-app");
let (_, stderr, code) =
run_autumn_failing(&project, &["generate", "model", "Post", "--id", "guid"]);
assert_eq!(code, Some(1), "--id guid must fail with exit 1");
assert!(
stderr.contains("guid") && stderr.contains("uuid") && stderr.contains("bigint"),
"error must list the bad value and accepted values; got: {stderr}"
);
}
#[test]
fn generate_scaffold_bad_id_type_errors() {
let (_tmp, project) = fresh_project("bad-id-scaffold-app");
let (_, stderr, code) = run_autumn_failing(
&project,
&["generate", "scaffold", "Post", "--id", "serial4"],
);
assert_eq!(code, Some(1), "--id serial4 must fail with exit 1");
assert!(
stderr.contains("serial4") && stderr.contains("uuid") && stderr.contains("bigint"),
"error must list the bad value and accepted values; got: {stderr}"
);
}
#[test]
fn generate_scaffold_project_default_uuid_config_only_is_rejected() {
let (_tmp, project) = fresh_project("project-default-uuid-config-app");
fs::write(
project.join("autumn.generate.toml"),
"[generate]\nid = \"uuid\"\n",
)
.unwrap();
let (_, stderr, code) = run_autumn_failing(
&project,
&[
"generate",
"scaffold",
"Post",
"title:String",
"--config",
"autumn.generate.toml",
],
);
assert_eq!(
code,
Some(1),
"scaffold resolving to uuid must fail with exit 1"
);
assert!(
stderr.contains("not yet supported") && stderr.contains("generate model"),
"error must come from the UUID scaffold gate (proving the [generate] \
default reached the scaffold path); got:\n{stderr}"
);
}
#[test]
fn generate_model_project_default_uuid_auto_discovered() {
let (_tmp, project) = fresh_project("project-default-uuid-auto-app");
fs::write(
project.join("autumn.generate.toml"),
"[generate]\nid = \"uuid\"\n",
)
.unwrap();
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: uuid::Uuid,"),
"auto-discovered [generate] id=uuid should emit uuid::Uuid; got:\n{model}"
);
let migrations: Vec<_> = fs::read_dir(project.join("migrations"))
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with("_create_posts"))
.collect();
let up = fs::read_to_string(migrations[0].path().join("up.sql")).unwrap();
assert!(
up.contains("id UUID PRIMARY KEY DEFAULT gen_random_uuid()"),
"auto-discovered project default migration should have UUID PK; got:\n{up}"
);
}
#[test]
fn generate_model_tolerates_malformed_scaffold_recipe_in_config() {
let (_tmp, project) = fresh_project("malformed-recipe-model-app");
fs::write(
project.join("autumn.generate.toml"),
"[generate]\nid = \"bigint\"\n\n[scaffold.Other]\nfields = [\"x:String\"]\nindex = [\"x\"]\n",
)
.unwrap();
run_autumn(&project, &["generate", "model", "Post", "title:String"]);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: i64,"),
"[generate] id=bigint should produce an i64 PK; got:\n{model}"
);
}
#[test]
fn generate_scaffold_project_default_uuid_is_rejected() {
let (_tmp, project) = fresh_project("project-default-uuid-scaffold-auto-app");
fs::write(
project.join("autumn.generate.toml"),
"[generate]\nid = \"uuid\"\n",
)
.unwrap();
let (_, stderr, code) =
run_autumn_failing(&project, &["generate", "scaffold", "Post", "title:String"]);
assert_eq!(
code,
Some(1),
"scaffold resolving to uuid must fail with exit 1"
);
assert!(
stderr.contains("not yet supported") && stderr.contains("generate model"),
"error must explain the limitation and point to `generate model`; got:\n{stderr}"
);
assert!(
!project.join("src/models/post.rs").exists(),
"rejected scaffold must not write a model file"
);
}
#[test]
fn generate_scaffold_auto_discovery_ignores_per_resource_recipe() {
let (_tmp, project) = fresh_project("auto-discovery-recipe-app");
fs::write(
project.join("autumn.generate.toml"),
"[scaffold.Post]\nfields = [\"name:String\"]\napi = true\n",
)
.unwrap();
run_autumn(&project, &["generate", "scaffold", "Post", "title:String"]);
assert!(
project.join("src/routes/posts.rs").is_file(),
"auto-discovery must not apply the recipe's api=true (HTML routes expected)"
);
let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub title: String,") && !model.contains("pub name: String,"),
"CLI fields must be used, not the recipe's fields; got:\n{model}"
);
}
#[test]
fn generate_tauri_scaffolds_expected_files() {
let (_tmp, project) = fresh_project("tauri-scaffold-app");
run_autumn(&project, &["generate", "tauri"]);
assert!(
project.join("src-tauri/tauri.conf.json").is_file(),
"src-tauri/tauri.conf.json must be created"
);
assert!(
project.join("src-tauri/Cargo.toml").is_file(),
"src-tauri/Cargo.toml must be created"
);
assert!(
project.join("src-tauri/build.rs").is_file(),
"src-tauri/build.rs must be created"
);
assert!(
project.join("src-tauri/src/main.rs").is_file(),
"src-tauri/src/main.rs must be created"
);
assert!(
project.join("src-tauri/src/lib.rs").is_file(),
"src-tauri/src/lib.rs must be created"
);
assert!(
project.join("src-tauri/tauri.linux.conf.json").is_file(),
"tauri.linux.conf.json must be created"
);
assert!(
project.join("src-tauri/tauri.macos.conf.json").is_file(),
"tauri.macos.conf.json must be created"
);
assert!(
project.join("src-tauri/tauri.windows.conf.json").is_file(),
"tauri.windows.conf.json must be created"
);
assert!(
project.join("src-tauri/stage-sidecar.sh").is_file(),
"stage-sidecar.sh must be created"
);
assert!(
project.join("src-tauri/stage-sidecar.ps1").is_file(),
"stage-sidecar.ps1 must be created"
);
assert!(
project.join("src-tauri/icons/icon.svg").is_file(),
"icons/icon.svg must be created"
);
for name in &["32x32.png", "128x128.png", "128x128@2x.png", "icon.png"] {
assert!(
project.join("src-tauri/icons").join(name).is_file(),
"icons/{name} must be created"
);
}
assert!(
project.join("src-tauri/icons/icon.ico").is_file(),
"icons/icon.ico must be created"
);
assert!(
project.join("src-tauri/icons/icon.icns").is_file(),
"icons/icon.icns must be created"
);
assert!(
project.join("src-tauri/.gitignore").is_file(),
"src-tauri/.gitignore must be created"
);
}
#[test]
fn generate_tauri_conf_is_valid_json_with_required_fields() {
let (_tmp, project) = fresh_project("tauri-conf-app");
run_autumn(&project, &["generate", "tauri"]);
let conf = fs::read_to_string(project.join("src-tauri/tauri.conf.json")).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&conf).expect("tauri.conf.json must be valid JSON");
assert!(parsed["identifier"].is_string(), "must have identifier");
assert!(parsed["productName"].is_string(), "must have productName");
assert!(
parsed["bundle"]["externalBin"].is_array(),
"must have bundle.externalBin"
);
assert!(
!parsed["bundle"]["externalBin"]
.as_array()
.unwrap()
.is_empty(),
"externalBin must not be empty"
);
assert!(parsed["bundle"]["icon"].is_array(), "must have bundle.icon");
assert!(
parsed["build"]["beforeBuildCommand"].is_null(),
"beforeBuildCommand must be absent from tauri.conf.json (lives in platform overlays)"
);
let bins = parsed["bundle"]["externalBin"].as_array().unwrap();
assert!(
bins.iter()
.any(|b| b.as_str().unwrap_or("").contains("tauri-conf-app")),
"externalBin must reference the app package name"
);
}
#[test]
fn generate_tauri_shell_cargo_toml_has_own_workspace() {
let (_tmp, project) = fresh_project("tauri-ws-app");
run_autumn(&project, &["generate", "tauri"]);
let cargo = fs::read_to_string(project.join("src-tauri/Cargo.toml")).unwrap();
assert!(
cargo.contains("[workspace]"),
"src-tauri/Cargo.toml must have its own [workspace] so it is independent"
);
assert!(cargo.contains("tauri"), "must depend on tauri");
assert!(
cargo.contains("tauri-plugin-shell"),
"must depend on tauri-plugin-shell"
);
}
#[test]
fn generate_tauri_lib_rs_has_sidecar_lifecycle() {
let (_tmp, project) = fresh_project("tauri-lifecycle-app");
run_autumn(&project, &["generate", "tauri"]);
let lib = fs::read_to_string(project.join("src-tauri/src/lib.rs")).unwrap();
assert!(
lib.contains("127.0.0.1:0"),
"must bind ephemeral loopback port"
);
assert!(
lib.contains("AUTUMN_SERVER__PORT"),
"must pass port env to sidecar"
);
assert!(
lib.contains("AUTUMN_MANAGED_PG_DATA_DIR"),
"must pass DB dir env (#1119)"
);
assert!(lib.contains(".sidecar("), "must spawn sidecar");
assert!(lib.contains("/health"), "must poll /health for readiness");
assert!(lib.contains(".kill()"), "must kill sidecar on window close");
assert!(
lib.contains("Destroyed"),
"must handle WindowEvent::Destroyed"
);
}
#[test]
fn generate_tauri_prints_prerequisites() {
let (_tmp, project) = fresh_project("tauri-prereq-app");
let (stdout, _stderr) = run_autumn(&project, &["generate", "tauri"]);
assert!(
stdout.contains("tauri-cli") || stdout.contains("cargo tauri"),
"must print Tauri CLI prerequisite; got:\n{stdout}"
);
assert!(
stdout.contains("embed-assets"),
"must mention embed-assets (#1004); got:\n{stdout}"
);
assert!(
stdout.contains("managed-pg"),
"must mention managed-pg (#1119); got:\n{stdout}"
);
}
#[test]
fn generate_tauri_is_additive_no_app_files_modified() {
let (_tmp, project) = fresh_project("tauri-additive-app");
let original_main = fs::read_to_string(project.join("src/main.rs")).unwrap();
let original_cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
run_autumn(&project, &["generate", "tauri"]);
assert_eq!(
original_main,
fs::read_to_string(project.join("src/main.rs")).unwrap(),
"src/main.rs must be unchanged after generate tauri"
);
assert_eq!(
original_cargo,
fs::read_to_string(project.join("Cargo.toml")).unwrap(),
"root Cargo.toml must be unchanged after generate tauri"
);
}
#[test]
fn generate_tauri_dry_run_writes_nothing() {
let (_tmp, project) = fresh_project("tauri-dry-run-app");
let (stdout, _stderr) = run_autumn(&project, &["generate", "tauri", "--dry-run"]);
assert!(
!project.join("src-tauri").exists(),
"dry-run must not create src-tauri/; got stdout:\n{stdout}"
);
assert!(
stdout.contains("tauri.conf.json") || stdout.contains("Would create"),
"dry-run must print the file plan; got:\n{stdout}"
);
}
#[test]
fn generate_tauri_collision_without_force_exits_nonzero() {
let (_tmp, project) = fresh_project("tauri-collision-app");
run_autumn(&project, &["generate", "tauri"]);
let (_, stderr, code) = run_autumn_failing(&project, &["generate", "tauri"]);
assert_eq!(code, Some(1), "re-running without --force must exit 1");
assert!(
stderr.contains("would overwrite") || stderr.contains("already exists"),
"must explain collision; got stderr:\n{stderr}"
);
}
#[test]
fn generate_tauri_force_is_idempotent() {
let (_tmp, project) = fresh_project("tauri-force-app");
run_autumn(&project, &["generate", "tauri"]);
run_autumn(&project, &["generate", "tauri", "--force"]);
let conf = fs::read_to_string(project.join("src-tauri/tauri.conf.json")).unwrap();
let _: serde_json::Value =
serde_json::from_str(&conf).expect("tauri.conf.json must still be valid after --force");
}
#[test]
fn generate_tauri_reuses_pwa_icon() {
let (_tmp, project) = fresh_project("tauri-pwa-icon-app");
run_autumn(&project, &["generate", "pwa"]);
let pwa_icon =
fs::read_to_string(project.join("static/icons/icon.svg")).expect("PWA icon must exist");
run_autumn(&project, &["generate", "tauri"]);
let tauri_icon = fs::read_to_string(project.join("src-tauri/icons/icon.svg"))
.expect("src-tauri/icons/icon.svg must be created");
assert_eq!(
pwa_icon, tauri_icon,
"src-tauri/icons/icon.svg must contain the same content as the PWA icon"
);
assert!(
project.join("static/icons/icon.svg").is_file(),
"PWA icon must still exist"
);
}
#[test]
fn controller_rerun_without_force_fails() {
let (_tmp, project) = fresh_project("controller-rerun-app");
run_autumn(
&project,
&["generate", "controller", "pages", "home", "about"],
);
let original = fs::read_to_string(project.join("src/routes/pages.rs")).unwrap();
let (_out, _err, code) = run_autumn_failing(
&project,
&["generate", "controller", "pages", "home", "contact"],
);
assert_eq!(code, Some(1), "second run must exit non-zero");
let after = fs::read_to_string(project.join("src/routes/pages.rs")).unwrap();
assert_eq!(
original, after,
"existing controller file must be untouched"
);
}
#[test]
#[ignore = "slow: cargo-builds a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn controller_generates_compiles_and_lists_routes() {
let (_tmp, project) = fresh_project("controller-html-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&[
"generate",
"controller",
"pages",
"home",
"about",
"contact",
],
);
let build = Command::new("cargo")
.args(["build"])
.current_dir(&project)
.output()
.expect("failed to run cargo build");
assert!(
build.status.success(),
"cargo build failed on generated controller:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr),
);
let (stdout, _stderr) = run_autumn(&project, &["routes"]);
for path in ["/pages/home", "/pages/about", "/pages/contact"] {
assert!(
stdout.contains(path),
"autumn routes must list {path}:\n{stdout}"
);
}
run_autumn(
&project,
&[
"generate",
"controller",
"pages",
"home",
"contact",
"services",
"--force",
],
);
let rebuild = Command::new("cargo")
.args(["build"])
.current_dir(&project)
.output()
.expect("failed to run cargo build after --force regen");
assert!(
rebuild.status.success(),
"cargo build failed after --force regen (stale route entry not pruned?):\n\
stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&rebuild.stdout),
String::from_utf8_lossy(&rebuild.stderr),
);
let (stdout2, _stderr2) = run_autumn(&project, &["routes"]);
for path in ["/pages/home", "/pages/contact", "/pages/services"] {
assert!(
stdout2.contains(path),
"autumn routes must list {path} after regen:\n{stdout2}"
);
}
assert!(
!stdout2.contains("/pages/about"),
"the dropped action /pages/about must no longer be listed:\n{stdout2}"
);
}
#[test]
#[ignore = "slow: cargo-builds a fresh project — run with `cargo test -p autumn-cli -- --ignored`"]
fn controller_api_generates_json() {
let (_tmp, project) = fresh_project("controller-api-build");
patch_generated_cargo_toml(&project);
run_autumn(
&project,
&["generate", "controller", "pages", "index", "stats", "--api"],
);
let file = fs::read_to_string(project.join("src/routes/pages.rs")).unwrap();
assert!(
file.contains("AutumnResult<Json<serde_json::Value>>"),
"--api controller must return JSON:\n{file}"
);
let build = Command::new("cargo")
.args(["build"])
.current_dir(&project)
.output()
.expect("failed to run cargo build");
assert!(
build.status.success(),
"cargo build failed on generated --api controller:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr),
);
let (stdout, _stderr) = run_autumn(&project, &["routes"]);
for path in ["/api/pages", "/api/pages/stats"] {
assert!(
stdout.contains(path),
"autumn routes must list {path}:\n{stdout}"
);
}
}