use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use oxipage_core::config::Config;
use oxipage_core::extension::{Extension, Lang, LobbyCard, Migration};
use oxipage_core::registry::ExtensionRegistry;
use oxipage_core::state::AppState;
use std::sync::Arc;
use tower::ServiceExt;
struct DummyExt;
#[async_trait::async_trait]
impl Extension for DummyExt {
fn id(&self) -> &'static str {
"dummy"
}
fn display_name(&self, lang: Lang) -> String {
match lang {
Lang::Ko => "더미".to_string(),
Lang::En => "Dummy".to_string(),
}
}
fn migrations(&self) -> Vec<Migration> {
vec![Migration {
version: 1,
name: "init",
sql: "CREATE TABLE IF NOT EXISTS dummy_t (id INTEGER PRIMARY KEY)",
}]
}
fn table_names(&self) -> Vec<&'static str> {
vec!["dummy_t"]
}
fn routes(&self) -> axum::Router<AppState> {
use axum::routing::get;
axum::Router::new().route(
"/",
get(|| async { axum::Json(serde_json::json!({"data": {"ok": true}})) }),
)
}
async fn lobby_summary(&self, _ctx: &AppState) -> Option<LobbyCard> {
None
}
}
async fn test_app() -> axum::Router {
let pool = oxipage_core::db::connect_memory().await.unwrap();
let registry = Arc::new(ExtensionRegistry::new(vec![Arc::new(DummyExt)]));
registry.run_migrations(&pool, &[]).await.unwrap();
let state = AppState {
db: pool,
config: Arc::new(Config::default()),
registry,
wasm_loader: None,
site_override: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
builders: std::sync::Arc::new(vec![]),
};
oxipage_core::http::build_app(state)
}
async fn body_string(res: axum::response::Response) -> String {
let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn healthz_returns_ok() {
let app = test_app().await;
let res = app
.oneshot(Request::get("/healthz").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(body_string(res).await.contains("\"ok\""));
}
#[tokio::test]
async fn manifest_lists_enabled_extensions() {
let app = test_app().await;
let res = app
.oneshot(
Request::get("/api/console/lobby/manifest")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.contains("\"id\":\"dummy\""));
assert!(body.contains("\"ko\":\"더미\""));
assert!(body.contains("\"en\":\"Dummy\""));
assert!(body.contains("\"default_lang\":\"ko\""));
}
#[tokio::test]
async fn extension_routes_are_mounted_under_api_v1() {
let app = test_app().await;
let res = app
.oneshot(
Request::get("/api/console/dummy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(body_string(res).await.contains("\"ok\":true"));
}
#[tokio::test]
async fn spa_fallback_serves_index_html_for_unknown_paths() {
let app = test_app().await;
let res = app
.oneshot(
Request::get("/some/unknown/path")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.to_lowercase().contains("<!doctype html"));
}
#[tokio::test]
async fn unknown_api_path_returns_404_json() {
let app = test_app().await;
let res = app
.oneshot(
Request::get("/api/console/nope/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let body = body_string(res).await;
assert!(body.contains("\"error\""));
}
async fn admin_app() -> axum::Router {
let pool = oxipage_core::db::connect_memory().await.unwrap();
let registry = Arc::new(ExtensionRegistry::new(vec![Arc::new(DummyExt)]));
registry.run_migrations(&pool, &[]).await.unwrap();
let state = AppState {
db: pool,
config: Arc::new(Config::default()),
registry,
wasm_loader: None,
site_override: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
builders: std::sync::Arc::new(vec![]),
};
oxipage_core::http::build_app(state)
}
fn admin_req(method: &str, uri: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("authorization", "Bearer test-admin-token")
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn extensions_list_returns_admin_state() {
let app = admin_app().await;
let res = app
.oneshot(admin_req("GET", "/api/console/extensions"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.contains("dummy"), "body: {body}");
}
#[tokio::test]
async fn disable_gates_extension_routes() {
let app = admin_app().await;
let before = app
.clone()
.oneshot(
Request::get("/api/console/dummy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(before.status(), StatusCode::OK);
let res = app
.clone()
.oneshot(admin_req("POST", "/api/console/extensions/dummy/disable"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let gated = app
.oneshot(
Request::get("/api/console/dummy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(gated.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn enable_restores_extension_routes() {
let app = admin_app().await;
app.clone()
.oneshot(admin_req("POST", "/api/console/extensions/dummy/disable"))
.await
.unwrap();
let res = app
.clone()
.oneshot(admin_req("POST", "/api/console/extensions/dummy/enable"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let after = app
.oneshot(
Request::get("/api/console/dummy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(after.status(), StatusCode::OK);
}
#[tokio::test]
async fn purge_marks_extension_and_gates_routes() {
let app = admin_app().await;
let res = app
.clone()
.oneshot(admin_req("DELETE", "/api/console/extensions/dummy"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.contains("\"purged\":true"), "body: {body}");
let gated = app
.oneshot(
Request::get("/api/console/dummy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(gated.status(), StatusCode::NOT_FOUND);
}
#[cfg_attr(target_os = "linux", ignore)]
#[tokio::test]
async fn install_writes_wasm_and_registers_state() {
let data_dir =
std::env::temp_dir().join(format!("oxipage-install-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&data_dir);
std::fs::create_dir_all(&data_dir).unwrap();
let pool = oxipage_core::db::connect_memory().await.unwrap();
let registry = Arc::new(ExtensionRegistry::new(vec![Arc::new(DummyExt)]));
registry.run_migrations(&pool, &[]).await.unwrap();
let mut config = Config::default();
config.server.data_dir = data_dir.clone();
let state = AppState {
db: pool.clone(),
config: Arc::new(config),
registry,
wasm_loader: None,
site_override: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
builders: std::sync::Arc::new(vec![]),
};
let app = oxipage_core::http::build_app(state);
let req = Request::builder()
.method("POST")
.uri("/api/console/extensions/install")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"wasm-demo"}"#))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "install should succeed");
let body = body_string(res).await;
assert!(body.contains("wasm-demo"), "body: {body}");
assert!(
body.contains("\"bytes\""),
"body should report byte count: {body}"
);
let wasm_path = data_dir.join("extensions").join("wasm-demo.wasm");
assert!(
wasm_path.exists(),
"wasm artifact should be written at {}",
wasm_path.display()
);
let meta = std::fs::metadata(&wasm_path).unwrap();
assert!(
meta.len() > 100,
"wasm artifact non-trivial size, got {}",
meta.len()
);
let (enabled,): (i64,) =
sqlx::query_as("SELECT enabled FROM extension_state WHERE extension_id = ?")
.bind("wasm-demo")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
enabled, 0,
"newly installed wasm ext should be disabled (enabled=0) until restart+enable"
);
let _ = std::fs::remove_dir_all(&data_dir);
}