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, Config, DirectoryConfig, JobsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType, StorageConfig, ThumbnailConfig, UiConfig, WebhookConfig, }; use pinakes_core::jobs::JobQueue; 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 } /// Build a POST request with ConnectInfo fn post_json(uri: &str, body: &str) -> Request { let mut req = Request::builder() .method("POST") .uri(uri) .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap(); req.extensions_mut().insert(test_addr()); req } async fn setup_app() -> axum::Router { 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; 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, }, ui: UiConfig::default(), accounts: AccountsConfig::default(), jobs: JobsConfig::default(), thumbnails: ThumbnailConfig::default(), webhooks: Vec::::new(), scheduled_tasks: vec![], }; 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(), sessions: Arc::new(RwLock::new(std::collections::HashMap::new())), job_queue, cache: Arc::new(CacheLayer::new(60)), scheduler: Arc::new(scheduler), }; pinakes_server::app::create_router(state) } #[tokio::test] async fn test_list_media_empty() { let app = setup_app().await; let response = app.oneshot(get("/api/v1/media")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let items: Vec = serde_json::from_slice(&body).unwrap(); assert_eq!(items.len(), 0); } #[tokio::test] async fn test_create_and_list_tags() { let app = setup_app().await; // Create a tag let response = app .clone() .oneshot(post_json("/api/v1/tags", r#"{"name":"Music"}"#)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // List tags let response = app.oneshot(get("/api/v1/tags")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let tags: Vec = serde_json::from_slice(&body).unwrap(); assert_eq!(tags.len(), 1); assert_eq!(tags[0]["name"], "Music"); } #[tokio::test] async fn test_search_empty() { let app = setup_app().await; let response = app.oneshot(get("/api/v1/search?q=test")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(result["total_count"], 0); } #[tokio::test] async fn test_media_not_found() { let app = setup_app().await; let response = app .oneshot(get("/api/v1/media/00000000-0000-0000-0000-000000000000")) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_collections_crud() { let app = setup_app().await; // Create collection let response = app .clone() .oneshot(post_json( "/api/v1/collections", r#"{"name":"Favorites","kind":"manual"}"#, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // List collections let response = app.oneshot(get("/api/v1/collections")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let cols: Vec = serde_json::from_slice(&body).unwrap(); assert_eq!(cols.len(), 1); assert_eq!(cols[0]["name"], "Favorites"); } #[tokio::test] async fn test_statistics_endpoint() { let app = setup_app().await; let response = app.oneshot(get("/api/v1/statistics")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let stats: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(stats["total_media"], 0); assert_eq!(stats["total_size_bytes"], 0); } #[tokio::test] async fn test_scheduled_tasks_endpoint() { let app = setup_app().await; let response = app.oneshot(get("/api/v1/tasks/scheduled")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let tasks: Vec = serde_json::from_slice(&body).unwrap(); assert!(!tasks.is_empty(), "should have default scheduled tasks"); // Verify structure of first task assert!(tasks[0]["id"].is_string()); assert!(tasks[0]["name"].is_string()); assert!(tasks[0]["schedule"].is_string()); }