use std::sync::Arc; use axum::{ body::Body, http::{Request, StatusCode}, }; use base64::{engine::general_purpose::STANDARD, Engine}; use sqlx::any::install_default_drivers; use tower::ServiceExt; use mikebase::{ build_router, config::{AppConfig, AuthConfig, DatabaseConfig, S3Config, ServerConfig}, db::Database, models::new_uuid, storage::S3Storage, AppState, }; // ── Helpers ─────────────────────────────────────────────────────────────────── async fn test_state() -> AppState { install_default_drivers(); let pool = sqlx::pool::PoolOptions::::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let db = Database { pool }; db.migrate().await.unwrap(); let cfg = Arc::new(AppConfig { s3: S3Config { endpoint: "http://localhost:19999".to_string(), region: "us-east-1".to_string(), bucket: "test-bucket".to_string(), access_key: "test".to_string(), secret_key: "test".to_string(), public_url: "http://localhost:19999/test-bucket".to_string(), }, database: DatabaseConfig { url: "sqlite::memory:".to_string() }, server: ServerConfig::default(), auth: Some(AuthConfig { username: "admin".to_string(), password: "secret".to_string(), }), }); let storage = S3Storage::new(&cfg); AppState { db, storage, cfg } } fn auth_header() -> String { format!("Basic {}", STANDARD.encode("admin:secret")) } fn wrong_auth_header() -> String { format!("Basic {}", STANDARD.encode("admin:wrong")) } async fn response_json(body: Body) -> serde_json::Value { let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap(); serde_json::from_slice(&bytes).unwrap() } /// Build a minimal multipart/form-data body. fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &[u8])]) -> Vec { let mut body = Vec::new(); for (name, filename, data) in parts { let disp = match filename { Some(fname) => format!( "Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream" ), None => format!("Content-Disposition: form-data; name=\"{name}\""), }; body.extend_from_slice(format!("--{boundary}\r\n{disp}\r\n\r\n").as_bytes()); body.extend_from_slice(data); body.extend_from_slice(b"\r\n"); } body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); body } // ── Public routes ───────────────────────────────────────────────────────────── #[tokio::test] async fn health_returns_200() { let app = build_router(test_state().await); let resp = app .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = response_json(resp.into_body()).await; assert_eq!(json["status"], "ok"); } #[tokio::test] async fn root_returns_html() { let app = build_router(test_state().await); let resp = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(resp .headers() .get("content-type") .unwrap() .to_str() .unwrap() .contains("text/html")); } #[tokio::test] async fn list_emotes_empty() { let app = build_router(test_state().await); let resp = app .oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = response_json(resp.into_body()).await; assert_eq!(json["emotes"], serde_json::json!([])); } #[tokio::test] async fn list_emotes_returns_seeded_data() { let state = test_state().await; let id = new_uuid(); state .db .create_emote(&id, "wave", Some("hello"), "emoji/wave.png") .await .unwrap(); let resp = build_router(state) .oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = response_json(resp.into_body()).await; assert_eq!(json["emotes"].as_array().unwrap().len(), 1); assert_eq!(json["emotes"][0]["name"], "wave"); } // ── Auth middleware ─────────────────────────────────────────────────────────── #[tokio::test] async fn manage_without_credentials_returns_401() { let app = build_router(test_state().await); let resp = app .oneshot(Request::builder().uri("/manage").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn manage_with_wrong_credentials_returns_401() { let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .uri("/manage") .header("authorization", wrong_auth_header()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn manage_with_correct_credentials_returns_200() { let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .uri("/manage") .header("authorization", auth_header()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn manage_emotes_requires_auth() { let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .uri("/manage/emotes") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } // ── POST /emotes input validation ───────────────────────────────────────────── #[tokio::test] async fn create_emote_without_auth_returns_401() { let boundary = "testboundary"; let body = multipart_body(boundary, &[("name", None, b"myemote")]); let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .method("POST") .uri("/emotes") .header("content-type", format!("multipart/form-data; boundary={boundary}")) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn create_emote_missing_name_returns_400() { let boundary = "testboundary"; let body = multipart_body(boundary, &[("file", Some("cat.png"), b"\x89PNG")]); let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .method("POST") .uri("/emotes") .header("authorization", auth_header()) .header("content-type", format!("multipart/form-data; boundary={boundary}")) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = response_json(resp.into_body()).await; assert!(json["error"].as_str().unwrap().contains("name")); } #[tokio::test] async fn create_emote_missing_file_returns_400() { let boundary = "testboundary"; let body = multipart_body(boundary, &[("name", None, b"myemote")]); let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .method("POST") .uri("/emotes") .header("authorization", auth_header()) .header("content-type", format!("multipart/form-data; boundary={boundary}")) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = response_json(resp.into_body()).await; assert!(json["error"].as_str().unwrap().contains("file")); } // ── PUT /emotes/{uuid} ──────────────────────────────────────────────────────── #[tokio::test] async fn update_emote_not_found_returns_404() { let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .method("PUT") .uri("/emotes/no-such-uuid") .header("authorization", auth_header()) .header("content-type", "application/json") .body(Body::from(r#"{"name":"new"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn update_emote_ok() { let state = test_state().await; let id = new_uuid(); state .db .create_emote(&id, "original", None, "emoji/x.png") .await .unwrap(); let resp = build_router(state) .oneshot( Request::builder() .method("PUT") .uri(format!("/emotes/{id}")) .header("authorization", auth_header()) .header("content-type", "application/json") .body(Body::from(r#"{"name":"updated"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = response_json(resp.into_body()).await; assert_eq!(json["name"], "updated"); } // ── DELETE /emotes/{uuid} ───────────────────────────────────────────────────── #[tokio::test] async fn delete_emote_not_found_returns_404() { let app = build_router(test_state().await); let resp = app .oneshot( Request::builder() .method("DELETE") .uri("/emotes/no-such-uuid") .header("authorization", auth_header()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn delete_emote_ok() { let state = test_state().await; let id = new_uuid(); state .db .create_emote(&id, "todelete", None, "emoji/gone.png") .await .unwrap(); // S3 delete will fail against the fake endpoint, but it is best-effort. let resp = build_router(state) .oneshot( Request::builder() .method("DELETE") .uri(format!("/emotes/{id}")) .header("authorization", auth_header()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); }