diff --git a/.gitignore b/.gitignore index 6e701f0..db60e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ Cargo.lock # Per Editor Configuration .idea/ .vscode/ + +# Uploads directory +uploads/ diff --git a/Cargo.toml b/Cargo.toml index 118a157..a0e4c60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,13 @@ path = "src/main.rs" name = "coduck-backend" [dependencies] -axum = "0.8.4" +anyhow = "1.0" +axum = { version = "0.8.4", features = ["json", "multipart"] } +chrono = { version = "0.4.38", features = ["serde"] } +reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.133" tokio = { version = "1.45.1", features = ["full"] } +uuid = { version = "1.17.0", features = ["v4"] } [dev-dependencies] -reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] } diff --git a/src/errors/language.rs b/src/errors/language.rs new file mode 100644 index 0000000..ea0bb02 --- /dev/null +++ b/src/errors/language.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub enum LanguageError { + UnsupportedExtension(String), + InvalidFilename, +} + +impl std::fmt::Display for LanguageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LanguageError::UnsupportedExtension(extension) => { + write!(f, "Unsupported file extension: {extension}") + } + LanguageError::InvalidFilename => write!(f, "Invalid filename"), + } + } +} diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..2ff9686 --- /dev/null +++ b/src/errors/mod.rs @@ -0,0 +1,3 @@ +mod language; + +pub(crate) use language::*; diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs new file mode 100644 index 0000000..d0e9a6c --- /dev/null +++ b/src/file_manager/handlers.rs @@ -0,0 +1,329 @@ +use crate::file_manager::{ + FileMetadata, Language, UpdateFileContentRequest, UpdateFilenameRequest, +}; + +use anyhow::{anyhow, Result}; +use axum::{ + extract::{Multipart, Path}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::Utc; +use std::path::PathBuf; +use tokio::fs; +use uuid::Uuid; + +const UPLOAD_DIR: &str = "uploads"; + +async fn file_exists(file_path: &PathBuf) -> bool { + tokio::fs::metadata(file_path).await.is_ok() +} + +pub async fn upload_file( + Path((problem_id, category)): Path<(u32, String)>, + multipart: Multipart, +) -> impl IntoResponse { + let (filename, data) = match extract_multipart_data(multipart).await { + Ok(data) => data, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(); + } + }; + + let file_id = Uuid::new_v4().to_string(); + let upload_dir = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category); + + let file_path = upload_dir.join(&filename); + + if file_exists(&file_path).await { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": format!("File '{filename}' already exists in this category") + })), + ) + .into_response(); + } + + let now = Utc::now() + .timestamp_nanos_opt() + .expect("Failed to get timestamp"); + let language = match Language::from_filename(&filename) { + Ok(lang) => lang, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(); + } + }; + + if let Err(e) = save_file(&upload_dir, &file_path, &data).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(); + } + + let metadata = FileMetadata { + id: file_id, + filename: filename, + language: language, + category: category, + size: data.len() as u64, + created_at: now, + updated_at: now, + }; + (StatusCode::CREATED, Json(metadata)).into_response() +} + +async fn save_file( + upload_dir: &PathBuf, + file_path: &PathBuf, + data: &axum::body::Bytes, +) -> Result<()> { + fs::create_dir_all(upload_dir) + .await + .map_err(|_| anyhow!("Failed to create directory"))?; + + fs::write(file_path, data) + .await + .map_err(|_| anyhow!("Failed to write file"))?; + + Ok(()) +} + +async fn extract_multipart_data(mut multipart: Multipart) -> Result<(String, axum::body::Bytes)> { + let field = multipart + .next_field() + .await? + .ok_or_else(|| anyhow!("Missing multipart field"))?; + + let filename = field + .file_name() + .ok_or_else(|| anyhow!("Missing filename in multipart field"))? + .to_string(); + + let data = field.bytes().await?; + + Ok((filename, data)) +} + +pub async fn get_file( + Path((problem_id, category, filename)): Path<(u32, String, String)>, +) -> impl IntoResponse { + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&filename); + + if !file_exists(&file_path).await { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File '{filename}' not found in category '{category}'") + })), + ) + .into_response(); + } + + match fs::read(&file_path).await { + Ok(content) => { + let content_str = String::from_utf8_lossy(&content); + + ( + StatusCode::OK, + [("Content-Type", "text/html; charset=UTF-8")], + content_str.to_string(), + ) + .into_response() + } + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to read file content" + })), + ) + .into_response(), + } +} + +pub async fn get_files_by_category( + Path((problem_id, category)): Path<(u32, String)>, +) -> impl IntoResponse { + let category_dir = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category); + + if !file_exists(&category_dir).await { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("Category '{category}' not found for problem {problem_id}") + })), + ) + .into_response(); + } + + let mut files = Vec::new(); + + match fs::read_dir(&category_dir).await { + Ok(mut entries) => { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_file() { + if let Some(filename) = path.file_name().and_then(|f| f.to_str()) { + files.push(filename.to_string()); + } + } + } + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to read directory" + })), + ) + .into_response(); + } + } + + (StatusCode::OK, Json(files)).into_response() +} + +pub async fn delete_file( + Path((problem_id, category, filename)): Path<(u32, String, String)>, +) -> impl IntoResponse { + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&filename); + + if !file_exists(&file_path).await { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File '{filename}' not found in category '{category}'") + })), + ) + .into_response(); + } + + match fs::remove_file(&file_path).await { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("File '{filename}' deleted successfully") + })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to delete file" + })), + ) + .into_response(), + } +} + +pub async fn update_file_content( + Path((problem_id, category, filename)): Path<(u32, String, String)>, + Json(update_request): Json, +) -> impl IntoResponse { + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&filename); + + if !file_exists(&file_path).await { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File '{filename}' not found in category '{category}'") + })), + ) + .into_response(); + } + + match fs::write(&file_path, &update_request.content).await { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("File '{filename}' updated successfully") + })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to update file" + })), + ) + .into_response(), + } +} + +pub async fn update_filename( + Path((problem_id, category)): Path<(u32, String)>, + Json(update_request): Json, +) -> impl IntoResponse { + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&update_request.old_filename); + + if !file_exists(&file_path).await { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!( + "File '{}' not found in category '{category}'", + update_request.old_filename + ) + })), + ) + .into_response(); + } + + match fs::rename( + &file_path, + &file_path.with_file_name(&update_request.new_filename), + ) + .await + { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!( + "File '{}' updated successfully to '{}'", + update_request.old_filename, update_request.new_filename + ) + })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to update filename" + })), + ) + .into_response(), + } +} diff --git a/src/file_manager/mod.rs b/src/file_manager/mod.rs new file mode 100644 index 0000000..bd9731a --- /dev/null +++ b/src/file_manager/mod.rs @@ -0,0 +1,5 @@ +mod handlers; +mod models; + +pub(crate) use handlers::*; +pub use models::*; diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs new file mode 100644 index 0000000..ea1747a --- /dev/null +++ b/src/file_manager/models.rs @@ -0,0 +1,159 @@ +use crate::errors::LanguageError; + +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::LazyLock}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct FileMetadata { + pub id: String, + pub filename: String, + pub language: Language, + pub category: String, + pub size: u64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateFileContentRequest { + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateFilenameRequest { + pub old_filename: String, + pub new_filename: String, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum Language { + Cpp, + Input, + Java, + Output, + Python, + Text, +} + +static EXTENSION_TO_LANGUAGE: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ("cpp", Language::Cpp), + ("in", Language::Input), + ("java", Language::Java), + ("out", Language::Output), + ("py", Language::Python), + ("txt", Language::Text), + ]) +}); + +impl Language { + pub fn from_filename(filename: &str) -> Result { + if !filename.contains('.') || filename.starts_with('.') { + return Err(LanguageError::InvalidFilename); + } + + let extension = filename + .split('.') + .next_back() + .ok_or(LanguageError::InvalidFilename)? + .to_lowercase(); + + if extension.is_empty() { + return Err(LanguageError::InvalidFilename); + } + + Self::from_extension(&extension) + } + + pub fn from_extension(extension: &str) -> Result { + EXTENSION_TO_LANGUAGE + .get(&extension.to_lowercase() as &str) + .cloned() + .ok_or(LanguageError::UnsupportedExtension(extension.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_language_from_valid_filename() { + assert_eq!( + Language::from_filename("solution.py").unwrap(), + Language::Python + ); + assert_eq!( + Language::from_filename("test.PY").unwrap(), + Language::Python + ); + + assert_eq!( + Language::from_filename("Solution.java").unwrap(), + Language::Java + ); + + assert_eq!(Language::from_filename("code.cpp").unwrap(), Language::Cpp); + + assert_eq!( + Language::from_filename("input.in").unwrap(), + Language::Input + ); + + assert_eq!( + Language::from_filename("output.out").unwrap(), + Language::Output + ); + } + + #[test] + fn fails_to_parse_language_from_invalid_filename() { + assert_eq!( + Language::from_filename(".java").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("py").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("file.").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("run.exe").unwrap_err(), + LanguageError::UnsupportedExtension("exe".to_string()) + ); + + assert_eq!( + Language::from_filename("").unwrap_err(), + LanguageError::InvalidFilename + ); + } + + #[test] + fn can_parse_language_from_valid_extension() { + assert_eq!(Language::from_extension("py").unwrap(), Language::Python); + assert_eq!(Language::from_extension("java").unwrap(), Language::Java); + assert_eq!(Language::from_extension("cpp").unwrap(), Language::Cpp); + assert_eq!(Language::from_extension("txt").unwrap(), Language::Text); + assert_eq!(Language::from_extension("in").unwrap(), Language::Input); + assert_eq!(Language::from_extension("out").unwrap(), Language::Output); + } + + #[test] + fn fails_to_parse_language_from_invalid_extension() { + assert_eq!( + Language::from_extension("").unwrap_err(), + LanguageError::UnsupportedExtension("".to_string()) + ); + assert_eq!( + Language::from_extension("no").unwrap_err(), + LanguageError::UnsupportedExtension("no".to_string()) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index f482b98..2c3e91d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,32 @@ -use axum::{routing::get, Router}; +mod errors; +pub mod file_manager; + +use axum::{ + routing::{delete, get, post, put}, + Router, +}; + +use crate::file_manager::{ + delete_file, get_file, get_files_by_category, update_file_content, update_filename, upload_file, +}; async fn health_check() -> &'static str { "OK" } pub fn build_router() -> Router { - Router::new().route("/health", get(health_check)) + let problems_router = Router::new() + .route("/{problem_id}/{category}", post(upload_file)) + .route("/{problem_id}/{category}/{filename}", get(get_file)) + .route("/{problem_id}/{category}", get(get_files_by_category)) + .route("/{problem_id}/{category}/{filename}", delete(delete_file)) + .route( + "/{problem_id}/{category}/{filename}", + put(update_file_content), + ) + .route("/{problem_id}/{category}", put(update_filename)); + + Router::new() + .route("/health", get(health_check)) + .nest("/problems", problems_router) } diff --git a/tests/file_manager/handlers.rs b/tests/file_manager/handlers.rs new file mode 100644 index 0000000..a8a3861 --- /dev/null +++ b/tests/file_manager/handlers.rs @@ -0,0 +1,427 @@ +use std::path::Path; +use tokio::fs; + +struct TestSetup { + problem_id: u32, + file_content: Vec, + filename: String, + multipart_body: String, + port: u16, +} + +async fn setup_test(problem_id: u32, file_content: &[u8], filename: &str) -> TestSetup { + // 테스트용 디렉토리만 정리 (uploads 전체가 아닌 특정 problem_id만) + let test_upload_dir = format!("uploads/{}", problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } + + let multipart_body = format!( + "--boundary\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n\ + Content-Type: text/plain\r\n\r\n\ + {}\r\n\ + --boundary--\r\n", + String::from_utf8_lossy(file_content) + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + tokio::spawn(async move { + let app = coduck_backend::build_router(); + axum::serve(listener, app) + .await + .expect("Server failed to start"); + }); + + TestSetup { + problem_id, + file_content: file_content.to_vec(), + filename: filename.to_string(), + multipart_body, + port, + } +} + +async fn upload_file_request(setup: &TestSetup) -> reqwest::Response { + reqwest::Client::new() + .post(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution", + port = setup.port, + problem_id = setup.problem_id + )) + .header("Content-Type", "multipart/form-data; boundary=boundary") + .body(setup.multipart_body.clone()) + .send() + .await + .unwrap() +} + +async fn cleanup_test(problem_id: u32) { + let test_upload_dir = format!("uploads/{}", problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } +} + +#[tokio::test] +async fn upload_file_success() { + let setup = setup_test( + 0, + b"print(int(input()) + int(input()))", + "aplusb-solution.py", + ) + .await; + + let response = upload_file_request(&setup).await; + + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + let response_json = response + .json::() + .await + .unwrap(); + assert_eq!(response_json.filename, setup.filename); + assert_eq!( + response_json.language, + coduck_backend::file_manager::Language::Python + ); + assert_eq!(response_json.category, "solution"); + + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); + assert!(Path::new(&expected_file_path).exists()); + + let saved_content = fs::read(&expected_file_path).await.unwrap(); + assert_eq!(saved_content, setup.file_content); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn upload_file_handles_duplicate_filename() { + let setup = setup_test(1, b"print('Hello, World!')", "duplicate-test.py").await; + + // 첫 번째 파일 업로드 (성공) + let response1 = upload_file_request(&setup).await; + assert_eq!(response1.status(), reqwest::StatusCode::CREATED); + + // 두 번째 파일 업로드 (같은 파일명, 실패해야 함) + let response2 = upload_file_request(&setup).await; + + // 중복 파일 에러 확인 + assert_eq!(response2.status(), reqwest::StatusCode::CONFLICT); + + let error_response = response2.json::().await.unwrap(); + assert!(error_response.get("error").is_some()); + assert!(error_response["error"] + .as_str() + .unwrap() + .contains("already exists")); + + // 파일이 실제로 하나만 존재하는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); + assert!(Path::new(&expected_file_path).exists()); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn get_file_success() { + let setup = setup_test( + 2, + b"#include \nint main() { std::cout << \"Hello, World!\" << std::endl; return 0; }", + "hello.cpp", + ) + .await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일 내용 조회 + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.filename + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + // Content-Type 확인 + let content_type = response + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert_eq!(content_type, "text/html; charset=UTF-8"); + + // 파일 내용 확인 + let file_content = response.text().await.unwrap(); + assert_eq!( + file_content, + "#include \nint main() { std::cout << \"Hello, World!\" << std::endl; return 0; }" + ); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn get_files_by_category_success() { + let setup = setup_test(3, b"print('Hello, World!')", "hello.py").await; + + // 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 카테고리별 파일 목록 조회 + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/{category}", + port = setup.port, + problem_id = setup.problem_id, + category = "solution" + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let files: Vec = response.json().await.unwrap(); + + assert_eq!(files.len(), 1); + assert_eq!(files[0], setup.filename); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn delete_file_success() { + let setup = setup_test(5, b"print('Hello, World!')", "delete-test.py").await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일이 존재하는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); + assert!(Path::new(&expected_file_path).exists()); + + // 파일 삭제 + let client = reqwest::Client::new(); + let response = client + .delete(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.filename + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let delete_response = response.json::().await.unwrap(); + assert!(delete_response.get("message").is_some()); + assert!(delete_response["message"] + .as_str() + .unwrap() + .contains("deleted successfully")); + + // 파일이 실제로 삭제되었는지 확인 + assert!(!Path::new(&expected_file_path).exists()); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn delete_file_not_found() { + let setup = setup_test(6, b"print('Hello, World!')", "not-found-test.py").await; + + // 파일을 업로드하지 않고 바로 삭제 시도 + let client = reqwest::Client::new(); + let response = client + .delete(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = "non-existent-file.py" + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + + let error_response = response.json::().await.unwrap(); + assert!(error_response.get("error").is_some()); + assert!(error_response["error"] + .as_str() + .unwrap() + .contains("not found")); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn update_file_content_success() { + let setup = setup_test(7, b"print(int(input()) - int(input()))", "update-test.py").await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일 수정 + let client = reqwest::Client::new(); + let update_data = serde_json::json!({ + "content": "print(int(input()) + int(input()))" + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.filename + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let update_response = response.json::().await.unwrap(); + assert!(update_response.get("message").is_some()); + assert!(update_response["message"] + .as_str() + .unwrap() + .contains("updated successfully")); + + // 파일 내용이 실제로 업데이트되었는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); + let updated_content = fs::read_to_string(&expected_file_path).await.unwrap(); + assert_eq!(updated_content, "print(int(input()) + int(input()))"); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn update_file_not_found() { + let setup = setup_test( + 8, + b"print(int(input()) - int(input()))", + "not-found-update-test.py", + ) + .await; + + // 파일을 업로드하지 않고 바로 수정 시도 + let client = reqwest::Client::new(); + let update_data = serde_json::json!({ + "content": "print('Updated content!')" + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = "non-existent-file.py" + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + + let error_response = response.json::().await.unwrap(); + assert!(error_response.get("error").is_some()); + assert!(error_response["error"] + .as_str() + .unwrap() + .contains("not found")); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn update_file_missing_content() { + let setup = setup_test(9, b"print('Hello, World!')", "missing-content-test.py").await; + + // 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // content 필드 없이 수정 시도 + let client = reqwest::Client::new(); + let update_data = serde_json::json!({ + "other_field": "some value" + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.filename + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn update_file_filename_success() { + let setup = setup_test(10, b"print(int(input()) + int(input()))", "aplusb.py").await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일 수정 + let client = reqwest::Client::new(); + let new_filename = "aplusb-AC.py"; + let update_data = serde_json::json!({ + "old_filename": setup.filename, + "new_filename": new_filename + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution", + port = setup.port, + problem_id = setup.problem_id + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let update_response = response.json::().await.unwrap(); + assert!(update_response.get("message").is_some()); + assert!(update_response["message"] + .as_str() + .unwrap() + .contains("updated successfully")); + + // 파일 이름이 실제로 업데이트되었는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, new_filename); + assert!(Path::new(&expected_file_path).exists()); + + cleanup_test(setup.problem_id).await; +} diff --git a/tests/file_manager/mod.rs b/tests/file_manager/mod.rs new file mode 100644 index 0000000..6470330 --- /dev/null +++ b/tests/file_manager/mod.rs @@ -0,0 +1 @@ +mod handlers; diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 0000000..aa6a160 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1 @@ +mod file_manager;