create(); $activeItem = ShopItem::create([ 'name' => 'Active', 'slug' => 'active_item', 'type' => 'one_time', 'price' => 100, 'is_active' => true, ]); $inactiveItem = ShopItem::create([ 'name' => 'Inactive', 'slug' => 'inactive_item', 'type' => 'one_time', 'price' => 100, 'is_active' => false, ]); $response = $this->actingAs($user)->getJson(route('shop.items')); $response->assertStatus(200); $responseItems = collect($response->json('items')); $this->assertTrue($responseItems->contains('id', $activeItem->id)); $this->assertFalse($responseItems->contains('id', $inactiveItem->id)); } /** * 测试商店商品列表会包含新增的特效单次卡与周卡。 */ public function test_items_include_new_effect_shop_cards(): void { $user = User::factory()->create(); $response = $this->actingAs($user)->getJson(route('shop.items')); $response->assertOk(); $response->assertJsonFragment(['slug' => 'once_meteors']); $response->assertJsonFragment(['slug' => 'once_gold-rain']); $response->assertJsonFragment(['slug' => 'once_hearts']); $response->assertJsonFragment(['slug' => 'once_confetti']); $response->assertJsonFragment(['slug' => 'once_fireflies']); $response->assertJsonFragment(['slug' => 'once_sakura']); $response->assertJsonFragment(['slug' => 'week_sakura']); $response->assertJsonFragment(['slug' => 'week_meteors']); $response->assertJsonFragment(['slug' => 'week_gold-rain']); $response->assertJsonFragment(['slug' => 'week_hearts']); $response->assertJsonFragment(['slug' => 'week_confetti']); $response->assertJsonFragment(['slug' => 'week_fireflies']); } /** * 测试一次性道具可以正常购买。 */ public function test_can_buy_one_time_item() { $user = User::factory()->create(['jjb' => 500]); $room = Room::create(['room_name' => '购买房']); $this->joinRoom($user, $room); $item = ShopItem::firstOrCreate( ['slug' => 'rename_card_test'], [ 'name' => 'Rename Card Test', 'type' => 'one_time', 'price' => 100, 'is_active' => true, ]); $response = $this->actingAs($user)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); $this->assertDatabaseHas('user_purchases', [ 'user_id' => $user->id, 'shop_item_id' => $item->id, ]); $this->assertDatabaseHas('users', [ 'id' => $user->id, 'jjb' => 400, ]); } /** * 测试余额不足时不能购买商品。 */ public function test_cannot_buy_if_insufficient_funds() { $user = User::factory()->create(['jjb' => 50]); $room = Room::create(['room_name' => '余额不足房']); $this->joinRoom($user, $room); $item = ShopItem::firstOrCreate( ['slug' => 'rename_card_test'], [ 'name' => 'Rename Card Test', 'type' => 'one_time', 'price' => 100, 'is_active' => true, ]); $response = $this->actingAs($user)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, ]); $response->assertStatus(400); $response->assertJson(['status' => 'error']); $this->assertDatabaseMissing('user_purchases', [ 'user_id' => $user->id, 'shop_item_id' => $item->id, ]); } /** * 测试已下架商品不能购买。 */ public function test_cannot_buy_inactive_item() { $user = User::factory()->create(['jjb' => 500]); $room = Room::create(['room_name' => '下架商品房']); $this->joinRoom($user, $room); $item = ShopItem::create([ 'name' => 'Old Card', 'slug' => 'old_card', 'type' => 'one_time', 'price' => 100, 'is_active' => false, ]); $response = $this->actingAs($user)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, ]); $response->assertStatus(400); $this->assertDatabaseMissing('user_purchases', [ 'user_id' => $user->id, ]); } /** * 测试改名卡可以被正常使用。 */ public function test_can_use_rename_card() { $user = User::factory()->create(['username' => 'OldName']); // Actually the service hardcodes 'rename_card' slug check: $item->slug === 'rename_card' // So we MUST use 'rename_card' $item = ShopItem::firstOrCreate( ['slug' => 'rename_card'], [ 'name' => 'Rename Card', 'type' => 'one_time', 'price' => 100, 'is_active' => true, ]); UserPurchase::create([ 'user_id' => $user->id, 'shop_item_id' => $item->id, 'status' => 'active', 'used_at' => null, 'cost_amount' => 100, 'currency_type' => 'gold', ]); $response = $this->actingAs($user)->postJson(route('shop.rename'), [ 'new_name' => 'NewName', ]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); // Assert user's name is updated $this->assertDatabaseHas('users', [ 'id' => $user->id, 'username' => 'NewName', ]); // Assert card is used $this->assertDatabaseHas('user_purchases', [ 'user_id' => $user->id, 'shop_item_id' => $item->id, 'status' => 'used', ]); } /** * 测试购买自动钓鱼卡时会以钓鱼播报身份广播,便于前端屏蔽规则命中。 */ public function test_buy_auto_fishing_card_broadcasts_as_fishing_sender(): void { Event::fake([MessageSent::class]); $user = User::factory()->create(['jjb' => 500]); $room = Room::create(['room_name' => '自动钓鱼房']); $this->joinRoom($user, $room); $item = ShopItem::create([ 'name' => '自动钓鱼卡(2小时)', 'slug' => 'auto_fishing_test_2h', 'type' => 'auto_fishing', 'price' => 100, 'duration_minutes' => 120, 'is_active' => true, ]); $response = $this->actingAs($user)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, ]); $response->assertOk(); $response->assertJson(['status' => 'success']); Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($user, $item, $room): bool { return $event->roomId === $room->id && ($event->message['from_user'] ?? null) === '钓鱼播报' && str_contains((string) ($event->message['content'] ?? ''), $user->username) && str_contains((string) ($event->message['content'] ?? ''), $item->name); }); } /** * 测试指定接收人购买单次特效时,购买者本端仍会拿到播放指令,且广播会带上接收人与操作者信息。 */ public function test_buy_instant_effect_for_recipient_returns_local_play_and_broadcasts_targeted_event(): void { Event::fake([EffectBroadcast::class]); $buyer = User::factory()->create([ 'username' => 'buyer-user', 'jjb' => 5000, ]); $room = Room::create(['room_name' => '指定特效房']); $this->joinRoom($buyer, $room); $recipient = User::factory()->create([ 'username' => 'receiver-user', ]); $item = ShopItem::create([ 'name' => '烟花单次卡', 'slug' => 'once_fireworks_targeted', 'type' => 'instant', 'price' => 888, 'icon' => '🎆', 'is_active' => true, ]); $response = $this->actingAs($buyer)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, 'recipient' => $recipient->username, 'message' => '送你一场烟花', ]); $response->assertOk(); $response->assertJson([ 'status' => 'success', 'play_effect' => $item->effectKey(), 'target_username' => $recipient->username, 'gift_message' => '送你一场烟花', ]); Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($buyer, $recipient, $item, $room): bool { return $event->roomId === $room->id && $event->type === $item->effectKey() && $event->operator === $buyer->username && $event->targetUsername === $recipient->username && $event->giftMessage === '送你一场烟花' && $event->broadcastWith()['operator'] === $buyer->username; }); } /** * 测试定向特效的目标用户名作为标识字段时保持原始值,避免前端比较失败。 */ public function test_buy_instant_effect_keeps_raw_target_username_identifier(): void { Event::fake([EffectBroadcast::class]); $buyer = User::factory()->create([ 'username' => 'buyer-user-raw', 'jjb' => 5000, ]); $room = Room::create(['room_name' => '原始目标名房']); $this->joinRoom($buyer, $room); $recipient = User::factory()->create([ 'username' => 'receiver&user', ]); $item = ShopItem::create([ 'name' => '烟花单次卡', 'slug' => 'once_fireworks_raw_target', 'type' => 'instant', 'price' => 888, 'is_active' => true, ]); $response = $this->actingAs($buyer)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, 'recipient' => $recipient->username, ]); $response->assertOk() ->assertJsonPath('target_username', $recipient->username); Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($recipient): bool { return $event->targetUsername === $recipient->username; }); } /** * 测试购买特效时缺少房间 ID 会被拒绝,避免广播到 room.0。 */ public function test_buy_instant_effect_requires_room_id(): void { $buyer = User::factory()->create([ 'jjb' => 5000, ]); $item = ShopItem::create([ 'name' => '烟花单次卡', 'slug' => 'once_fireworks_missing_room', 'type' => 'instant', 'price' => 888, 'is_active' => true, ]); $response = $this->actingAs($buyer)->postJson(route('shop.buy'), [ 'item_id' => $item->id, ]); $response->assertStatus(422); } /** * 测试购买特效的赠言会转义后再进入广播载荷。 */ public function test_buy_instant_effect_escapes_gift_message(): void { Event::fake([EffectBroadcast::class, MessageSent::class]); $buyer = User::factory()->create([ 'jjb' => 5000, ]); $room = Room::create(['room_name' => '赠言安全房']); $this->joinRoom($buyer, $room); $item = ShopItem::create([ 'name' => '烟花单次卡', 'slug' => 'once_fireworks_safe_message', 'type' => 'instant', 'price' => 888, 'is_active' => true, ]); $response = $this->actingAs($buyer)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, 'recipient' => 'all', 'message' => '', ]); $response->assertOk() ->assertJsonPath('gift_message', '<img src=x onerror=alert(1)>'); Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event): bool { return $event->giftMessage === '<img src=x onerror=alert(1)>'; }); Event::assertDispatched(MessageSent::class, function (MessageSent $event): bool { return str_contains((string) ($event->message['content'] ?? ''), '<img src=x onerror=alert(1)>') && ! str_contains((string) ($event->message['content'] ?? ''), ''); }); } /** * 测试购买签到补签卡会扣金币并生成可用背包记录。 */ public function test_buy_sign_repair_card_creates_active_purchase(): void { $user = User::factory()->create(['jjb' => 12000]); $room = Room::create(['room_name' => '补签卡房']); $this->joinRoom($user, $room); $item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail(); $item->update([ 'price' => 10000, 'is_active' => true, ]); $response = $this->actingAs($user)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, ]); $response->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('jjb', 2000); $this->assertDatabaseHas('user_purchases', [ 'user_id' => $user->id, 'shop_item_id' => $item->id, 'status' => 'active', 'price_paid' => 10000, ]); } /** * 测试补签卡支持一次购买多张。 */ public function test_buy_sign_repair_card_supports_quantity(): void { $user = User::factory()->create(['jjb' => 35000]); $room = Room::create(['room_name' => '多张补签卡房']); $this->joinRoom($user, $room); $item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail(); $item->update([ 'price' => 10000, 'is_active' => true, ]); $response = $this->actingAs($user)->postJson(route('shop.buy'), [ 'item_id' => $item->id, 'room_id' => $room->id, 'quantity' => 3, ]); $response->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('quantity', 3) ->assertJsonPath('total_price', 30000) ->assertJsonPath('jjb', 5000); $this->assertSame(3, UserPurchase::query() ->where('user_id', $user->id) ->where('shop_item_id', $item->id) ->where('status', 'active') ->count()); } /** * 将测试用户写入指定房间在线表,模拟已经进入聊天室的状态。 */ private function joinRoom(User $user, Room $room): void { Redis::hset("room:{$room->id}:users", $user->username, json_encode([ 'id' => $user->id, 'username' => $user->username, ], JSON_UNESCAPED_UNICODE)); } }