use std::net::SocketAddr; use std::sync::Arc; use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use tokio::sync::RwLock; use tower::ServiceExt; use pinakes_core::cache::CacheLayer; use pinakes_core::config::{ AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig, JobsConfig, PhotoConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType, StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, WebhookConfig, }; use pinakes_core::jobs::JobQueue; use pinakes_core::plugin::PluginManager; use pinakes_core::storage::StorageBackend; use pinakes_core::storage::sqlite::SqliteBackend; /// Fake socket address for tests (governor needs ConnectInfo) fn test_addr() -> ConnectInfo { ConnectInfo("127.0.0.1:9999".parse().unwrap()) } /// Build a GET request with ConnectInfo for rate limiter compatibility fn get(uri: &str) -> Request { let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); req.extensions_mut().insert(test_addr()); req } async fn setup_app_with_plugins() -> (axum::Router, Arc, tempfile::TempDir) { let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); backend.run_migrations().await.expect("migrations"); let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; // Create temp directories for plugin manager (automatically cleaned up when TempDir drops) let temp_dir = tempfile::TempDir::new().expect("create temp dir"); let data_dir = temp_dir.path().join("data"); let cache_dir = temp_dir.path().join("cache"); std::fs::create_dir_all(&data_dir).expect("create data dir"); std::fs::create_dir_all(&cache_dir).expect("create cache dir"); let plugin_config = PluginsConfig { enabled: true, data_dir: data_dir.clone(), cache_dir: cache_dir.clone(), plugin_dirs: vec![], enable_hot_reload: false, allow_unsigned: true, max_concurrent_ops: 2, plugin_timeout_secs: 10, }; let plugin_manager = PluginManager::new(data_dir, cache_dir, plugin_config.clone().into()) .expect("create plugin manager"); let plugin_manager = Arc::new(plugin_manager); let config = Config { storage: StorageConfig { backend: StorageBackendType::Sqlite, sqlite: Some(SqliteConfig { path: ":memory:".into(), }), postgres: None, }, directories: DirectoryConfig { roots: vec![] }, scanning: ScanningConfig { watch: false, poll_interval_secs: 300, ignore_patterns: vec![], import_concurrency: 8, }, server: ServerConfig { host: "127.0.0.1".to_string(), port: 3000, api_key: None, tls: TlsConfig::default(), authentication_disabled: true, }, ui: UiConfig::default(), accounts: AccountsConfig::default(), jobs: JobsConfig::default(), thumbnails: ThumbnailConfig::default(), webhooks: Vec::::new(), scheduled_tasks: vec![], plugins: plugin_config, transcoding: TranscodingConfig::default(), enrichment: EnrichmentConfig::default(), cloud: CloudConfig::default(), analytics: AnalyticsConfig::default(), photos: PhotoConfig::default(), }; let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); let config = Arc::new(RwLock::new(config)); let scheduler = pinakes_core::scheduler::TaskScheduler::new( job_queue.clone(), tokio_util::sync::CancellationToken::new(), config.clone(), None, ); let state = pinakes_server::state::AppState { storage, config, config_path: None, scan_progress: pinakes_core::scan::ScanProgress::new(), job_queue, cache: Arc::new(CacheLayer::new(60)), scheduler: Arc::new(scheduler), plugin_manager: Some(plugin_manager.clone()), transcode_service: None, }; let router = pinakes_server::app::create_router(state); (router, plugin_manager, temp_dir) } #[tokio::test] async fn test_list_plugins_empty() { let (app, _pm, _tmp) = setup_app_with_plugins().await; let response = app.oneshot(get("/api/v1/plugins")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let plugins: Vec = serde_json::from_slice(&body).unwrap(); assert_eq!(plugins.len(), 0, "should start with no plugins loaded"); } #[tokio::test] async fn test_plugin_manager_exists() { let (app, _pm, _tmp) = setup_app_with_plugins().await; // Verify plugin manager is accessible let plugins = _pm.list_plugins().await; assert_eq!(plugins.len(), 0); // Verify API endpoint works let response = app.oneshot(get("/api/v1/plugins")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_plugin_not_found() { let (app, _pm, _tmp) = setup_app_with_plugins().await; let response = app .oneshot(get("/api/v1/plugins/nonexistent")) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_plugin_enable_disable() { let (app, pm, _tmp) = setup_app_with_plugins().await; // Verify plugin manager is initialized assert!(pm.list_plugins().await.is_empty()); // For this test, we would need to actually load a plugin first // Since we don't have a real WASM plugin loaded, we'll just verify // the endpoints exist and return appropriate errors let mut req = Request::builder() .method("POST") .uri("/api/v1/plugins/test-plugin/enable") .body(Body::empty()) .unwrap(); req.extensions_mut().insert(test_addr()); let response = app.clone().oneshot(req).await.unwrap(); // Should be NOT_FOUND since plugin doesn't exist assert_eq!(response.status(), StatusCode::NOT_FOUND); // Test disable endpoint let mut req = Request::builder() .method("POST") .uri("/api/v1/plugins/test-plugin/disable") .body(Body::empty()) .unwrap(); req.extensions_mut().insert(test_addr()); let response = app.oneshot(req).await.unwrap(); // Should also be NOT_FOUND assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_plugin_uninstall_not_found() { let (app, _pm, _tmp) = setup_app_with_plugins().await; let mut req = Request::builder() .method("DELETE") .uri("/api/v1/plugins/nonexistent") .body(Body::empty()) .unwrap(); req.extensions_mut().insert(test_addr()); let response = app.oneshot(req).await.unwrap(); // Expect 400 or 404 when plugin doesn't exist assert!( response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND ); }