mod common; use axum::{ body::Body, http::{Request, StatusCode}, }; use common::*; use http_body_util::BodyExt; use tower::ServiceExt; #[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()); } #[tokio::test] async fn test_user_management_crud() { let app = setup_app().await; // Create a user let response = app .clone() .oneshot(post_json( "/api/v1/users", r#"{"username":"testuser","password":"password123","role":"viewer"}"#, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let user: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(user["username"], "testuser"); assert_eq!(user["role"], "viewer"); let user_id = user["id"].as_str().unwrap(); // List users let response = app.clone().oneshot(get("/api/v1/users")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let users: Vec = serde_json::from_slice(&body).unwrap(); assert_eq!(users.len(), 1); assert_eq!(users[0]["username"], "testuser"); // Get specific user let response = app .clone() .oneshot(get(&format!("/api/v1/users/{user_id}"))) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let retrieved_user: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(retrieved_user["username"], "testuser"); // Delete user let mut req = Request::builder() .method("DELETE") .uri(format!("/api/v1/users/{user_id}")) .body(Body::empty()) .unwrap(); req.extensions_mut().insert(test_addr()); let response = app.clone().oneshot(req).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); // Verify user is deleted let response = app .oneshot(get(&format!("/api/v1/users/{user_id}"))) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_health_endpoint() { let app = setup_app().await; // Health endpoint should be publicly accessible let response = app.oneshot(get("/api/v1/health")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_user_duplicate_username() { let app = setup_app().await; // Create first user let response = app .clone() .oneshot(post_json( "/api/v1/users", r#"{"username":"duplicate","password":"password1","role":"viewer"}"#, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Try to create user with same username let response = app .oneshot(post_json( "/api/v1/users", r#"{"username":"duplicate","password":"password2","role":"viewer"}"#, )) .await .unwrap(); // Should fail with conflict (409) for duplicate username assert_eq!(response.status(), StatusCode::CONFLICT); } #[tokio::test] async fn test_unauthenticated_request_rejected() { let (app, ..) = setup_app_with_auth().await; // Request without Bearer token let response = app.oneshot(get("/api/v1/media")).await.unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_invalid_token_rejected() { let (app, ..) = setup_app_with_auth().await; let response = app .oneshot(get_authed("/api/v1/media", "totally-invalid-token")) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_login_valid_credentials() { let (app, ..) = setup_app_with_auth().await; let response = app .oneshot(post_json( "/api/v1/auth/login", r#"{"username":"admin","password":"adminpass"}"#, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert!(body["token"].is_string()); assert_eq!(body["username"], "admin"); assert_eq!(body["role"], "admin"); } #[tokio::test] async fn test_login_invalid_password() { let (app, ..) = setup_app_with_auth().await; let response = app .oneshot(post_json( "/api/v1/auth/login", r#"{"username":"admin","password":"wrongpassword"}"#, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_login_unknown_user() { let (app, ..) = setup_app_with_auth().await; let response = app .oneshot(post_json( "/api/v1/auth/login", r#"{"username":"nonexistent","password":"whatever"}"#, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_auth_me_endpoint() { let (app, admin_token, ..) = setup_app_with_auth().await; let response = app .oneshot(get_authed("/api/v1/auth/me", &admin_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert_eq!(body["username"], "admin"); assert_eq!(body["role"], "admin"); } #[tokio::test] async fn test_logout() { let (app, admin_token, ..) = setup_app_with_auth().await; // Logout let response = app .clone() .oneshot(post_json_authed("/api/v1/auth/logout", "", &admin_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Subsequent requests with same token should fail let response = app .oneshot(get_authed("/api/v1/media", &admin_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_viewer_cannot_access_editor_routes() { let (app, _, _, viewer_token) = setup_app_with_auth().await; // POST /tags is an editor-only route let response = app .oneshot(post_json_authed( "/api/v1/tags", r#"{"name":"test"}"#, &viewer_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_viewer_cannot_access_admin_routes() { let (app, _, _, viewer_token) = setup_app_with_auth().await; // GET /users is an admin-only route let response = app .oneshot(get_authed("/api/v1/users", &viewer_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_editor_cannot_access_admin_routes() { let (app, _, editor_token, _) = setup_app_with_auth().await; let response = app .oneshot(get_authed("/api/v1/users", &editor_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_editor_can_write() { let (app, _, editor_token, _) = setup_app_with_auth().await; let response = app .oneshot(post_json_authed( "/api/v1/tags", r#"{"name":"EditorTag"}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_admin_can_access_all() { let (app, admin_token, ..) = setup_app_with_auth().await; // Viewer route let response = app .clone() .oneshot(get_authed("/api/v1/media", &admin_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Editor route let response = app .clone() .oneshot(post_json_authed( "/api/v1/tags", r#"{"name":"AdminTag"}"#, &admin_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Admin route let response = app .oneshot(get_authed("/api/v1/users", &admin_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_rating_invalid_stars_zero() { let (app, _, editor_token, _) = setup_app_with_auth().await; let response = app .oneshot(post_json_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000/ratings", r#"{"stars":0}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_rating_invalid_stars_six() { let (app, _, editor_token, _) = setup_app_with_auth().await; let response = app .oneshot(post_json_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000/ratings", r#"{"stars":6}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_comment_empty_text() { let (app, _, editor_token, _) = setup_app_with_auth().await; let response = app .oneshot(post_json_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000/comments", r#"{"text":""}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_favorites_list_empty() { let (app, _, _, viewer_token) = setup_app_with_auth().await; let response = app .oneshot(get_authed("/api/v1/favorites", &viewer_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert!(body.as_array().unwrap().is_empty()); } #[tokio::test] async fn test_playlist_crud() { let (app, _, editor_token, _) = setup_app_with_auth().await; // Create let response = app .clone() .oneshot(post_json_authed( "/api/v1/playlists", r#"{"name":"My Playlist"}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; let playlist_id = body["id"].as_str().unwrap().to_string(); assert_eq!(body["name"], "My Playlist"); // List let response = app .clone() .oneshot(get_authed("/api/v1/playlists", &editor_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert_eq!(body.as_array().unwrap().len(), 1); // Get let response = app .clone() .oneshot(get_authed( &format!("/api/v1/playlists/{playlist_id}"), &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Update let response = app .clone() .oneshot(patch_json_authed( &format!("/api/v1/playlists/{playlist_id}"), r#"{"name":"Updated Playlist","description":"A test description"}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert_eq!(body["name"], "Updated Playlist"); // Delete let response = app .clone() .oneshot(delete_authed( &format!("/api/v1/playlists/{playlist_id}"), &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_playlist_empty_name() { let (app, _, editor_token, _) = setup_app_with_auth().await; let response = app .oneshot(post_json_authed( "/api/v1/playlists", r#"{"name":""}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_most_viewed_empty() { let (app, _, _, viewer_token) = setup_app_with_auth().await; let response = app .oneshot(get_authed("/api/v1/analytics/most-viewed", &viewer_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert!(body.as_array().unwrap().is_empty()); } #[tokio::test] async fn test_record_event_and_query() { let (app, _, editor_token, _) = setup_app_with_auth().await; // Record an event let response = app .clone() .oneshot(post_json_authed( "/api/v1/analytics/events", r#"{"event_type":"view","duration_secs":5.0}"#, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert_eq!(body["recorded"], true); } #[tokio::test] async fn test_transcode_session_not_found() { let (app, _, _, viewer_token) = setup_app_with_auth().await; let response = app .oneshot(get_authed( "/api/v1/transcode/00000000-0000-0000-0000-000000000000", &viewer_token, )) .await .unwrap(); // Should be 404 or 500 (not found in DB) assert!( response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR ); } #[tokio::test] async fn test_transcode_list_empty() { let (app, _, _, viewer_token) = setup_app_with_auth().await; let response = app .oneshot(get_authed("/api/v1/transcode", &viewer_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_body(response).await; assert!(body.as_array().unwrap().is_empty()); } #[tokio::test] async fn test_hls_segment_no_session() { let (app, _, _, viewer_token) = setup_app_with_auth().await; let response = app .oneshot(get_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000/stream/hls/720p/\ segment0.ts", &viewer_token, )) .await .unwrap(); // Should fail because media doesn't exist or no transcode session assert!( response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR ); } #[tokio::test] async fn test_subtitles_list() { let (app, _, _, viewer_token) = setup_app_with_auth().await; // Should return empty for nonexistent media (or not found) let response = app .oneshot(get_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000/subtitles", &viewer_token, )) .await .unwrap(); assert!( response.status() == StatusCode::OK || response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR ); } #[tokio::test] async fn test_health_public() { let (app, ..) = setup_app_with_auth().await; // Health endpoint should be accessible without auth even when accounts // enabled let response = app.oneshot(get("/api/v1/health")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_invalid_uuid_in_path() { let (app, _, _, viewer_token) = setup_app_with_auth().await; let response = app .oneshot(get_authed("/api/v1/media/not-a-uuid", &viewer_token)) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_oversized_comment() { let (app, _, editor_token, _) = setup_app_with_auth().await; let long_text: String = "x".repeat(10_001); let body = format!(r#"{{"text":"{long_text}"}}"#); let response = app .oneshot(post_json_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000/comments", &body, &editor_token, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_share_link_expired() { // Uses no-auth setup since share links are complex to test with auth // (need real media items). Verify the expire check logic works. let app = setup_app().await; // First import a dummy file to get a media_id, but we can't without a real // file. So let's test the public share access endpoint with a nonexistent // token. let response = app .oneshot(get("/api/v1/s/nonexistent_token")) .await .unwrap(); // Should fail with not found or internal error (no such share link) assert!( response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR ); }