use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use module_runtime::RuntimeSnapshotRecord; use serde_json::{Value, json}; use shared_contracts::story::{ BeginStoryRuntimeSessionRequest, BeginStorySessionRequest, ContinueStoryRequest, ResolveStoryRuntimeActionRequest, StoryEventPayload, StoryRuntimeMutationResponse, StoryRuntimeSnapshotPayload, StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse, }; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn begin_story_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let now_micros = current_utc_micros(); let actor_user_id = authenticated.claims().user_id().to_string(); let result = state .spacetime_client() .begin_story_session( module_story::generate_story_session_id(now_micros), payload.runtime_session_id, actor_user_id, payload.world_profile_id, payload.initial_prompt, payload.opening_summary, now_micros, ) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; Ok(json_success_body( Some(&request_context), StorySessionMutationResponse { story_session: story_session_payload_from_record(result.session), story_event: story_event_payload_from_record(result.event), }, )) } pub async fn begin_story_runtime_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let now_micros = current_utc_micros(); let actor_user_id = authenticated.claims().user_id().to_string(); let runtime_session_id = module_runtime_story::generate_runtime_session_id( actor_user_id.as_str(), payload.custom_world_profile.as_ref(), &payload.character, now_micros, ); let story_session_id = module_story::generate_story_session_id(now_micros); let built = module_runtime_story::build_runtime_story_bootstrap( &payload, module_runtime_story::RuntimeStoryBootstrapSeed { runtime_session_id, story_session_id, actor_user_id: actor_user_id.clone(), now_micros, }, ) .map_err(|message| { story_sessions_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-runtime", "message": message, })), ) })?; let story_result = state .spacetime_client() .begin_story_session( built.story_session_id.clone(), built.runtime_session_id.clone(), actor_user_id.clone(), built.world_profile_id, built.initial_prompt, built.opening_summary, now_micros, ) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; let persisted = persist_story_runtime_snapshot(&state, &request_context, actor_user_id, built.snapshot) .await?; Ok(json_success_body( Some(&request_context), StoryRuntimeMutationResponse { projection: build_story_runtime_projection_from_persisted( story_session_payload_from_record(story_result.session), vec![story_event_payload_from_record(story_result.event)], &persisted, None, ), }, )) } pub async fn continue_story( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let now_micros = current_utc_micros(); let actor_user_id = authenticated.claims().user_id().to_string(); let story_session_id = payload.story_session_id; let current_state = state .spacetime_client() .get_story_session_state(story_session_id.clone()) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; require_story_session_owner( &request_context, ¤t_state.session.actor_user_id, &actor_user_id, )?; let result = state .spacetime_client() .continue_story( story_session_id, module_story::generate_story_event_id(now_micros), payload.narrative_text, payload.choice_function_id, now_micros, ) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; Ok(json_success_body( Some(&request_context), StorySessionMutationResponse { story_session: story_session_payload_from_record(result.session), story_event: story_event_payload_from_record(result.event), }, )) } pub async fn resolve_story_runtime_action( State(state): State, Path(story_session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let now_micros = current_utc_micros(); let actor_user_id = authenticated.claims().user_id().to_string(); let story_session_id = validate_story_runtime_action_path( &request_context, story_session_id, payload.story_session_id.as_str(), )?; let story_state = state .spacetime_client() .get_story_session_state(story_session_id.clone()) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; require_story_session_owner( &request_context, &story_state.session.actor_user_id, &actor_user_id, )?; let snapshot_record = state .get_runtime_snapshot_record(actor_user_id.clone()) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })? .ok_or_else(|| { story_sessions_error_response( &request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "story-runtime", "message": "当前用户缺少 runtime snapshot", })), ) })?; let snapshot = story_runtime_snapshot_payload_from_record(&snapshot_record); validate_story_runtime_client_version( &request_context, payload.client_version, &snapshot.game_state, )?; let resolved = module_runtime_story::resolve_story_runtime_action( module_runtime_story::StoryRuntimeActionResolveInput { story_session_id: story_state.session.story_session_id.clone(), runtime_session_id: story_state.session.runtime_session_id.clone(), snapshot, request: payload, }, ) .map_err(|message| { story_sessions_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-runtime", "message": message, })), ) })?; let story_result = state .spacetime_client() .continue_story( story_state.session.story_session_id, module_story::generate_story_event_id(now_micros), resolved.narrative_text, resolved.choice_function_id, now_micros, ) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; let persisted = persist_story_runtime_snapshot(&state, &request_context, actor_user_id, resolved.snapshot) .await?; Ok(json_success_body( Some(&request_context), StoryRuntimeMutationResponse { projection: build_story_runtime_projection_from_persisted( story_session_payload_from_record(story_result.session), vec![story_event_payload_from_record(story_result.event)], &persisted, Some(resolved.server_version), ), }, )) } pub async fn get_story_session_state( State(state): State, Path(story_session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let actor_user_id = authenticated.claims().user_id().to_string(); let result = state .spacetime_client() .get_story_session_state(story_session_id) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; require_story_session_owner( &request_context, &result.session.actor_user_id, &actor_user_id, )?; Ok(json_success_body( Some(&request_context), StorySessionStateResponse { story_session: story_session_payload_from_record(result.session), story_events: result .events .into_iter() .map(story_event_payload_from_record) .collect(), }, )) } async fn persist_story_runtime_snapshot( state: &AppState, request_context: &RequestContext, user_id: String, snapshot: StoryRuntimeSnapshotPayload, ) -> Result { validate_story_runtime_snapshot_payload(&snapshot).map_err(|message| { story_sessions_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-runtime", "message": message, })), ) })?; let now = OffsetDateTime::now_utc(); let saved_at = snapshot .saved_at .as_deref() .and_then(module_runtime_story::normalize_required_string) .map(|value| parse_rfc3339(value.as_str())) .transpose() .map_err(|error| { story_sessions_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-runtime", "field": "snapshot.savedAt", "message": format!("savedAt 非法: {error}"), })), ) })? .unwrap_or(now); let saved_at_micros = offset_datetime_to_unix_micros(saved_at); let updated_at_micros = offset_datetime_to_unix_micros(now); let game_state = canonicalize_story_runtime_game_state(snapshot.game_state); state .put_runtime_snapshot_record( user_id, saved_at_micros, snapshot.bottom_tab, game_state, snapshot.current_story, updated_at_micros, ) .await .map_err(|error| { story_sessions_error_response(request_context, map_story_session_client_error(error)) }) } fn canonicalize_story_runtime_game_state(mut game_state: Value) -> Value { if let Some(root) = game_state.as_object_mut() { // 中文注释:NPC 交易 / 赠礼 view 是展示层投影,持久快照只保存可复算真相。 root.remove("runtimeNpcInteraction"); } game_state } fn validate_story_runtime_snapshot_payload( snapshot: &StoryRuntimeSnapshotPayload, ) -> Result<(), String> { if module_runtime_story::normalize_required_string(snapshot.bottom_tab.as_str()).is_none() { return Err("snapshot.bottomTab 不能为空".to_string()); } if !snapshot.game_state.is_object() { return Err("snapshot.gameState 必须是 JSON object".to_string()); } if snapshot .current_story .as_ref() .is_some_and(|current_story| !current_story.is_object()) { return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string()); } Ok(()) } fn story_runtime_snapshot_payload_from_record( record: &RuntimeSnapshotRecord, ) -> StoryRuntimeSnapshotPayload { let mut game_state = record.game_state.clone(); module_runtime_story::write_runtime_npc_interaction_view(&mut game_state); StoryRuntimeSnapshotPayload { saved_at: Some(record.saved_at.clone()), bottom_tab: record.bottom_tab.clone(), game_state, current_story: record.current_story.clone(), } } fn build_story_runtime_projection_from_persisted( story_session: StorySessionPayload, story_events: Vec, record: &RuntimeSnapshotRecord, resolved_version: Option, ) -> shared_contracts::story::StoryRuntimeProjectionResponse { let snapshot = story_runtime_snapshot_payload_from_record(record); let current_story = snapshot.current_story.as_ref(); let options = module_runtime_story::build_runtime_story_options(current_story, &snapshot.game_state); let current_narrative_text = read_story_runtime_current_text(current_story) .or_else(|| Some(story_session.latest_narrative_text.clone())); let action_result_text = read_story_runtime_current_field(current_story, "resultText"); let toast = read_story_runtime_current_field(current_story, "toast"); let server_version = resolve_story_runtime_projection_version(&snapshot.game_state, resolved_version); module_runtime_story::build_story_runtime_projection( module_runtime_story::StoryRuntimeProjectionSource { story_session, story_events, game_state: snapshot.game_state, options, server_version, current_narrative_text, action_result_text, toast, }, ) } fn resolve_story_runtime_projection_version( game_state: &Value, resolved_version: Option, ) -> u32 { module_runtime_story::read_u32_field(game_state, "runtimeActionVersion") .or(resolved_version) .unwrap_or(1) } fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option { read_story_runtime_current_field(current_story, "text") .or_else(|| read_story_runtime_current_field(current_story, "storyText")) } fn read_story_runtime_current_field(current_story: Option<&Value>, field: &str) -> Option { current_story? .as_object()? .get(field)? .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn validate_story_runtime_action_path( request_context: &RequestContext, path_story_session_id: String, body_story_session_id: &str, ) -> Result { let path_story_session_id = module_runtime_story::normalize_required_string(path_story_session_id.as_str()) .ok_or_else(|| { story_sessions_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-runtime", "message": "storySessionId 不能为空", })), ) })?; let body_story_session_id = module_runtime_story::normalize_required_string( body_story_session_id, ) .ok_or_else(|| { story_sessions_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-runtime", "message": "request.storySessionId 不能为空", })), ) })?; if path_story_session_id != body_story_session_id { return Err(story_sessions_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "story-runtime", "message": "path storySessionId 与 request.storySessionId 不一致", "pathStorySessionId": path_story_session_id, "requestStorySessionId": body_story_session_id, })), )); } Ok(path_story_session_id) } fn validate_story_runtime_client_version( request_context: &RequestContext, client_version: Option, game_state: &Value, ) -> Result<(), Response> { let Some(client_version) = client_version else { return Ok(()); }; let Some(server_version) = module_runtime_story::read_u32_field(game_state, "runtimeActionVersion") else { return Ok(()); }; if client_version == server_version { return Ok(()); } Err(story_sessions_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "story-runtime", "message": "运行时版本已变化,请先同步最新快照后再提交动作", "clientVersion": client_version, "serverVersion": server_version, })), )) } fn story_session_payload_from_record( record: module_story::StorySessionRecord, ) -> StorySessionPayload { StorySessionPayload { story_session_id: record.story_session_id, runtime_session_id: record.runtime_session_id, actor_user_id: record.actor_user_id, world_profile_id: record.world_profile_id, initial_prompt: record.initial_prompt, opening_summary: record.opening_summary, latest_narrative_text: record.latest_narrative_text, latest_choice_function_id: record.latest_choice_function_id, status: record.status, version: record.version, created_at: record.created_at, updated_at: record.updated_at, } } fn story_event_payload_from_record(record: module_story::StoryEventRecord) -> StoryEventPayload { StoryEventPayload { event_id: record.event_id, story_session_id: record.story_session_id, event_kind: record.event_kind, narrative_text: record.narrative_text, choice_function_id: record.choice_function_id, created_at: record.created_at, } } pub async fn get_story_runtime_projection( State(state): State, Path(story_session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let actor_user_id = authenticated.claims().user_id().to_string(); let source = state .spacetime_client() .get_story_runtime_projection_source(story_session_id, actor_user_id) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; Ok(json_success_body( Some(&request_context), module_runtime_story::build_story_runtime_projection(source), )) } fn map_story_session_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn story_sessions_error_response(request_context: &RequestContext, error: AppError) -> Response { // story session 路由需要保留 request_context,确保错误 envelope 与 requestId 一致。 error.into_response_with_context(Some(request_context)) } fn require_story_session_owner( request_context: &RequestContext, resource_actor_user_id: &str, authenticated_actor_user_id: &str, ) -> Result<(), Response> { if resource_actor_user_id == authenticated_actor_user_id { return Ok(()); } // 这里只做 HTTP 鉴权边界判断,story session 的推进规则仍由 SpacetimeDB 领域层处理。 Err(story_sessions_error_response( request_context, AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "story-session", "message": "story session 不属于当前用户", })), )) } fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } #[cfg(test)] mod tests { use std::time::Duration; 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, json}; use time::OffsetDateTime; use tower::ServiceExt; use super::{build_story_runtime_projection_from_persisted, require_story_session_owner}; use crate::{ app::build_router, config::AppConfig, request_context::RequestContext, state::AppState, }; use module_runtime::RuntimeSnapshotRecord; use shared_contracts::story::StorySessionPayload; #[tokio::test] async fn begin_story_session_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/story/sessions") .header("content-type", "application/json") .body(Body::from( json!({ "runtimeSessionId": "runtime_001", "worldProfileId": "profile_001", "initialPrompt": "进入营地", "openingSummary": "营地开场" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn begin_story_session_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("POST") .uri("/api/story/sessions") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "runtimeSessionId": "runtime_001", "worldProfileId": "profile_001", "initialPrompt": "进入营地", "openingSummary": "营地开场" }) .to_string(), )) .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()) ); } #[tokio::test] async fn continue_story_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/story/sessions/continue") .header("content-type", "application/json") .body(Body::from( json!({ "storySessionId": "storysess_001", "narrativeText": "你看见篝火边有人招手。", "choiceFunctionId": "talk_to_npc" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn begin_story_runtime_session_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/story/sessions/runtime") .header("content-type", "application/json") .body(Body::from( json!({ "worldType": "CUSTOM", "customWorldProfile": { "id": "profile_001" }, "character": { "id": "hero_001", "name": "沈砺" }, "runtimeMode": "play", "disablePersistence": false }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn begin_story_runtime_session_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("POST") .uri("/api/story/sessions/runtime") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "worldType": "CUSTOM", "customWorldProfile": { "id": "profile_001" }, "character": { "id": "hero_001", "name": "沈砺" }, "runtimeMode": "play", "disablePersistence": false }) .to_string(), )) .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()) ); } #[tokio::test] async fn resolve_story_runtime_action_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/story/sessions/storysess_001/actions/resolve") .header("content-type", "application/json") .body(Body::from( json!({ "storySessionId": "storysess_001", "clientVersion": 1, "functionId": "idle_observe_signs", "actionText": "观察周围迹象", "payload": { "optionText": "观察周围迹象" } }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn resolve_story_runtime_action_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("POST") .uri("/api/story/sessions/storysess_001/actions/resolve") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "storySessionId": "storysess_001", "clientVersion": 1, "functionId": "idle_observe_signs", "actionText": "观察周围迹象", "payload": { "optionText": "观察周围迹象" } }) .to_string(), )) .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()) ); } #[tokio::test] async fn continue_story_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("POST") .uri("/api/story/sessions/continue") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "storySessionId": "storysess_001", "narrativeText": "你看见篝火边有人招手。", "choiceFunctionId": "talk_to_npc" }) .to_string(), )) .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()) ); } #[tokio::test] async fn get_story_session_state_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/story/sessions/storysess_001/state") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_story_session_state_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/story/sessions/storysess_001/state") .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()) ); } #[tokio::test] async fn get_story_runtime_projection_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/story/sessions/storysess_001/runtime-projection") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_story_runtime_projection_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/story/sessions/storysess_001/runtime-projection") .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()) ); } #[test] fn story_runtime_projection_version_prefers_runtime_action_version() { let projection = build_story_runtime_projection_from_persisted( StorySessionPayload { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_1".to_string(), world_profile_id: "profile_1".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "最新故事".to_string(), latest_choice_function_id: Some("npc_chat".to_string()), status: "active".to_string(), version: 9, created_at: "1.000000Z".to_string(), updated_at: "3.000000Z".to_string(), }, vec![], &RuntimeSnapshotRecord { user_id: "user_1".to_string(), version: 2, saved_at: "3.000000Z".to_string(), saved_at_micros: 3, bottom_tab: "adventure".to_string(), game_state: json!({ "runtimeSessionId": "runtime_001", "runtimeActionVersion": 7, "playerHp": 30, "playerMaxHp": 40, "playerMana": 10, "playerMaxMana": 20, "playerCurrency": 0, "playerInventory": [], "playerEquipment": { "weapon": null, "armor": null, "relic": null }, "inBattle": false, "npcInteractionActive": false, "storyHistory": [] }), current_story: None, game_state_json: "{}".to_string(), current_story_json: None, created_at_micros: 1, updated_at_micros: 3, }, None, ); assert_eq!(projection.server_version, 7); } #[test] fn story_session_owner_guard_rejects_mismatched_actor() { let context = RequestContext::new( "req_story_owner_guard".to_string(), "GET /api/story/sessions/storysess_001/state".to_string(), Duration::ZERO, true, ); let response = require_story_session_owner(&context, "user_owner", "user_other") .expect_err("mismatched actor should be forbidden"); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[test] fn story_session_owner_guard_accepts_matching_actor() { let context = RequestContext::new( "req_story_owner_guard".to_string(), "GET /api/story/sessions/storysess_001/state".to_string(), Duration::ZERO, true, ); require_story_session_owner(&context, "user_owner", "user_owner") .expect("matching actor should pass"); } async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state .seed_test_phone_user_with_password("13800138108", "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_story_sessions"), 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") } }