新增聊天室成就系统与消息保留策略
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户成就系统功能测试。
|
||||
*
|
||||
* 覆盖固定成就扫描、重复扫描幂等性与本人可见通知。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\Message;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\UserCurrencyLog;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证成就扫描服务和 Artisan 命令的核心行为。
|
||||
*/
|
||||
class AchievementServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 每个测试前清空 Redis 房间状态。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->flushChatRoomRedisState();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试命令可以按现有日志解锁聊天、签到与游戏成就。
|
||||
*/
|
||||
public function test_scan_command_unlocks_achievements_from_existing_logs(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'ach']);
|
||||
$user = User::factory()->create([
|
||||
'username' => 'achiever',
|
||||
'room_id' => $room->id,
|
||||
'jjb' => 700000,
|
||||
'bank_jjb' => 300000,
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '成就聊天',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now()->subMinutes($i + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
DailySignIn::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'room_id' => $room->id,
|
||||
'sign_in_date' => today(),
|
||||
'streak_days' => 7,
|
||||
'gold_reward' => 10,
|
||||
'exp_reward' => 20,
|
||||
'charm_reward' => 0,
|
||||
]);
|
||||
|
||||
UserCurrencyLog::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'currency' => 'gold',
|
||||
'amount' => 1000,
|
||||
'balance_after' => 1000,
|
||||
'source' => CurrencySource::GAME_REWARD->value,
|
||||
'remark' => '猜谜奖励',
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
UserCurrencyLog::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'currency' => 'gold',
|
||||
'amount' => -1000,
|
||||
'balance_after' => 0,
|
||||
'source' => CurrencySource::BACCARAT_BET->value,
|
||||
'remark' => '百家乐下注',
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$this->artisan('achievements:scan', ['--user' => $user->username])
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_first_message',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_100_messages',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'signin_7_streak',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'game_riddle_win',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'game_win_1000',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'game_loss_1000',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'growth_assets_1000000',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievement_progress', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_100_messages',
|
||||
'progress_value' => 100,
|
||||
'threshold_value' => 100,
|
||||
]);
|
||||
|
||||
$this->assertNotNull(UserAchievement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('achievement_key', 'chat_100_messages')
|
||||
->value('achieved_at'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试重复扫描不会重复创建同一个用户成就。
|
||||
*/
|
||||
public function test_scan_command_is_idempotent_for_same_achievement(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'idem']);
|
||||
$user = User::factory()->create(['username' => 'idem_user', 'room_id' => $room->id]);
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '第一条',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
|
||||
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
|
||||
|
||||
$this->assertSame(1, UserAchievement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('achievement_key', 'chat_first_message')
|
||||
->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试成就解锁通知只发给本人并带悄悄话标记。
|
||||
*/
|
||||
public function test_unlock_notification_is_private_to_the_user(): void
|
||||
{
|
||||
Queue::fake([SaveMessageJob::class]);
|
||||
|
||||
$room = Room::create(['room_name' => 'notice']);
|
||||
$user = User::factory()->create(['username' => 'notice_user', 'room_id' => $room->id]);
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '第一条',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
app(AchievementService::class)->scanUser($user, notify: true);
|
||||
|
||||
$messages = collect(Redis::lrange("room:{$room->id}:messages", 0, -1))
|
||||
->map(fn (string $item): array => json_decode($item, true));
|
||||
$notice = $messages->first(fn (array $item): bool => ($item['action'] ?? '') === 'achievement_unlocked');
|
||||
|
||||
$this->assertNotNull($notice);
|
||||
$this->assertSame($user->username, $notice['to_user'] ?? null);
|
||||
$this->assertTrue((bool) ($notice['is_secret'] ?? false));
|
||||
$this->assertSame(Message::RETENTION_SYSTEM_NOTICE, $notice['retention_type'] ?? null);
|
||||
Queue::assertPushed(SaveMessageJob::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试打开我的成就页面时会静默补算已达标成就。
|
||||
*/
|
||||
public function test_achievement_page_silently_unlocks_reached_achievements(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'page']);
|
||||
$user = User::factory()->create(['username' => 'page_user', 'room_id' => $room->id]);
|
||||
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '页面触发补算',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_first_message',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('achievements.index'))
|
||||
->assertOk()
|
||||
->assertSee('已解锁 1 /', false);
|
||||
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_first_message',
|
||||
]);
|
||||
|
||||
$this->assertNotNull(UserAchievement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('achievement_key', 'chat_first_message')
|
||||
->value('achieved_at'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试我的成就页面可以按已完成和未达成筛选。
|
||||
*/
|
||||
public function test_achievement_page_can_filter_by_unlocked_and_locked_tabs(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'tabs']);
|
||||
$user = User::factory()->create(['username' => 'tab_user', 'room_id' => $room->id]);
|
||||
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '筛选测试',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('achievements.index', ['status' => 'unlocked']))
|
||||
->assertOk()
|
||||
->assertSee('已完成')
|
||||
->assertSee('初来乍到')
|
||||
->assertDontSee('百句达人');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('achievements.index', ['status' => 'locked']))
|
||||
->assertOk()
|
||||
->assertSee('未达成')
|
||||
->assertSee('百句达人')
|
||||
->assertDontSee('初来乍到');
|
||||
}
|
||||
}
|
||||
@@ -854,6 +854,84 @@ class ChatControllerTest extends TestCase
|
||||
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试消息清理命令会永久保留普通用户聊天,只删除过期游戏/临时通知。
|
||||
*/
|
||||
public function test_purge_command_keeps_user_chat_but_deletes_expired_notices(): void
|
||||
{
|
||||
$userMessage = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => 'normal_user',
|
||||
'to_user' => '大家',
|
||||
'content' => '这条用户聊天需要永久保留',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
$gameNotice = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => '过期游戏通知',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_GAME_NOTICE,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
$temporaryNotice = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => '进出播报',
|
||||
'to_user' => '大家',
|
||||
'content' => '过期进出播报',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => 'system_welcome',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_EPHEMERAL_NOTICE,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
$this->artisan('messages:purge', [
|
||||
'--days' => 30,
|
||||
'--image-days' => 3,
|
||||
])->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseHas('messages', ['id' => $userMessage->id]);
|
||||
$this->assertDatabaseMissing('messages', ['id' => $gameNotice->id]);
|
||||
$this->assertDatabaseMissing('messages', ['id' => $temporaryNotice->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试迁移前默认归为 user_chat 的旧游戏通知也会被清理。
|
||||
*/
|
||||
public function test_purge_command_deletes_legacy_game_notice_patterns(): void
|
||||
{
|
||||
$legacyNotice = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => '钓鱼播报',
|
||||
'to_user' => '大家',
|
||||
'content' => '旧钓鱼通知',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => 'fishing_result',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
$this->artisan('messages:purge', [
|
||||
'--days' => 30,
|
||||
'--image-days' => 3,
|
||||
])->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('messages', ['id' => $legacyNotice->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试心跳接口可以正常返回成功响应。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user