//! Integration tests for the themes CRUD endpoints. //! //! Tests: //! - GET /api/v1/themes — list user's themes //! - POST /api/v1/themes — create a theme //! - PUT /api/v1/themes/:id — update a theme //! - DELETE /api/v1/themes/:id — delete a theme //! //! Covers authentication, validation, ownership isolation, and CRUD operations. //! //! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. mod common; use axum::http::StatusCode; fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } // ═══════════════════════════════════════════════════════════════════════════ // Authentication // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn list_themes_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (status, body) = app .get_with_session("/api/v1/themes", "invalid-token") .await; assert_eq!( status, StatusCode::UNAUTHORIZED, "GET /themes without auth should return 401" ); assert_eq!(body["error"], "unauthorized"); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — List // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn list_themes_returns_created_theme() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-list@example.com") .await; // Create a theme so the list is not empty let body = serde_json::json!({ "name": "Test Theme", "theme": "Intelligence Artificielle", "categories": ["AI News", "Research"] }); let (create_status, _) = app .post_with_session("/api/v1/themes", &body, &session) .await; assert_eq!(create_status, StatusCode::CREATED); // List themes let (status, resp) = app.get_with_session("/api/v1/themes", &session).await; assert_eq!(status, StatusCode::OK, "GET /themes should return 200"); let themes = resp.as_array().expect("Response should be an array"); assert!( !themes.is_empty(), "User should have at least 1 theme after creation" ); assert_eq!(themes[0]["name"], "Test Theme"); assert_eq!(themes[0]["theme"], "Intelligence Artificielle"); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Create // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn create_theme_returns_201() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-create@example.com") .await; let body = serde_json::json!({ "name": "Test Theme", "theme": "Intelligence Artificielle", "categories": ["AI News", "Research"] }); let (status, resp) = app .post_with_session("/api/v1/themes", &body, &session) .await; assert_eq!( status, StatusCode::CREATED, "POST /themes with valid data should return 201" ); assert!( resp["id"].as_str().is_some(), "Response should contain an id" ); assert_eq!(resp["name"], "Test Theme"); let categories = resp["categories"] .as_array() .expect("categories should be an array"); assert_eq!(categories.len(), 2); assert_eq!(categories[0], "AI News"); assert_eq!(categories[1], "Research"); } #[tokio::test] async fn create_theme_empty_name_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-empty-name@example.com") .await; let body = serde_json::json!({ "name": "", "theme": "Intelligence Artificielle", "categories": ["AI News"] }); let (status, _resp) = app .post_with_session("/api/v1/themes", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "POST /themes with empty name should return 422" ); } #[tokio::test] async fn create_theme_empty_categories_returns_422() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-empty-cats@example.com") .await; let body = serde_json::json!({ "name": "Test Theme", "theme": "Intelligence Artificielle", "categories": [] }); let (status, _resp) = app .post_with_session("/api/v1/themes", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "POST /themes with empty categories should return 422" ); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Update // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn update_theme_changes_name() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-update@example.com") .await; // Create a theme let body = serde_json::json!({ "name": "Original Name", "theme": "Intelligence Artificielle", "categories": ["AI News"] }); let (status, created) = app .post_with_session("/api/v1/themes", &body, &session) .await; assert_eq!(status, StatusCode::CREATED); let theme_id = created["id"].as_str().unwrap(); // Update the name let update_body = serde_json::json!({ "name": "New Name" }); let (status, resp) = app .put_with_session( &format!("/api/v1/themes/{}", theme_id), &update_body, &session, ) .await; assert_eq!( status, StatusCode::OK, "PUT /themes/:id should return 200" ); assert_eq!(resp["name"], "New Name"); // Other fields should remain unchanged assert_eq!(resp["theme"], "Intelligence Artificielle"); } #[tokio::test] async fn update_nonexistent_theme_returns_404() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-update-404@example.com") .await; let random_id = uuid::Uuid::new_v4(); let update_body = serde_json::json!({ "name": "New Name" }); let (status, _resp) = app .put_with_session( &format!("/api/v1/themes/{}", random_id), &update_body, &session, ) .await; assert_eq!( status, StatusCode::NOT_FOUND, "PUT /themes/:id with nonexistent id should return 404" ); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Delete // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn delete_theme_returns_204() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-delete@example.com") .await; // Create a theme let body = serde_json::json!({ "name": "To Delete", "theme": "Intelligence Artificielle", "categories": ["AI News"] }); let (status, created) = app .post_with_session("/api/v1/themes", &body, &session) .await; assert_eq!(status, StatusCode::CREATED); let theme_id = created["id"].as_str().unwrap(); // Delete it let (status, _resp) = app .delete_with_session(&format!("/api/v1/themes/{}", theme_id), &session) .await; assert_eq!( status, StatusCode::NO_CONTENT, "DELETE /themes/:id should return 204" ); // Verify it's gone from the list let (status, resp) = app.get_with_session("/api/v1/themes", &session).await; assert_eq!(status, StatusCode::OK); let themes = resp.as_array().expect("Response should be an array"); let found = themes.iter().any(|t| t["id"].as_str() == Some(theme_id)); assert!(!found, "Deleted theme should not appear in list"); } #[tokio::test] async fn delete_nonexistent_theme_returns_404() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_id, session) = app .create_authenticated_user("themes-delete-404@example.com") .await; let random_id = uuid::Uuid::new_v4(); let (status, _resp) = app .delete_with_session(&format!("/api/v1/themes/{}", random_id), &session) .await; assert_eq!( status, StatusCode::NOT_FOUND, "DELETE /themes/:id with nonexistent id should return 404" ); } // ═══════════════════════════════════════════════════════════════════════════ // Ownership isolation // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn themes_are_isolated_per_user() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; // Create user A with a theme let (_user_a_id, session_a) = app .create_authenticated_user("themes-user-a@example.com") .await; let body_a = serde_json::json!({ "name": "User A Theme", "theme": "Intelligence Artificielle", "categories": ["AI News"] }); let (status, _) = app .post_with_session("/api/v1/themes", &body_a, &session_a) .await; assert_eq!(status, StatusCode::CREATED); // Create user B let (_user_b_id, session_b) = app .create_authenticated_user("themes-user-b@example.com") .await; // User B's theme list should be empty let (status, resp) = app.get_with_session("/api/v1/themes", &session_b).await; assert_eq!(status, StatusCode::OK); let themes = resp.as_array().expect("Response should be an array"); let has_user_a_theme = themes.iter().any(|t| t["name"] == "User A Theme"); assert!( !has_user_a_theme, "User B should not see User A's themes" ); }