use std::fs;
use std::path::Path;
use std::process::{Command, Output};
use sofie_model::testing::{TemporaryDirectory, manifest};
const ROUTES: &str = "use sofie::prelude::*;
pub fn routes(routes: &mut Routes<Web>) {
routes.get(\"/orders\", queries::list_orders);
routes.post(\"/orders\", actions::place_order);
}
";
struct Fixture {
directory: TemporaryDirectory,
}
impl Fixture {
fn new() -> Self {
let fixture = Self {
directory: TemporaryDirectory::new("sofie-cli"),
};
fixture.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\"]\n",
);
fixture.write(
"app/Cargo.toml",
&manifest("shop", "order = { path = \"../modules/order\" }\n"),
);
fixture.write(
"app/src/main.rs",
"mod wiring;\n\nfn main() {\n let _ = (wiring::MODULES, wiring::app, wiring::worker);\n}\n",
);
fixture.write("modules/order/Cargo.toml", &manifest("order", ""));
fixture.write(
"modules/order/src/lib.rs",
"mod actions;\npub mod module;\nmod queries;\nmod wiring;\nmod areas {\n pub mod web {\n pub mod routes;\n }\n}\n",
);
fixture.write(
"modules/order/src/module.rs",
"pub const MODULE: sofie::Module = sofie::Module {\n name: \"order\",\n tier: sofie::Tier::Core,\n context: None,\n settings: &[],\n ..crate::wiring::GENERATED\n};\n",
);
fixture.write("modules/order/src/actions.rs", "");
fixture.write("modules/order/src/queries.rs", "");
fixture.write("modules/order/migrations/0001_create_order.sql", "");
fixture.write("modules/order/src/areas/web/routes.rs", ROUTES);
fixture
}
fn write(&self, relative: &str, text: &str) {
self.directory.write(relative, text);
}
fn read(&self, relative: &str) -> String {
fs::read_to_string(self.directory.path(relative)).unwrap()
}
fn sofie(&self, arguments: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_sofie"))
.args(arguments)
.arg(self.directory.root())
.output()
.unwrap()
}
}
fn stdout(output: &Output) -> String {
String::from_utf8(output.stdout.clone()).unwrap()
}
#[test]
fn a_generated_app_checks_clean() {
let fixture = Fixture::new();
assert_eq!(fixture.sofie(&["gen"]).status.code(), Some(0));
assert_eq!(fixture.sofie(&["gen", "--check"]).status.code(), Some(0));
let check = fixture.sofie(&["check"]);
assert_eq!(stdout(&check), "ok\n");
assert_eq!(check.status.code(), Some(0));
let wiring = fixture.read("app/src/wiring.rs");
assert!(
wiring.starts_with("// @generated by `sofie gen`; do not edit.\n"),
"{wiring}"
);
assert!(wiring.contains("&[order::module::MODULE]"), "{wiring}");
let module_wiring = fixture.read("modules/order/src/wiring.rs");
assert!(
module_wiring.contains("crate::areas::web::routes::routes"),
"{module_wiring}"
);
}
#[test]
fn a_broken_rule_is_a_finding_and_stops_gen() {
let fixture = Fixture::new();
fixture.write(
"modules/order/src/areas/web/routes.rs",
&ROUTES.replace("queries::list_orders", "actions::list_orders"),
);
let check = fixture.sofie(&["check"]);
let generate = fixture.sofie(&["gen"]);
let text = stdout(&check);
assert_eq!(check.status.code(), Some(1), "{text}");
assert!(
text.contains("error[route-kind]: GET /orders is handled by `actions::list_orders`"),
"{text}"
);
assert!(
text.contains("\n --> modules/order/src/areas/web/routes.rs:4\n"),
"{text}"
);
assert!(text.ends_with("\n1 problem\n"), "{text}");
assert_eq!(generate.status.code(), Some(1));
assert!(String::from_utf8_lossy(&generate.stderr).contains("error[route-kind]"));
assert!(!fixture.directory.path("app/src/wiring.rs").exists());
}
#[test]
fn a_key_one_language_lacks_is_a_finding_and_its_files_are_wired_once_complete() {
let fixture = Fixture::new();
fixture.write(
"modules/order/lang/tr.toml",
"[orders]\ntitle = \"SipariÅŸler\"\n",
);
let check = fixture.sofie(&["check"]);
let text = stdout(&check);
assert_eq!(check.status.code(), Some(1), "{text}");
assert!(
text.contains("error[screen-template]: `orders.title` has no `en` translation"),
"{text}"
);
assert!(
text.contains(" --> modules/order/lang/en.toml:1\n"),
"{text}"
);
fixture.write(
"modules/order/lang/en.toml",
"[orders]\ntitle = \"Orders\"\n",
);
assert_eq!(fixture.sofie(&["gen"]).status.code(), Some(0));
let wiring = fixture.read("app/src/wiring.rs");
for expected in [
" &[\"en\", \"tr\"],\n",
"(\"order\", sofie::db::embed!(\"../modules/order/lang/tr.toml\"))",
] {
assert!(wiring.contains(expected), "missing {expected} in {wiring}");
}
assert_eq!(stdout(&fixture.sofie(&["check"])), "ok\n");
}
#[test]
fn stale_wiring_is_a_finding() {
let fixture = Fixture::new();
let check = fixture.sofie(&["check"]);
let text = stdout(&check);
assert_eq!(check.status.code(), Some(1), "{text}");
assert!(text.contains("error[stale-wiring]:"), "{text}");
assert!(
text.contains(&format!(
" --> {}:1\n",
Path::new("app/src/wiring.rs").display()
)),
"{text}"
);
}
#[test]
fn gen_check_fails_on_a_stale_file() {
let fixture = Fixture::new();
let check = fixture.sofie(&["gen", "--check"]);
assert_eq!(check.status.code(), Some(1));
assert!(String::from_utf8_lossy(&check.stderr).contains("wiring.rs: stale"));
}
#[test]
fn dead_code_is_a_finding_unless_allowed_and_a_stale_allowance_is_reported() {
let fixture = Fixture::new();
fixture.write("helper/Cargo.toml", &manifest("helper", ""));
fixture.write("helper/src/lib.rs", "");
fixture.write(
"modules/order/Cargo.toml",
&manifest("order", "helper = { path = \"../../helper\" }\n"),
);
fixture.write("modules/order/src/actions.rs", "pub fn forgotten() {}\n");
assert_eq!(fixture.sofie(&["gen"]).status.code(), Some(0));
let dead = stdout(&fixture.sofie(&["check"]));
fixture.write(
"dead-code-allow.toml",
"[[item]]\npackage = \"order\"\nname = \"forgotten\"\nreason = \"kept for a test\"\n\n\
[[dependency]]\npackage = \"order\"\nname = \"helper\"\nreason = \"kept for a test\"\n\n\
[[item]]\npackage = \"order\"\nname = \"routes\"\nreason = \"it is used\"\n",
);
let allowed = stdout(&fixture.sofie(&["check"]));
for expected in [
"error[unused-item]: `forgotten` is `pub` but nothing names it outside its definition",
"\n --> modules/order/src/actions.rs:1\n",
"error[unused-dependency]: `helper` is in [dependencies] but no source of `order` names `helper`",
"\n --> modules/order/Cargo.toml:7\n",
] {
assert!(dead.contains(expected), "missing {expected} in {dead}");
}
assert!(dead.ends_with("\n2 problems\n"), "{dead}");
assert!(!allowed.contains("error[unused-ratio]"), "{allowed}");
assert!(
allowed.contains("error[stale-allowance]: `routes` of `order` is allowed as unused"),
"{allowed}"
);
assert!(allowed.ends_with("\n1 problem\n"), "{allowed}");
}
#[test]
fn structure_ceilings_hold_only_an_app_that_opts_in() {
let fixture = Fixture::new();
fixture.write(
"modules/order/src/actions.rs",
"// one\n// two\n// three\n// four\n// five\n",
);
assert_eq!(fixture.sofie(&["gen"]).status.code(), Some(0));
let default = stdout(&fixture.sofie(&["check"]));
fixture.write(
"app/Cargo.toml",
&format!(
"{}\n[package.metadata.sofie]\nstructure-ceilings = true\n",
manifest("shop", "order = { path = \"../modules/order\" }\n")
),
);
let opted = stdout(&fixture.sofie(&["check"]));
assert_eq!(default, "ok\n");
assert!(
opted.contains("error[comment-density]: `modules/order` has comments on more than"),
"{opted}"
);
assert!(opted.ends_with("\n1 problem\n"), "{opted}");
}
const MEMBER: &str = "pub struct Member;
impl Area for Member {
const NAME: &'static str = \"member\";
const PREFIX: &'static str = \"/account\";
const SIGN_IN: &'static str = \"/sign-in\";
type Principal = sofie_identity::Account;
}
";
#[test]
fn gen_writes_route_builders_and_member_areas_into_the_contract_until_written_by_hand() {
let fixture = Fixture::new();
fixture.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\", \"modules/*/contract\"]\n",
);
fixture.write(
"app/Cargo.toml",
&manifest(
"shop",
"order = { path = \"../modules/order\" }\norder-contract = { path = \"../modules/order/contract\" }\n",
),
);
fixture.write(
"modules/order/contract/Cargo.toml",
&manifest("order-contract", ""),
);
fixture.write(
"modules/order/contract/src/lib.rs",
"pub mod area;\nmod r#gen;\npub use r#gen::routes;\n",
);
fixture.write("modules/order/contract/src/area.rs", MEMBER);
fixture.write(
"modules/order/src/areas/member/routes.rs",
"pub fn routes(routes: &mut Routes<Member>) {\n routes.screen(\"/orders\", \"orders\", queries::list_orders);\n}\n",
);
fixture.write(
"modules/order/src/areas/member/screens/orders.html",
"<Section title=\"Orders\"></Section>\n",
);
fixture.write(
"modules/order/src/lib.rs",
"mod actions;\npub mod module;\nmod queries;\npub mod areas {\n pub mod member {\n pub mod routes;\n }\n}\n",
);
fs::remove_dir_all(fixture.directory.path("modules/order/src/areas/web")).unwrap();
let written = fixture.sofie(&["gen"]);
assert_eq!(
written.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&written.stderr)
);
let generated = fixture.read("modules/order/contract/src/gen/mod.rs");
fixture.write(
"modules/order/contract/src/area.rs",
&format!("{MEMBER}\nimpl sofie_identity::MemberArea for Member {{\n const HOME: &'static str = \"/\";\n}}\n"),
);
let stale = fixture.sofie(&["gen", "--check"]);
fixture.sofie(&["gen"]);
let rewritten = fixture.read("modules/order/contract/src/gen/mod.rs");
assert!(
generated.contains("impl sofie_identity::MemberArea for crate::area::Member {\n const HOME: &'static str = \"/account/orders\";\n}\n"),
"{generated}"
);
assert!(
generated.contains("pub fn list_orders() -> sofie::http::RoutePath {\n sofie::http::RoutePath::new(sofie::http::Method::GET, \"/account/orders\".to_owned())"),
"{generated}"
);
assert_eq!(stale.status.code(), Some(1));
assert!(String::from_utf8_lossy(&stale.stderr).contains("mod.rs: stale"));
assert!(!rewritten.contains("MemberArea"), "{rewritten}");
assert!(rewritten.contains("pub mod routes"), "{rewritten}");
let routes = stdout(&fixture.sofie(&["routes"]));
let explained = stdout(&fixture.sofie(&["explain", "order.member.list_orders"]));
let unknown = fixture.sofie(&["explain", "nothing"]);
assert!(
routes
.lines()
.nth(1)
.is_some_and(|row| row.split_whitespace().collect::<Vec<_>>()
== [
"member",
"GET",
"/account/orders",
"order.member.list_orders",
"order::queries::list_orders",
"principal's",
"own",
"screen(orders)"
]),
"{routes}"
);
assert!(
explained
.starts_with("route order.member.list_orders\n area: member\n GET /account/orders\n"),
"{explained}"
);
assert_eq!(unknown.status.code(), Some(1));
}
const CONFIG: &str = "#[derive(sofie::Config)]
pub struct OrderConfig {
/// Printed before every invoice number.
invoice_prefix: String,
/// Hours an order may stay unpaid.
window_hours: Option<u64>,
database: sofie::config::Database,
}
";
fn with_config() -> Fixture {
let fixture = Fixture::new();
fixture.write("modules/order/src/config.rs", CONFIG);
fixture.write(
"modules/order/src/lib.rs",
&format!(
"pub mod config;\n{}",
fixture.read("modules/order/src/lib.rs")
),
);
fixture
}
#[test]
fn gen_writes_each_module_s_env_example_and_the_app_s() {
let fixture = with_config();
assert_eq!(fixture.sofie(&["gen"]).status.code(), Some(0));
assert_eq!(
fixture.read("modules/order/.env.example"),
"# @generated by `sofie gen`; do not edit.\n\n\
# Printed before every invoice number.\nORDER_INVOICE_PREFIX=\n\n\
# Hours an order may stay unpaid.\n# ORDER_WINDOW_HOURS=\n\n\
# DATABASE_URL when unset.\n# ORDER_DATABASE_URL=\n"
);
let app = fixture.read(".env.example");
assert!(app.contains("\nDATABASE_URL=\n"), "{app}");
assert!(app.contains("\n# SOFIE_MIGRATE=\n"), "{app}");
assert!(
app.ends_with(
"# Module order\n\n# Printed before every invoice number.\nORDER_INVOICE_PREFIX=\n\n\
# Hours an order may stay unpaid.\n# ORDER_WINDOW_HOURS=\n\n\
# DATABASE_URL when unset.\n# ORDER_DATABASE_URL=\n"
),
"{app}"
);
assert!(
fixture
.read("app/src/wiring.rs")
.contains("sofie::config::load::<order::config::OrderConfig>(\"order\")?;"),
);
}
#[test]
fn config_check_names_each_missing_or_malformed_key_without_its_value() {
let fixture = with_config();
fixture.write(
".env.production",
"# production\nDATABASE_URL=postgres://db/shop\nSOFIE_MIGRATE=yes\nORDER_WINDOW_HOURS=\"-2\"\n",
);
let check = fixture.sofie(&["config", "check", "--env", "production"]);
let text = stdout(&check);
assert_eq!(check.status.code(), Some(1), "{text}");
for expected in [
"error[config]: SOFIE_SIGNING_KEYS is not set, and the app cannot start without it\n --> .env.production:1\n",
"error[config]: ORDER_INVOICE_PREFIX is not set, and the app cannot start without it\n --> .env.production:1\n = help: add `ORDER_INVOICE_PREFIX=…`: Printed before every invoice number.\n",
"error[config]: SOFIE_MIGRATE is not `on` or `off`\n --> .env.production:3\n",
"error[config]: ORDER_WINDOW_HOURS is not a whole number of 0 or more\n --> .env.production:4\n",
] {
assert!(text.contains(expected), "missing {expected} in {text}");
}
assert!(text.ends_with("\n4 problems\n"), "{text}");
assert!(
!text.contains("-2") && !text.contains("postgres://"),
"{text}"
);
fixture.write(
".env.production",
"DATABASE_URL=x\nSOFIE_SIGNING_KEYS=k\nORDER_INVOICE_PREFIX=INV-\n",
);
let clean = fixture.sofie(&["config", "check", "--env", "production"]);
assert_eq!(
(stdout(&clean).as_str(), clean.status.code()),
("ok\n", Some(0))
);
}
#[test]
fn fix_declares_modules_adds_the_app_dependency_and_regenerates() {
let fixture = Fixture::new();
fixture.write("modules/order/src/lib.rs", "mod actions;\nmod queries;\n");
fixture.write("app/Cargo.toml", &manifest("shop", ""));
let fixed = fixture.sofie(&["fix"]);
let text = stdout(&fixed);
assert_eq!(fixed.status.code(), Some(0), "{text}");
for expected in [
"declared `module` in modules/order/src/lib.rs\n",
"declared `areas::web::routes` in modules/order/src/lib.rs\n",
"added `order` to the [dependencies] of app/Cargo.toml\n",
"wiring.rs\n",
] {
assert!(text.contains(expected), "missing {expected} in {text}");
}
assert!(text.ends_with("ok\n"), "{text}");
assert_eq!(
fixture.read("modules/order/src/lib.rs"),
"mod actions;\npub mod module;\nmod queries;\npub mod areas {\n pub mod web {\n \
pub mod routes;\n }\n}\n"
);
assert!(
fixture
.read("app/Cargo.toml")
.contains("order = { path = \"../modules/order\" }")
);
}
#[test]
fn fix_removes_a_stale_allowance_behind_stale_wiring() {
let fixture = Fixture::new();
assert_eq!(fixture.sofie(&["gen"]).status.code(), Some(0));
fixture.write(
"dead-code-allow.toml",
"[[item]]\npackage = \"order\"\nname = \"routes\"\nreason = \"it is used\"\n",
);
fixture.write("modules/order/migrations/0002_add_note.sql", "");
let fixed = fixture.sofie(&["fix"]);
let text = stdout(&fixed);
assert_eq!(fixed.status.code(), Some(0), "{text}");
assert!(text.contains("removed 1 stale allowance(s)"), "{text}");
assert!(text.ends_with("ok\n"), "{text}");
}
#[test]
fn fix_adds_no_area_contract_an_app_without_identity_never_names() {
let fixture = Fixture::new();
fixture.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\", \"modules/*/contract\"]\n",
);
fixture.write(
"modules/order/contract/Cargo.toml",
&manifest("order-contract", ""),
);
fixture.write(
"modules/order/contract/src/lib.rs",
"pub mod area;\nmod r#gen;\npub use r#gen::routes;\n",
);
fixture.write("modules/order/contract/src/area.rs", MEMBER);
fixture.write(
"modules/order/src/areas/member/routes.rs",
"pub fn routes(routes: &mut Routes<Member>) {\n routes.get(\"/orders\", queries::list_orders);\n}\n",
);
fixture.write(
"modules/order/src/lib.rs",
"mod actions;\npub mod module;\nmod queries;\nmod wiring;\nmod areas {\n pub mod member {\n pub mod routes;\n }\n}\n",
);
fs::remove_dir_all(fixture.directory.path("modules/order/src/areas/web")).unwrap();
let fixed = fixture.sofie(&["fix"]);
let text = stdout(&fixed);
assert!(!text.contains("added `order-contract`"), "{text}");
assert!(!fixture.read("app/Cargo.toml").contains("order-contract"));
}
#[test]
fn docs_are_written_and_held_to_the_code() {
let fixture = Fixture::new();
assert_eq!(fixture.sofie(&["gen"]).status.code(), Some(0));
let docs = fixture.sofie(&["docs"]);
assert_eq!(
docs.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&docs.stderr)
);
assert_eq!(stdout(&fixture.sofie(&["check"])), "ok\n");
let module = fixture.read("docs/gen/modules/order.md");
assert!(
module.contains(
"| GET | `/orders` | `order.web.list_orders` | `order::queries::list_orders` |"
),
"{module}"
);
let security = fixture.read("docs/gen/security.md");
assert!(
security.contains("| GET | `/orders` | `web` | anyone | — | any | — | — |"),
"{security}"
);
let telemetry = fixture.read("docs/gen/telemetry.md");
assert!(
telemetry.contains("| `GET order.web.list_orders` | request | `/orders` |"),
"{telemetry}"
);
fixture.write(
"modules/order/src/areas/web/routes.rs",
&ROUTES.replace(
" routes.post(",
" routes.get(\"/orders/old\", queries::old_orders);\n routes.post(",
),
);
let check = fixture.sofie(&["check"]);
let docs_check = fixture.sofie(&["docs", "--check"]);
let gen_check = fixture.sofie(&["gen", "--check"]);
let generated = fixture.sofie(&["gen"]);
let text = stdout(&check);
assert!(text.contains("error[stale-docs]:"), "{text}");
assert!(text.contains("docs/gen/flows.md:1"), "{text}");
assert!(text.contains("docs/gen/security.md:1"), "{text}");
assert!(text.contains("docs/gen/telemetry.md:1"), "{text}");
assert_eq!(docs_check.status.code(), Some(1));
let stale = String::from_utf8_lossy(&gen_check.stderr);
assert!(stale.contains("docs/gen/security.md: stale"), "{stale}");
assert_eq!(generated.status.code(), Some(0));
assert_eq!(stdout(&fixture.sofie(&["check"])), "ok\n");
assert!(
fixture
.read("docs/gen/security.md")
.contains("`/orders/old`")
);
}
#[test]
fn a_new_app_and_module_pass_check() {
let directory = TemporaryDirectory::new("sofie-cli-new");
let sofie = |arguments: &[&str], in_directory: &Path| {
Command::new(env!("CARGO_BIN_EXE_sofie"))
.args(arguments)
.current_dir(in_directory)
.output()
.unwrap()
};
let app = sofie(&["new", "app", "demo"], directory.root());
let root = directory.path("demo");
let module = sofie(&["new", "module", "billing"], &root);
let check = sofie(&["check"], &root);
assert_eq!(
app.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&app.stderr)
);
assert_eq!(
module.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&module.stderr)
);
assert_eq!(
stdout(&check),
"ok\n",
"{}",
String::from_utf8_lossy(&check.stderr)
);
for file in [
"app/src/wiring.rs",
".env.example",
"modules/billing/src/module.rs",
"modules/billing/src/wiring.rs",
"modules/billing/contract/Cargo.toml",
"modules/billing/src/areas/web/routes.rs",
"modules/billing/migrations/.gitkeep",
"modules/billing/lang/.gitkeep",
"modules/billing/.env.example",
] {
assert!(root.join(file).is_file(), "missing {file}");
}
assert!(
fs::read_to_string(root.join(".gitignore"))
.unwrap()
.lines()
.any(|line| line == "/.env"),
"the secrets `sofie dev` reads from `.env` stay out of the repository"
);
let refused = sofie(&["new", "module", "billing"], &root);
assert_eq!(refused.status.code(), Some(2));
}
fn app_with_new_areas(directory: &TemporaryDirectory) -> std::path::PathBuf {
let sofie = |arguments: &[&str], in_directory: &Path| {
let output = Command::new(env!("CARGO_BIN_EXE_sofie"))
.args(arguments)
.current_dir(in_directory)
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(0),
"{arguments:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
};
sofie(&["new", "app", "demo"], directory.root());
let root = directory.path("demo");
sofie(&["new", "module", "billing"], &root);
for (area, access) in [
("member", "owned"),
("console", "permitted"),
("docs", "public"),
] {
sofie(&["new", "area", "billing", area, access], &root);
}
root
}
#[test]
fn a_new_area_of_each_access_passes_check_and_brings_identity_for_members() {
let directory = TemporaryDirectory::new("sofie-cli-new-area");
let root = app_with_new_areas(&directory);
let check = Command::new(env!("CARGO_BIN_EXE_sofie"))
.arg("check")
.current_dir(&root)
.output()
.unwrap();
assert_eq!(
stdout(&check),
"ok\n",
"{}",
String::from_utf8_lossy(&check.stderr)
);
let read = |file: &str| fs::read_to_string(root.join(file)).unwrap();
let area = read("modules/billing/contract/src/area.rs");
for principal in [
"type Principal = Account;",
"type Principal = Permitted;",
"type Principal = Visitor;",
] {
assert!(area.contains(principal), "{area}");
}
assert!(
area.contains("const SIGN_IN: &'static str = \"/console/sign-in\";"),
"{area}"
);
let app_manifest = read("app/Cargo.toml");
assert!(
app_manifest.contains("sofie-identity.workspace = true")
&& app_manifest.contains("billing-contract = { path"),
"{app_manifest}"
);
assert!(
read("modules/billing/contract/Cargo.toml")
.contains("sofie-identity = { workspace = true, features = [\"permissions\"] }")
);
let wiring = read("app/src/wiring.rs");
assert!(
wiring.contains("sofie_identity::member::<billing_contract::area::Console>"),
"{wiring}"
);
assert!(
!wiring.contains("member::<billing_contract::area::Docs>"),
"a public area signs nobody in: {wiring}"
);
assert!(
read("modules/billing/contract/src/gen/mod.rs")
.contains("const HOME: &'static str = \"/member/home\";")
);
let refused = Command::new(env!("CARGO_BIN_EXE_sofie"))
.args(["new", "area", "billing", "docs", "public"])
.current_dir(&root)
.output()
.unwrap();
assert_eq!(refused.status.code(), Some(2));
}
#[test]
fn a_public_area_in_a_second_module_of_an_identity_app_passes_check() {
let directory = TemporaryDirectory::new("sofie-cli-second-public-area");
let sofie = |arguments: &[&str], in_directory: &Path| {
Command::new(env!("CARGO_BIN_EXE_sofie"))
.args(arguments)
.current_dir(in_directory)
.output()
.unwrap()
};
sofie(&["new", "app", "demo"], directory.root());
let root = directory.path("demo");
for arguments in [
&["new", "module", "billing"][..],
&["new", "area", "billing", "member", "owned"],
&["new", "module", "blog"],
&["new", "area", "blog", "docs", "public"],
&["check"],
] {
let output = sofie(arguments, &root);
assert_eq!(
output.status.code(),
Some(0),
"{arguments:?}: {}{}",
stdout(&output),
String::from_utf8_lossy(&output.stderr)
);
}
}
#[cfg(feature = "slow-tests")]
#[test]
fn a_new_area_of_each_access_compiles_in_an_app_without_identity() {
let directory = TemporaryDirectory::new("sofie-cli-new-area-build");
let root = app_with_new_areas(&directory);
let repository = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
fs::copy(repository.join("Cargo.lock"), root.join("Cargo.lock")).unwrap();
let shared_target = std::env::current_exe()
.unwrap()
.ancestors()
.nth(3)
.unwrap()
.to_path_buf();
let cargo = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.args([
"check",
"--offline",
"--quiet",
"--package",
"demo",
"--target-dir",
])
.arg(shared_target)
.current_dir(&root)
.output()
.unwrap();
assert!(
cargo.status.success(),
"{}",
String::from_utf8_lossy(&cargo.stderr)
);
}
#[test]
fn bench_refuses_production_before_it_writes_or_loads_anything() {
let fixture = Fixture::new();
let output = fixture.sofie(&["bench", "--environment", "production"]);
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{error}");
assert!(
error.contains("refuses the `production` environment"),
"{error}"
);
assert!(!fixture.directory.path("bench").exists());
}
#[test]
fn build_refuses_stale_wiring_before_compiling() {
let fixture = Fixture::new();
let output = fixture.sofie(&["build"]);
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{error}");
assert!(error.contains("stale generated files"), "{error}");
assert!(error.contains("run `sofie gen"), "{error}");
}
#[test]
fn mcp_writes_nothing_but_the_protocol_to_stdout_in_any_build() {
let fixture = Fixture::new();
let output = fixture.sofie(&["mcp"]);
assert_eq!(stdout(&output), "");
let stderr = String::from_utf8_lossy(&output.stderr);
if cfg!(feature = "mcp") {
assert!(output.status.success(), "{stderr}");
} else {
assert!(stderr.contains("--features mcp"), "{stderr}");
}
}
#[test]
fn fuzz_refuses_to_send_no_requests() {
let fixture = Fixture::new();
let output = fixture.sofie(&["fuzz", "--requests", "0"]);
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(2), "{error}");
assert!(error.contains("usage"), "{error}");
}
#[test]
fn a_generator_whose_gen_fails_says_it_kept_what_it_wrote() {
let fixture = Fixture::new();
fixture.write(
"modules/order/src/areas/web/routes.rs",
&ROUTES.replace("queries::list_orders", "actions::list_orders"),
);
let output = fixture.sofie(&[
"new",
"route",
"order",
"web",
"get",
"/receipts",
"receipts",
]);
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{error}");
assert!(error.contains("error[route-kind]"), "{error}");
assert!(error.contains("kept the new code above"), "{error}");
assert!(
fixture
.read("modules/order/src/queries.rs")
.contains("fn receipts(")
);
}
#[test]
fn schedule_run_refuses_a_remote_database_before_building() {
let fixture = Fixture::new();
fixture.write(".env", "DATABASE_URL=postgres://db.example.com/shop\n");
let remote = fixture.sofie(&["schedule", "run", "order.jobs.expire"]);
let error = String::from_utf8_lossy(&remote.stderr);
assert_eq!(remote.status.code(), Some(2), "{error}");
assert!(error.contains("schedule run"), "{error}");
assert!(!fixture.directory.path("app/src/wiring.rs").exists());
}
#[test]
fn help_prints_the_usage_and_succeeds() {
for flag in ["--help", "-h", "help"] {
let output = Command::new(env!("CARGO_BIN_EXE_sofie"))
.arg(flag)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(0), "{flag}");
assert!(stdout(&output).starts_with("usage: sofie gen"), "{flag}");
}
let unknown = Command::new(env!("CARGO_BIN_EXE_sofie"))
.arg("frobnicate")
.output()
.unwrap();
assert_eq!(unknown.status.code(), Some(2));
}
#[test]
fn gen_reaches_the_code_it_writes_for_hand_written_routes_and_tasks() {
let directory = TemporaryDirectory::new("sofie-cli-reach");
let sofie = |arguments: &[&str], in_directory: &Path| {
Command::new(env!("CARGO_BIN_EXE_sofie"))
.args(arguments)
.current_dir(in_directory)
.output()
.unwrap()
};
sofie(&["new", "app", "demo"], directory.root());
let root = directory.path("demo");
sofie(&["new", "module", "billing"], &root);
let module = root.join("modules/billing/src");
fs::write(
module.join("lib.rs"),
"pub mod config;\npub mod module;\npub mod queries;\npub mod tasks;\nmod wiring;\n\n\
mod areas {\n pub mod web {\n pub mod routes;\n }\n}\n",
)
.unwrap();
fs::write(
module.join("queries.rs"),
"use sofie::prelude::*;\n\npub async fn list_invoices() -> Result<()> {\n Ok(())\n}\n",
)
.unwrap();
fs::write(
module.join("tasks.rs"),
"use sofie::prelude::*;\n\n#[task]\npub async fn reindex() -> Result<()> {\n Ok(())\n}\n",
)
.unwrap();
fs::write(
module.join("areas/web/routes.rs"),
"use sofie::prelude::*;\n\nuse crate::queries;\n\npub fn routes(routes: &mut Routes<Web>) {\n \
routes.get(\"/invoices\", queries::list_invoices);\n}\n",
)
.unwrap();
let generated = sofie(&["gen"], &root);
let check = sofie(&["check"], &root);
let error = String::from_utf8_lossy(&generated.stderr);
assert_eq!(generated.status.code(), Some(0), "{error}");
assert_eq!(stdout(&check), "ok\n", "{}", stdout(&check));
let read = |file: &str| fs::read_to_string(root.join(file)).unwrap();
assert!(
read("app/src/main.rs").contains("sofie::run_with_tasks("),
"{}",
read("app/src/main.rs")
);
assert!(read("app/src/main.rs").contains("demo::worker, demo::tasks"));
assert!(
read("app/src/lib.rs").contains("tasks"),
"{}",
read("app/src/lib.rs")
);
let contract = read("modules/billing/contract/src/lib.rs");
assert!(
contract.contains("mod r#gen;\npub use r#gen::routes;"),
"{contract}"
);
let manifest = read("modules/billing/Cargo.toml");
assert!(manifest.contains("\"tasks\""), "{manifest}");
}
#[test]
fn feature_refuses_a_name_the_facade_does_not_declare_and_leaves_the_manifest() {
let directory = TemporaryDirectory::new("sofie-cli-feature");
directory.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\"]\n",
);
let app_manifest = manifest(
"featured",
"sofie = { package = \"sofie-framework\", path = \"../facade\" }\n",
);
directory.write("app/Cargo.toml", &app_manifest);
directory.write("app/src/lib.rs", "");
directory.write(
"facade/Cargo.toml",
"[package]\nname = \"sofie-framework\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n\
[features]\ni18n = []\n",
);
directory.write("facade/src/lib.rs", "");
let sofie = |arguments: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_sofie"))
.args(arguments)
.arg(directory.root())
.output()
.unwrap()
};
let unknown = sofie(&["feature", "add", "i18m"]);
let toggled = sofie(&["feature", "toggle", "i18n"]);
let error = String::from_utf8_lossy(&unknown.stderr);
assert_eq!(unknown.status.code(), Some(2), "{error}");
assert!(
error.contains("`sofie` has no feature `i18m`; its features are i18n"),
"{error}"
);
assert_eq!(toggled.status.code(), Some(2));
assert_eq!(
fs::read_to_string(directory.path("app/Cargo.toml")).unwrap(),
app_manifest
);
}