@@ -55,6 +55,8 @@ fn init_with_admin(env: &Env) -> (Address, Address, StellarHuntsClient) {
5555#[ test]
5656fn test_set_question_per_level_admin_only ( ) {
5757 let env = Env :: default ( ) ;
58+ env. mock_all_auths ( ) ;
59+ let ( _admin, client) = init_with_admin ( & env) ;
5860 let ( admin, contract_address, client) = init_with_admin ( & env) ;
5961
6062 env. mock_auths ( & [ MockAuth {
@@ -94,6 +96,8 @@ fn test_set_question_per_level_unauthorized() {
9496#[ test]
9597fn test_add_and_get_question ( ) {
9698 let env = Env :: default ( ) ;
99+ env. mock_all_auths ( ) ;
100+ let ( _admin, client) = init_with_admin ( & env) ;
97101 let ( admin, contract_address, client) = init_with_admin ( & env) ;
98102
99103 let level = crate :: Levels :: Easy ;
@@ -420,6 +424,87 @@ fn test_require_admin_not_initialized() {
420424 client. set_question_per_level ( & 5u32 ) ;
421425}
422426
427+ #[ test]
428+ fn test_claim_level_completion_nft_retry_safe_on_nft_panic( ) {
429+ let env = Env :: default ( ) ;
430+ env. mock_all_auths ( ) ;
431+
432+ let admin = new_admin ( & env) ;
433+ let contract_id = env. register_contract ( None , StellarHunts ) ;
434+ let client = StellarHuntsClient :: new ( & env, & contract_id) ;
435+ client. init ( & admin) ;
436+
437+ let player = user ( & env) ;
438+
439+ // Register and initialise the NFT contract, granting the game
440+ // contract the minter role.
441+ let nft_id = env. register_contract ( None , stellar_hunts_nft:: StellarHuntsNft ) ;
442+ let nft_client =
443+ stellar_hunts_nft:: StellarHuntsNftClient :: new ( & env, & nft_id) ;
444+ nft_client. init (
445+ & admin,
446+ & contract_id,
447+ & soroban_sdk:: String :: from_str ( & env, "ipfs://placeholder/" ) ,
448+ & soroban_sdk:: String :: from_str ( & env, "StellarHuntsBadge" ) ,
449+ & soroban_sdk:: String :: from_str ( & env, "SHB" ) ,
450+ ) ;
451+
452+ // Wire the game contract to the NFT contract.
453+ client. set_nft_contract_address ( & nft_id) ;
454+
455+ // Setup: 1 question per level so the player can complete Easy quickly.
456+ client. set_question_per_level ( & 1u32 ) ;
457+ let level = crate :: Levels :: Easy ;
458+ client. add_question ( & level, & b ( & env, "Q?" ) , & b ( & env, "A" ) , & b ( & env, "H" ) ) ;
459+
460+ // Player completes Easy level.
461+ assert ! ( client. submit_answer( & player, & 1u64 , & b( & env, "A" ) ) ) ;
462+
463+ // ---- First mint: success ----
464+ client. claim_level_completion_nft ( & player, & level) ;
465+ assert ! ( nft_client. has_level_badge( & player, & level) ) ;
466+
467+ // Verify the game contract recorded the mint.
468+ let lp = client. get_player_level_progress ( & player, & level) ;
469+ assert ! ( lp. nft_minted) ;
470+
471+ // ---- Simulate out-of-sync state ----
472+ // The NFT contract still holds the badge, but we reset the game
473+ // contract's nft_minted flag as if a previous cross-contract call
474+ // was interrupted before the storage write.
475+ env. as_contract ( & contract_id, || {
476+ let lp_key = crate :: DataKey :: PlayerLevelProgress ( player. clone ( ) , level. clone ( ) ) ;
477+ let mut lp: crate :: LevelProgress =
478+ env. storage ( ) . persistent ( ) . get ( & lp_key) . unwrap ( ) ;
479+ lp. nft_minted = false ;
480+ env. storage ( ) . persistent ( ) . set ( & lp_key, & lp) ;
481+ } ) ;
482+
483+ // Confirm the flag was reset.
484+ let lp_reset = client. get_player_level_progress ( & player, & level) ;
485+ assert ! ( !lp_reset. nft_minted) ;
486+
487+ // ---- Second mint attempt: should panic ----
488+ // The game contract sees nft_minted == false and proceeds to call
489+ // the NFT contract, which already has the badge -> AlreadyHasBadge.
490+ let should_panic = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
491+ client. claim_level_completion_nft ( & player, & level) ;
492+ } ) ) ;
493+ assert ! (
494+ should_panic. is_err( ) ,
495+ "expected AlreadyHasBadge panic from NFT contract"
496+ ) ;
497+
498+ // ---- Retry-safe assertion ----
499+ // Because the game contract writes lp.nft_minted = true AFTER the
500+ // cross-contract call, a panic in the NFT contract means the write
501+ // never executes. The flag must remain false so the player (or an
502+ // off-chain retry loop) can safely retry the claim.
503+ let lp_final = client. get_player_level_progress ( & player, & level) ;
504+ assert ! (
505+ !lp_final. nft_minted,
506+ "nft_minted must remain false so claim_level_completion_nft is retry-safe"
507+ ) ;
423508// ---------------------------------------------------------------------
424509// Summary of negative-auth coverage added:
425510// • test_set_question_per_level_unauthorized
0 commit comments