use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use slave_framework::testing::TestDatabase;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("slave-test-{}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
Self(dir)
}
fn new_app(&self) -> PathBuf {
let out = slave(
&self.0,
&["new", "app", "--framework-path", &framework_path()],
);
assert!(out.status.success(), "{}", stderr(&out));
self.0.join("app")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn framework_path() -> String {
format!("{}/framework", env!("CARGO_MANIFEST_DIR"))
}
fn slave(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_slave"))
.args(args)
.current_dir(dir)
.output()
.expect("run slave")
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn read(path: impl AsRef<Path>) -> String {
fs::read_to_string(path.as_ref())
.unwrap_or_else(|err| panic!("read {}: {err}", path.as_ref().display()))
}
const SKELETON: &[&str] = &[
"Cargo.toml",
"AGENTS.md",
"CLAUDE.md",
".env.example",
".env",
"src/main.rs",
"src/app.rs",
"src/base/mod.rs",
"src/base/auth.rs",
"src/base/mcp.rs",
"src/base/config.rs",
"src/base/error.rs",
"src/base/state.rs",
"src/modules/mod.rs",
"src/modules/user/mod.rs",
"src/modules/user/tools.rs",
"src/modules/user/models/user.rs",
"src/modules/user/services/user_service.rs",
"src/modules/user/dto/user.rs",
"src/modules/role/mod.rs",
"src/modules/role/tools.rs",
"src/modules/role/models/role.rs",
"src/modules/role/models/user_role.rs",
"src/modules/role/services/role_service.rs",
"src/modules/role/dto/role.rs",
"src/modules/auth/mod.rs",
"src/modules/auth/tools.rs",
"src/modules/auth/services/auth_service.rs",
"src/modules/auth/dto/auth.rs",
"src/lib.rs",
"src/bin/migrate.rs",
"Toasty.toml",
"config/apps.toml",
"apps/README.md",
"database/history.toml",
"tests/mcp.rs",
];
#[test]
fn new_creates_core_application() {
let tmp = TempDir::new();
let app = tmp.new_app();
for file in SKELETON {
assert!(app.join(file).is_file(), "missing {file}");
}
let manifest = read(app.join("Cargo.toml"));
assert!(manifest.contains("name = \"app\""));
assert!(manifest.contains("[package.metadata.slave]"));
assert!(manifest.contains("slave-framework = { path = "));
assert!(manifest.contains("default-run = \"app\""));
assert!(manifest.contains("members = [\"apps/*\"]"), "{manifest}");
assert!(!manifest.contains("slave-gateway"), "{manifest}");
assert!(!app.join("gateway").exists());
assert!(read(app.join("config/apps.toml")).contains("app = \"mcp\""));
assert!(read(app.join("CLAUDE.md")).contains("@AGENTS.md"));
let agents = read(app.join("AGENTS.md"));
assert!(agents.starts_with("# AGENTS.md — app"), "{agents}");
assert!(agents.contains("No REST business endpoints"));
let modules = read(app.join("src/modules/mod.rs"));
for module in ["auth", "role", "user"] {
assert!(modules.contains(&format!("pub mod {module};")), "{modules}");
assert!(
modules.contains(&format!("{module}::tools(),")),
"{modules}"
);
}
let env = read(app.join(".env"));
assert!(
env.contains("DATABASE_URL=turso:database/database.db"),
"{env}"
);
assert!(!env.contains("TURSO_"), "{env}");
assert!(read(app.join(".gitignore")).contains("/database/*.db"));
}
#[test]
fn new_application_has_no_rest_business_api() {
let tmp = TempDir::new();
let app = tmp.new_app();
for dir in ["auth", "role", "user"] {
let domain = app.join("src/modules").join(dir);
assert!(!domain.join("routes.rs").exists(), "{dir}/routes.rs");
assert!(!domain.join("controllers").exists(), "{dir}/controllers");
}
let router = read(app.join("src/app.rs"));
assert_eq!(router.matches(".route(").count(), 1, "{router}");
assert!(
router.contains(".route(\"/health\", get(health))"),
"{router}"
);
assert!(router.contains(".nest_service(mcp::PATH,"), "{router}");
assert!(!router.contains(".merge("), "{router}");
assert!(read(app.join("src/base/mcp.rs")).contains("pub const PATH: &str = \"/mcp\";"));
}
#[test]
fn new_defaults_to_crates_io_framework() {
let tmp = TempDir::new();
assert!(slave(&tmp.0, &["new", "app"]).status.success());
let manifest = read(tmp.0.join("app/Cargo.toml"));
assert!(
manifest.contains("slave-framework = { version = \"0.7\", default-features = false }"),
"{manifest}"
);
assert!(!manifest.contains("slave-gateway"), "{manifest}");
let out = slave(&tmp.0, &["new", "routed", "--gateway"]);
assert!(out.status.success(), "{}", stderr(&out));
let manifest = read(tmp.0.join("routed/Cargo.toml"));
assert!(manifest.contains("slave-gateway = \"0.7\""), "{manifest}");
assert!(
manifest.contains("members = [\"apps/*\", \"gateway\"]"),
"{manifest}"
);
assert!(tmp.0.join("routed/gateway/src/main.rs").is_file());
assert!(
read(tmp.0.join("routed/gateway/Cargo.toml")).contains("slave-gateway.workspace = true")
);
}
#[test]
fn gateway_is_installed_on_demand() {
let tmp = TempDir::new();
let app = tmp.new_app();
let has_gateway = || app.join("gateway/Cargo.toml").is_file();
let out = slave(&app, &["make:app", "tool"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(!has_gateway());
assert!(read(app.join("config/apps.toml")).contains("app = \"tool\"\nport = 4000"));
let list = String::from_utf8_lossy(&slave(&app, &["route:list"]).stdout).into_owned();
assert!(list.contains("(direct)"), "{list}");
assert!(list.contains("gateway not installed"), "{list}");
let out = slave(
&app,
&["make:app", "dashboard", "--domain", "admin.localhost"],
);
assert!(out.status.success(), "{}", stderr(&out));
assert!(String::from_utf8_lossy(&out.stdout).contains("installed the gateway"));
assert!(has_gateway());
let manifest = read(app.join("Cargo.toml"));
assert!(manifest.contains("slave-gateway = { path = "), "{manifest}");
assert_eq!(manifest.matches("slave-gateway").count(), 1, "{manifest}");
assert!(manifest.contains("\"gateway\"]"), "{manifest}");
let out = slave(&app, &["build", "--list"]);
assert!(out.status.success(), "{}", stderr(&out));
let list = String::from_utf8_lossy(&out.stdout).into_owned();
for expected in ["backend", "gateway/**", "apps/dashboard/**", "tool"] {
assert!(list.contains(expected), "missing `{expected}` in:\n{list}");
}
let out = slave(&app, &["build", "nope"]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("unknown component `nope`"),
"{}",
stderr(&out)
);
let out = slave(&app, &["install:gateway"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(String::from_utf8_lossy(&out.stdout).contains("already installed"));
assert_eq!(read(app.join("Cargo.toml")), manifest);
}
#[test]
fn serve_requires_a_build() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["serve", "--port", "39123"]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("run `slave build backend` first"),
"{}",
stderr(&out)
);
}
#[test]
fn new_refuses_existing_directory_and_bad_input() {
let tmp = TempDir::new();
fs::create_dir(tmp.0.join("taken")).unwrap();
assert!(!slave(&tmp.0, &["new", "taken"]).status.success());
assert!(!slave(&tmp.0, &["new", "../escape"]).status.success());
assert!(!slave(&tmp.0, &["new", "has space"]).status.success());
let out = slave(&tmp.0, &["new", "x", "--framework-path", "/nonexistent"]);
assert!(!out.status.success());
assert!(!tmp.0.join("x").exists());
}
#[test]
fn make_model_creates_domain_with_default_module() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["make:model", "BlogPost"]);
assert!(out.status.success(), "{}", stderr(&out));
let domain = app.join("src/modules/blog_post");
assert!(read(domain.join("models/blog_post.rs")).contains("pub struct BlogPost"));
assert!(read(domain.join("models/mod.rs")).contains("pub mod blog_post;"));
assert!(read(domain.join("mod.rs")).contains("mod models;"));
assert!(read(app.join("src/modules/mod.rs")).contains("pub mod blog_post;"));
}
#[test]
fn make_model_supports_several_models_per_module() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["make:model", "Profile", "--module", "user"]);
assert!(out.status.success(), "{}", stderr(&out));
let models = read(app.join("src/modules/user/models/mod.rs"));
assert_eq!(models, "pub mod profile;\npub mod user;\n");
assert!(app.join("src/modules/user/models/profile.rs").is_file());
assert_eq!(
read(app.join("src/modules/user/mod.rs"))
.matches("mod models;")
.count(),
1
);
}
#[test]
fn make_tools_creates_and_registers_tools() {
let tmp = TempDir::new();
let app = tmp.new_app();
assert!(slave(&app, &["make:model", "Post"]).status.success());
let out = slave(&app.join("src"), &["make:tools", "PostTools"]);
assert!(out.status.success(), "{}", stderr(&out));
let domain = read(app.join("src/modules/post/mod.rs"));
for line in ["mod models;", "mod tools;", "pub use tools::tools;"] {
assert!(domain.contains(line), "missing `{line}` in:\n{domain}");
}
let tools = read(app.join("src/modules/post/tools.rs"));
for expected in [
"#[tool_router(router = post_tools,",
"McpServer::post_tools()",
"async fn post_list(",
"async fn post_get(",
"self.authenticate(&parts)",
] {
assert!(
tools.contains(expected),
"missing `{expected}` in:\n{tools}"
);
}
assert!(!app.join("src/modules/post/routes.rs").exists());
assert!(!app.join("src/modules/post/controllers").exists());
let modules = read(app.join("src/modules/mod.rs"));
assert_eq!(modules.matches("pub mod post;").count(), 1, "{modules}");
assert_eq!(modules.matches("post::tools(),").count(), 1, "{modules}");
let out = slave(&app, &["make:tools", "PostTools"]);
assert!(!out.status.success());
assert_eq!(read(app.join("src/modules/mod.rs")), modules);
}
#[test]
fn make_accepts_explicit_module() {
let tmp = TempDir::new();
let app = tmp.new_app();
let args = ["make:tools", "AdminTools", "--module", "admin_panel"];
let out = slave(&app, &args);
assert!(out.status.success(), "{}", stderr(&out));
let tools = read(app.join("src/modules/admin_panel/tools.rs"));
assert!(tools.contains("McpServer::admin_panel_tools()"), "{tools}");
assert!(tools.contains("async fn admin_list("), "{tools}");
let modules = read(app.join("src/modules/mod.rs"));
assert!(modules.contains("admin_panel::tools(),"), "{modules}");
}
#[test]
fn make_controller_is_gone() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(&app, &["make:controller", "PostController"]);
assert!(!out.status.success());
assert!(!app.join("src/modules/post").exists());
}
fn routed_hosts(app: &Path) -> Vec<String> {
read(app.join("config/apps.toml"))
.lines()
.filter_map(|line| line.trim().strip_prefix("host = "))
.map(|host| host.trim_matches('"').to_owned())
.collect()
}
#[test]
fn make_app_creates_an_axum_app_and_routes_its_domain() {
let tmp = TempDir::new();
let app = tmp.new_app();
let out = slave(
&app,
&["make:app", "storefront", "--domain", "{toko}.localhost"],
);
assert!(out.status.success(), "{}", stderr(&out));
let out = slave(
&app.join("src"),
&["make:app", "dashboard", "--domain", "admin.localhost"],
);
assert!(out.status.success(), "{}", stderr(&out));
for file in [
"Cargo.toml",
"src/main.rs",
"src/mcp.rs",
"src/pages.rs",
"src/views.rs",
"templates/layout.html",
"templates/home.html",
"templates/message.html",
] {
assert!(
app.join("apps/dashboard").join(file).is_file(),
"missing {file}"
);
}
let manifest = read(app.join("apps/dashboard/Cargo.toml"));
assert!(manifest.contains("name = \"dashboard\""), "{manifest}");
assert!(
manifest.contains("slave-framework = { workspace = true, features = [\"oidc\"] }"),
"{manifest}"
);
assert!(read(app.join("apps/dashboard/src/main.rs")).contains("DEFAULT_PORT: u16 = 4001;"));
assert!(read(app.join("apps/storefront/src/main.rs")).contains("DEFAULT_PORT: u16 = 4000;"));
assert!(read(app.join("apps/dashboard/src/main.rs")).contains("TITLE: &str = \"Dashboard\";"));
assert!(manifest.contains("minijinja = "), "{manifest}");
assert!(read(app.join("apps/dashboard/src/views.rs")).contains("minijinja::Environment"));
assert_eq!(
routed_hosts(&app),
["mcp.localhost", "{toko}.localhost", "admin.localhost"]
);
let out = slave(&app, &["route:match", "admin.localhost"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("→ dashboard"));
let out = slave(&app, &["route:list"]);
assert!(out.status.success(), "{}", stderr(&out));
let list = String::from_utf8_lossy(&out.stdout);
for expected in [
"admin.localhost",
"dashboard",
"127.0.0.1:4001",
"down",
"3 entries",
] {
assert!(list.contains(expected), "missing `{expected}` in:\n{list}");
}
let out = slave(&app, &["route:match", "TokoX.localhost:8000"]);
assert!(out.status.success(), "{}", stderr(&out));
let matched = String::from_utf8_lossy(&out.stdout);
assert!(matched.contains("storefront (127.0.0.1:4000)"), "{matched}");
assert!(matched.contains("x-slave-param-toko: tokox"), "{matched}");
let out = slave(&app, &["route:match", "a.b.localhost"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("no route matches"));
}
#[test]
fn make_app_rejects_unknown_stacks() {
let tmp = TempDir::new();
let app = tmp.new_app();
for args in [
&["make:app", "x", "--domain", "x.localhost", "--stack", "vue"][..],
&["make:app", "x", "--stack", "axum"],
&["make:app", "x", "--stack", "dioxus"],
] {
assert!(!slave(&app, args).status.success(), "{args:?}");
}
assert!(!app.join("apps/x").exists());
}
#[test]
fn make_app_uses_the_chosen_template_engine() {
let tmp = TempDir::new();
let app = tmp.new_app();
for (engine, dependency, view) in [
("tera", "tera = ", "templates/home.html"),
("handlebars", "handlebars = ", "templates/home.hbs"),
("liquid", "liquid = ", "templates/home.liquid"),
] {
let out = slave(&app, &["make:app", engine, "--stack", engine]);
assert!(out.status.success(), "{}", stderr(&out));
let dir = app.join("apps").join(engine);
let manifest = read(dir.join("Cargo.toml"));
assert!(manifest.contains(dependency), "{manifest}");
assert!(!manifest.contains("minijinja"), "{manifest}");
assert!(dir.join(view).is_file(), "{engine}: missing {view}");
assert!(dir.join("src/views.rs").is_file());
}
}
#[test]
fn make_app_routes_paths_on_one_domain() {
let tmp = TempDir::new();
let app = tmp.new_app();
for args in [
&["make:app", "site", "--domain", "localhost"][..],
&["make:app", "dashboard", "--path", "/admin"],
&["make:app", "console", "--path", "/console"],
&[
"make:app",
"panel",
"--domain",
"localhost",
"--path",
"/panel",
],
] {
let out = slave(&app, args);
assert!(out.status.success(), "{args:?}: {}", stderr(&out));
}
let config = read(app.join("config/apps.toml"));
assert!(
config.contains("path = \"/admin\"\napp = \"dashboard\""),
"{config}"
);
assert!(
config.contains("host = \"localhost\"\npath = \"/panel\""),
"{config}"
);
let matches = |target: &str| {
let out = slave(&app, &["route:match", target]);
assert!(out.status.success(), "{target}: {}", stderr(&out));
String::from_utf8_lossy(&out.stdout).into_owned()
};
assert!(matches("localhost:8000/admin/users").contains("→ dashboard"));
assert!(matches("localhost/administrator").contains("→ site"));
assert!(matches("localhost/panel").contains("→ panel"));
assert!(matches("example.com/console/x").contains("→ console"));
assert!(matches("localhost").contains("→ site"));
for args in [
&["make:app", "other", "--path", "/admin"][..],
&["make:app", "other", "--path", "admin"],
&["make:app", "other", "--path", "/admin/"],
] {
assert!(!slave(&app, args).status.success(), "{args:?}");
}
}
#[test]
fn make_app_rejects_conflicts_and_bad_input() {
let tmp = TempDir::new();
let app = tmp.new_app();
let ok = slave(
&app,
&["make:app", "dashboard", "--domain", "admin.localhost"],
);
assert!(ok.status.success(), "{}", stderr(&ok));
let config = read(app.join("config/apps.toml"));
for args in [
&["make:app", "dashboard", "--domain", "other.localhost"][..],
&["make:app", "panel", "--domain", "admin.localhost"],
&[
"make:app",
"panel",
"--domain",
"x.localhost",
"--port",
"4000",
],
&["make:app", "mcp", "--domain", "x.localhost"],
&["make:app", "app", "--domain", "x.localhost"],
&["make:app", "gateway", "--domain", "x.localhost"],
&["make:app", "Panel", "--domain", "x.localhost"],
&["make:app", "panel", "--domain", "{a}{b}.localhost"],
&["make:app", "panel", "--domain", "bad host"],
] {
let out = slave(&app, args);
assert!(!out.status.success(), "{args:?} should fail");
}
assert_eq!(read(app.join("config/apps.toml")), config, "unchanged");
assert!(!app.join("apps/panel").exists());
}
#[test]
fn make_refuses_to_overwrite_without_force() {
let tmp = TempDir::new();
let app = tmp.new_app();
let model = app.join("src/modules/user/models/user.rs");
let original = read(&model);
let modules_before = read(app.join("src/modules/mod.rs"));
let out = slave(&app, &["make:model", "User"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("already exists"));
assert_eq!(read(&model), original);
assert_eq!(read(app.join("src/modules/mod.rs")), modules_before);
let out = slave(&app, &["make:model", "User", "--force"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_ne!(read(&model), original);
let models = read(app.join("src/modules/user/models/mod.rs"));
assert_eq!(models.matches("pub mod user;").count(), 1);
}
#[test]
fn make_requires_a_project() {
let tmp = TempDir::new();
let out = slave(&tmp.0, &["make:model", "User"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("not inside a slave application"));
}
#[test]
fn make_rejects_invalid_names() {
let tmp = TempDir::new();
let app = tmp.new_app();
for args in [
&["make:model", "user"][..],
&["make:model", "User-Model"],
&["make:model", "../User"],
&["make:model", "Type"],
&["make:tools", "Tools"],
&["make:model", "Post", "--module", "Post"],
&["make:model", "Post", "--module", "../post"],
] {
assert!(!slave(&app, args).status.success(), "{args:?}");
}
}
fn file_names(dir: &Path) -> Vec<String> {
let mut files: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
files.sort();
files
}
#[test]
fn new_includes_the_initial_toasty_migration() {
let tmp = TempDir::new();
let app = tmp.new_app();
let migrations = file_names(&app.join("database/migrations"));
let snapshots = file_names(&app.join("database/snapshots"));
assert_eq!(migrations.len(), 1, "{migrations:?}");
assert_eq!(snapshots.len(), 1, "{snapshots:?}");
let migration = &migrations[0];
let prefix = &migration[..15];
assert!(
prefix.as_bytes()[8] == b'_' && migration.ends_with("_create_users_and_roles.sql"),
"{migration}"
);
assert_eq!(snapshots[0], format!("{prefix}_snapshot.toml"));
let history = read(app.join("database/history.toml"));
assert!(history.contains(&format!("name = \"{migration}\"")), "{history}");
assert!(history.contains(&format!("snapshot_name = \"{}\"", snapshots[0])));
let sql = read(app.join("database/migrations").join(migration));
for table in ["users", "roles", "user_roles"] {
assert!(sql.contains(&format!("CREATE TABLE \"{table}\"")), "{sql}");
}
assert!(sql.contains("('admin'), ('user')"), "{sql}");
assert!(read(app.join("Toasty.toml")).contains("path = \"database\""));
}
#[test]
fn make_migration_rejects_bad_names() {
let tmp = TempDir::new();
let app = tmp.new_app();
for name in ["CreatePosts", "create-posts", "1_init", ""] {
let out = slave(&app, &["make:migration", name]);
assert!(!out.status.success(), "{name}");
}
assert_eq!(file_names(&app.join("database/migrations")).len(), 1);
}
#[test]
fn migrate_commands_require_a_project() {
let tmp = TempDir::new();
for command in ["migrate", "migrate:fresh", "make:migration"] {
let mut args = vec![command];
if command == "make:migration" {
args.push("create_posts");
}
let out = slave(&tmp.0, &args);
assert!(!out.status.success(), "{command}");
assert!(
stderr(&out).contains("not inside a slave application"),
"{command}"
);
}
}
#[test]
#[ignore]
fn generated_application_builds_migrates_and_passes_its_tests() {
let tmp = TempDir::new();
let app = tmp.new_app();
let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/generated-app");
let database = TestDatabase::start();
let run = |program: &str, args: &[&str]| {
let out = Command::new(program)
.args(args)
.envs(database.envs())
.env("CARGO_TARGET_DIR", &target)
.current_dir(&app)
.output()
.expect("run command");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(
out.status.success(),
"{program} {args:?}\n{stdout}\n{}",
stderr(&out)
);
stdout
};
let slave_bin = env!("CARGO_BIN_EXE_slave");
let built = run(slave_bin, &["build"]);
assert!(built.contains("Built app (MCP backend) in"), "{built}");
for args in [
&["make:model", "Post"][..],
&["make:tools", "PostTools"],
&["make:app", "storefront", "--domain", "{toko}.localhost"],
] {
let out = slave(&app, args);
assert!(out.status.success(), "{args:?}: {}", stderr(&out));
}
let built = run(slave_bin, &["build", "storefront", "gateway"]);
assert!(built.contains("Built storefront, gateway in"), "{built}");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
run(&cargo, &["fmt", "--all", "--check"]);
run(&cargo, &["test", "--workspace", "--quiet"]);
let generated = run(slave_bin, &["make:migration", "create_posts"]);
assert!(generated.contains("create_posts.sql"), "{generated}");
let migrations = file_names(&app.join("database/migrations"));
assert_eq!(migrations.len(), 2, "{migrations:?}");
let sql = read(app.join("database/migrations").join(&migrations[1]));
assert!(sql.contains("CREATE TABLE \"posts\""), "{sql}");
let unchanged = run(slave_bin, &["make:migration", "nothing_changed"]);
assert!(unchanged.contains("No migration needed"), "{unchanged}");
let migrated = run(slave_bin, &["migrate"]);
assert!(
migrated.contains("Successfully applied 2 migration(s)"),
"{migrated}"
);
let again = run(slave_bin, &["migrate"]);
assert!(again.contains("already applied"), "{again}");
}