flushChatRoomRedisState();
}
/**
* 测试商品列表接口只返回上架商品。
*/
public function test_items_returns_active_shop_items()
{
$user = User::factory()->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));
}
}