use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use http::Request;
use http_body_util::BodyExt;
use tokio::sync::OnceCell;
use tower::ServiceExt;
use umbral::App;
use umbral::Settings;
use umbral::check::{SystemCheck, SystemCheckFinding};
use umbral::migrate::{ModelMeta, models_for_plugin, plugin_order, registered_plugins};
use umbral::plugin::{AppContext, Plugin, PluginError};
use umbral::web::{Router, get};
use umbral_core::app::BuildError;
use umbral_core::migrate::{APP_PLUGIN_NAME, Column};
use umbral_core::orm::{Post, SqlType};
type OrderLog = Arc<Mutex<Vec<&'static str>>>;
struct TestPlugin {
name: &'static str,
deps: &'static [&'static str],
on_ready_flag: Arc<AtomicBool>,
check_flag: Arc<AtomicBool>,
route_path: &'static str,
model_table: &'static str,
order_log: Option<OrderLog>,
}
impl TestPlugin {
fn minimal(name: &'static str) -> Self {
Self {
name,
deps: &[],
on_ready_flag: Arc::new(AtomicBool::new(false)),
check_flag: Arc::new(AtomicBool::new(false)),
route_path: "/__test/unused",
model_table: "__unused__",
order_log: None,
}
}
}
impl Plugin for TestPlugin {
fn name(&self) -> &'static str {
self.name
}
fn dependencies(&self) -> &'static [&'static str] {
self.deps
}
fn models(&self) -> Vec<ModelMeta> {
let model_name = format!("{}__Model", self.name);
vec![ModelMeta {
display: model_name.clone(),
icon: "database".to_string(),
database: None,
singleton: false,
unique_together: Vec::new(),
indexes: Vec::new(),
ordering: Vec::new(),
m2m_relations: Vec::new(),
soft_delete: false,
app_label: "app".to_string(),
name: model_name,
table: self.model_table.to_string(),
fields: vec![Column {
name: "id".to_string(),
ty: SqlType::BigInt,
primary_key: true,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
}],
}]
}
fn routes(&self) -> Router {
let path = self.route_path;
Router::new().route(path, get(|| async { "ok" }))
}
fn system_checks(&self) -> Vec<SystemCheck> {
register_check_flag(self.name, self.check_flag.clone());
vec![SystemCheck {
id: self.name,
run: run_test_check,
}]
}
fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
self.on_ready_flag.store(true, Ordering::SeqCst);
if let Some(log) = &self.order_log {
log.lock().expect("order log not poisoned").push(self.name);
}
Ok(())
}
}
static CHECK_FLAGS: OnceLock<Mutex<HashMap<&'static str, Arc<AtomicBool>>>> = OnceLock::new();
fn check_flags() -> &'static Mutex<HashMap<&'static str, Arc<AtomicBool>>> {
CHECK_FLAGS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn register_check_flag(id: &'static str, flag: Arc<AtomicBool>) {
check_flags()
.lock()
.expect("check_flags mutex not poisoned")
.insert(id, flag);
}
fn run_test_check(ctx: &umbral::check::CheckContext<'_>) -> Vec<SystemCheckFinding> {
let _ = ctx; let map = check_flags()
.lock()
.expect("check_flags mutex not poisoned");
for flag in map.values() {
flag.store(true, Ordering::SeqCst);
}
Vec::new()
}
static BOOT: OnceCell<BootState> = OnceCell::const_new();
struct BootState {
parent_on_ready: Arc<AtomicBool>,
parent_check: Arc<AtomicBool>,
child_on_ready: Arc<AtomicBool>,
order: OrderLog,
router: Mutex<Option<Router>>,
}
async fn boot() -> &'static BootState {
BOOT.get_or_init(|| async {
let parent_on_ready = Arc::new(AtomicBool::new(false));
let parent_check = Arc::new(AtomicBool::new(false));
let child_on_ready = Arc::new(AtomicBool::new(false));
let order: OrderLog = Arc::new(Mutex::new(Vec::new()));
let parent = TestPlugin {
name: "parent_plugin",
deps: &[],
on_ready_flag: parent_on_ready.clone(),
check_flag: parent_check.clone(),
route_path: "/parent/ping",
model_table: "parent_table",
order_log: Some(order.clone()),
};
let child = TestPlugin {
name: "child_plugin",
deps: &["parent_plugin"],
on_ready_flag: child_on_ready.clone(),
check_flag: Arc::new(AtomicBool::new(false)),
route_path: "/child/ping",
model_table: "child_table",
order_log: Some(order.clone()),
};
let settings = Settings::from_env().expect("figment defaults always load in a test env");
let pool = umbral::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite should always connect");
let app = App::builder()
.settings(settings)
.database("default", pool)
.model::<Post>()
.plugin(parent)
.plugin(child)
.build()
.expect("App::build() should succeed on the happy path");
BootState {
parent_on_ready,
parent_check,
child_on_ready,
order,
router: Mutex::new(Some(app.into_router())),
}
})
.await
}
fn shared_router() -> Router {
let state = BOOT.get().expect("shared_router called before boot");
state
.router
.lock()
.expect("shared router mutex not poisoned")
.clone()
.expect("router stored after build")
}
#[tokio::test]
async fn plugin_on_ready_fires() {
let state = boot().await;
assert!(
state.parent_on_ready.load(Ordering::SeqCst),
"parent_plugin's on_ready should have fired by the end of App::build()",
);
assert!(
state.child_on_ready.load(Ordering::SeqCst),
"child_plugin's on_ready should have fired by the end of App::build()",
);
}
#[tokio::test]
async fn plugin_models_land_in_per_plugin_registry() {
boot().await;
let plugins = registered_plugins();
assert!(
plugins.contains(&APP_PLUGIN_NAME.to_string()),
"implicit `app` plugin should appear in registered_plugins(); got {plugins:?}",
);
assert!(
plugins.contains(&"parent_plugin".to_string()),
"parent_plugin should appear in registered_plugins(); got {plugins:?}",
);
assert!(
plugins.contains(&"child_plugin".to_string()),
"child_plugin should appear in registered_plugins(); got {plugins:?}",
);
let app_models = models_for_plugin(APP_PLUGIN_NAME);
assert_eq!(
app_models.len(),
1,
"implicit `app` plugin should hold the one `.model::<Post>()` registration; got {app_models:?}",
);
assert_eq!(
app_models[0].table, "post",
"the one model registered via `.model::<T>()` is `Post`; got {:?}",
app_models[0].table,
);
let parent_models = models_for_plugin("parent_plugin");
assert_eq!(
parent_models.len(),
1,
"parent_plugin contributes one model"
);
assert_eq!(parent_models[0].table, "parent_table");
let child_models = models_for_plugin("child_plugin");
assert_eq!(child_models.len(), 1, "child_plugin contributes one model");
assert_eq!(child_models[0].table, "child_table");
}
#[tokio::test]
async fn plugin_system_check_runs_during_build() {
let state = boot().await;
assert!(
state.parent_check.load(Ordering::SeqCst),
"parent_plugin's system_check should have run during phase 4 of App::build()",
);
}
#[tokio::test]
async fn plugin_routes_mount_under_the_app_router() {
boot().await;
let router = shared_router();
let response = router
.oneshot(
Request::builder()
.uri("/parent/ping")
.body(axum::body::Body::empty())
.expect("build a GET request"),
)
.await
.expect("router should respond to /parent/ping without erroring");
assert_eq!(
response.status(),
http::StatusCode::OK,
"parent_plugin's /parent/ping should respond 200 OK; got {}",
response.status(),
);
let body = response
.into_body()
.collect()
.await
.expect("collect response body")
.to_bytes();
assert_eq!(
&body[..],
b"ok",
"the /parent/ping handler returns \"ok\"; got {:?}",
std::str::from_utf8(&body[..]),
);
let router = shared_router();
let response = router
.oneshot(
Request::builder()
.uri("/child/ping")
.body(axum::body::Body::empty())
.expect("build a GET request"),
)
.await
.expect("router should respond to /child/ping without erroring");
assert_eq!(response.status(), http::StatusCode::OK);
}
#[tokio::test]
async fn topological_order_governs_on_ready() {
let state = boot().await;
let order = state
.order
.lock()
.expect("order log mutex not poisoned")
.clone();
assert_eq!(
order,
vec!["parent_plugin", "child_plugin"],
"parent_plugin must fire before child_plugin per the topological sort; got {order:?}",
);
}
#[tokio::test]
async fn plugin_order_reflects_the_topological_sort() {
boot().await;
let order = plugin_order();
assert_eq!(
order,
vec![
"parent_plugin".to_string(),
"child_plugin".to_string(),
APP_PLUGIN_NAME.to_string(),
],
"plugin_order must list dependencies before dependents, with `app` last; got {order:?}",
);
}
async fn failing_build_settings_and_pool() -> (Settings, sqlx::SqlitePool) {
let settings = Settings::from_env().expect("figment defaults always load in a test env");
let pool = umbral::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite should always connect");
(settings, pool)
}
#[tokio::test]
async fn reserved_name_app_rejected() {
let (settings, pool) = failing_build_settings_and_pool().await;
let result = App::builder()
.settings(settings)
.database("default", pool)
.plugin(TestPlugin::minimal("app"))
.build();
match result {
Err(BuildError::ReservedPluginName) => {}
Err(other) => panic!("expected BuildError::ReservedPluginName, got {other:?}"),
Ok(_) => panic!("a plugin named `app` must be rejected, but build() succeeded"),
}
}
#[tokio::test]
async fn duplicate_plugin_names_rejected() {
let (settings, pool) = failing_build_settings_and_pool().await;
let result = App::builder()
.settings(settings)
.database("default", pool)
.plugin(TestPlugin::minimal("duplicate"))
.plugin(TestPlugin::minimal("duplicate"))
.build();
match result {
Err(BuildError::DuplicatePluginName { name }) => {
assert_eq!(
name, "duplicate",
"BuildError::DuplicatePluginName should carry the colliding name; got {name}",
);
}
Err(other) => panic!("expected BuildError::DuplicatePluginName, got {other:?}"),
Ok(_) => panic!("two plugins with the same name must be rejected"),
}
}
#[tokio::test]
async fn unmet_dependency_rejected() {
let (settings, pool) = failing_build_settings_and_pool().await;
let mut plugin = TestPlugin::minimal("dependent");
plugin.deps = &["does_not_exist"];
let result = App::builder()
.settings(settings)
.database("default", pool)
.plugin(plugin)
.build();
match result {
Err(BuildError::DependencyNotFound { plugin, missing }) => {
assert_eq!(plugin, "dependent");
assert_eq!(missing, "does_not_exist");
}
Err(other) => panic!("expected BuildError::DependencyNotFound, got {other:?}"),
Ok(_) => panic!("a plugin with an unregistered dependency must be rejected"),
}
}
#[tokio::test]
async fn cycle_rejected() {
let (settings, pool) = failing_build_settings_and_pool().await;
let mut a = TestPlugin::minimal("cycle_a");
a.deps = &["cycle_b"];
let mut b = TestPlugin::minimal("cycle_b");
b.deps = &["cycle_a"];
let result = App::builder()
.settings(settings)
.database("default", pool)
.plugin(a)
.plugin(b)
.build();
match result {
Err(BuildError::PluginCycle { names }) => {
assert!(
names.contains(&"cycle_a") && names.contains(&"cycle_b"),
"PluginCycle.names should cover both plugins in the cycle; got {names:?}",
);
}
Err(other) => panic!("expected BuildError::PluginCycle, got {other:?}"),
Ok(_) => panic!("a dependency cycle must be rejected"),
}
}
#[tokio::test]
async fn model_alias_returns_none_for_plugins_without_a_database_override() {
let _ = boot().await;
assert_eq!(
umbral::migrate::model_alias("Post"),
None,
"Post belongs to the implicit `app` plugin which has no database() override; \
model_alias should return None so resolve_pool falls back to the default pool"
);
}