From 4cd3e971b71e74d1e5e5c3922df22cd86b236cfd Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Fri, 17 Oct 2025 02:05:09 +0900 Subject: [PATCH 1/4] refactor: Make GitManager stateless and accept problem_id --- src/file_manager/git.rs | 168 ++++++++++++++++++++++------------------ src/file_manager/mod.rs | 1 + src/lib.rs | 5 +- 3 files changed, 98 insertions(+), 76 deletions(-) diff --git a/src/file_manager/git.rs b/src/file_manager/git.rs index a728ff8..82fa480 100644 --- a/src/file_manager/git.rs +++ b/src/file_manager/git.rs @@ -8,25 +8,21 @@ use tokio::fs; const DEFAULT_DIRECTORIES: [&str; 3] = ["solutions", "tests", "statements"]; #[derive(Debug)] -struct GitManager { - problem_id: u32, +pub(crate) struct GitManager { base_path: PathBuf, } impl GitManager { - fn new(problem_id: u32, base_path: PathBuf) -> Self { - Self { - problem_id, - base_path, - } + pub(crate) fn new(base_path: PathBuf) -> Self { + Self { base_path } } - fn get_upload_path(&self) -> PathBuf { - self.base_path.join(self.problem_id.to_string()) + fn get_upload_path(&self, problem_id: u32) -> PathBuf { + self.base_path.join(problem_id.to_string()) } - fn git_init(&self) -> Result<()> { - let path = self.get_upload_path(); + fn git_init(&self, problem_id: u32) -> Result<()> { + let path = self.get_upload_path(problem_id); Repository::init(&path) .and_then(|repo| { let mut config = repo.config()?; @@ -38,24 +34,24 @@ impl GitManager { .with_context(|| format!("Failed to init git repo at {path:?}")) } - async fn create_problem(&self) -> Result<()> { - self.git_init()?; - self.create_default_directories().await?; - self.git_add_all()?; + async fn create_problem(&self, problem_id: u32) -> Result<()> { + self.git_init(problem_id)?; + self.create_default_directories(problem_id).await?; + self.git_add_all(problem_id)?; Ok(()) } - fn git_add_all(&self) -> Result<()> { - let repo = self.get_repository()?; + fn git_add_all(&self, problem_id: u32) -> Result<()> { + let repo = self.get_repository(problem_id)?; let mut idx = repo.index()?; idx.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?; idx.write()?; Ok(()) } - fn git_commit(&self, message: String) -> Result { - self.git_add_all()?; - let repo = self.get_repository()?; + fn git_commit(&self, problem_id: u32, message: String) -> Result { + self.git_add_all(problem_id)?; + let repo = self.get_repository(problem_id)?; let mut idx = repo.index()?; let tree_id = idx.write_tree()?; let tree = repo.find_tree(tree_id)?; @@ -75,8 +71,8 @@ impl GitManager { Ok(commit_oid.to_string()) } - fn git_status(&self) -> Result> { - let repo = self.get_repository()?; + fn git_status(&self, problem_id: u32) -> Result> { + let repo = self.get_repository(problem_id)?; let mut status_opts = StatusOptions::new(); status_opts .include_untracked(true) @@ -91,8 +87,8 @@ impl GitManager { Ok(file_infos) } - fn git_log(&self) -> Result> { - let repo = self.get_repository()?; + fn git_log(&self, problem_id: u32) -> Result> { + let repo = self.get_repository(problem_id)?; let mut revwalk = repo.revwalk()?; revwalk.push_head()?; let mut changed_logs = Vec::new(); @@ -133,8 +129,8 @@ impl GitManager { Ok(changed_logs) } - async fn create_default_directories(&self) -> Result<()> { - let base_path = self.get_upload_path(); + async fn create_default_directories(&self, problem_id: u32) -> Result<()> { + let base_path = self.get_upload_path(problem_id); for dir in DEFAULT_DIRECTORIES { let path = base_path.join(dir); fs::create_dir_all(path) @@ -175,8 +171,8 @@ impl GitManager { } } - fn get_repository(&self) -> Result { - let path = self.get_upload_path(); + fn get_repository(&self, problem_id: u32) -> Result { + let path = self.get_upload_path(problem_id); Repository::open(&path) .with_context(|| format!("Failed to open git repository at {path:?}")) } @@ -233,11 +229,15 @@ mod tests { async fn can_init_git_repository() -> Result<(), std::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); - assert!(git_manager.git_init().is_ok()); - assert!(Path::new(git_manager.get_upload_path().to_str().unwrap()).exists()); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); + assert!(git_manager.git_init(problem_id).is_ok()); + assert!(Path::new(git_manager.get_upload_path(problem_id).to_str().unwrap()).exists()); assert!(Path::new( - format!("{}/.git", git_manager.get_upload_path().to_str().unwrap()).as_str() + format!( + "{}/.git", + git_manager.get_upload_path(problem_id).to_str().unwrap() + ) + .as_str() ) .exists()); @@ -248,9 +248,9 @@ mod tests { async fn can_set_config() -> Result<(), std::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); - assert!(git_manager.git_init().is_ok()); - let repo = git_manager.get_repository().unwrap(); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); + assert!(git_manager.git_init(problem_id).is_ok()); + let repo = git_manager.get_repository(problem_id).unwrap(); let config = repo.config().unwrap(); assert_eq!(config.get_string("user.name"), Ok("admin".to_string())); assert_eq!( @@ -265,24 +265,31 @@ mod tests { async fn can_create_default_file() -> Result<(), std::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); - assert!(git_manager.create_default_directories().await.is_ok()); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); + assert!(git_manager + .create_default_directories(problem_id) + .await + .is_ok()); assert!(Path::new( format!( "{}/solutions", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ) .as_str() ) .exists()); assert!(Path::new( - format!("{}/tests", git_manager.get_upload_path().to_str().unwrap()).as_str() + format!( + "{}/tests", + git_manager.get_upload_path(problem_id).to_str().unwrap() + ) + .as_str() ) .exists()); assert!(Path::new( format!( "{}/statements", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ) .as_str() ) @@ -295,29 +302,37 @@ mod tests { async fn can_create_problem() -> Result<(), std::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); - assert!(git_manager.create_problem().await.is_ok()); - assert!(Path::new(git_manager.get_upload_path().to_str().unwrap()).exists()); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); + assert!(git_manager.create_problem(problem_id).await.is_ok()); + assert!(Path::new(git_manager.get_upload_path(problem_id).to_str().unwrap()).exists()); assert!(Path::new( - format!("{}/.git", git_manager.get_upload_path().to_str().unwrap()).as_str() + format!( + "{}/.git", + git_manager.get_upload_path(problem_id).to_str().unwrap() + ) + .as_str() ) .exists()); assert!(Path::new( format!( "{}/solutions", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ) .as_str() ) .exists()); assert!(Path::new( - format!("{}/tests", git_manager.get_upload_path().to_str().unwrap()).as_str() + format!( + "{}/tests", + git_manager.get_upload_path(problem_id).to_str().unwrap() + ) + .as_str() ) .exists()); assert!(Path::new( format!( "{}/statements", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ) .as_str() ) @@ -330,19 +345,19 @@ mod tests { async fn can_get_git_status() -> Result<(), tokio::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); - git_manager.git_init().unwrap(); + git_manager.git_init(problem_id).unwrap(); fs::create_dir_all(format!( "{}/tests", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() )) .await?; fs::write( format!( "{}/tests/1.in", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "1 2", ) @@ -350,13 +365,13 @@ mod tests { fs::write( format!( "{}/tests/1.out", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "3", ) .await?; - let file_infos = git_manager.git_status().unwrap(); + let file_infos = git_manager.git_status(problem_id).unwrap(); let expected = vec![ FileInfo { status: "ADDED".to_string(), @@ -376,20 +391,20 @@ mod tests { async fn can_git_add() -> Result<(), tokio::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); - git_manager.git_init().unwrap(); + git_manager.git_init(problem_id).unwrap(); - let repo = git_manager.get_repository().unwrap(); + let repo = git_manager.get_repository(problem_id).unwrap(); fs::create_dir_all(format!( "{}/tests", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() )) .await?; fs::write( format!( "{}/tests/1.in", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "1 2", ) @@ -397,13 +412,13 @@ mod tests { fs::write( format!( "{}/tests/1.out", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "3", ) .await?; - assert!(git_manager.git_add_all().is_ok()); + assert!(git_manager.git_add_all(problem_id).is_ok()); let statuses = repo.statuses(None).unwrap(); @@ -422,18 +437,18 @@ mod tests { async fn can_commit() -> Result<(), tokio::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); - git_manager.git_init().unwrap(); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); + git_manager.git_init(problem_id).unwrap(); fs::create_dir_all(format!( "{}/tests", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() )) .await?; fs::write( format!( "{}/tests/1.in", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "1 2", ) @@ -441,7 +456,7 @@ mod tests { fs::write( format!( "{}/tests/1.out", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "3", ) @@ -449,9 +464,11 @@ mod tests { let commit_message = "add test 1"; - assert!(git_manager.git_commit(commit_message.to_string()).is_ok()); + assert!(git_manager + .git_commit(problem_id, commit_message.to_string()) + .is_ok()); - let repo = git_manager.get_repository().unwrap(); + let repo = git_manager.get_repository(problem_id).unwrap(); let head = repo.head().unwrap(); let commit = head.peel_to_commit().unwrap(); @@ -464,14 +481,17 @@ mod tests { async fn can_get_log() -> Result<(), tokio::io::Error> { let temp_dir = TempDir::new()?; let problem_id = 0; - let git_manager = GitManager::new(problem_id, temp_dir.path().to_path_buf()); - git_manager.git_init().unwrap(); - git_manager.create_default_directories().await.unwrap(); + let git_manager = GitManager::new(temp_dir.path().to_path_buf()); + git_manager.git_init(problem_id).unwrap(); + git_manager + .create_default_directories(problem_id) + .await + .unwrap(); fs::write( format!( "{}/tests/1.in", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "1 2", ) @@ -479,17 +499,17 @@ mod tests { fs::write( format!( "{}/tests/1.out", - git_manager.get_upload_path().to_str().unwrap() + git_manager.get_upload_path(problem_id).to_str().unwrap() ), "3", ) .await?; git_manager - .git_commit("create default file".to_string()) + .git_commit(problem_id, "create default file".to_string()) .unwrap(); - let log = git_manager.git_log().unwrap(); + let log = git_manager.git_log(problem_id).unwrap(); let expected_path = vec![ FileInfo { diff --git a/src/file_manager/mod.rs b/src/file_manager/mod.rs index b9b92aa..1f05d80 100644 --- a/src/file_manager/mod.rs +++ b/src/file_manager/mod.rs @@ -4,6 +4,7 @@ mod models; mod service; mod storage; +pub(crate) use git::*; pub(crate) use handlers::*; pub use models::*; pub use service::*; diff --git a/src/lib.rs b/src/lib.rs index 5d57e6f..5ea49db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use crate::file_manager::{ delete_file, get_file, get_files_by_category, update_file_content, update_filename, - upload_file, FileService, LocalFileStorage, + upload_file, FileService, GitManager, LocalFileStorage, }; async fn health_check() -> &'static str { @@ -31,7 +31,8 @@ pub fn build_router() -> Router { panic!("UPLOAD_DIR must point to a directory"); } - let storage = LocalFileStorage::new(upload_path); + let storage = LocalFileStorage::new(upload_path.clone()); + let _git_manager = GitManager::new(upload_path); let file_service = Arc::new(FileService::new(storage)); let problems_router = Router::new() From 25e2df012afffc4b5501fbb01d3e28073cbff184 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Fri, 17 Oct 2025 20:52:31 +0900 Subject: [PATCH 2/4] feat: Add commit and log functionality to GitManager and update API routes --- src/file_manager/git.rs | 57 ++++---- src/file_manager/handlers.rs | 57 +++++++- src/file_manager/models.rs | 5 + src/lib.rs | 19 ++- tests/file_manager/handlers.rs | 256 +++++++++++++++++++++++++-------- 5 files changed, 304 insertions(+), 90 deletions(-) diff --git a/src/file_manager/git.rs b/src/file_manager/git.rs index 82fa480..ec06be7 100644 --- a/src/file_manager/git.rs +++ b/src/file_manager/git.rs @@ -1,13 +1,12 @@ -#![allow(dead_code)] - use anyhow::{Context, Result}; use git2::{DiffOptions, IndexAddOption, Repository, StatusOptions, Time}; +use serde::{Serialize, Serializer}; use std::path::PathBuf; use tokio::fs; const DEFAULT_DIRECTORIES: [&str; 3] = ["solutions", "tests", "statements"]; -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct GitManager { base_path: PathBuf, } @@ -34,14 +33,14 @@ impl GitManager { .with_context(|| format!("Failed to init git repo at {path:?}")) } - async fn create_problem(&self, problem_id: u32) -> Result<()> { + pub(crate) async fn create_problem(&self, problem_id: u32) -> Result<()> { self.git_init(problem_id)?; self.create_default_directories(problem_id).await?; - self.git_add_all(problem_id)?; + self.add_all(problem_id)?; Ok(()) } - fn git_add_all(&self, problem_id: u32) -> Result<()> { + fn add_all(&self, problem_id: u32) -> Result<()> { let repo = self.get_repository(problem_id)?; let mut idx = repo.index()?; idx.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?; @@ -49,8 +48,8 @@ impl GitManager { Ok(()) } - fn git_commit(&self, problem_id: u32, message: String) -> Result { - self.git_add_all(problem_id)?; + pub(crate) fn commit(&self, problem_id: u32, message: String) -> Result { + self.add_all(problem_id)?; let repo = self.get_repository(problem_id)?; let mut idx = repo.index()?; let tree_id = idx.write_tree()?; @@ -71,7 +70,7 @@ impl GitManager { Ok(commit_oid.to_string()) } - fn git_status(&self, problem_id: u32) -> Result> { + pub(crate) fn status(&self, problem_id: u32) -> Result> { let repo = self.get_repository(problem_id)?; let mut status_opts = StatusOptions::new(); status_opts @@ -87,7 +86,7 @@ impl GitManager { Ok(file_infos) } - fn git_log(&self, problem_id: u32) -> Result> { + pub(crate) fn log(&self, problem_id: u32) -> Result> { let repo = self.get_repository(problem_id)?; let mut revwalk = repo.revwalk()?; revwalk.push_head()?; @@ -178,18 +177,26 @@ impl GitManager { } } -#[derive(Debug, PartialEq, Eq)] -struct ChangedLog { - user: String, - time: Time, - message: String, - paths: Vec, +#[derive(Debug, PartialEq, Eq, Serialize)] +pub(crate) struct ChangedLog { + pub(crate) user: String, + #[serde(serialize_with = "serialize_git_time_seconds")] + pub(crate) time: Time, + pub(crate) message: String, + pub(crate) paths: Vec, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +pub(crate) struct FileInfo { + pub(crate) status: String, + pub(crate) path: String, } -#[derive(Debug, PartialEq, Eq)] -struct FileInfo { - status: String, - path: String, +fn serialize_git_time_seconds(time: &Time, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_i64(time.seconds()) } #[cfg(test)] @@ -371,7 +378,7 @@ mod tests { ) .await?; - let file_infos = git_manager.git_status(problem_id).unwrap(); + let file_infos = git_manager.status(problem_id).unwrap(); let expected = vec![ FileInfo { status: "ADDED".to_string(), @@ -418,7 +425,7 @@ mod tests { ) .await?; - assert!(git_manager.git_add_all(problem_id).is_ok()); + assert!(git_manager.add_all(problem_id).is_ok()); let statuses = repo.statuses(None).unwrap(); @@ -465,7 +472,7 @@ mod tests { let commit_message = "add test 1"; assert!(git_manager - .git_commit(problem_id, commit_message.to_string()) + .commit(problem_id, commit_message.to_string()) .is_ok()); let repo = git_manager.get_repository(problem_id).unwrap(); @@ -506,10 +513,10 @@ mod tests { .await?; git_manager - .git_commit(problem_id, "create default file".to_string()) + .commit(problem_id, "create default file".to_string()) .unwrap(); - let log = git_manager.git_log(problem_id).unwrap(); + let log = git_manager.log(problem_id).unwrap(); let expected_path = vec![ FileInfo { diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index f7d4368..e0de3e0 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -1,4 +1,6 @@ -use crate::file_manager::{FileService, UpdateFileContentRequest, UpdateFilenameRequest}; +use crate::file_manager::{ + CommitRequest, FileService, GitManager, UpdateFileContentRequest, UpdateFilenameRequest, +}; use anyhow::{anyhow, Result}; use axum::{ @@ -252,3 +254,56 @@ pub async fn update_filename( } } } + +pub async fn create_problem( + State(git_manager): State, + Path(problem_id): Path, +) -> Response { + match git_manager.create_problem(problem_id).await { + Ok(_) => ( + StatusCode::CREATED, + Json(serde_json::json!({ "message": "Problem initialized" })), + ) + .into_response(), + Err(e) => handle_error(e), + } +} + +pub async fn commit_changes( + State(git_manager): State, + Path(problem_id): Path, + Json(body): Json, +) -> Response { + if body.message.trim().is_empty() { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ "error": "Commit message cannot be empty" })), + ) + .into_response(); + } + + match git_manager.commit(problem_id, body.message) { + Ok(oid) => (StatusCode::OK, Json(serde_json::json!({ "commit": oid }))).into_response(), + Err(e) => handle_error(e), + } +} + +pub async fn get_log( + State(git_manager): State, + Path(problem_id): Path, +) -> Response { + match git_manager.log(problem_id) { + Ok(logs) => (StatusCode::OK, Json(logs)).into_response(), + Err(e) => handle_error(e), + } +} + +pub async fn get_status( + State(git_manager): State, + Path(problem_id): Path, +) -> Response { + match git_manager.status(problem_id) { + Ok(statuses) => (StatusCode::OK, Json(statuses)).into_response(), + Err(e) => handle_error(e), + } +} diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index eb1161f..2e1cb41 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -25,6 +25,11 @@ pub struct UpdateFilenameRequest { pub new_filename: String, } +#[derive(Debug, Deserialize)] +pub struct CommitRequest { + pub message: String, +} + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum Language { diff --git a/src/lib.rs b/src/lib.rs index 5ea49db..f5091f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,9 @@ use std::path::PathBuf; use std::sync::Arc; use crate::file_manager::{ - delete_file, get_file, get_files_by_category, update_file_content, update_filename, - upload_file, FileService, GitManager, LocalFileStorage, + commit_changes, create_problem, delete_file, get_file, get_files_by_category, get_log, + get_status, update_file_content, update_filename, upload_file, FileService, GitManager, + LocalFileStorage, }; async fn health_check() -> &'static str { @@ -32,9 +33,13 @@ pub fn build_router() -> Router { } let storage = LocalFileStorage::new(upload_path.clone()); - let _git_manager = GitManager::new(upload_path); + let git_manager = GitManager::new(upload_path); let file_service = Arc::new(FileService::new(storage)); + let init_router = Router::new() + .route("/{problem_id}", post(create_problem)) + .with_state(git_manager.clone()); + let problems_router = Router::new() .route("/{problem_id}/{category}", post(upload_file)) .route("/{problem_id}/{category}/{filename}", get(get_file)) @@ -46,8 +51,16 @@ pub fn build_router() -> Router { ) .route("/{problem_id}/{category}", put(update_filename)); + let changes_router = Router::new() + .route("/{problem_id}/commit", post(commit_changes)) + .route("/{problem_id}/log", get(get_log)) + .route("/{problem_id}/status", get(get_status)) + .with_state(git_manager); + Router::new() .route("/health", get(health_check)) + .nest("/problems/init", init_router) .nest("/problems", problems_router) + .nest("/changes", changes_router) .with_state(file_service) } diff --git a/tests/file_manager/handlers.rs b/tests/file_manager/handlers.rs index fca788c..b190ca2 100644 --- a/tests/file_manager/handlers.rs +++ b/tests/file_manager/handlers.rs @@ -4,28 +4,16 @@ use tokio::fs; struct TestSetup { problem_id: u32, - file_content: Vec, - filename: String, - multipart_body: String, port: u16, temp_dir: TempDir, } -async fn setup_test(file_content: &[u8], filename: &str) -> TestSetup { +async fn setup_test() -> TestSetup { let problem_id = 0; let temp_dir = TempDir::new().unwrap(); std::env::set_var("UPLOAD_DIR", temp_dir.path().to_str().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(); @@ -40,15 +28,12 @@ async fn setup_test(file_content: &[u8], filename: &str) -> TestSetup { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; TestSetup { problem_id, - file_content: file_content.to_vec(), - filename: filename.to_string(), - multipart_body, port, temp_dir, } } -async fn upload_file_request(setup: &TestSetup) -> reqwest::Response { +async fn upload_file_request(setup: &TestSetup, multipart_body: String) -> reqwest::Response { reqwest::Client::new() .post(&format!( "http://127.0.0.1:{port}/problems/{problem_id}/solution", @@ -56,25 +41,51 @@ async fn upload_file_request(setup: &TestSetup) -> reqwest::Response { problem_id = setup.problem_id )) .header("Content-Type", "multipart/form-data; boundary=boundary") - .body(setup.multipart_body.clone()) + .body(multipart_body) + .send() + .await + .unwrap() +} + +async fn create_problem_request(setup: &TestSetup) -> reqwest::Response { + reqwest::Client::new() + .post(&format!( + "http://127.0.0.1:{port}/problems/init/{problem_id}", + port = setup.port, + problem_id = setup.problem_id + )) .send() .await .unwrap() } +fn multipart_body(filename: &str, content: &[u8]) -> String { + 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(content) + ) +} + #[tokio::test] #[serial_test::serial] async fn upload_file_success() { - let setup = setup_test(b"print(int(input()) + int(input()))", "aplusb-solution.py").await; + let setup = setup_test().await; + let filename = "aplusb-solution.py"; + let content = b"print(int(input()) + int(input()))"; + let multipart_body = multipart_body(filename, content); - let response = upload_file_request(&setup).await; + let response = upload_file_request(&setup, multipart_body).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.filename, filename); assert_eq!( response_json.language, coduck_backend::file_manager::Language::Python @@ -85,23 +96,26 @@ async fn upload_file_success() { "{}/{}/solution/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, - setup.filename + 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); + assert_eq!(saved_content, content); } #[tokio::test] #[serial_test::serial] async fn upload_file_handles_duplicate_filename() { - let setup = setup_test(b"print('Hello, World!')", "duplicate-test.py").await; + let setup = setup_test().await; + let filename = "duplicate-test.py"; + let content = b"print('Hello, World!')"; + let multipart_body = multipart_body(filename, content); - let response1 = upload_file_request(&setup).await; + let response1 = upload_file_request(&setup, multipart_body.clone()).await; assert_eq!(response1.status(), reqwest::StatusCode::CREATED); - let response2 = upload_file_request(&setup).await; + let response2 = upload_file_request(&setup, multipart_body).await; assert_eq!(response2.status(), reqwest::StatusCode::CONFLICT); let error_response = response2.json::().await.unwrap(); @@ -115,7 +129,7 @@ async fn upload_file_handles_duplicate_filename() { "{}/{}/solution/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, - setup.filename + filename ); assert!(Path::new(&expected_file_path).exists()); } @@ -123,13 +137,12 @@ async fn upload_file_handles_duplicate_filename() { #[tokio::test] #[serial_test::serial] async fn get_file_success() { - let setup = setup_test( - b"#include \nint main() { std::cout << \"Hello, World!\" << std::endl; return 0; }", - "hello.cpp", - ) - .await; + let setup = setup_test().await; + let filename = "hello.cpp"; + let content = b"#include \nint main() { std::cout << \"Hello, World!\" << std::endl; return 0; }"; + let multipart_body = multipart_body(filename, content); - let upload_response = upload_file_request(&setup).await; + let upload_response = upload_file_request(&setup, multipart_body).await; assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); // Retrieve file content @@ -139,7 +152,7 @@ async fn get_file_success() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.filename + filename = filename )) .send() .await @@ -156,18 +169,18 @@ async fn get_file_success() { assert_eq!(content_type, "text/plain; 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; }" - ); + assert_eq!(file_content, String::from_utf8_lossy(content)); } #[tokio::test] #[serial_test::serial] async fn get_files_by_category_success() { - let setup = setup_test(b"print('Hello, World!')", "hello.py").await; + let setup = setup_test().await; + let filename = "hello.py"; + let content = b"print('Hello, World!')"; + let multipart_body = multipart_body(filename, content); - let upload_response = upload_file_request(&setup).await; + let upload_response = upload_file_request(&setup, multipart_body).await; assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); // Retrieve file list by category @@ -187,15 +200,18 @@ async fn get_files_by_category_success() { let files: Vec = response.json().await.unwrap(); assert_eq!(files.len(), 1); - assert_eq!(files[0], setup.filename); + assert_eq!(files[0], filename); } #[tokio::test] #[serial_test::serial] async fn delete_file_success() { - let setup = setup_test(b"print('Hello, World!')", "delete-test.py").await; + let setup = setup_test().await; + let filename = "delete-test.py"; + let content = b"print('Hello, World!')"; + let multipart_body = multipart_body(filename, content); - let upload_response = upload_file_request(&setup).await; + let upload_response = upload_file_request(&setup, multipart_body).await; assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); // Check if file exists @@ -203,7 +219,7 @@ async fn delete_file_success() { "{}/{}/solution/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, - setup.filename + filename ); assert!(Path::new(&expected_file_path).exists()); @@ -214,7 +230,7 @@ async fn delete_file_success() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.filename + filename = filename )) .send() .await @@ -235,7 +251,7 @@ async fn delete_file_success() { #[tokio::test] #[serial_test::serial] async fn delete_file_not_found() { - let setup = setup_test(b"print('Hello, World!')", "not-found-test.py").await; + let setup = setup_test().await; // Attempt to delete without uploading file first let client = reqwest::Client::new(); @@ -263,9 +279,12 @@ async fn delete_file_not_found() { #[tokio::test] #[serial_test::serial] async fn update_file_content_success() { - let setup = setup_test(b"print(int(input()) - int(input()))", "update-test.py").await; + let setup = setup_test().await; + let filename = "update-test.py"; + let content = b"print(int(input()) - int(input()))"; + let multipart_body = multipart_body(filename, content); - let upload_response = upload_file_request(&setup).await; + let upload_response = upload_file_request(&setup, multipart_body).await; assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); // Modify file @@ -279,7 +298,7 @@ async fn update_file_content_success() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.filename + filename = filename )) .json(&update_data) .send() @@ -300,7 +319,7 @@ async fn update_file_content_success() { "{}/{}/solution/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, - setup.filename + filename ); let updated_content = fs::read_to_string(&expected_file_path).await.unwrap(); assert_eq!(updated_content, "print(int(input()) + int(input()))"); @@ -309,11 +328,7 @@ async fn update_file_content_success() { #[tokio::test] #[serial_test::serial] async fn update_file_not_found() { - let setup = setup_test( - b"print(int(input()) - int(input()))", - "not-found-update-test.py", - ) - .await; + let setup = setup_test().await; // Attempt to modify without uploading file first let client = reqwest::Client::new(); @@ -346,9 +361,12 @@ async fn update_file_not_found() { #[tokio::test] #[serial_test::serial] async fn update_file_missing_content() { - let setup = setup_test(b"print('Hello, World!')", "missing-content-test.py").await; + let setup = setup_test().await; + let filename = "missing-content-test.py"; + let content = b"print('Hello, World!')"; + let multipart_body = multipart_body(filename, content); - let upload_response = upload_file_request(&setup).await; + let upload_response = upload_file_request(&setup, multipart_body).await; assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); // Attempt to modify without content field @@ -362,7 +380,7 @@ async fn update_file_missing_content() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.filename + filename = filename )) .json(&update_data) .send() @@ -375,16 +393,20 @@ async fn update_file_missing_content() { #[tokio::test] #[serial_test::serial] async fn update_filename_success() { - let setup = setup_test(b"print(int(input()) + int(input()))", "aplusb.py").await; + let setup = setup_test().await; + let filename = "aplusb.py"; + let content = b"print(int(input()) + int(input()))"; + let multipart_body = multipart_body(filename, content); - let upload_response = upload_file_request(&setup).await; + let upload_response = upload_file_request(&setup, multipart_body).await; assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); // Modify file let client = reqwest::Client::new(); let new_filename = "aplusb-AC.py"; + let old_filename = filename; let update_data = serde_json::json!({ - "old_filename": setup.filename, + "old_filename": old_filename, "new_filename": new_filename }); @@ -417,3 +439,115 @@ async fn update_filename_success() { ); assert!(Path::new(&expected_file_path).exists()); } + +#[tokio::test] +#[serial_test::serial] +async fn init_creates_repo_and_directories() { + let server = setup_test().await; + let problem_id = 0; + + let response = create_problem_request(&server).await; + + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + + let base = format!("{}/{problem_id}", server.temp_dir.path().to_str().unwrap()); + assert!(Path::new(&base).exists()); + assert!(Path::new(&format!("{base}/.git")).exists()); + assert!(Path::new(&format!("{base}/solutions")).exists()); + assert!(Path::new(&format!("{base}/tests")).exists()); + assert!(Path::new(&format!("{base}/statements")).exists()); +} + +#[tokio::test] +#[serial_test::serial] +async fn status_lists_untracked_changes() { + let server = setup_test().await; + let problem_id = 0; + + create_problem_request(&server).await; + + let base = format!("{}/{problem_id}", server.temp_dir.path().to_str().unwrap()); + fs::create_dir_all(format!("{base}/tests")).await.unwrap(); + fs::write(format!("{base}/tests/1.in"), b"1 2") + .await + .unwrap(); + fs::write(format!("{base}/tests/1.out"), b"3") + .await + .unwrap(); + + let response = reqwest::Client::new() + .get(&format!( + "http://127.0.0.1:{}/changes/{problem_id}/status", + server.port + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let statuses: Vec = response.json().await.unwrap(); + + // Should contain both files as ADDED + assert!(statuses + .iter() + .any(|e| e["status"] == "ADDED" && e["path"] == "tests/1.in")); + assert!(statuses + .iter() + .any(|e| e["status"] == "ADDED" && e["path"] == "tests/1.out")); +} + +#[tokio::test] +#[serial_test::serial] +async fn commit_and_log_returns_commit_and_paths() { + let server = setup_test().await; + let problem_id = 0; + + create_problem_request(&server).await; + + // create files + let base = format!("{}/{problem_id}", server.temp_dir.path().to_str().unwrap()); + fs::create_dir_all(format!("{base}/tests")).await.unwrap(); + fs::write(format!("{base}/tests/1.in"), b"1 2") + .await + .unwrap(); + fs::write(format!("{base}/tests/1.out"), b"3") + .await + .unwrap(); + + // commit + let commit_body = serde_json::json!({ "message": "add tests" }); + let commit_res = reqwest::Client::new() + .post(&format!( + "http://127.0.0.1:{}/changes/{problem_id}/commit", + server.port + )) + .json(&commit_body) + .send() + .await + .unwrap(); + assert_eq!(commit_res.status(), reqwest::StatusCode::OK); + let commit_json: serde_json::Value = commit_res.json().await.unwrap(); + assert!(commit_json.get("commit").and_then(|v| v.as_str()).is_some()); + + // log + let log_res = reqwest::Client::new() + .get(&format!( + "http://127.0.0.1:{}/changes/{problem_id}/log", + server.port + )) + .send() + .await + .unwrap(); + assert_eq!(log_res.status(), reqwest::StatusCode::OK); + let logs: Vec = log_res.json().await.unwrap(); + assert!(!logs.is_empty()); + let first = &logs[0]; + assert!(first.get("message").is_some()); + let paths = first.get("paths").and_then(|v| v.as_array()).unwrap(); + assert!(paths + .iter() + .any(|p| p["status"] == "ADDED" && p["path"] == "tests/1.in")); + assert!(paths + .iter() + .any(|p| p["status"] == "ADDED" && p["path"] == "tests/1.out")); +} From 3922ebf62e51212f9bc81a6af86009c51574f215 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Fri, 17 Oct 2025 21:41:01 +0900 Subject: [PATCH 3/4] fix: Update API routes to use plural 'solutions' instead of singular 'solution' --- tests/file_manager/handlers.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/tests/file_manager/handlers.rs b/tests/file_manager/handlers.rs index b190ca2..27e3bcb 100644 --- a/tests/file_manager/handlers.rs +++ b/tests/file_manager/handlers.rs @@ -36,7 +36,7 @@ async fn setup_test() -> TestSetup { async fn upload_file_request(setup: &TestSetup, multipart_body: String) -> reqwest::Response { reqwest::Client::new() .post(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions", port = setup.port, problem_id = setup.problem_id )) @@ -90,10 +90,10 @@ async fn upload_file_success() { response_json.language, coduck_backend::file_manager::Language::Python ); - assert_eq!(response_json.category, "solution"); + assert_eq!(response_json.category, "solutions"); let expected_file_path = format!( - "{}/{}/solution/{}", + "{}/{}/solutions/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, filename @@ -126,7 +126,7 @@ async fn upload_file_handles_duplicate_filename() { .contains("already exists")); let expected_file_path = format!( - "{}/{}/solution/{}", + "{}/{}/solutions/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, filename @@ -149,7 +149,7 @@ async fn get_file_success() { let client = reqwest::Client::new(); let response = client .get(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions/{filename}", port = setup.port, problem_id = setup.problem_id, filename = filename @@ -187,10 +187,9 @@ async fn get_files_by_category_success() { let client = reqwest::Client::new(); let response = client .get(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/{category}", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions", port = setup.port, problem_id = setup.problem_id, - category = "solution" )) .send() .await @@ -216,7 +215,7 @@ async fn delete_file_success() { // Check if file exists let expected_file_path = format!( - "{}/{}/solution/{}", + "{}/{}/solutions/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, filename @@ -227,7 +226,7 @@ async fn delete_file_success() { let client = reqwest::Client::new(); let response = client .delete(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions/{filename}", port = setup.port, problem_id = setup.problem_id, filename = filename @@ -257,7 +256,7 @@ async fn delete_file_not_found() { let client = reqwest::Client::new(); let response = client .delete(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions/{filename}", port = setup.port, problem_id = setup.problem_id, filename = "non-existent-file.py" @@ -295,7 +294,7 @@ async fn update_file_content_success() { let response = client .put(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions/{filename}", port = setup.port, problem_id = setup.problem_id, filename = filename @@ -316,7 +315,7 @@ async fn update_file_content_success() { // Verify that file content was actually updated let expected_file_path = format!( - "{}/{}/solution/{}", + "{}/{}/solutions/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, filename @@ -338,7 +337,7 @@ async fn update_file_not_found() { let response = client .put(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions/{filename}", port = setup.port, problem_id = setup.problem_id, filename = "non-existent-file.py" @@ -377,7 +376,7 @@ async fn update_file_missing_content() { let response = client .put(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions/{filename}", port = setup.port, problem_id = setup.problem_id, filename = filename @@ -412,7 +411,7 @@ async fn update_filename_success() { let response = client .put(&format!( - "http://127.0.0.1:{port}/problems/{problem_id}/solution", + "http://127.0.0.1:{port}/problems/{problem_id}/solutions", port = setup.port, problem_id = setup.problem_id )) @@ -432,7 +431,7 @@ async fn update_filename_success() { // Verify that filename was actually updated let expected_file_path = format!( - "{}/{}/solution/{}", + "{}/{}/solutions/{}", setup.temp_dir.path().to_str().unwrap(), setup.problem_id, new_filename From f2bc0be2bd0044e46daec42c7904543f33a9529b Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Fri, 17 Oct 2025 21:56:21 +0900 Subject: [PATCH 4/4] fix: Correct old_filename reference in update_filename_success test --- tests/file_manager/handlers.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/file_manager/handlers.rs b/tests/file_manager/handlers.rs index 27e3bcb..bbf2b9f 100644 --- a/tests/file_manager/handlers.rs +++ b/tests/file_manager/handlers.rs @@ -403,9 +403,8 @@ async fn update_filename_success() { // Modify file let client = reqwest::Client::new(); let new_filename = "aplusb-AC.py"; - let old_filename = filename; let update_data = serde_json::json!({ - "old_filename": old_filename, + "old_filename": filename, "new_filename": new_filename });