use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use serde_json::{Value, json}; use shared_contracts::runtime::{RuntimeInventorySlotResponse, RuntimeInventoryStateResponse}; use spacetime_client::SpacetimeClientError; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn get_runtime_inventory_state( State(state): State, Path(runtime_session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let actor_user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_runtime_inventory_state(runtime_session_id, actor_user_id) .await .map_err(|error| { runtime_inventory_error_response( &request_context, map_runtime_inventory_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), RuntimeInventoryStateResponse { runtime_session_id: record.runtime_session_id, actor_user_id: record.actor_user_id, backpack_items: record .backpack_items .into_iter() .map(map_runtime_inventory_slot_response) .collect(), equipment_items: record .equipment_items .into_iter() .map(map_runtime_inventory_slot_response) .collect(), }, )) } fn map_runtime_inventory_slot_response( record: module_inventory::RuntimeInventorySlotRecord, ) -> RuntimeInventorySlotResponse { RuntimeInventorySlotResponse { slot_id: record.slot_id, container_kind: record.container_kind, slot_key: record.slot_key, item_id: record.item_id, category: record.category, name: record.name, description: record.description, quantity: record.quantity, rarity: record.rarity, tags: record.tags, stackable: record.stackable, stack_key: record.stack_key, equipment_slot_id: record.equipment_slot_id, source_kind: record.source_kind, source_reference_id: record.source_reference_id, created_at: record.created_at, updated_at: record.updated_at, } } fn map_runtime_inventory_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-inventory"), _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), })) } fn runtime_inventory_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } #[cfg(test)] mod tests { use axum::{ body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::Value; use time::OffsetDateTime; use tower::ServiceExt; use crate::{app::build_router, config::AppConfig, state::AppState}; #[tokio::test] async fn runtime_inventory_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/runtime/sessions/runtime_001/inventory") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn runtime_inventory_returns_bad_gateway_when_spacetime_not_published() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/runtime/sessions/runtime_001/inventory") .header("authorization", format!("Bearer {token}")) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(false)); assert_eq!( payload["error"]["details"]["provider"], Value::String("spacetimedb".to_string()) ); } async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state .seed_test_phone_user_with_password("13800138103", "secret123") .await .id; state } fn issue_access_token(state: &AppState) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), session_id: state.seed_test_refresh_session_for_user_id( "user_00000001", "sess_runtime_inventory", ), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("背包查询用户".to_string()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } }