diff --git a/server/src/error.rs b/server/src/error.rs index cf31bfe..f6aec2d 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -12,7 +12,7 @@ use openapi_generated::{ types::Object, }; -use crate::repository::RepositoryError; +use crate::{problem::ProblemProjectionError, repository::RepositoryError}; type BoxError = Box; @@ -40,6 +40,10 @@ pub(crate) enum AppError { #[source] source: BoxError, }, + #[error("active run was not found")] + RunNotFound, + #[error("problem is locked")] + ProblemLocked, } impl AppError { @@ -68,6 +72,12 @@ impl From for AppError { } } +impl From for AppError { + fn from(error: ProblemProjectionError) -> Self { + Self::internal(error) + } +} + impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, code, message) = match &self { @@ -93,6 +103,16 @@ impl IntoResponse for AppError { "internal server error".to_owned(), ) } + Self::RunNotFound => ( + StatusCode::NOT_FOUND, + "RUN_NOT_FOUND", + "挑戦中のrunが見つかりません".to_owned(), + ), + Self::ProblemLocked => ( + StatusCode::CONFLICT, + "PROBLEM_LOCKED", + "この問題はまだ解放されていません".to_owned(), + ), }; let body = ErrorResponse::new(ErrorResponseError::new( diff --git a/server/src/handler.rs b/server/src/handler.rs index 5533e0a..1af86c7 100644 --- a/server/src/handler.rs +++ b/server/src/handler.rs @@ -10,11 +10,14 @@ use axum::{ }; use axum_extra::extract::cookie::{Cookie, CookieJar}; use chrono::Utc; -use openapi_generated::models::{ActiveRunResponse, GuestLoginRequest, GuestLoginResponse, User}; +use openapi_generated::models::{ + ActiveRunResponse, GuestLoginRequest, GuestLoginResponse, ProblemResponse, User, +}; use uuid::Uuid; use crate::{ AppState, OPENAPI_DOCUMENT, auth::current_user::CurrentUser, config::AuthMode, error::AppError, + problem::build_problem_response, }; pub(crate) async fn ping() -> Response { @@ -155,3 +158,35 @@ pub(crate) async fn start_or_resume_run( Ok(Json(response)) } + +pub(crate) async fn get_problem( + State(state): State, + user: CurrentUser, + Path((room_id, problem_id)): Path<(String, String)>, +) -> Result, AppError> { + let room_id = + Uuid::parse_str(&room_id).map_err(|_| AppError::bad_request("invalid room_id"))?; + + let problem_id = + Uuid::parse_str(&problem_id).map_err(|_| AppError::bad_request("invalid problem_id"))?; + + let run = state + .auth_repository + .find_active_run(user.user_id, room_id) + .await? + .ok_or(AppError::RunNotFound)?; + + let problem = state + .auth_repository + .find_problem_for_run(run.id, room_id, problem_id) + .await? + .ok_or_else(|| AppError::not_found("problem not found"))?; + + if problem.status == "locked" { + return Err(AppError::ProblemLocked); + } + + let response = build_problem_response(problem, state.asset_url_resolver.as_ref())?; + + Ok(Json(response)) +} diff --git a/server/src/lib.rs b/server/src/lib.rs index a0241b9..8cdc6ce 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -5,6 +5,7 @@ use axum::{ routing::{get, post}, }; use config::AuthMode; +use problem::{AssetUrlResolver, UnconfiguredAssetUrlResolver}; use repository::AuthRepository; use sqlx::MySqlPool; use tower_http::trace::TraceLayer; @@ -37,6 +38,7 @@ pub const OPENAPI_DOCUMENT: &str = include_str!(concat!(env!("OUT_DIR"), "/opena pub struct AppState { pub(crate) auth_mode: AuthMode, pub(crate) auth_repository: Arc, + pub(crate) asset_url_resolver: Arc, } impl AppState { @@ -45,8 +47,18 @@ impl AppState { Self { auth_mode, auth_repository, + asset_url_resolver: Arc::new(UnconfiguredAssetUrlResolver), } } + + #[must_use] + pub fn with_asset_url_resolver( + mut self, + asset_url_resolver: Arc, + ) -> Self { + self.asset_url_resolver = asset_url_resolver; + self + } } pub fn app(state: AppState) -> Router { @@ -75,6 +87,10 @@ pub fn app(state: AppState) -> Router { "/api/rooms/{room_id}/runs", post(handler::start_or_resume_run).fallback(handler::method_not_allowed), ) + .route( + "/api/rooms/{room_id}/problems/{problem_id}", + get(handler::get_problem).fallback(handler::method_not_allowed), + ) .fallback(handler::not_found) .layer(TraceLayer::new_for_http()) .with_state(state) diff --git a/server/src/problem.rs b/server/src/problem.rs index 9e7f4e2..fdf8dcb 100644 --- a/server/src/problem.rs +++ b/server/src/problem.rs @@ -1,5 +1,7 @@ +mod asset_url; mod loader; mod model; +mod public; mod seeder; mod validation; @@ -7,11 +9,14 @@ use std::{io, path::PathBuf}; use thiserror::Error; +pub(crate) use asset_url::UnconfiguredAssetUrlResolver; +pub use asset_url::{AssetUrlResolveError, AssetUrlResolver}; pub use loader::load_problem_data; pub use model::{ Asset, InputSchema, JudgeConfig, Operation, Problem, ProblemCatalog, ProblemType, Room, SubmissionType, }; +pub use public::{ProblemProjectionError, build_problem_response}; pub use seeder::{ProblemSeedError, SeedSummary, seed_problem_data}; #[derive(Debug, Error)] diff --git a/server/src/problem/asset_url.rs b/server/src/problem/asset_url.rs new file mode 100644 index 0000000..7b598ea --- /dev/null +++ b/server/src/problem/asset_url.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +#[error("asset URL could not be resolved")] +pub struct AssetUrlResolveError; + +pub trait AssetUrlResolver: Send + Sync { + fn resolve(&self, object_key: &str) -> Result; +} + +pub(crate) struct UnconfiguredAssetUrlResolver; + +impl AssetUrlResolver for UnconfiguredAssetUrlResolver { + fn resolve(&self, _object_key: &str) -> Result { + Err(AssetUrlResolveError) + } +} diff --git a/server/src/problem/public.rs b/server/src/problem/public.rs new file mode 100644 index 0000000..3834d0e --- /dev/null +++ b/server/src/problem/public.rs @@ -0,0 +1,255 @@ +use openapi_generated::models::{ + AnswerInputSchema as PublicAnswerInputSchema, Asset as PublicAsset, + ProblemInputSchema as PublicProblemInputSchema, ProblemResponse, + ProblemStatus as PublicProblemStatus, ProblemType as PublicProblemType, + QueryInputSchema as PublicQueryInputSchema, SubmissionType as PublicSubmissionType, +}; +use thiserror::Error; + +use super::{AssetUrlResolveError, AssetUrlResolver}; +use crate::repository::ProblemDetailRecord; + +#[derive(Debug, Error)] +pub enum ProblemProjectionError { + #[error("stored problem field is invalid: {field}")] + InvalidStoredField { field: &'static str }, + + #[error(transparent)] + AssetUrl(#[from] AssetUrlResolveError), +} + +pub fn build_problem_response( + record: ProblemDetailRecord, + asset_url_resolver: &dyn AssetUrlResolver, +) -> Result { + let problem_type = match record.problem_type.as_str() { + "small" => PublicProblemType::Small, + "final" => PublicProblemType::Final, + _ => { + return Err(ProblemProjectionError::InvalidStoredField { + field: "problem_type", + }); + } + }; + + let submission_type = match record.submission_type.as_str() { + "operation_sequence" => PublicSubmissionType::OperationSequence, + "string" => PublicSubmissionType::String, + _ => { + return Err(ProblemProjectionError::InvalidStoredField { + field: "submission_type", + }); + } + }; + + let status = match record.status.as_str() { + "locked" => PublicProblemStatus::Locked, + "available" => PublicProblemStatus::Available, + "cleared" => PublicProblemStatus::Cleared, + _ => { + return Err(ProblemProjectionError::InvalidStoredField { field: "status" }); + } + }; + + let assets = record + .assets + .0 + .into_iter() + .map(|asset| { + let url = asset_url_resolver.resolve(&asset.object_key)?; + + Ok(PublicAsset::new(asset.asset_type, url, asset.alt)) + }) + .collect::, ProblemProjectionError>>()?; + + let input_schema = record.input_schema.0; + + let query = PublicQueryInputSchema::new( + "operation_sequence".to_owned(), + input_schema.query.allowed_controls, + input_schema.query.max_operations, + ); + + let answer = PublicAnswerInputSchema::new("string".to_owned(), input_schema.answer.max_length); + + let input_schema = PublicProblemInputSchema::new(query, answer); + + let hint_count = i32::try_from(record.hint_count).map_err(|_| { + ProblemProjectionError::InvalidStoredField { + field: "hint_count", + } + })?; + + if hint_count < 0 { + return Err(ProblemProjectionError::InvalidStoredField { + field: "hint_count", + }); + } + + Ok(ProblemResponse::new( + record.id, + record.number, + problem_type, + record.title, + record.body_markdown, + submission_type, + assets, + status, + input_schema, + hint_count, + )) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use sqlx::types::Json; + use uuid::Uuid; + + use super::{ProblemProjectionError, build_problem_response}; + use crate::{ + problem::{ + AssetUrlResolveError, AssetUrlResolver, + model::{ + AnswerInputSchema, AnswerInputType, Asset, InputSchema, QueryInputSchema, + QueryInputType, + }, + }, + repository::ProblemDetailRecord, + }; + + #[derive(Default)] + struct RecordingAssetUrlResolver { + object_keys: Mutex>, + } + + impl AssetUrlResolver for RecordingAssetUrlResolver { + fn resolve(&self, object_key: &str) -> Result { + self.object_keys + .lock() + .expect("resolver call log should not be poisoned") + .push(object_key.to_owned()); + + Ok("/assets/problems/birthday.png".to_owned()) + } + } + + struct FailingAssetUrlResolver; + + impl AssetUrlResolver for FailingAssetUrlResolver { + fn resolve(&self, _object_key: &str) -> Result { + Err(AssetUrlResolveError) + } + } + + fn valid_record() -> ProblemDetailRecord { + ProblemDetailRecord { + id: Uuid::parse_str("22222222-2222-4222-8222-222222222221") + .expect("fixture problem ID should be valid"), + number: 1, + problem_type: "small".to_owned(), + title: "生年月日".to_owned(), + body_markdown: "問題文です".to_owned(), + submission_type: "operation_sequence".to_owned(), + assets: Json(vec![Asset { + asset_type: "image".to_owned(), + object_key: "private/problem-assets/birthday.png".to_owned(), + alt: "問題資料".to_owned(), + }]), + input_schema: Json(InputSchema { + query: QueryInputSchema { + input_type: QueryInputType::OperationSequence, + allowed_controls: vec!["down".to_owned(), "right".to_owned(), "up".to_owned()], + max_operations: 100, + }, + answer: AnswerInputSchema { + input_type: AnswerInputType::String, + max_length: 50, + }, + }), + status: "available".to_owned(), + hint_count: 2, + } + } + + #[test] + fn public_problem_matches_openapi_fixture() { + let resolver = RecordingAssetUrlResolver::default(); + + let response = build_problem_response(valid_record(), &resolver) + .expect("valid problem should be projected"); + + let actual = serde_json::to_value(response).expect("response should serialize"); + + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../../openapi/examples/problems/available-response.json" + )) + .expect("OpenAPI fixture should be valid JSON"); + + assert_eq!(actual, expected); + + assert_eq!( + *resolver + .object_keys + .lock() + .expect("resolver call log should not be poisoned"), + vec!["private/problem-assets/birthday.png".to_owned()], + ); + } + + #[test] + fn invalid_stored_type_does_not_expose_its_value() { + let resolver = RecordingAssetUrlResolver::default(); + let forbidden_value = "private-invalid-problem-type"; + let mut record = valid_record(); + record.problem_type = forbidden_value.to_owned(); + + let error = build_problem_response(record, &resolver) + .expect_err("invalid problem type should be rejected"); + + assert!(matches!( + error, + ProblemProjectionError::InvalidStoredField { + field: "problem_type" + } + )); + assert!(!error.to_string().contains(forbidden_value)); + assert!(!format!("{error:?}").contains(forbidden_value)); + } + + #[test] + fn invalid_hint_counts_are_rejected() { + let resolver = RecordingAssetUrlResolver::default(); + + for invalid_hint_count in [-1, i64::from(i32::MAX) + 1] { + let mut record = valid_record(); + record.hint_count = invalid_hint_count; + + let error = build_problem_response(record, &resolver) + .expect_err("invalid hint count should be rejected"); + + assert!(matches!( + error, + ProblemProjectionError::InvalidStoredField { + field: "hint_count" + } + )); + } + } + + #[test] + fn resolver_error_does_not_expose_object_key() { + let forbidden_object_key = "private/problem-assets/do-not-log.png"; + + let mut record = valid_record(); + record.assets.0[0].object_key = forbidden_object_key.to_owned(); + + let error = build_problem_response(record, &FailingAssetUrlResolver) + .expect_err("resolver failure should be returned"); + + assert!(matches!(error, ProblemProjectionError::AssetUrl(_))); + assert!(!error.to_string().contains(forbidden_object_key)); + assert!(!format!("{error:?}").contains(forbidden_object_key)); + } +} diff --git a/server/src/repository.rs b/server/src/repository.rs index 6e7d471..c34f8c6 100644 --- a/server/src/repository.rs +++ b/server/src/repository.rs @@ -1,6 +1,9 @@ -use crate::game_progress::{ - ActiveRunState, ClearProblemError, ClearProblemPlan, ProblemState, ProblemStatus, - plan_problem_clear, +use crate::{ + game_progress::{ + ActiveRunState, ClearProblemError, ClearProblemPlan, ProblemState, ProblemStatus, + plan_problem_clear, + }, + problem::{Asset, InputSchema}, }; use async_trait::async_trait; use chrono::{DateTime, Utc}; @@ -113,6 +116,20 @@ pub struct ProblemProgressRecord { pub cleared_at: Option>, } +#[derive(Clone, Debug, Eq, PartialEq, FromRow)] +pub struct ProblemDetailRecord { + pub id: Uuid, + pub number: i32, + pub problem_type: String, + pub title: String, + pub body_markdown: String, + pub submission_type: String, + pub assets: sqlx::types::Json>, + pub input_schema: sqlx::types::Json, + pub status: String, + pub hint_count: i64, +} + #[cfg_attr( not(test), expect( @@ -191,6 +208,15 @@ pub trait AuthRepository: Send + Sync { unimplemented!("find_active_run is not implemented for this repository") } + async fn find_problem_for_run( + &self, + _run_id: Uuid, + _room_id: Uuid, + _problem_id: Uuid, + ) -> Result, RepositoryError> { + unimplemented!("find_problem_for_run is not implemented for this repository") + } + async fn create_run( &self, _id: Uuid, @@ -367,6 +393,42 @@ impl AuthRepository for SqlxUserRepository { .map_err(RepositoryError::Database) } + async fn find_problem_for_run( + &self, + run_id: Uuid, + room_id: Uuid, + problem_id: Uuid, + ) -> Result, RepositoryError> { + sqlx::query_as::<_, ProblemDetailRecord>( + r#" + SELECT + problems.problem_id AS id, + problems.number, + problems.problem_type, + problems.title, + problems.body_markdown, + problems.submission_type, + problems.assets, + problems.input_schema, + problem_progress.status, + CAST(JSON_LENGTH(problems.hints) AS SIGNED) AS hint_count + FROM problems + INNER JOIN problem_progress + ON problem_progress.problem_id = problems.problem_id + AND problem_progress.run_id = ? + WHERE problems.room_id = ? + AND problems.problem_id = ? + LIMIT 1 + "#, + ) + .bind(run_id) + .bind(room_id) + .bind(problem_id) + .fetch_optional(&self.pool) + .await + .map_err(RepositoryError::Database) + } + async fn create_run( &self, id: Uuid, diff --git a/server/tests/api.rs b/server/tests/api.rs index 824e8d5..e225f9d 100644 --- a/server/tests/api.rs +++ b/server/tests/api.rs @@ -18,13 +18,16 @@ use server::{ AppState, app, config::AuthMode, migrate, + problem::{Asset, AssetUrlResolveError, AssetUrlResolver, InputSchema}, repository::{ - AuthRepository, AuthUserRecord, RepositoryError, RoomRecord, RunRecord, SqlxUserRepository, + AuthRepository, AuthUserRecord, ProblemDetailRecord, RepositoryError, RoomRecord, + RunRecord, SqlxUserRepository, }, }; use sqlx::{ MySqlPool, mysql::{MySqlConnectOptions, MySqlPoolOptions}, + types::Json, }; use tower::ServiceExt; use uuid::Uuid; @@ -34,6 +37,54 @@ const MOCK_RESUME_ROOM_ID: &str = "11111111-1111-4111-8111-111111111111"; const MOCK_NEW_ROOM_ID: &str = "33333333-3333-4333-8333-333333333333"; const MOCK_CLEARED_ROOM_ID: &str = "44444444-4444-4444-8444-444444444444"; const MOCK_CLEARED_PROBLEM_ID: &str = "22222222-2222-4222-8222-222222222221"; +const MOCK_LOCKED_PROBLEM_ID: &str = "22222222-2222-4222-8222-222222222222"; +const MOCK_CLEARED_DETAIL_PROBLEM_ID: &str = "22222222-2222-4222-8222-222222222223"; +const MOCK_DATABASE_ERROR_PROBLEM_ID: &str = "22222222-2222-4222-8222-222222222224"; + +fn problem_detail_record(id: Uuid, status: &str) -> ProblemDetailRecord { + ProblemDetailRecord { + id, + number: 1, + problem_type: "small".to_owned(), + title: "生年月日".to_owned(), + body_markdown: "問題文です".to_owned(), + submission_type: "operation_sequence".to_owned(), + assets: Json(vec![Asset { + asset_type: "image".to_owned(), + object_key: "private/problem-assets/birthday.png".to_owned(), + alt: "問題資料".to_owned(), + }]), + input_schema: Json( + serde_json::from_value::(json!({ + "query": { + "type": "operation_sequence", + "allowed_controls": ["down", "right", "up"], + "max_operations": 100 + }, + "answer": { + "type": "string", + "max_length": 50 + } + })) + .expect("problem input schema should be valid"), + ), + status: status.to_owned(), + hint_count: 2, + } +} + +struct StubAssetUrlResolver; + +impl AssetUrlResolver for StubAssetUrlResolver { + fn resolve(&self, object_key: &str) -> Result { + assert_eq!( + object_key, "private/problem-assets/birthday.png", + "expected object key should be passed to the resolver", + ); + + Ok("/assets/problems/birthday.png".to_owned()) + } +} struct StubAuthRepository; @@ -162,6 +213,40 @@ impl AuthRepository for StubAuthRepository { Ok(vec![]) } } + + async fn find_problem_for_run( + &self, + run_id: Uuid, + room_id: Uuid, + problem_id: Uuid, + ) -> Result, RepositoryError> { + let active_run_id = Uuid::from_str(MOCK_RESUME_ROOM_ID).unwrap(); + + if run_id != active_run_id || room_id != active_run_id { + return Ok(None); + } + + let available_id = Uuid::from_str(MOCK_CLEARED_PROBLEM_ID).unwrap(); + let locked_id = Uuid::from_str(MOCK_LOCKED_PROBLEM_ID).unwrap(); + let cleared_id = Uuid::from_str(MOCK_CLEARED_DETAIL_PROBLEM_ID).unwrap(); + let database_error_id = Uuid::from_str(MOCK_DATABASE_ERROR_PROBLEM_ID).unwrap(); + + if problem_id == database_error_id { + return Err(RepositoryError::Database(sqlx::Error::Protocol( + "simulated private database failure".to_owned(), + ))); + } + + if problem_id == available_id { + Ok(Some(problem_detail_record(problem_id, "available"))) + } else if problem_id == locked_id { + Ok(Some(problem_detail_record(problem_id, "locked"))) + } else if problem_id == cleared_id { + Ok(Some(problem_detail_record(problem_id, "cleared"))) + } else { + Ok(None) + } + } } #[derive(Default)] @@ -886,6 +971,11 @@ fn test_app() -> Router { app(AppState::new(AuthMode::Demo, Arc::new(StubAuthRepository))) } +fn problem_test_app() -> Router { + app(AppState::new(AuthMode::Demo, Arc::new(StubAuthRepository)) + .with_asset_url_resolver(Arc::new(StubAssetUrlResolver))) +} + async fn request(app: &Router, request: Request) -> axum::response::Response { app.clone().oneshot(request).await.unwrap() } @@ -1239,3 +1329,477 @@ async fn start_run_already_cleared_returns_409() { assert_eq!(body["error"]["code"], "CONFLICT"); assert_eq!(body["error"]["message"], "room already cleared"); } + +#[tokio::test] +async fn get_available_problem_matches_openapi_fixture() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/{MOCK_RESUME_ROOM_ID}/problems/{MOCK_CLEARED_PROBLEM_ID}" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let actual: serde_json::Value = body_json(response).await; + + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../openapi/examples/problems/available-response.json" + )) + .expect("OpenAPI fixture should be valid JSON"); + + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn get_cleared_problem_succeeds() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/{MOCK_RESUME_ROOM_ID}/problems/{MOCK_CLEARED_DETAIL_PROBLEM_ID}" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let body: serde_json::Value = body_json(response).await; + + assert_eq!(body["id"], MOCK_CLEARED_DETAIL_PROBLEM_ID); + assert_eq!(body["status"], "cleared"); +} + +#[tokio::test] +async fn get_locked_problem_matches_openapi_fixture() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/{MOCK_RESUME_ROOM_ID}/problems/{MOCK_LOCKED_PROBLEM_ID}" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let actual: serde_json::Value = body_json(response).await; + + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../openapi/examples/problems/error-problem-locked.json" + )) + .expect("OpenAPI fixture should be valid JSON"); + + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn get_missing_problem_returns_404() { + let app = problem_test_app(); + let missing_problem_id = "99999999-9999-4999-8999-999999999999"; + + let req = Request::get(format!( + "/api/rooms/{MOCK_RESUME_ROOM_ID}/problems/{missing_problem_id}" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let body: serde_json::Value = body_json(response).await; + + assert_eq!(body["error"]["code"], "NOT_FOUND"); + assert_eq!(body["error"]["message"], "problem not found"); + assert_eq!(body["error"]["details"], json!({})); +} + +#[tokio::test] +async fn get_problem_unauthorized_matches_openapi_fixture() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/{MOCK_RESUME_ROOM_ID}/problems/{MOCK_CLEARED_PROBLEM_ID}" + )) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let actual: serde_json::Value = body_json(response).await; + + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../openapi/examples/auth/error-unauthorized.json" + )) + .expect("OpenAPI fixture should be valid JSON"); + + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn get_problem_without_active_run_matches_openapi_fixture() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/{MOCK_NEW_ROOM_ID}/problems/{MOCK_CLEARED_PROBLEM_ID}" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let actual: serde_json::Value = body_json(response).await; + + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../openapi/examples/runs/error-run-not-found.json" + )) + .expect("OpenAPI fixture should be valid JSON"); + + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn get_problem_invalid_room_id_returns_400() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/not-a-uuid/problems/{MOCK_CLEARED_PROBLEM_ID}" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let body: serde_json::Value = body_json(response).await; + + assert_eq!(body["error"]["code"], "BAD_REQUEST"); + assert_eq!(body["error"]["message"], "invalid room_id"); + assert_eq!(body["error"]["details"], json!({})); +} + +#[tokio::test] +async fn get_problem_invalid_problem_id_returns_400() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/{MOCK_RESUME_ROOM_ID}/problems/not-a-uuid" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let body: serde_json::Value = body_json(response).await; + + assert_eq!(body["error"]["code"], "BAD_REQUEST"); + assert_eq!(body["error"]["message"], "invalid problem_id"); + assert_eq!(body["error"]["details"], json!({})); +} + +#[tokio::test] +async fn get_problem_repository_error_returns_500_without_details() { + let app = problem_test_app(); + + let req = Request::get(format!( + "/api/rooms/{MOCK_RESUME_ROOM_ID}/problems/{MOCK_DATABASE_ERROR_PROBLEM_ID}" + )) + .header(header::COOKIE, format!("demo_session={MOCK_SESSION_ID}")) + .body(Body::empty()) + .unwrap(); + + let response = request(&app, req).await; + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json"); + + let body = body_bytes(response).await; + let body_text = std::str::from_utf8(&body).expect("response body should be UTF-8"); + + assert!( + !body_text.contains("simulated private database failure"), + "database error details must not be exposed" + ); + + let body: serde_json::Value = + serde_json::from_slice(&body).expect("response body should be valid JSON"); + + assert_eq!(body["error"]["details"], json!({})); +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to a disposable MariaDB database"] +async fn mariadb_problem_detail_repository_is_scoped_to_run_and_room() { + let pool = connect_test_database().await; + + migrate(&pool).await.expect("migration should succeed"); + + let user_id = Uuid::new_v4(); + let room_id = Uuid::new_v4(); + let problem_id = Uuid::new_v4(); + let run_id = Uuid::new_v4(); + + let room_number = (room_id.as_u128() % 2_000_000_000) as i32 + 1; + let provider_subject = format!("problem-detail-test-{user_id}"); + + sqlx::query( + r#" + INSERT INTO users ( + user_id, + auth_provider, + provider_subject, + display_name + ) + VALUES (?, 'demo', ?, 'problem-detail-test-user') + "#, + ) + .bind(user_id) + .bind(&provider_subject) + .execute(&pool) + .await + .expect("test user should be inserted"); + + sqlx::query( + r#" + INSERT INTO rooms ( + room_id, + number, + name, + genre, + description, + is_published + ) + VALUES ( + ?, ?, 'problem-detail-test-room', + 'test', 'problem detail repository test', 1 + ) + "#, + ) + .bind(room_id) + .bind(room_number) + .execute(&pool) + .await + .expect("test room should be inserted"); + + sqlx::query( + r#" + INSERT INTO problems ( + problem_id, + room_id, + number, + problem_type, + title, + body_markdown, + submission_type, + assets, + input_schema, + hints, + judge_config, + depends_on_problem_id, + is_required + ) + VALUES ( + ?, ?, 1, 'small', 'MariaDB test problem', + 'MariaDBから取得する問題文です', + 'operation_sequence', + ?, ?, ?, ?, NULL, 1 + ) + "#, + ) + .bind(problem_id) + .bind(room_id) + .bind(Json(json!([ + { + "type": "image", + "object_key": "private/problem-assets/mariadb-test.png", + "alt": "MariaDBテスト画像" + } + ]))) + .bind(Json(json!({ + "query": { + "type": "operation_sequence", + "allowed_controls": ["up", "down"], + "max_operations": 20 + }, + "answer": { + "type": "string", + "max_length": 40 + } + }))) + .bind(Json(json!([ + { + "body_markdown": "非公開ヒント1" + }, + { + "body_markdown": "非公開ヒント2" + } + ]))) + .bind(Json(json!({ + "type": "operation_sequence", + "correct_operations": [ + { + "control": "up", + "count": 1 + } + ], + "candidates": [] + }))) + .execute(&pool) + .await + .expect("test problem should be inserted"); + + sqlx::query( + r#" + INSERT INTO runs ( + run_id, + user_id, + room_id, + status, + started_at, + cleared_at + ) + VALUES (?, ?, ?, 'active', CURRENT_TIMESTAMP(3), NULL) + "#, + ) + .bind(run_id) + .bind(user_id) + .bind(room_id) + .execute(&pool) + .await + .expect("test run should be inserted"); + + sqlx::query( + r#" + INSERT INTO problem_progress ( + run_id, + problem_id, + status, + answer_attempt_count, + cleared_at + ) + VALUES (?, ?, 'available', 0, NULL) + "#, + ) + .bind(run_id) + .bind(problem_id) + .execute(&pool) + .await + .expect("test problem progress should be inserted"); + + let repository = SqlxUserRepository::new(pool.clone()); + + let record = repository + .find_problem_for_run(run_id, room_id, problem_id) + .await + .expect("problem lookup should succeed") + .expect("problem should be found for the active run"); + + assert_eq!(record.id, problem_id); + assert_eq!(record.number, 1); + assert_eq!(record.problem_type, "small"); + assert_eq!(record.title, "MariaDB test problem"); + assert_eq!(record.body_markdown, "MariaDBから取得する問題文です"); + assert_eq!(record.submission_type, "operation_sequence"); + assert_eq!(record.status, "available"); + assert_eq!(record.hint_count, 2); + + assert_eq!(record.assets.0.len(), 1); + assert_eq!(record.assets.0[0].asset_type, "image"); + assert_eq!( + record.assets.0[0].object_key, + "private/problem-assets/mariadb-test.png" + ); + assert_eq!(record.assets.0[0].alt, "MariaDBテスト画像"); + + let input_schema = + serde_json::to_value(&record.input_schema.0).expect("input schema should serialize"); + + assert_eq!(input_schema["query"]["type"], "operation_sequence"); + assert_eq!(input_schema["query"]["max_operations"], 20); + assert_eq!(input_schema["answer"]["type"], "string"); + assert_eq!(input_schema["answer"]["max_length"], 40); + + let wrong_run = repository + .find_problem_for_run(Uuid::new_v4(), room_id, problem_id) + .await + .expect("lookup with another run should succeed"); + + assert!( + wrong_run.is_none(), + "problem must not be returned for another run" + ); + + let wrong_room = repository + .find_problem_for_run(run_id, Uuid::new_v4(), problem_id) + .await + .expect("lookup with another room should succeed"); + + assert!( + wrong_room.is_none(), + "problem must not be returned for another room" + ); + + let wrong_problem = repository + .find_problem_for_run(run_id, room_id, Uuid::new_v4()) + .await + .expect("lookup with another problem should succeed"); + + assert!( + wrong_problem.is_none(), + "unknown problem must not be returned" + ); + + sqlx::query("DELETE FROM runs WHERE run_id = ?") + .bind(run_id) + .execute(&pool) + .await + .expect("test run should be removed"); + + sqlx::query("DELETE FROM problems WHERE problem_id = ?") + .bind(problem_id) + .execute(&pool) + .await + .expect("test problem should be removed"); + + sqlx::query("DELETE FROM rooms WHERE room_id = ?") + .bind(room_id) + .execute(&pool) + .await + .expect("test room should be removed"); + + sqlx::query("DELETE FROM users WHERE user_id = ?") + .bind(user_id) + .execute(&pool) + .await + .expect("test user should be removed"); + + pool.close().await; +}