use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use module_combat::{ BattleMode, BattleStateInput, ResolveCombatActionInput, generate_battle_state_id, }; use module_npc::{NPC_FIGHT_FUNCTION_ID, NPC_SPAR_FUNCTION_ID, ResolveNpcInteractionInput}; use serde_json::{Value, json}; use shared_contracts::story::{ CreateStoryBattleRequest, CreateStoryNpcBattleRequest, CreateStoryNpcBattleResponse, ResolveStoryBattleRequest, ResolveStoryBattleResponse, StoryBattleRewardItemPayload, StoryBattleRewardItemRequest, StoryBattleStatePayload, StoryBattleStateResponse, StoryCombatActionPayload, StoryNpcInteractionPayload, StoryNpcStanceProfilePayload, StoryNpcStatePayload, }; use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; use spacetime_client::{ResolveNpcBattleInteractionInput, SpacetimeClientError}; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn create_story_battle( 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 battle_mode = parse_battle_mode_strict(&payload.battle_mode).ok_or_else(|| { story_battles_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-battle", "message": "battleMode 仅支持 fight 或 spar", })), ) })?; let reward_items = parse_story_battle_reward_items(&payload.reward_items).map_err(|message| { story_battles_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-battle", "message": message, })), ) })?; let story_state = state .spacetime_client() .get_story_session_state(payload.story_session_id.clone()) .await .map_err(|error| { story_battles_error_response(&request_context, map_story_battle_client_error(error)) })?; require_story_session_owner_for_battle( &request_context, &story_state.session.actor_user_id, &actor_user_id, )?; require_story_session_runtime_for_battle( &request_context, &story_state.session.runtime_session_id, &payload.runtime_session_id, )?; let result = state .spacetime_client() .create_battle_state(BattleStateInput { battle_state_id: generate_battle_state_id(now_micros), story_session_id: payload.story_session_id, runtime_session_id: payload.runtime_session_id, actor_user_id, chapter_id: payload.chapter_id, target_npc_id: payload.target_npc_id, target_name: payload.target_name, battle_mode, player_hp: payload.player_hp, player_max_hp: payload.player_max_hp, player_mana: payload.player_mana, player_max_mana: payload.player_max_mana, target_hp: payload.target_hp, target_max_hp: payload.target_max_hp, experience_reward: payload.experience_reward, reward_items, created_at_micros: now_micros, }) .await .map_err(|error| { story_battles_error_response(&request_context, map_story_battle_client_error(error)) })?; Ok(json_success_body( Some(&request_context), StoryBattleStateResponse { battle_state: build_battle_state_payload(&result), }, )) } pub async fn resolve_story_battle( 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 battle_state_id = payload.battle_state_id; let current_battle = state .spacetime_client() .get_battle_state(battle_state_id.clone()) .await .map_err(|error| { story_battles_error_response(&request_context, map_story_battle_client_error(error)) })?; require_story_battle_owner( &request_context, ¤t_battle.actor_user_id, &actor_user_id, )?; let result = state .spacetime_client() .resolve_combat_action(ResolveCombatActionInput { battle_state_id, function_id: payload.function_id, action_text: payload.action_text, base_damage: payload.base_damage, mana_cost: payload.mana_cost, heal: payload.heal, mana_restore: payload.mana_restore, counter_multiplier_basis_points: payload.counter_multiplier_basis_points, updated_at_micros: now_micros, }) .await .map_err(|error| { story_battles_error_response(&request_context, map_story_battle_client_error(error)) })?; Ok(json_success_body( Some(&request_context), ResolveStoryBattleResponse { battle_state: build_battle_state_payload(&result.battle_state), combat: StoryCombatActionPayload { damage_dealt: result.damage_dealt, damage_taken: result.damage_taken, outcome: result.outcome, }, }, )) } pub async fn get_story_battle_state( State(state): State, Path(battle_state_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_battle_state(battle_state_id) .await .map_err(|error| { story_battles_error_response(&request_context, map_story_battle_client_error(error)) })?; require_story_battle_owner(&request_context, &result.actor_user_id, &actor_user_id)?; Ok(json_success_body( Some(&request_context), StoryBattleStateResponse { battle_state: build_battle_state_payload(&result), }, )) } pub async fn create_story_npc_battle( 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 interaction_function_id = parse_npc_battle_interaction_function_id_strict(&payload.interaction_function_id) .ok_or_else(|| { story_battles_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-npc-battle", "message": "interactionFunctionId 仅支持 npc_fight 或 npc_spar", })), ) })?; let reward_items = parse_story_battle_reward_items(&payload.reward_items).map_err(|message| { story_battles_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-npc-battle", "message": message, })), ) })?; let story_state = state .spacetime_client() .get_story_session_state(payload.story_session_id.clone()) .await .map_err(|error| { story_battles_error_response(&request_context, map_story_battle_client_error(error)) })?; require_story_session_owner_for_battle( &request_context, &story_state.session.actor_user_id, &actor_user_id, )?; require_story_session_runtime_for_battle( &request_context, &story_state.session.runtime_session_id, &payload.runtime_session_id, )?; let result = state .spacetime_client() .resolve_npc_battle_interaction(ResolveNpcBattleInteractionInput { npc_interaction: ResolveNpcInteractionInput { runtime_session_id: payload.runtime_session_id, npc_id: payload.npc_id, npc_name: payload.npc_name, interaction_function_id, release_npc_id: payload.release_npc_id, updated_at_micros: now_micros, }, story_session_id: payload.story_session_id, actor_user_id, battle_state_id: payload.battle_state_id, player_hp: payload.player_hp, player_max_hp: payload.player_max_hp, player_mana: payload.player_mana, player_max_mana: payload.player_max_mana, target_hp: payload.target_hp, target_max_hp: payload.target_max_hp, experience_reward: payload.experience_reward, reward_items, }) .await .map_err(|error| { story_battles_error_response(&request_context, map_story_battle_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CreateStoryNpcBattleResponse { npc_interaction: build_npc_interaction_payload(&result.npc_interaction), battle_state: build_battle_state_payload(&result.battle_state), }, )) } fn build_battle_state_payload( record: &spacetime_client::BattleStateRecord, ) -> StoryBattleStatePayload { StoryBattleStatePayload { battle_state_id: record.battle_state_id.clone(), story_session_id: record.story_session_id.clone(), runtime_session_id: record.runtime_session_id.clone(), actor_user_id: record.actor_user_id.clone(), chapter_id: record.chapter_id.clone(), target_npc_id: record.target_npc_id.clone(), target_name: record.target_name.clone(), battle_mode: record.battle_mode.clone(), status: record.status.clone(), player_hp: record.player_hp, player_max_hp: record.player_max_hp, player_mana: record.player_mana, player_max_mana: record.player_max_mana, target_hp: record.target_hp, target_max_hp: record.target_max_hp, experience_reward: record.experience_reward, reward_items: record .reward_items .iter() .map(build_battle_reward_item_payload) .collect(), turn_index: record.turn_index, last_action_function_id: record.last_action_function_id.clone(), last_action_text: record.last_action_text.clone(), last_result_text: record.last_result_text.clone(), last_damage_dealt: record.last_damage_dealt, last_damage_taken: record.last_damage_taken, last_outcome: record.last_outcome.clone(), version: record.version, created_at: record.created_at.clone(), updated_at: record.updated_at.clone(), } } fn build_battle_reward_item_payload( item: &module_runtime_item::RuntimeItemRewardItemSnapshot, ) -> StoryBattleRewardItemPayload { StoryBattleRewardItemPayload { item_id: item.item_id.clone(), category: item.category.clone(), item_name: item.item_name.clone(), description: item.description.clone(), quantity: item.quantity, rarity: format_runtime_item_reward_item_rarity(item.rarity).to_string(), tags: item.tags.clone(), stackable: item.stackable, stack_key: item.stack_key.clone(), equipment_slot_id: item .equipment_slot_id .map(format_runtime_item_equipment_slot) .map(ToOwned::to_owned), } } fn format_runtime_item_reward_item_rarity( value: module_runtime_item::RuntimeItemRewardItemRarity, ) -> &'static str { match value { module_runtime_item::RuntimeItemRewardItemRarity::Common => "common", module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => "uncommon", module_runtime_item::RuntimeItemRewardItemRarity::Rare => "rare", module_runtime_item::RuntimeItemRewardItemRarity::Epic => "epic", module_runtime_item::RuntimeItemRewardItemRarity::Legendary => "legendary", } } fn format_runtime_item_equipment_slot( value: module_runtime_item::RuntimeItemEquipmentSlot, ) -> &'static str { match value { module_runtime_item::RuntimeItemEquipmentSlot::Weapon => "weapon", module_runtime_item::RuntimeItemEquipmentSlot::Armor => "armor", module_runtime_item::RuntimeItemEquipmentSlot::Relic => "relic", } } fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> StoryNpcStatePayload { StoryNpcStatePayload { npc_state_id: record.npc_state_id.clone(), runtime_session_id: record.runtime_session_id.clone(), npc_id: record.npc_id.clone(), npc_name: record.npc_name.clone(), affinity: record.affinity, relation_stance: record.relation_stance.clone(), help_used: record.help_used, chatted_count: record.chatted_count, gifts_given: record.gifts_given, recruited: record.recruited, trade_stock_signature: record.trade_stock_signature.clone(), revealed_facts: record.revealed_facts.clone(), known_attribute_rumors: record.known_attribute_rumors.clone(), first_meaningful_contact_resolved: record.first_meaningful_contact_resolved, seen_backstory_chapter_ids: record.seen_backstory_chapter_ids.clone(), stance_profile: StoryNpcStanceProfilePayload { trust: record.trust, warmth: record.warmth, ideological_fit: record.ideological_fit, fear_or_guard: record.fear_or_guard, loyalty: record.loyalty, current_conflict_tag: record.current_conflict_tag.clone(), recent_approvals: record.recent_approvals.clone(), recent_disapprovals: record.recent_disapprovals.clone(), }, created_at: record.created_at.clone(), updated_at: record.updated_at.clone(), } } fn build_npc_interaction_payload( record: &spacetime_client::NpcInteractionRecord, ) -> StoryNpcInteractionPayload { StoryNpcInteractionPayload { npc_state: build_npc_state_payload(&record.npc_state), interaction_status: record.interaction_status.clone(), action_text: record.action_text.clone(), result_text: record.result_text.clone(), story_text: record.story_text.clone(), battle_mode: record.battle_mode.clone(), encounter_closed: record.encounter_closed, affinity_changed: record.affinity_changed, previous_affinity: record.previous_affinity, next_affinity: record.next_affinity, } } fn parse_battle_mode_strict(raw: &str) -> Option { match raw.trim() { "fight" => Some(BattleMode::Fight), "spar" => Some(BattleMode::Spar), _ => None, } } fn parse_npc_battle_interaction_function_id_strict(raw: &str) -> Option { match raw.trim() { NPC_FIGHT_FUNCTION_ID => Some(NPC_FIGHT_FUNCTION_ID.to_string()), NPC_SPAR_FUNCTION_ID => Some(NPC_SPAR_FUNCTION_ID.to_string()), _ => None, } } fn parse_story_battle_reward_items( values: &[StoryBattleRewardItemRequest], ) -> Result, String> { values.iter().map(parse_story_battle_reward_item).collect() } fn parse_story_battle_reward_item( value: &StoryBattleRewardItemRequest, ) -> Result { Ok(module_runtime_item::RuntimeItemRewardItemSnapshot { item_id: normalize_required_string(&value.item_id) .ok_or_else(|| "battleState.rewardItems[].itemId 不能为空".to_string())?, category: normalize_required_string(&value.category) .ok_or_else(|| "battleState.rewardItems[].category 不能为空".to_string())?, item_name: normalize_required_string(&value.item_name) .ok_or_else(|| "battleState.rewardItems[].itemName 不能为空".to_string())?, description: normalize_optional_string(value.description.clone()), quantity: value.quantity, rarity: parse_runtime_item_reward_item_rarity(&value.rarity)?, tags: normalize_string_list(value.tags.clone()), stackable: value.stackable, stack_key: value.stack_key.trim().to_string(), equipment_slot_id: value .equipment_slot_id .as_deref() .map(parse_runtime_item_equipment_slot) .transpose()?, }) } fn parse_runtime_item_reward_item_rarity( raw: &str, ) -> Result { match raw.trim() { "common" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Common), "uncommon" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Uncommon), "rare" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Rare), "epic" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Epic), "legendary" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Legendary), _ => Err( "battleState.rewardItems[].rarity 仅支持 common/uncommon/rare/epic/legendary" .to_string(), ), } } fn parse_runtime_item_equipment_slot( raw: &str, ) -> Result { match raw.trim() { "weapon" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Weapon), "armor" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Armor), "relic" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Relic), _ => Err("battleState.rewardItems[].equipmentSlotId 仅支持 weapon/armor/relic".to_string()), } } fn map_story_battle_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_battles_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } fn require_story_session_owner_for_battle( request_context: &RequestContext, resource_actor_user_id: &str, authenticated_actor_user_id: &str, ) -> Result<(), Response> { require_resource_owner( request_context, resource_actor_user_id, authenticated_actor_user_id, "story-session", "story session 不属于当前用户,不能创建战斗", ) } fn require_story_battle_owner( request_context: &RequestContext, resource_actor_user_id: &str, authenticated_actor_user_id: &str, ) -> Result<(), Response> { require_resource_owner( request_context, resource_actor_user_id, authenticated_actor_user_id, "story-battle", "battle state 不属于当前用户", ) } fn require_story_session_runtime_for_battle( request_context: &RequestContext, session_runtime_id: &str, requested_runtime_id: &str, ) -> Result<(), Response> { if session_runtime_id == requested_runtime_id { return Ok(()); } Err(story_battles_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "story-session", "message": "runtimeSessionId 与 story session 不匹配,不能创建战斗", })), )) } fn require_resource_owner( request_context: &RequestContext, resource_actor_user_id: &str, authenticated_actor_user_id: &str, provider: &'static str, message: &'static str, ) -> Result<(), Response> { if resource_actor_user_id == authenticated_actor_user_id { return Ok(()); } // API 层只做登录用户与资源属主的边界检查;战斗结算仍由 module-combat 与 SpacetimeDB 承担。 Err(story_battles_error_response( request_context, AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": provider, "message": message, })), )) } 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::{require_story_battle_owner, require_story_session_runtime_for_battle}; use crate::{ app::build_router, config::AppConfig, request_context::RequestContext, state::AppState, }; #[tokio::test] async fn create_story_battle_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/battles") .header("content-type", "application/json") .body(Body::from( json!({ "storySessionId": "storysess_001", "runtimeSessionId": "runtime_001", "targetNpcId": "npc_001", "targetName": "黑爪狼", "battleMode": "fight", "playerHp": 60, "playerMaxHp": 60, "playerMana": 20, "playerMaxMana": 20, "targetHp": 30, "targetMaxHp": 30 }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn create_story_npc_battle_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/npc/battle") .header("content-type", "application/json") .body(Body::from( json!({ "storySessionId": "storysess_001", "runtimeSessionId": "runtime_001", "npcId": "npc_001", "npcName": "试剑门徒", "interactionFunctionId": "npc_fight", "playerHp": 60, "playerMaxHp": 60, "playerMana": 20, "playerMaxMana": 20, "targetHp": 30, "targetMaxHp": 30 }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn create_story_battle_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/battles") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "storySessionId": "storysess_001", "runtimeSessionId": "runtime_001", "targetNpcId": "npc_001", "targetName": "黑爪狼", "battleMode": "fight", "playerHp": 60, "playerMaxHp": 60, "playerMana": 20, "playerMaxMana": 20, "targetHp": 30, "targetMaxHp": 30 }) .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 create_story_npc_battle_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/npc/battle") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "storySessionId": "storysess_001", "runtimeSessionId": "runtime_001", "npcId": "npc_001", "npcName": "试剑门徒", "interactionFunctionId": "npc_fight", "playerHp": 60, "playerMaxHp": 60, "playerMana": 20, "playerMaxMana": 20, "targetHp": 30, "targetMaxHp": 30 }) .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_battle_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/battles/battle_001") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn resolve_story_battle_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/battles/resolve") .header("content-type", "application/json") .body(Body::from( json!({ "battleStateId": "battle_001", "functionId": "battle_attack_basic", "actionText": "普通攻击", "baseDamage": 10, "manaCost": 0, "heal": 0, "manaRestore": 0, "counterMultiplierBasisPoints": 10000 }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_story_battle_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/battles/battle_001") .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 resolve_story_battle_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/battles/resolve") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "battleStateId": "battle_001", "functionId": "battle_attack_basic", "actionText": "普通攻击", "baseDamage": 10, "manaCost": 0, "heal": 0, "manaRestore": 0, "counterMultiplierBasisPoints": 10000 }) .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()) ); } #[test] fn story_battle_owner_guard_rejects_mismatched_actor() { let context = RequestContext::new( "req_story_battle_owner_guard".to_string(), "GET /api/story/battles/battle_001".to_string(), Duration::ZERO, true, ); let response = require_story_battle_owner(&context, "user_owner", "user_other") .expect_err("mismatched actor should be forbidden"); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[test] fn story_battle_owner_guard_accepts_matching_actor() { let context = RequestContext::new( "req_story_battle_owner_guard".to_string(), "GET /api/story/battles/battle_001".to_string(), Duration::ZERO, true, ); require_story_battle_owner(&context, "user_owner", "user_owner") .expect("matching actor should pass"); } #[test] fn story_battle_runtime_guard_rejects_mismatched_runtime_session() { let context = RequestContext::new( "req_story_battle_runtime_guard".to_string(), "POST /api/story/battles".to_string(), Duration::ZERO, true, ); let response = require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_other") .expect_err("mismatched runtime session should be bad request"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[test] fn story_battle_runtime_guard_accepts_matching_runtime_session() { let context = RequestContext::new( "req_story_battle_runtime_guard".to_string(), "POST /api/story/battles".to_string(), Duration::ZERO, true, ); require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_owner") .expect("matching runtime session 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("13800138107", "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: "sess_story_battles".to_string(), 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") } }