diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php
index 6e29aac..0e8c5bc 100644
--- a/app/Http/Controllers/AuthController.php
+++ b/app/Http/Controllers/AuthController.php
@@ -39,7 +39,15 @@ class AuthController extends Controller
if ($user) {
// 用户存在,验证密码
- if (Hash::check($password, $user->password)) {
+ $passwordMatches = false;
+ try {
+ $passwordMatches = Hash::check($password, $user->password);
+ } catch (\RuntimeException $e) {
+ // Hash::check() in Laravel 11/12 throws if the hash isn't a valid bcrypt string
+ $passwordMatches = false;
+ }
+
+ if ($passwordMatches) {
// Bcrypt 验证通过
// 检测是否被封禁 (后台管理员级别获得豁免权,防止误把自己关在门外)
diff --git a/app/Http/Requests/StoreRoomRequest.php b/app/Http/Requests/StoreRoomRequest.php
index 0a0165f..e94d1a2 100644
--- a/app/Http/Requests/StoreRoomRequest.php
+++ b/app/Http/Requests/StoreRoomRequest.php
@@ -33,7 +33,7 @@ class StoreRoomRequest extends FormRequest
public function rules(): array
{
return [
- 'name' => ['required', 'string', 'max:50', 'unique:rooms,name'],
+ 'name' => ['required', 'string', 'max:50', 'unique:rooms,room_name'],
'description' => ['nullable', 'string', 'max:255'],
];
}
diff --git a/app/Http/Requests/UpdateRoomRequest.php b/app/Http/Requests/UpdateRoomRequest.php
index 67b39ae..778e870 100644
--- a/app/Http/Requests/UpdateRoomRequest.php
+++ b/app/Http/Requests/UpdateRoomRequest.php
@@ -31,7 +31,7 @@ class UpdateRoomRequest extends FormRequest
public function rules(): array
{
return [
- 'name' => ['required', 'string', 'max:50', 'unique:rooms,name,'.$this->route('id')],
+ 'name' => ['required', 'string', 'max:50', 'unique:rooms,room_name,'.$this->route('id')],
'description' => ['nullable', 'string', 'max:255'],
];
}
diff --git a/app/Services/WechatBot/KafkaConsumerService.php b/app/Services/WechatBot/KafkaConsumerService.php
index 2a2d054..6c03ebb 100644
--- a/app/Services/WechatBot/KafkaConsumerService.php
+++ b/app/Services/WechatBot/KafkaConsumerService.php
@@ -33,16 +33,32 @@ class KafkaConsumerService
protected string $groupId = '';
/**
- * 构造函数 — 从 SysParam 获取配置
+ * 构造函数
*/
public function __construct()
{
- $param = SysParam::where('alias', 'wechat_bot_config')->first();
- if ($param && ! empty($param->body)) {
- $config = json_decode($param->body, true);
- $this->brokers = $config['kafka']['brokers'] ?? '';
- $this->topic = $config['kafka']['topic'] ?? '';
- $this->groupId = $config['kafka']['group_id'] ?? 'chatroom_wechat_bot';
+ // 延迟加载配置
+ }
+
+ /**
+ * 加载 Kafka 配置
+ */
+ protected function loadConfig(): void
+ {
+ if (! empty($this->brokers)) {
+ return; // 已经加载过
+ }
+
+ try {
+ $param = SysParam::where('alias', 'wechat_bot_config')->first();
+ if ($param && ! empty($param->body)) {
+ $config = json_decode($param->body, true);
+ $this->brokers = $config['kafka']['brokers'] ?? '';
+ $this->topic = $config['kafka']['topic'] ?? '';
+ $this->groupId = $config['kafka']['group_id'] ?? 'chatroom_wechat_bot';
+ }
+ } catch (\Throwable $e) {
+ Log::warning('加载 Kafka 配置失败', ['error' => $e->getMessage()]);
}
}
@@ -51,6 +67,8 @@ class KafkaConsumerService
*/
public function createConsumer(): ?Consumer
{
+ $this->loadConfig();
+
if (empty($this->brokers) || empty($this->topic)) {
Log::warning('WechatBot Kafka: brokers or topic is empty. Consumer not started.');
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
index 584104c..744f580 100644
--- a/database/factories/UserFactory.php
+++ b/database/factories/UserFactory.php
@@ -24,21 +24,12 @@ class UserFactory extends Factory
public function definition(): array
{
return [
- 'name' => fake()->name(),
+ 'username' => fake()->unique()->name(),
'email' => fake()->unique()->safeEmail(),
- 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
+ 'sex' => 1,
+ 'user_level' => 1,
];
}
-
- /**
- * Indicate that the model's email address should be unverified.
- */
- public function unverified(): static
- {
- return $this->state(fn (array $attributes) => [
- 'email_verified_at' => null,
- ]);
- }
}
diff --git a/database/migrations/2026_02_27_074855_create_shop_tables.php b/database/migrations/2026_02_27_074855_create_shop_tables.php
index 948f6db..4281905 100644
--- a/database/migrations/2026_02_27_074855_create_shop_tables.php
+++ b/database/migrations/2026_02_27_074855_create_shop_tables.php
@@ -48,7 +48,7 @@ return new class extends Migration
['name' => '🌧️ 下雨周卡', 'slug' => 'week_rain', 'description' => '7天内进房间自动下雨', 'icon' => '🌧️', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 6, 'is_active' => true, 'created_at' => now(), 'updated_at' => now()],
['name' => '⚡ 雷电周卡', 'slug' => 'week_lightning', 'description' => '7天内进房间自动雷电', 'icon' => '⚡', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 7, 'is_active' => true, 'created_at' => now(), 'updated_at' => now()],
['name' => '❄️ 下雪周卡', 'slug' => 'week_snow', 'description' => '7天内进房间自动下雪', 'icon' => '❄️', 'price' => 8888, 'type' => 'duration', 'duration_days' => 7, 'sort_order' => 8, 'is_active' => true, 'created_at' => now(), 'updated_at' => now()],
- ['name' => '✏️ 改名卡', 'slug' => 'rename_card', 'description' => '修改一次用户名(受黑名单限制)', 'icon' => '✏️', 'price' => 5000, 'type' => 'rename', 'duration_days' => null, 'sort_order' => 9, 'is_active' => true, 'created_at' => now(), 'updated_at' => now()],
+ ['name' => '✏️ 改名卡', 'slug' => 'rename_card', 'description' => '修改一次用户名(受黑名单限制)', 'icon' => '✏️', 'price' => 5000, 'type' => 'one_time', 'duration_days' => null, 'sort_order' => 9, 'is_active' => true, 'created_at' => now(), 'updated_at' => now()],
]);
// ── 购买记录表 ────────────────────────────────────────────────
diff --git a/database/migrations/2026_02_28_141645_create_feedback_tables.php b/database/migrations/2026_02_28_141645_create_feedback_tables.php
index 0b9f3b7..953cce7 100644
--- a/database/migrations/2026_02_28_141645_create_feedback_tables.php
+++ b/database/migrations/2026_02_28_141645_create_feedback_tables.php
@@ -67,7 +67,7 @@ return new class extends Migration
// 联合唯一索引:每个用户每条反馈只能赞同一次
$table->unique(['feedback_id', 'user_id'], 'uq_vote');
- $table->index('feedback_id', 'idx_feedback_id');
+ $table->index('feedback_id');
});
// ——— 表3:补充评论 ———
@@ -83,7 +83,7 @@ return new class extends Migration
$table->boolean('is_admin')->default(false)->comment('是否为 id=1 管理员的官方回复');
$table->timestamps();
- $table->index('feedback_id', 'idx_feedback_id');
+ $table->index('feedback_id');
});
}
diff --git a/phpunit.xml b/phpunit.xml
index d703241..6c94aec 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -23,8 +23,8 @@
-
-
+
+
diff --git a/tests/Feature/AuthControllerTest.php b/tests/Feature/AuthControllerTest.php
new file mode 100644
index 0000000..0bb8a55
--- /dev/null
+++ b/tests/Feature/AuthControllerTest.php
@@ -0,0 +1,184 @@
+ 'superlevel'],
+ ['body' => '100']
+ );
+ }
+
+ public function test_can_register_new_user()
+ {
+ Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
+
+ $response = $this->postJson('/login', [
+ 'username' => 'newuser',
+ 'password' => 'secret123',
+ 'captcha' => '1234',
+ 'bSex' => '1',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJsonPath('status', 'success');
+
+ $this->assertDatabaseHas('users', [
+ 'username' => 'newuser',
+ 'user_level' => 1,
+ 'sex' => 1,
+ ]);
+
+ $this->assertAuthenticated();
+ }
+
+ public function test_cannot_register_with_blacklisted_username()
+ {
+ Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
+
+ UsernameBlacklist::create([
+ 'username' => 'admin',
+ 'type' => 'permanent',
+ ]);
+
+ $response = $this->postJson('/login', [
+ 'username' => 'admin',
+ 'password' => 'secret123',
+ 'captcha' => '1234',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonPath('status', 'error');
+
+ $this->assertDatabaseMissing('users', [
+ 'username' => 'admin',
+ ]);
+
+ $this->assertGuest();
+ }
+
+ public function test_can_login_existing_user()
+ {
+ Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
+
+ $user = User::factory()->create([
+ 'username' => 'testuser',
+ 'password' => Hash::make('password123'),
+ ]);
+
+ $response = $this->postJson('/login', [
+ 'username' => 'testuser',
+ 'password' => 'password123',
+ 'captcha' => '1234',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJsonPath('status', 'success');
+
+ $this->assertAuthenticatedAs($user);
+ }
+
+ public function test_login_md5_user_upgrades_to_bcrypt()
+ {
+ Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false);
+
+ $password = 'oldsecret';
+ $user = User::factory()->create([
+ 'username' => 'olduser',
+ 'password' => 'temp',
+ ]);
+
+ \Illuminate\Support\Facades\DB::table('users')
+ ->where('id', $user->id)
+ ->update(['password' => md5($password)]);
+
+ $response = $this->postJson('/login', [
+ 'username' => 'olduser',
+ 'password' => $password,
+ 'captcha' => '1234',
+ ]);
+
+ if ($response->status() !== 200) {
+ dd($response->json());
+ }
+
+ $response->assertStatus(200)
+ ->assertJsonPath('status', 'success');
+
+ $user->refresh();
+ $this->assertTrue(Hash::check($password, $user->password));
+ $this->assertAuthenticatedAs($user);
+ }
+
+ public function test_banned_user_cannot_login()
+ {
+ $user = User::factory()->create([
+ 'username' => 'banneduser',
+ 'password' => Hash::make('secret123'),
+ 'user_level' => -1, // banned
+ ]);
+
+ $response = $this->postJson('/login', [
+ 'username' => 'banneduser',
+ 'password' => 'secret123',
+ 'captcha' => '1234',
+ ]);
+
+ $response->assertStatus(403)
+ ->assertJsonPath('status', 'error');
+
+ $this->assertGuest();
+ }
+
+ public function test_banned_ip_cannot_login()
+ {
+ Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(true);
+
+ $user = User::factory()->create([
+ 'username' => 'normaluser',
+ 'password' => Hash::make('secret'),
+ ]);
+
+ $response = $this->postJson('/login', [
+ 'username' => 'normaluser',
+ 'password' => 'secret',
+ 'captcha' => '1234',
+ ]);
+
+ $response->assertStatus(403);
+ $this->assertGuest();
+ }
+
+ public function test_can_logout()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post('/logout');
+
+ $response->assertRedirect('/');
+ $this->assertGuest();
+ }
+}
diff --git a/tests/Feature/BaccaratControllerTest.php b/tests/Feature/BaccaratControllerTest.php
new file mode 100644
index 0000000..8c50bb6
--- /dev/null
+++ b/tests/Feature/BaccaratControllerTest.php
@@ -0,0 +1,200 @@
+ 'baccarat'],
+ [
+ 'name' => 'Baccarat',
+ 'icon' => 'baccarat',
+ 'description' => 'Baccarat Game',
+ 'enabled' => true,
+ 'params' => [
+ 'min_bet' => 100,
+ 'max_bet' => 50000,
+ ],
+ ]
+ );
+ }
+
+ public function test_can_get_current_round()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $round = BaccaratRound::forceCreate([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'total_bet_big' => 0,
+ 'total_bet_small' => 0,
+ 'total_bet_triple' => 0,
+ 'bet_count' => 0,
+ 'bet_count_big' => 0,
+ 'bet_count_small' => 0,
+ 'bet_count_triple' => 0,
+ 'total_payout' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('baccarat.current'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['round' => ['id', 'status', 'bet_closes_at']]);
+ $this->assertEquals($round->id, $response->json('round.id'));
+ }
+
+ public function test_can_bet()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 200]);
+
+ $round = BaccaratRound::forceCreate([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'total_bet_big' => 0,
+ 'total_bet_small' => 0,
+ 'total_bet_triple' => 0,
+ 'bet_count' => 0,
+ 'bet_count_big' => 0,
+ 'bet_count_small' => 0,
+ 'bet_count_triple' => 0,
+ 'total_payout' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('baccarat.bet'), [
+ 'round_id' => $round->id,
+ 'bet_type' => 'big',
+ 'amount' => 100,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ $this->assertEquals(100, $user->fresh()->jjb);
+ $this->assertDatabaseHas('baccarat_bets', [
+ 'round_id' => $round->id,
+ 'user_id' => $user->id,
+ 'bet_type' => 'big',
+ 'amount' => 100,
+ ]);
+
+ Event::assertDispatched(\App\Events\BaccaratPoolUpdated::class);
+ }
+
+ public function test_cannot_bet_out_of_range()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 200]);
+
+ $round = BaccaratRound::forceCreate([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'total_bet_big' => 0,
+ 'total_bet_small' => 0,
+ 'total_bet_triple' => 0,
+ 'bet_count' => 0,
+ 'bet_count_big' => 0,
+ 'bet_count_small' => 0,
+ 'bet_count_triple' => 0,
+ 'total_payout' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('baccarat.bet'), [
+ 'round_id' => $round->id,
+ 'bet_type' => 'big',
+ 'amount' => 50, // Less than min_bet
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false]);
+ }
+
+ public function test_cannot_bet_twice_in_same_round()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 200]);
+
+ $round = BaccaratRound::forceCreate([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'total_bet_big' => 0,
+ 'total_bet_small' => 0,
+ 'total_bet_triple' => 0,
+ 'bet_count' => 0,
+ 'bet_count_big' => 0,
+ 'bet_count_small' => 0,
+ 'bet_count_triple' => 0,
+ 'total_payout' => 0,
+ ]);
+
+ BaccaratBet::forceCreate([
+ 'round_id' => $round->id,
+ 'user_id' => $user->id,
+ 'bet_type' => 'big',
+ 'amount' => 100,
+ 'status' => 'pending',
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('baccarat.bet'), [
+ 'round_id' => $round->id,
+ 'bet_type' => 'small',
+ 'amount' => 100,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false]);
+ }
+
+ public function test_can_get_history()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ BaccaratRound::forceCreate([
+ 'status' => 'settled',
+ 'bet_opens_at' => now()->subMinutes(2),
+ 'bet_closes_at' => now()->subMinutes(1),
+ 'settled_at' => now(),
+ 'dice1' => 1,
+ 'dice2' => 2,
+ 'dice3' => 3,
+ 'total_points' => 6,
+ 'result' => 'small',
+ 'total_bet_big' => 0,
+ 'total_bet_small' => 0,
+ 'total_bet_triple' => 0,
+ 'bet_count' => 0,
+ 'bet_count_big' => 0,
+ 'bet_count_small' => 0,
+ 'bet_count_triple' => 0,
+ 'total_payout' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('baccarat.history'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['history']);
+ $this->assertCount(1, $response->json('history'));
+ }
+}
diff --git a/tests/Feature/BankControllerTest.php b/tests/Feature/BankControllerTest.php
new file mode 100644
index 0000000..561ed66
--- /dev/null
+++ b/tests/Feature/BankControllerTest.php
@@ -0,0 +1,154 @@
+create([
+ 'jjb' => 1000,
+ 'bank_jjb' => 5000,
+ ]);
+
+ BankLog::create([
+ 'user_id' => $user->id,
+ 'type' => 'deposit',
+ 'amount' => 500,
+ 'balance_after' => 5000,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('bank.info'));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ 'jjb' => 1000,
+ 'bank_jjb' => 5000,
+ ]);
+ $response->assertJsonCount(1, 'logs');
+ }
+
+ public function test_deposit_transfers_jjb_to_bank()
+ {
+ $user = User::factory()->create([
+ 'jjb' => 1000,
+ 'bank_jjb' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('bank.deposit'), [
+ 'amount' => 500,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ 'jjb' => 500,
+ 'bank_jjb' => 500,
+ ]);
+
+ $this->assertDatabaseHas('users', [
+ 'id' => $user->id,
+ 'jjb' => 500,
+ 'bank_jjb' => 500,
+ ]);
+
+ $this->assertDatabaseHas('bank_logs', [
+ 'user_id' => $user->id,
+ 'type' => 'deposit',
+ 'amount' => 500,
+ 'balance_after' => 500,
+ ]);
+ }
+
+ public function test_deposit_fails_if_insufficient_funds()
+ {
+ $user = User::factory()->create([
+ 'jjb' => 100,
+ 'bank_jjb' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('bank.deposit'), [
+ 'amount' => 500,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'error',
+ ]);
+
+ $this->assertDatabaseMissing('bank_logs', [
+ 'user_id' => $user->id,
+ ]);
+ }
+
+ public function test_withdraw_transfers_bank_to_jjb()
+ {
+ $user = User::factory()->create([
+ 'jjb' => 0,
+ 'bank_jjb' => 1000,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('bank.withdraw'), [
+ 'amount' => 500,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ 'jjb' => 500,
+ 'bank_jjb' => 500,
+ ]);
+
+ $this->assertDatabaseHas('bank_logs', [
+ 'user_id' => $user->id,
+ 'type' => 'withdraw',
+ 'amount' => 500,
+ 'balance_after' => 500,
+ ]);
+ }
+
+ public function test_withdraw_fails_if_insufficient_funds()
+ {
+ $user = User::factory()->create([
+ 'jjb' => 0,
+ 'bank_jjb' => 100,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('bank.withdraw'), [
+ 'amount' => 500,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'error',
+ ]);
+ }
+
+ public function test_ranking_returns_paginated_users_ordered_by_bank_jjb()
+ {
+ $user = User::factory()->create(); // To act as
+
+ User::factory()->create(['bank_jjb' => 1000, 'username' => 'Rich']);
+ User::factory()->create(['bank_jjb' => 500, 'username' => 'Poorer']);
+
+ $response = $this->actingAs($user)->getJson(route('bank.ranking'));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ ]);
+
+ $ranking = $response->json('ranking');
+ $this->assertCount(2, $ranking);
+ $this->assertEquals('Rich', $ranking[0]['username']);
+ $this->assertEquals('Poorer', $ranking[1]['username']);
+ }
+}
diff --git a/tests/Feature/ChatBotControllerTest.php b/tests/Feature/ChatBotControllerTest.php
new file mode 100644
index 0000000..4697a83
--- /dev/null
+++ b/tests/Feature/ChatBotControllerTest.php
@@ -0,0 +1,125 @@
+create();
+
+ $response = $this->actingAs($user)->postJson(route('chatbot.chat'), [
+ 'message' => 'Hello',
+ 'room_id' => 1,
+ ]);
+
+ $response->assertStatus(403);
+ $response->assertJson(['status' => 'error']);
+ }
+
+ public function test_chatbot_can_reply()
+ {
+ $user = User::factory()->create();
+
+ Sysparam::updateOrCreate(['alias' => 'chatbot_enabled'], ['body' => '1']);
+
+ User::factory()->create([
+ 'username' => 'AI小班长',
+ 'exp_num' => 0,
+ 'jjb' => 0,
+ ]);
+
+ // Mock the AiChatService
+ $this->mock(AiChatService::class, function (MockInterface $mock) {
+ $mock->shouldReceive('chat')
+ ->once()
+ ->andReturn([
+ 'reply' => 'Hello from AI',
+ 'provider' => 'test_provider',
+ 'model' => 'test_model',
+ ]);
+ });
+
+ $response = $this->actingAs($user)->postJson(route('chatbot.chat'), [
+ 'message' => 'Hello',
+ 'room_id' => 1,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ 'reply' => 'Hello from AI',
+ 'provider' => 'test_provider',
+ ]);
+ }
+
+ public function test_chatbot_can_give_gold()
+ {
+ $user = User::factory()->create(['jjb' => 0]);
+
+ Sysparam::updateOrCreate(['alias' => 'chatbot_enabled'], ['body' => '1']);
+ Sysparam::updateOrCreate(['alias' => 'chatbot_max_daily_rewards'], ['body' => '1']);
+ Sysparam::updateOrCreate(['alias' => 'chatbot_max_gold'], ['body' => '500']);
+
+ User::factory()->create([
+ 'username' => 'AI小班长',
+ 'exp_num' => 0,
+ 'jjb' => 1000, // Ensure AI bot has enough gold
+ ]);
+
+ // Mock the AiChatService
+ $this->mock(AiChatService::class, function (MockInterface $mock) {
+ $mock->shouldReceive('chat')
+ ->once()
+ ->andReturn([
+ 'reply' => 'Here is some gold! [ACTION:GIVE_GOLD]',
+ 'provider' => 'test_provider',
+ 'model' => 'test_model',
+ ]);
+ });
+
+ $response = $this->actingAs($user)->postJson(route('chatbot.chat'), [
+ 'message' => 'Give me gold',
+ 'room_id' => 1,
+ ]);
+
+ $response->assertStatus(200);
+
+ // User should have received gold
+ $user->refresh();
+ $this->assertGreaterThan(0, $user->jjb);
+ $this->assertLessThanOrEqual(500, $user->jjb);
+ }
+
+ public function test_clear_context()
+ {
+ $user = User::factory()->create();
+
+ $this->mock(AiChatService::class, function (MockInterface $mock) use ($user) {
+ $mock->shouldReceive('clearContext')
+ ->once()
+ ->with($user->id);
+ });
+
+ $response = $this->actingAs($user)->postJson(route('chatbot.clear'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['status' => 'success']);
+ }
+}
diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php
new file mode 100644
index 0000000..c59e81e
--- /dev/null
+++ b/tests/Feature/ChatControllerTest.php
@@ -0,0 +1,150 @@
+ 'testroom']);
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->get(route('chat.room', $room->id));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('chat.frame');
+
+ // Assert user was added to room in redis
+ $this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
+ }
+
+ public function test_can_send_message()
+ {
+ $room = Room::create(['room_name' => 'test_send']);
+ $user = User::factory()->create();
+
+ // 进房
+ $this->actingAs($user)->get(route('chat.room', $room->id));
+
+ $response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [
+ 'to_user' => '大家',
+ 'content' => '测试消息',
+ 'is_secret' => false,
+ 'font_color' => '#000000',
+ 'action' => 'say',
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['status' => 'success']);
+
+ // 查看 Redis 里的消息记录
+ $messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
+ $this->assertNotEmpty($messages);
+
+ $found = false;
+ foreach ($messages as $msgJson) {
+ $msg = json_decode($msgJson, true);
+ if ($msg['from_user'] === $user->username && $msg['content'] === '测试消息') {
+ $found = true;
+ break;
+ }
+ }
+ $this->assertTrue($found, 'Message not found in Redis');
+ }
+
+ public function test_can_trigger_heartbeat()
+ {
+ $room = Room::create(['room_name' => 'test_hb']);
+ $user = User::factory()->create(['exp_num' => 0]);
+
+ $response = $this->actingAs($user)->postJson(route('chat.heartbeat', $room->id));
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['status' => 'success']);
+
+ $user->refresh();
+ $this->assertGreaterThanOrEqual(0, $user->exp_num); // Might be 1 depending on sysparam
+ }
+
+ public function test_can_leave_room()
+ {
+ $room = Room::create(['room_name' => 'test_leave']);
+ $user = User::factory()->create();
+
+ // 进房
+ $this->actingAs($user)->get(route('chat.room', $room->id));
+ $this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
+
+ // 显式退房
+ $response = $this->actingAs($user)->postJson(route('chat.leave', $room->id).'?explicit=1');
+
+ $response->assertStatus(200);
+
+ // 缓存中被移除
+ $this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username));
+ }
+
+ public function test_can_get_rooms_online_status()
+ {
+ $user = User::factory()->create();
+ $room1 = Room::create(['room_name' => 'room1']);
+ $room2 = Room::create(['room_name' => 'room2']);
+
+ $this->actingAs($user)->get(route('chat.room', $room1->id));
+
+ $response = $this->actingAs($user)->getJson(route('chat.rooms-online-status'));
+
+ $response->assertStatus(200);
+
+ // Assert room1 has 1 online, room2 has 0
+ $response->assertJsonFragment([
+ 'id' => $room1->id,
+ 'online' => 1,
+ ]);
+ $response->assertJsonFragment([
+ 'id' => $room2->id,
+ 'online' => 0,
+ ]);
+ }
+
+ public function test_can_set_announcement()
+ {
+ $user = User::factory()->create(['user_level' => 100]); // superadmin
+ $room = Room::create(['room_name' => 'test_ann', 'room_owner' => 'someone']);
+
+ $response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
+ 'announcement' => 'This is a new test announcement',
+ ]);
+
+ $response->assertStatus(200);
+
+ $room->refresh();
+ $this->assertStringContainsString('This is a new test announcement', $room->announcement);
+ }
+
+ public function test_cannot_set_announcement_without_permission()
+ {
+ $user = User::factory()->create(['user_level' => 0]);
+ $room = Room::create(['room_name' => 'test_ann2', 'room_owner' => 'someone']);
+
+ $response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
+ 'announcement' => 'This is a new test announcement',
+ ]);
+
+ $response->assertStatus(403);
+ }
+}
diff --git a/tests/Feature/DutyHallControllerTest.php b/tests/Feature/DutyHallControllerTest.php
new file mode 100644
index 0000000..9a69c3b
--- /dev/null
+++ b/tests/Feature/DutyHallControllerTest.php
@@ -0,0 +1,84 @@
+create();
+
+ $department = Department::create(['name' => 'Support', 'rank' => 1]);
+ $position = Position::create(['name' => 'Helper', 'department_id' => $department->id, 'rank' => 1, 'type' => 'temp']);
+
+ UserPosition::create([
+ 'user_id' => $user->id,
+ 'position_id' => $position->id,
+ 'status' => 'active',
+ 'appointed_at' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->get(route('duty-hall.index', ['tab' => 'roster']));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('duty-hall.index');
+ $response->assertViewHas('tab', 'roster');
+
+ $currentStaff = $response->viewData('currentStaff');
+ $this->assertNotNull($currentStaff);
+ $this->assertEquals(1, $currentStaff->count());
+ $this->assertEquals('Support', $currentStaff->first()->name);
+ }
+
+ public function test_can_view_duty_hall_leaderboard_tabs()
+ {
+ $user = User::factory()->create();
+
+ PositionDutyLog::create([
+ 'user_id' => $user->id,
+ 'position_id' => 1,
+ 'login_at' => now()->subMinutes(30),
+ 'logout_at' => now(),
+ 'duration_seconds' => 1800,
+ ]);
+
+ PositionAuthorityLog::create([
+ 'user_id' => $user->id, // operator
+ 'target_user_id' => $user->id, // target
+ 'action_type' => 'reward',
+ 'amount' => 500,
+ ]);
+
+ $tabs = ['day', 'week', 'month', 'all'];
+
+ foreach ($tabs as $tab) {
+ $response = $this->actingAs($user)->get(route('duty-hall.index', ['tab' => $tab]));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('duty-hall.index');
+ $response->assertViewHas('tab', $tab);
+
+ $leaderboard = $response->viewData('leaderboard');
+ $this->assertNotNull($leaderboard);
+
+ if ($leaderboard->count() > 0) {
+ $row = $leaderboard->first();
+ $this->assertEquals($user->id, $row->user_id);
+ $this->assertEquals(1800, $row->total_seconds);
+ $this->assertEquals(1, $row->reward_count);
+ $this->assertEquals(500, $row->reward_total);
+ }
+ }
+ }
+}
diff --git a/tests/Feature/EarnControllerTest.php b/tests/Feature/EarnControllerTest.php
new file mode 100644
index 0000000..7a2b157
--- /dev/null
+++ b/tests/Feature/EarnControllerTest.php
@@ -0,0 +1,78 @@
+create([
+ 'jjb' => 0,
+ 'exp_num' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('earn.video_reward'), [
+ 'room_id' => 1,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'success' => true,
+ ]);
+
+ $this->assertDatabaseHas('users', [
+ 'id' => $user->id,
+ 'jjb' => 5000, // Reward is hardcoded to 5000
+ 'exp_num' => 500, // Exp is hardcoded to 500
+ ]);
+
+ // Cooldown should be set
+ $this->assertTrue((bool) Redis::exists("earn_video:cooldown:{$user->id}"));
+ }
+
+ public function test_cannot_claim_during_cooldown()
+ {
+ $user = User::factory()->create();
+
+ // First claim
+ $this->actingAs($user)->postJson(route('earn.video_reward'));
+
+ // Second claim immediately should fail due to cooldown
+ $response = $this->actingAs($user)->postJson(route('earn.video_reward'));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'success' => false,
+ 'message' => '操作过快,请稍后再试。',
+ ]);
+ }
+
+ public function test_cannot_exceed_daily_limit()
+ {
+ $user = User::factory()->create();
+ $dateKey = now()->format('Y-m-d');
+
+ // Manually set daily count to max limit (3)
+ Redis::set("earn_video:count:{$user->id}:{$dateKey}", 3);
+
+ $response = $this->actingAs($user)->postJson(route('earn.video_reward'));
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment([
+ 'success' => false,
+ ]);
+ }
+}
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
index 8364a84..22ef0c5 100644
--- a/tests/Feature/ExampleTest.php
+++ b/tests/Feature/ExampleTest.php
@@ -2,11 +2,13 @@
namespace Tests\Feature;
-// use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
+ use RefreshDatabase;
+
/**
* A basic test example.
*/
diff --git a/tests/Feature/FishingControllerTest.php b/tests/Feature/FishingControllerTest.php
new file mode 100644
index 0000000..08b471a
--- /dev/null
+++ b/tests/Feature/FishingControllerTest.php
@@ -0,0 +1,125 @@
+ 'fishing'],
+ [
+ 'name' => 'Fishing',
+ 'icon' => 'fish',
+ 'description' => 'Fishing Game',
+ 'enabled' => true,
+ 'params' => [
+ 'fishing_cost' => 5,
+ 'fishing_cooldown' => 300,
+ ],
+ ]
+ );
+ }
+
+ public function test_can_cast_rod()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 10]);
+
+ $response = $this->actingAs($user)->postJson(route('fishing.cast', ['id' => 1]));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ 'cost' => 5,
+ ]);
+
+ $this->assertEquals(5, $user->fresh()->jjb);
+ }
+
+ public function test_cannot_cast_when_on_cooldown()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 10]);
+ Redis::setex("fishing:cd:{$user->id}", 100, time());
+
+ $response = $this->actingAs($user)->postJson(route('fishing.cast', ['id' => 1]));
+
+ $response->assertStatus(429);
+ $response->assertJson([
+ 'status' => 'error',
+ ]);
+ }
+
+ public function test_cannot_cast_without_enough_gold()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 2]);
+
+ $response = $this->actingAs($user)->postJson(route('fishing.cast', ['id' => 1]));
+
+ $response->assertStatus(422);
+ $response->assertJson([
+ 'status' => 'error',
+ ]);
+ }
+
+ public function test_can_reel_after_waiting()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 10]);
+
+ $token = 'test-token';
+ $waitTime = 0; // Set to 0 so we can test immediately
+
+ Redis::set("fishing:token:{$user->id}", json_encode([
+ 'token' => $token,
+ 'cast_at' => time() - 1, // Simulate past
+ 'wait_time' => 0,
+ ]));
+
+ $response = $this->actingAs($user)->postJson(route('fishing.reel', ['id' => 1]), [
+ 'token' => $token,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ ]);
+
+ // Cooldown should be set
+ $this->assertTrue((bool) Redis::exists("fishing:cd:{$user->id}"));
+ }
+
+ public function test_cannot_reel_with_invalid_token()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 10]);
+
+ Redis::set("fishing:token:{$user->id}", json_encode([
+ 'token' => 'valid-token',
+ 'cast_at' => time(),
+ 'wait_time' => 0,
+ ]));
+
+ $response = $this->actingAs($user)->postJson(route('fishing.reel', ['id' => 1]), [
+ 'token' => 'invalid-token',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJson([
+ 'status' => 'error',
+ ]);
+ }
+}
diff --git a/tests/Feature/FortuneTellingControllerTest.php b/tests/Feature/FortuneTellingControllerTest.php
new file mode 100644
index 0000000..90de760
--- /dev/null
+++ b/tests/Feature/FortuneTellingControllerTest.php
@@ -0,0 +1,142 @@
+ 'fortune_telling'],
+ [
+ 'name' => 'Fortune Telling',
+ 'icon' => 'fortune',
+ 'description' => 'Fortune Telling Game',
+ 'enabled' => true,
+ 'params' => [
+ 'free_count_per_day' => 1,
+ 'extra_cost' => 500,
+ ],
+ ]
+ );
+ }
+
+ public function test_can_get_today_status()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->getJson(route('fortune.today'));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'enabled' => true,
+ 'today_count' => 0,
+ 'free_count' => 1,
+ 'free_used' => 0,
+ 'has_free_left' => true,
+ 'extra_cost' => 500,
+ ]);
+ $response->assertJsonStructure(['latest']);
+ }
+
+ public function test_can_tell_free()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 0]); // Note: 0 jjb needed for free
+
+ $response = $this->actingAs($user)->postJson(route('fortune.tell'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true, 'is_free' => true]);
+
+ $this->assertDatabaseHas('fortune_logs', [
+ 'user_id' => $user->id,
+ 'is_free' => 1,
+ ]);
+ }
+
+ public function test_cannot_tell_paid_without_enough_gold()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 0]);
+
+ // Consume free tell
+ FortuneLog::create([
+ 'user_id' => $user->id,
+ 'grade' => 'great_luck',
+ 'text' => 'Test',
+ 'buff_desc' => 'Test',
+ 'is_free' => true,
+ 'cost' => 0,
+ 'fortune_date' => today(),
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('fortune.tell'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false]);
+ }
+
+ public function test_can_tell_paid_with_enough_gold()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 500]);
+
+ // Consume free tell
+ FortuneLog::create([
+ 'user_id' => $user->id,
+ 'grade' => 'great_luck',
+ 'text' => 'Test',
+ 'buff_desc' => 'Test',
+ 'is_free' => true,
+ 'cost' => 0,
+ 'fortune_date' => today(),
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('fortune.tell'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true, 'is_free' => false]);
+
+ $this->assertDatabaseHas('fortune_logs', [
+ 'user_id' => $user->id,
+ 'is_free' => 0,
+ 'cost' => 500,
+ ]);
+
+ $this->assertEquals(0, $user->fresh()->jjb);
+ }
+
+ public function test_can_get_history()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ FortuneLog::create([
+ 'user_id' => $user->id,
+ 'grade' => 'great_luck',
+ 'text' => 'Test',
+ 'buff_desc' => 'Test',
+ 'is_free' => true,
+ 'cost' => 0,
+ 'fortune_date' => today(),
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('fortune.history'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['history']);
+ $this->assertCount(1, $response->json('history'));
+ }
+}
diff --git a/tests/Feature/FriendControllerTest.php b/tests/Feature/FriendControllerTest.php
new file mode 100644
index 0000000..3a10e2e
--- /dev/null
+++ b/tests/Feature/FriendControllerTest.php
@@ -0,0 +1,169 @@
+create();
+ $target = User::factory()->create();
+
+ // 此时不是好友
+ $response = $this->actingAs($me)->getJson(route('friend.status', $target->username));
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'is_friend' => false,
+ 'mutual' => false,
+ ]);
+
+ // 我加了对方
+ FriendRequest::create(['who' => $me->username, 'towho' => $target->username, 'sub_time' => now()]);
+
+ $response = $this->actingAs($me)->getJson(route('friend.status', $target->username));
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'is_friend' => true,
+ 'mutual' => false,
+ ]);
+
+ // 对方也加了我
+ FriendRequest::create(['who' => $target->username, 'towho' => $me->username, 'sub_time' => now()]);
+
+ $response = $this->actingAs($me)->getJson(route('friend.status', $target->username));
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'is_friend' => true,
+ 'mutual' => true,
+ ]);
+ }
+
+ public function test_cannot_add_self_as_friend()
+ {
+ $me = User::factory()->create();
+
+ $response = $this->actingAs($me)->postJson(route('friend.add', $me->username));
+
+ $response->assertStatus(422);
+ $response->assertJsonFragment(['message' => '不能将自己加为好友']);
+ }
+
+ public function test_cannot_add_nonexistent_user()
+ {
+ $me = User::factory()->create();
+
+ $response = $this->actingAs($me)->postJson(route('friend.add', 'nonexistent_foo'));
+
+ $response->assertStatus(404);
+ $response->assertJsonFragment(['message' => '用户不存在']);
+ }
+
+ public function test_can_add_friend()
+ {
+ $me = User::factory()->create();
+ $target = User::factory()->create();
+
+ $response = $this->actingAs($me)->postJson(route('friend.add', $target->username));
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['status' => 'success']);
+
+ $this->assertDatabaseHas('friend_requests', [
+ 'who' => $me->username,
+ 'towho' => $target->username,
+ ]);
+ }
+
+ public function test_cannot_add_same_friend_twice()
+ {
+ $me = User::factory()->create();
+ $target = User::factory()->create();
+
+ FriendRequest::create(['who' => $me->username, 'towho' => $target->username, 'sub_time' => now()]);
+
+ $response = $this->actingAs($me)->postJson(route('friend.add', $target->username));
+
+ $response->assertStatus(422);
+ $response->assertJsonFragment(['message' => '已是好友,无需重复添加']);
+
+ // 确保只有1条记录
+ $this->assertEquals(1, FriendRequest::where('who', $me->username)->where('towho', $target->username)->count());
+ }
+
+ public function test_can_remove_friend()
+ {
+ $me = User::factory()->create();
+ $target = User::factory()->create();
+
+ FriendRequest::create(['who' => $me->username, 'towho' => $target->username, 'sub_time' => now()]);
+
+ $response = $this->actingAs($me)->deleteJson(route('friend.remove', $target->username));
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['status' => 'success']);
+
+ $this->assertDatabaseMissing('friend_requests', [
+ 'who' => $me->username,
+ 'towho' => $target->username,
+ ]);
+ }
+
+ public function test_cannot_remove_non_friend()
+ {
+ $me = User::factory()->create();
+ $target = User::factory()->create();
+
+ $response = $this->actingAs($me)->deleteJson(route('friend.remove', $target->username));
+
+ $response->assertStatus(404);
+ $response->assertJsonFragment(['message' => '好友关系不存在']);
+ }
+
+ public function test_can_view_friend_list()
+ {
+ $me = User::factory()->create();
+
+ $myFriend = User::factory()->create();
+ $someoneWhoAddedMe = User::factory()->create();
+ $mutualFriend = User::factory()->create();
+
+ // 我加了他
+ FriendRequest::create(['who' => $me->username, 'towho' => $myFriend->username, 'sub_time' => now()]);
+
+ // 他加了我 (Pending)
+ FriendRequest::create(['who' => $someoneWhoAddedMe->username, 'towho' => $me->username, 'sub_time' => now()]);
+
+ // 互相好友
+ FriendRequest::create(['who' => $me->username, 'towho' => $mutualFriend->username, 'sub_time' => now()]);
+ FriendRequest::create(['who' => $mutualFriend->username, 'towho' => $me->username, 'sub_time' => now()]);
+
+ $response = $this->actingAs($me)->getJson(route('friend.index'));
+
+ $response->assertStatus(200);
+
+ $response->assertJsonStructure([
+ 'status',
+ 'friends' => [
+ '*' => ['username', 'headface', 'user_level', 'sex', 'mutual', 'sub_time', 'is_online'],
+ ],
+ 'pending' => [
+ '*' => ['username', 'headface', 'user_level', 'sex', 'added_at', 'is_online'],
+ ],
+ ]);
+
+ $responseData = $response->json();
+
+ $this->assertCount(2, $responseData['friends']);
+ $this->assertCount(1, $responseData['pending']);
+
+ // 验证 pending 列表
+ $this->assertEquals($someoneWhoAddedMe->username, $responseData['pending'][0]['username']);
+ }
+}
diff --git a/tests/Feature/GomokuControllerTest.php b/tests/Feature/GomokuControllerTest.php
new file mode 100644
index 0000000..657746c
--- /dev/null
+++ b/tests/Feature/GomokuControllerTest.php
@@ -0,0 +1,176 @@
+ 'gomoku'],
+ [
+ 'name' => 'Gomoku',
+ 'icon' => 'gomoku',
+ 'description' => 'Gomoku Game',
+ 'enabled' => true,
+ 'params' => [
+ 'invite_timeout' => 60,
+ 'pvp_reward' => 80,
+ 'pve_easy_fee' => 0,
+ 'pve_normal_fee' => 10,
+ 'pve_hard_fee' => 30,
+ 'pve_expert_fee' => 80,
+ ],
+ ]
+ );
+ }
+
+ private function createRoom(int $ownerId)
+ {
+ return Room::forceCreate([
+ 'id' => 100, // Safe room id
+ 'room_name' => 'test',
+ 'room_auto' => 'open',
+ 'room_owner' => 'owner',
+ 'room_des' => 'test description',
+ 'announcement' => 'welcome',
+ 'room_top' => '',
+ 'room_title' => 'test title',
+ 'room_keep' => 0,
+ 'room_time' => now(),
+ 'room_tt' => 0,
+ 'room_html' => 0,
+ 'room_exp' => 0,
+ 'build_time' => now(),
+ 'permit_level' => 0,
+ 'door_open' => 1,
+ 'ooooo' => 0,
+ 'visit_num' => 0,
+ ]);
+ }
+
+ public function test_can_create_pve_game()
+ {
+ /** @var \App\Models\User $owner */
+ $owner = User::factory()->create();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $room = $this->createRoom($owner->id);
+
+ $response = $this->actingAs($user)->postJson(route('gomoku.create'), [
+ 'mode' => 'pve',
+ 'room_id' => $room->id,
+ 'ai_level' => 1,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+ $this->assertDatabaseHas('gomoku_games', [
+ 'mode' => 'pve',
+ 'player_black_id' => $user->id,
+ ]);
+ }
+
+ public function test_can_create_pvp_game()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $owner */
+ $owner = User::factory()->create();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $room = $this->createRoom($owner->id);
+
+ $response = $this->actingAs($user)->postJson(route('gomoku.create'), [
+ 'mode' => 'pvp',
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+ $this->assertDatabaseHas('gomoku_games', [
+ 'mode' => 'pvp',
+ 'status' => 'waiting',
+ 'player_black_id' => $user->id,
+ ]);
+ }
+
+ public function test_user_can_join_pvp()
+ {
+ /** @var \App\Models\User $owner */
+ $owner = User::factory()->create();
+
+ /** @var \App\Models\User $user1 */
+ $user1 = User::factory()->create();
+
+ /** @var \App\Models\User $user2 */
+ $user2 = User::factory()->create();
+
+ $room = $this->createRoom($owner->id);
+
+ $game = GomokuGame::create([
+ 'mode' => 'pvp',
+ 'room_id' => $room->id,
+ 'player_black_id' => $user1->id,
+ 'status' => 'waiting',
+ 'board' => [],
+ 'current_turn' => 1,
+ 'entry_fee' => 0,
+ 'invite_expires_at' => now()->addMinutes(1),
+ ]);
+
+ $response = $this->actingAs($user2)->postJson(route('gomoku.join', $game));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+ $this->assertEquals($user2->id, $game->fresh()->player_white_id);
+ $this->assertEquals('playing', $game->fresh()->status);
+ }
+
+ public function test_user_can_get_active_game()
+ {
+ /** @var \App\Models\User $owner */
+ $owner = User::factory()->create();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $room = $this->createRoom($owner->id);
+
+ $game = GomokuGame::create([
+ 'mode' => 'pve',
+ 'room_id' => $room->id,
+ 'player_black_id' => $user->id,
+ 'ai_level' => 1,
+ 'status' => 'playing',
+ 'board' => [],
+ 'current_turn' => 1,
+ 'entry_fee' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('gomoku.active'));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'ok' => true,
+ 'has_active' => true,
+ 'game_id' => $game->id,
+ ]);
+ }
+}
diff --git a/tests/Feature/GuestbookControllerTest.php b/tests/Feature/GuestbookControllerTest.php
new file mode 100644
index 0000000..78ebfb0
--- /dev/null
+++ b/tests/Feature/GuestbookControllerTest.php
@@ -0,0 +1,173 @@
+create();
+ $otherUser = User::factory()->create();
+
+ // Public message
+ Guestbook::create([
+ 'who' => $otherUser->username,
+ 'towho' => null,
+ 'secret' => 0,
+ 'text_title' => 'Public Title',
+ 'text_body' => 'Public message body',
+ 'ip' => '127.0.0.1',
+ 'post_time' => now(),
+ ]);
+
+ // Secret message to someone else
+ Guestbook::create([
+ 'who' => $otherUser->username,
+ 'towho' => 'anotheruser',
+ 'secret' => 1,
+ 'text_title' => 'Secret Title',
+ 'text_body' => 'Secret message body',
+ 'ip' => '127.0.0.1',
+ 'post_time' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->get(route('guestbook.index', ['tab' => 'public']));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('guestbook.index');
+ $response->assertSee('Public message body');
+ $response->assertDontSee('Secret message body');
+ }
+
+ public function test_can_post_public_message()
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('guestbook.store'), [
+ 'text_title' => 'Hello',
+ 'text_body' => 'World',
+ ]);
+
+ $response->assertRedirect();
+
+ $this->assertDatabaseHas('guestbooks', [
+ 'who' => $user->username,
+ 'towho' => null,
+ 'secret' => 0,
+ 'text_body' => 'World',
+ ]);
+ }
+
+ public function test_can_post_secret_message_to_user()
+ {
+ $user = User::factory()->create();
+ $targetUser = User::factory()->create(['username' => 'target']);
+
+ $response = $this->actingAs($user)->post(route('guestbook.store'), [
+ 'text_title' => 'Secret',
+ 'text_body' => 'Top secret',
+ 'towho' => 'target',
+ 'secret' => 1,
+ ]);
+
+ $response->assertRedirect();
+
+ $this->assertDatabaseHas('guestbooks', [
+ 'who' => $user->username,
+ 'towho' => 'target',
+ 'secret' => 1,
+ 'text_body' => 'Top secret',
+ ]);
+ }
+
+ public function test_cannot_post_message_to_non_existent_user()
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('guestbook.store'), [
+ 'text_title' => 'Secret',
+ 'text_body' => 'Top secret',
+ 'towho' => 'nonexistent',
+ 'secret' => 1,
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('error');
+
+ $this->assertDatabaseMissing('guestbooks', [
+ 'who' => $user->username,
+ 'towho' => 'nonexistent',
+ ]);
+ }
+
+ public function test_user_can_delete_own_message()
+ {
+ $user = User::factory()->create();
+
+ $message = Guestbook::create([
+ 'who' => $user->username,
+ 'towho' => null,
+ 'secret' => 0,
+ 'text_title' => 'My Body',
+ 'text_body' => 'Delete me',
+ 'ip' => '127.0.0.1',
+ 'post_time' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->delete(route('guestbook.destroy', $message->id));
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+ $this->assertDatabaseMissing('guestbooks', ['id' => $message->id]);
+ }
+
+ public function test_user_cannot_delete_others_message()
+ {
+ $owner = User::factory()->create();
+ $otherUser = User::factory()->create(['user_level' => 1]); // regular user
+
+ $message = Guestbook::create([
+ 'who' => $owner->username,
+ 'towho' => null,
+ 'secret' => 0,
+ 'text_title' => 'Their Body',
+ 'text_body' => 'Cant touch this',
+ 'ip' => '127.0.0.1',
+ 'post_time' => now(),
+ ]);
+
+ $response = $this->actingAs($otherUser)->delete(route('guestbook.destroy', $message->id));
+
+ $response->assertStatus(403);
+ $this->assertDatabaseHas('guestbooks', ['id' => $message->id]);
+ }
+
+ public function test_admin_can_delete_others_message()
+ {
+ $owner = User::factory()->create();
+ $admin = User::factory()->create(['user_level' => 15]);
+
+ $message = Guestbook::create([
+ 'who' => $owner->username,
+ 'towho' => null,
+ 'secret' => 0,
+ 'text_title' => 'Their Body',
+ 'text_body' => 'Delete by admin',
+ 'ip' => '127.0.0.1',
+ 'post_time' => now(),
+ ]);
+
+ $response = $this->actingAs($admin)->delete(route('guestbook.destroy', $message->id));
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+ $this->assertDatabaseMissing('guestbooks', ['id' => $message->id]);
+ }
+}
diff --git a/tests/Feature/HolidayControllerTest.php b/tests/Feature/HolidayControllerTest.php
new file mode 100644
index 0000000..55fb92b
--- /dev/null
+++ b/tests/Feature/HolidayControllerTest.php
@@ -0,0 +1,139 @@
+create();
+ $event = HolidayEvent::create([
+ 'name' => 'Test Holiday',
+ 'status' => 'active',
+ 'expires_at' => now()->addDays(1),
+ 'max_claimants' => 10,
+ 'claimed_amount' => 0,
+ 'total_amount' => 5000,
+ 'distribute_type' => 'fixed',
+ 'send_at' => now(),
+ 'repeat_type' => 'once',
+ 'target_type' => 'all',
+ ]);
+
+ HolidayClaim::create([
+ 'event_id' => $event->id,
+ 'user_id' => $user->id,
+ 'amount' => 500,
+ 'claimed_at' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('holiday.status', ['event' => $event->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'claimable' => true,
+ 'amount' => 500,
+ ]);
+ }
+
+ public function test_can_claim_holiday_bonus()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 100]);
+ $event = HolidayEvent::create([
+ 'name' => 'Test Holiday',
+ 'status' => 'active',
+ 'expires_at' => now()->addDays(1),
+ 'max_claimants' => 10,
+ 'claimed_amount' => 0,
+ 'total_amount' => 5000,
+ 'distribute_type' => 'fixed',
+ 'send_at' => now(),
+ 'repeat_type' => 'once',
+ 'target_type' => 'all',
+ ]);
+
+ HolidayClaim::create([
+ 'event_id' => $event->id,
+ 'user_id' => $user->id,
+ 'amount' => 500,
+ 'claimed_at' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('holiday.claim', ['event' => $event->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ // Verify currency incremented
+ $this->assertEquals(600, $user->fresh()->jjb);
+
+ // Verify claim is deleted
+ $this->assertDatabaseMissing('holiday_claims', [
+ 'event_id' => $event->id,
+ 'user_id' => $user->id,
+ ]);
+ }
+
+ public function test_cannot_claim_if_not_in_list()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ $event = HolidayEvent::create([
+ 'name' => 'Test Holiday',
+ 'status' => 'active',
+ 'expires_at' => now()->addDays(1),
+ 'max_claimants' => 10,
+ 'claimed_amount' => 0,
+ 'total_amount' => 5000,
+ 'distribute_type' => 'fixed',
+ 'send_at' => now(),
+ 'repeat_type' => 'once',
+ 'target_type' => 'all',
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('holiday.claim', ['event' => $event->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
+ }
+
+ public function test_cannot_claim_if_expired()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ $event = HolidayEvent::create([
+ 'name' => 'Test Holiday',
+ 'status' => 'completed',
+ 'expires_at' => now()->subDays(1),
+ 'max_claimants' => 10,
+ 'claimed_amount' => 0,
+ 'total_amount' => 5000,
+ 'distribute_type' => 'fixed',
+ 'send_at' => now(),
+ 'repeat_type' => 'once',
+ 'target_type' => 'all',
+ ]);
+
+ HolidayClaim::create([
+ 'event_id' => $event->id,
+ 'user_id' => $user->id,
+ 'amount' => 500,
+ 'claimed_at' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('holiday.claim', ['event' => $event->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false, 'message' => '活动已结束或已过期。']);
+ }
+}
diff --git a/tests/Feature/HorseRaceControllerTest.php b/tests/Feature/HorseRaceControllerTest.php
new file mode 100644
index 0000000..517715b
--- /dev/null
+++ b/tests/Feature/HorseRaceControllerTest.php
@@ -0,0 +1,185 @@
+ 'horse_racing'],
+ [
+ 'name' => 'Horse Racing',
+ 'icon' => 'horse',
+ 'description' => 'Horse Racing Game',
+ 'enabled' => true,
+ 'params' => [
+ 'min_bet' => 100,
+ 'max_bet' => 100000,
+ 'house_take_percent' => 5,
+ ],
+ ]
+ );
+ }
+
+ public function test_can_get_current_race()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $race = HorseRace::create([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'horses' => [
+ ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
+ ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
+ ],
+ 'total_bets' => 0,
+ 'total_pool' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('horse-race.current'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['race' => ['id', 'status', 'bet_closes_at', 'horses']]);
+ $this->assertEquals($race->id, $response->json('race.id'));
+ }
+
+ public function test_can_bet()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 500]);
+
+ $race = HorseRace::create([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'horses' => [
+ ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
+ ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
+ ],
+ 'total_bets' => 0,
+ 'total_pool' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('horse-race.bet'), [
+ 'race_id' => $race->id,
+ 'horse_id' => 1,
+ 'amount' => 100,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ $this->assertEquals(400, $user->fresh()->jjb);
+ $this->assertDatabaseHas('horse_bets', [
+ 'race_id' => $race->id,
+ 'user_id' => $user->id,
+ 'horse_id' => 1,
+ 'amount' => 100,
+ ]);
+ }
+
+ public function test_cannot_bet_out_of_range()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 500]);
+
+ $race = HorseRace::create([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'horses' => [
+ ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
+ ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
+ ],
+ 'total_bets' => 0,
+ 'total_pool' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('horse-race.bet'), [
+ 'race_id' => $race->id,
+ 'horse_id' => 1,
+ 'amount' => 50, // Less than min_bet
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false]);
+ }
+
+ public function test_cannot_bet_twice_in_same_race()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 500]);
+
+ $race = HorseRace::create([
+ 'status' => 'betting',
+ 'bet_opens_at' => now(),
+ 'bet_closes_at' => now()->addMinutes(1),
+ 'horses' => [
+ ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
+ ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
+ ],
+ 'total_bets' => 0,
+ 'total_pool' => 0,
+ ]);
+
+ HorseBet::forceCreate([
+ 'race_id' => $race->id,
+ 'user_id' => $user->id,
+ 'horse_id' => 1,
+ 'amount' => 100,
+ 'status' => 'pending',
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('horse-race.bet'), [
+ 'race_id' => $race->id,
+ 'horse_id' => 2,
+ 'amount' => 100,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false]);
+ }
+
+ public function test_can_get_history()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ HorseRace::create([
+ 'status' => 'settled',
+ 'bet_opens_at' => now()->subMinutes(2),
+ 'bet_closes_at' => now()->subMinutes(1),
+ 'settled_at' => now(),
+ 'winner_horse_id' => 1,
+ 'horses' => [
+ ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
+ ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
+ ],
+ 'total_bets' => 1,
+ 'total_pool' => 100,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('horse-race.history'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['history']);
+ $this->assertCount(1, $response->json('history'));
+ }
+}
diff --git a/tests/Feature/InviteControllerTest.php b/tests/Feature/InviteControllerTest.php
new file mode 100644
index 0000000..43d004e
--- /dev/null
+++ b/tests/Feature/InviteControllerTest.php
@@ -0,0 +1,43 @@
+create();
+
+ $response = $this->get(route('invite.link', $inviter->id));
+
+ $response->assertRedirect(route('home'));
+ $response->assertCookie('inviter_id', $inviter->id);
+ }
+
+ public function test_handle_ignores_invalid_inviter()
+ {
+ $response = $this->get(route('invite.link', 99999));
+
+ $response->assertRedirect(route('home'));
+ $response->assertCookieMissing('inviter_id');
+ }
+
+ public function test_leaderboard_displays_inviters()
+ {
+ $inviter = User::factory()->create();
+ $user = User::factory()->create();
+
+ User::factory()->count(2)->create(['inviter_id' => $inviter->id]);
+
+ $response = $this->actingAs($user)->get(route('invite.leaderboard'));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('invite.leaderboard');
+ }
+}
diff --git a/tests/Feature/LeaderboardControllerTest.php b/tests/Feature/LeaderboardControllerTest.php
new file mode 100644
index 0000000..69eb075
--- /dev/null
+++ b/tests/Feature/LeaderboardControllerTest.php
@@ -0,0 +1,73 @@
+create(['exp_num' => 10, 'jjb' => 100, 'meili' => 5]);
+
+ Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
+ Sysparam::updateOrCreate(['alias' => 'leaderboard_limit'], ['body' => '20']);
+
+ // Create users for leaderboard
+ User::factory()->create(['user_level' => 10, 'exp_num' => 100, 'jjb' => 1000, 'meili' => 50]);
+ User::factory()->create(['user_level' => 5, 'exp_num' => 50, 'jjb' => 500, 'meili' => 20]);
+
+ // Super admin should be hidden
+ User::factory()->create(['user_level' => 100, 'exp_num' => 10000, 'jjb' => 100000, 'meili' => 5000]);
+
+ $response = $this->actingAs($user)->get(route('leaderboard.index'));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('leaderboard.index');
+
+ $topLevels = $response->viewData('topLevels');
+ $this->assertNotNull($topLevels);
+ $this->assertEquals(3, $topLevels->count()); // Excludes super admin
+ $this->assertEquals(10, $topLevels->first()->user_level);
+
+ $topExp = $response->viewData('topExp');
+ $this->assertNotNull($topExp);
+ $this->assertEquals(3, $topExp->count());
+ $this->assertEquals(100, $topExp->first()->exp_num);
+
+ $topWealth = $response->viewData('topWealth');
+ $this->assertNotNull($topWealth);
+ $this->assertEquals(3, $topWealth->count());
+ $this->assertEquals(1000, $topWealth->first()->jjb);
+
+ $topCharm = $response->viewData('topCharm');
+ $this->assertNotNull($topCharm);
+ $this->assertEquals(3, $topCharm->count());
+ $this->assertEquals(50, $topCharm->first()->meili);
+ }
+
+ public function test_can_view_today_leaderboard()
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->get(route('leaderboard.today'));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('leaderboard.today');
+ }
+
+ public function test_can_view_my_currency_logs()
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->get(route('currency.my-logs'));
+
+ $response->assertStatus(200);
+ $response->assertViewIs('leaderboard.my-logs');
+ }
+}
diff --git a/tests/Feature/LotteryControllerTest.php b/tests/Feature/LotteryControllerTest.php
new file mode 100644
index 0000000..3abd09f
--- /dev/null
+++ b/tests/Feature/LotteryControllerTest.php
@@ -0,0 +1,214 @@
+ 'lottery'],
+ [
+ 'name' => 'Lottery',
+ 'icon' => 'lottery',
+ 'description' => 'Lottery Game',
+ 'enabled' => true,
+ 'params' => [
+ 'ticket_price' => 100,
+ ],
+ ]
+ );
+ }
+
+ public function test_can_get_current()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $issue = LotteryIssue::create([
+ 'issue_no' => '2026101',
+ 'status' => 'open',
+ 'red1' => 0,
+ 'red2' => 0,
+ 'red3' => 0,
+ 'blue' => 0,
+ 'pool_amount' => 5000,
+ 'carry_amount' => 0,
+ 'is_super_issue' => false,
+ 'no_winner_streak' => 0,
+ 'total_tickets' => 0,
+ 'payout_amount' => 0,
+ 'sell_closes_at' => now()->addMinutes(1),
+ 'draw_at' => now()->addMinutes(2),
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('lottery.current'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['issue' => ['id', 'issue_no', 'status', 'pool_amount']]);
+ $this->assertEquals($issue->id, $response->json('issue.id'));
+ }
+
+ public function test_can_quick_pick()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->getJson(route('lottery.quick-pick', ['count' => 2]));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['numbers']);
+ $this->assertCount(2, $response->json('numbers'));
+ }
+
+ public function test_cannot_buy_without_enough_gold()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 50]);
+
+ LotteryIssue::create([
+ 'issue_no' => '2026101',
+ 'status' => 'open',
+ 'red1' => 0,
+ 'red2' => 0,
+ 'red3' => 0,
+ 'blue' => 0,
+ 'pool_amount' => 5000,
+ 'carry_amount' => 0,
+ 'is_super_issue' => false,
+ 'no_winner_streak' => 0,
+ 'total_tickets' => 0,
+ 'payout_amount' => 0,
+ 'sell_closes_at' => now()->addMinutes(1),
+ 'draw_at' => now()->addMinutes(2),
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('lottery.buy'), [
+ 'numbers' => [
+ [
+ 'reds' => [1, 2, 3],
+ 'blue' => 4,
+ ],
+ ],
+ 'quick_pick' => false,
+ ]);
+
+ $response->assertStatus(422); // Exception thrown with 422
+ }
+
+ public function test_can_buy_with_enough_gold()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 200]);
+
+ LotteryIssue::create([
+ 'issue_no' => '2026101',
+ 'status' => 'open',
+ 'red1' => 0,
+ 'red2' => 0,
+ 'red3' => 0,
+ 'blue' => 0,
+ 'pool_amount' => 5000,
+ 'carry_amount' => 0,
+ 'is_super_issue' => false,
+ 'no_winner_streak' => 0,
+ 'total_tickets' => 0,
+ 'payout_amount' => 0,
+ 'sell_closes_at' => now()->addMinutes(1),
+ 'draw_at' => now()->addMinutes(2),
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('lottery.buy'), [
+ 'numbers' => [
+ [
+ 'reds' => [1, 2, 3],
+ 'blue' => 4,
+ ],
+ ],
+ 'quick_pick' => false,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['status' => 'success']);
+
+ $this->assertEquals(100, $user->fresh()->jjb); // cost is 100 per ticket
+ }
+
+ public function test_can_get_history()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ LotteryIssue::create([
+ 'issue_no' => '2026100',
+ 'status' => 'settled',
+ 'red1' => 1,
+ 'red2' => 2,
+ 'red3' => 3,
+ 'blue' => 4,
+ 'pool_amount' => 5000,
+ 'carry_amount' => 0,
+ 'is_super_issue' => false,
+ 'no_winner_streak' => 0,
+ 'total_tickets' => 10,
+ 'payout_amount' => 1000,
+ 'draw_at' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('lottery.history'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['issues']);
+ $this->assertCount(1, $response->json('issues'));
+ }
+
+ public function test_can_get_my_tickets()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $issue = LotteryIssue::create([
+ 'issue_no' => '2026100',
+ 'status' => 'settled',
+ 'red1' => 1,
+ 'red2' => 2,
+ 'red3' => 3,
+ 'blue' => 4,
+ 'pool_amount' => 5000,
+ 'carry_amount' => 0,
+ 'is_super_issue' => false,
+ 'no_winner_streak' => 0,
+ 'total_tickets' => 10,
+ 'payout_amount' => 1000,
+ 'draw_at' => now(),
+ ]);
+
+ LotteryTicket::create([
+ 'issue_id' => $issue->id,
+ 'user_id' => $user->id,
+ 'red1' => 1,
+ 'red2' => 2,
+ 'red3' => 3,
+ 'blue' => 4,
+ 'amount' => 100,
+ 'is_quick_pick' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('lottery.my'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['tickets']);
+ $this->assertCount(1, $response->json('tickets'));
+ }
+}
diff --git a/tests/Feature/MarriageControllerTest.php b/tests/Feature/MarriageControllerTest.php
new file mode 100644
index 0000000..110b871
--- /dev/null
+++ b/tests/Feature/MarriageControllerTest.php
@@ -0,0 +1,269 @@
+ 'test',
+ 'hyname1' => 'test1',
+ 'hytime' => now(),
+ 'hygb' => 'test',
+ 'hyjb' => 'test',
+ 'i' => 0,
+ ];
+ }
+
+ public function test_can_get_divorce_config()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->getJson(route('marriage.divorce-config'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure([
+ 'mutual_charm_penalty',
+ 'forced_charm_penalty',
+ 'mutual_cooldown_days',
+ 'forced_cooldown_days',
+ ]);
+ }
+
+ public function test_can_get_status_unmarried()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->getJson(route('marriage.status'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['married' => false]);
+ }
+
+ public function test_can_get_status_married()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ /** @var \App\Models\User $partner */
+ $partner = User::factory()->create();
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $user->id,
+ 'partner_id' => $partner->id,
+ 'status' => 'married',
+ 'married_at' => now(),
+ 'intimacy' => 100,
+ ], $this->createLegacyMarriageData()));
+
+ $response = $this->actingAs($user)->getJson(route('marriage.status'));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'married' => true,
+ 'status' => 'married',
+ ]);
+ $response->assertJsonPath('marriage.partner.id', $partner->id);
+ }
+
+ public function test_can_get_target_status()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ /** @var \App\Models\User $target */
+ $target = User::factory()->create();
+ /** @var \App\Models\User $partner */
+ $partner = User::factory()->create();
+
+ Marriage::create(array_merge([
+ 'user_id' => $target->id,
+ 'partner_id' => $partner->id,
+ 'status' => 'married',
+ 'married_at' => now(),
+ ], $this->createLegacyMarriageData()));
+
+ $response = $this->actingAs($user)->getJson(route('marriage.target-status', [
+ 'username' => $target->username,
+ ]));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'married' => true,
+ ]);
+ $response->assertJsonPath('marriage.partner_name', $partner->username);
+ }
+
+ public function test_can_get_my_rings()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $ring = ShopItem::create([
+ 'name' => 'Diamond Ring',
+ 'slug' => 'diamond_ring',
+ 'type' => 'ring',
+ 'price' => 1000,
+ 'currency' => 'gold',
+ 'use_type' => 'permanent',
+ ]);
+
+ $purchase = UserPurchase::create([
+ 'user_id' => $user->id,
+ 'shop_item_id' => $ring->id,
+ 'status' => 'active',
+ 'number' => 1,
+ 'amount' => 1000,
+ 'currency' => 'gold',
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('marriage.rings'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['status', 'rings']);
+ $this->assertCount(1, $response->json('rings'));
+ $this->assertEquals($purchase->id, $response->json('rings.0.purchase_id'));
+ }
+
+ public function test_can_propose()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 10000, 'sex' => 1]);
+ /** @var \App\Models\User $target */
+ $target = User::factory()->create(['sex' => 2]);
+
+ $ring = ShopItem::create([
+ 'name' => 'Diamond Ring',
+ 'slug' => 'diamond_ring',
+ 'type' => 'ring',
+ 'price' => 1000,
+ 'currency' => 'gold',
+ 'use_type' => 'permanent',
+ ]);
+
+ $purchase = UserPurchase::create([
+ 'user_id' => $user->id,
+ 'shop_item_id' => $ring->id,
+ 'status' => 'active',
+ 'number' => 1,
+ 'amount' => 1000,
+ 'currency' => 'gold',
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('marriage.propose'), [
+ 'target_username' => $target->username,
+ 'ring_purchase_id' => $purchase->id,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ Event::assertDispatched(\App\Events\MarriageProposed::class);
+ }
+
+ public function test_can_accept_proposal()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ /** @var \App\Models\User $partner */
+ $partner = User::factory()->create();
+
+ $ring = ShopItem::create([
+ 'name' => 'Diamond',
+ 'slug' => 'diamond',
+ 'type' => 'ring',
+ 'price' => 1000,
+ 'currency' => 'gold',
+ 'use_type' => 'permanent',
+ ]);
+
+ $purchase = UserPurchase::create([
+ 'user_id' => $partner->id,
+ 'shop_item_id' => $ring->id,
+ 'status' => 'active',
+ 'number' => 1,
+ 'amount' => 1000,
+ 'currency' => 'gold',
+ ]);
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $partner->id,
+ 'partner_id' => $user->id,
+ 'status' => 'pending',
+ 'ring_purchase_id' => $purchase->id,
+ 'proposed_at' => now(),
+ ], $this->createLegacyMarriageData()));
+
+ $response = $this->actingAs($user)->postJson(route('marriage.accept', ['marriage' => $marriage->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ Event::assertDispatched(\App\Events\MarriageAccepted::class);
+ }
+
+ public function test_can_reject_proposal()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ /** @var \App\Models\User $partner */
+ $partner = User::factory()->create();
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $partner->id,
+ 'partner_id' => $user->id,
+ 'status' => 'pending',
+ 'proposed_at' => now(),
+ ], $this->createLegacyMarriageData()));
+
+ $response = $this->actingAs($user)->postJson(route('marriage.reject', ['marriage' => $marriage->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ Event::assertDispatched(\App\Events\MarriageRejected::class);
+ }
+
+ public function test_can_divorce_mutual()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ /** @var \App\Models\User $partner */
+ $partner = User::factory()->create();
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $user->id,
+ 'partner_id' => $partner->id,
+ 'status' => 'married',
+ 'married_at' => now()->subDays(10), // Needs to have marriage age
+ ], $this->createLegacyMarriageData()));
+
+ $response = $this->actingAs($user)->postJson(route('marriage.divorce', ['marriage' => $marriage->id]), [
+ 'type' => 'mutual',
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ Event::assertDispatched(\App\Events\MarriageDivorceRequested::class);
+ }
+}
diff --git a/tests/Feature/RedPacketControllerTest.php b/tests/Feature/RedPacketControllerTest.php
new file mode 100644
index 0000000..d7ee26e
--- /dev/null
+++ b/tests/Feature/RedPacketControllerTest.php
@@ -0,0 +1,165 @@
+ 'superlevel'], ['body' => '100']);
+ }
+
+ public function test_normal_user_cannot_send_red_packet()
+ {
+ $user = User::factory()->create(['user_level' => 10]);
+
+ $response = $this->actingAs($user)->postJson(route('command.red_packet.send'), [
+ 'room_id' => 1,
+ 'type' => 'gold',
+ ]);
+
+ $response->assertStatus(403);
+ $response->assertJson(['status' => 'error']);
+ }
+
+ public function test_superadmin_can_send_red_packet()
+ {
+ $admin = User::factory()->create(['user_level' => 100]);
+
+ $response = $this->actingAs($admin)->postJson(route('command.red_packet.send'), [
+ 'room_id' => 1,
+ 'type' => 'gold',
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['status' => 'success']);
+
+ $this->assertDatabaseHas('red_packet_envelopes', [
+ 'sender_id' => $admin->id,
+ 'room_id' => 1,
+ 'type' => 'gold',
+ 'status' => 'active',
+ ]);
+
+ $envelope = RedPacketEnvelope::first();
+ // Check Redis for parts
+ $this->assertEquals(10, Redis::llen("red_packet:{$envelope->id}:amounts"));
+ }
+
+ public function test_cannot_send_multiple_active_packets_in_same_room()
+ {
+ $admin = User::factory()->create(['user_level' => 100]);
+
+ $this->actingAs($admin)->postJson(route('command.red_packet.send'), [
+ 'room_id' => 1,
+ 'type' => 'gold',
+ ]);
+
+ $response = $this->actingAs($admin)->postJson(route('command.red_packet.send'), [
+ 'room_id' => 1,
+ 'type' => 'gold',
+ ]);
+
+ $response->assertStatus(422);
+ }
+
+ public function test_user_can_claim_red_packet()
+ {
+ $admin = User::factory()->create(['user_level' => 100]);
+ $user = User::factory()->create(['jjb' => 100]);
+
+ // Send packet
+ $this->actingAs($admin)->postJson(route('command.red_packet.send'), [
+ 'room_id' => 1,
+ 'type' => 'gold',
+ ]);
+
+ $envelope = RedPacketEnvelope::first();
+
+ // Claim packet
+ $response = $this->actingAs($user)->postJson(route('red_packet.claim', ['envelopeId' => $envelope->id]), [
+ 'room_id' => 1,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['status' => 'success']);
+
+ $this->assertDatabaseHas('red_packet_claims', [
+ 'envelope_id' => $envelope->id,
+ 'user_id' => $user->id,
+ ]);
+
+ // Verify currency incremented
+ $this->assertGreaterThan(100, $user->fresh()->jjb);
+ }
+
+ public function test_user_cannot_claim_same_packet_twice()
+ {
+ $admin = User::factory()->create(['user_level' => 100]);
+ $user = User::factory()->create();
+
+ $this->actingAs($admin)->postJson(route('command.red_packet.send'), [
+ 'room_id' => 1,
+ 'type' => 'gold',
+ ]);
+
+ $envelope = RedPacketEnvelope::first();
+
+ // First claim
+ $this->actingAs($user)->postJson(route('red_packet.claim', ['envelopeId' => $envelope->id]), [
+ 'room_id' => 1,
+ ]);
+
+ // Second claim
+ $response = $this->actingAs($user)->postJson(route('red_packet.claim', ['envelopeId' => $envelope->id]), [
+ 'room_id' => 1,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJson(['message' => '您已经领过这个礼包了']);
+ }
+
+ public function test_can_check_packet_status()
+ {
+ $admin = User::factory()->create(['user_level' => 100]);
+ $user = User::factory()->create();
+
+ $this->actingAs($admin)->postJson(route('command.red_packet.send'), [
+ 'room_id' => 1,
+ 'type' => 'gold',
+ ]);
+
+ $envelope = RedPacketEnvelope::first();
+
+ $response = $this->actingAs($user)->getJson(route('red_packet.status', ['envelopeId' => $envelope->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'status' => 'success',
+ 'has_claimed' => false,
+ 'is_expired' => false,
+ ]);
+
+ // Claim it
+ $this->actingAs($user)->postJson(route('red_packet.claim', ['envelopeId' => $envelope->id]), [
+ 'room_id' => 1,
+ ]);
+
+ $response2 = $this->actingAs($user)->getJson(route('red_packet.status', ['envelopeId' => $envelope->id]));
+ $response2->assertJson([
+ 'status' => 'success',
+ 'has_claimed' => true,
+ ]);
+ }
+}
diff --git a/tests/Feature/RoomControllerTest.php b/tests/Feature/RoomControllerTest.php
new file mode 100644
index 0000000..01bc038
--- /dev/null
+++ b/tests/Feature/RoomControllerTest.php
@@ -0,0 +1,201 @@
+create();
+ $room = Room::create([
+ 'room_name' => 'TestRoom',
+ 'room_owner' => $user->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($user)->get(route('rooms.index'));
+
+ $response->assertStatus(200);
+ $response->assertSee('TestRoom');
+ }
+
+ public function test_can_create_room_if_level_is_high_enough()
+ {
+ // Require level 10
+ $user = User::factory()->create(['user_level' => 10]);
+
+ $response = $this->actingAs($user)->post(route('rooms.store'), [
+ 'name' => 'NewRoom',
+ 'description' => 'Test Description',
+ ]);
+
+ $response->assertRedirect(route('rooms.index'));
+ $this->assertDatabaseHas('rooms', [
+ 'room_name' => 'NewRoom',
+ 'room_owner' => $user->username,
+ ]);
+ }
+
+ public function test_cannot_create_room_if_level_too_low()
+ {
+ $user = User::factory()->create(['user_level' => 9]);
+
+ $response = $this->actingAs($user)->post(route('rooms.store'), [
+ 'name' => 'NewRoom',
+ ]);
+
+ $response->assertStatus(403);
+ $this->assertDatabaseMissing('rooms', [
+ 'room_name' => 'NewRoom',
+ ]);
+ }
+
+ public function test_room_owner_can_update_room()
+ {
+ $user = User::factory()->create();
+ $room = Room::create([
+ 'room_name' => 'OldName',
+ 'room_owner' => $user->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($user)->put(route('rooms.update', $room->id), [
+ 'name' => 'NewName',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+ $this->assertDatabaseHas('rooms', [
+ 'id' => $room->id,
+ 'room_name' => 'NewName',
+ ]);
+ }
+
+ public function test_non_owner_cannot_update_room()
+ {
+ $owner = User::factory()->create(['user_level' => 1]);
+ $attacker = User::factory()->create(['user_level' => 1]);
+ $room = Room::create([
+ 'room_name' => 'OldName',
+ 'room_owner' => $owner->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($attacker)->put(route('rooms.update', $room->id), [
+ 'name' => 'HackName',
+ ]);
+
+ $response->assertStatus(403);
+ $this->assertDatabaseHas('rooms', [
+ 'id' => $room->id,
+ 'room_name' => 'OldName',
+ ]);
+ }
+
+ public function test_admin_can_update_any_room()
+ {
+ $owner = User::factory()->create(['user_level' => 1]);
+ $admin = User::factory()->create(['user_level' => 15]);
+ $room = Room::create([
+ 'room_name' => 'OldName',
+ 'room_owner' => $owner->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($admin)->put(route('rooms.update', $room->id), [
+ 'name' => 'AdminRoom',
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('rooms', [
+ 'id' => $room->id,
+ 'room_name' => 'AdminRoom',
+ ]);
+ }
+
+ public function test_room_owner_can_destroy_non_system_room()
+ {
+ $user = User::factory()->create();
+ $room = Room::create([
+ 'room_name' => 'ToDelete',
+ 'room_owner' => $user->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($user)->delete(route('rooms.destroy', $room->id));
+
+ $response->assertRedirect(route('rooms.index'));
+ $this->assertDatabaseMissing('rooms', [
+ 'id' => $room->id,
+ ]);
+ }
+
+ public function test_cannot_destroy_system_room()
+ {
+ $user = User::factory()->create(['user_level' => 20]);
+ $room = Room::create([
+ 'room_name' => 'SysRoom',
+ 'room_owner' => $user->username,
+ 'room_keep' => true,
+ ]);
+
+ $response = $this->actingAs($user)->delete(route('rooms.destroy', $room->id));
+
+ $response->assertStatus(403);
+ $this->assertDatabaseHas('rooms', [
+ 'id' => $room->id,
+ ]);
+ }
+
+ public function test_room_owner_can_transfer_room()
+ {
+ $owner = User::factory()->create();
+ $target = User::factory()->create();
+
+ $room = Room::create([
+ 'room_name' => 'TransferMe',
+ 'room_owner' => $owner->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($owner)->post(route('rooms.transfer', $room->id), [
+ 'target_username' => $target->username,
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+ $this->assertDatabaseHas('rooms', [
+ 'id' => $room->id,
+ 'room_owner' => $target->username,
+ ]);
+ }
+
+ public function test_cannot_transfer_to_invalid_user()
+ {
+ $owner = User::factory()->create();
+
+ $room = Room::create([
+ 'room_name' => 'TransferMe',
+ 'room_owner' => $owner->username,
+ 'room_keep' => false,
+ ]);
+
+ $response = $this->actingAs($owner)->post(route('rooms.transfer', $room->id), [
+ 'target_username' => 'ghost_user_999',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('error');
+ $this->assertDatabaseHas('rooms', [
+ 'id' => $room->id,
+ 'room_owner' => $owner->username,
+ ]);
+ }
+}
diff --git a/tests/Feature/ShopControllerTest.php b/tests/Feature/ShopControllerTest.php
new file mode 100644
index 0000000..679d490
--- /dev/null
+++ b/tests/Feature/ShopControllerTest.php
@@ -0,0 +1,169 @@
+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_can_buy_one_time_item()
+ {
+ $user = User::factory()->create(['jjb' => 500]);
+
+ $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' => 1,
+ ]);
+
+ $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]);
+
+ $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,
+ ]);
+
+ $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]);
+
+ $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,
+ ]);
+
+ $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',
+ ]);
+ }
+}
diff --git a/tests/Feature/SlotMachineControllerTest.php b/tests/Feature/SlotMachineControllerTest.php
new file mode 100644
index 0000000..6b13393
--- /dev/null
+++ b/tests/Feature/SlotMachineControllerTest.php
@@ -0,0 +1,143 @@
+ 'slot_machine'],
+ [
+ 'name' => 'Slot Machine',
+ 'icon' => 'slot',
+ 'description' => 'Slot Machine Game',
+ 'enabled' => true,
+ 'params' => [
+ 'cost_per_spin' => 100,
+ 'daily_limit' => 100,
+ 'jackpot_payout' => 100,
+ 'triple_payout' => 50,
+ 'same_payout' => 10,
+ 'pair_payout' => 2,
+ 'curse_enabled' => true,
+ ],
+ ]
+ );
+ }
+
+ public function test_can_get_info()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->getJson(route('slot.info'));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'enabled' => true,
+ 'cost_per_spin' => 100,
+ 'daily_limit' => 100,
+ 'used_today' => 0,
+ ]);
+ $response->assertJsonStructure(['symbols']);
+ }
+
+ public function test_can_spin_enough_gold()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 200]);
+
+ $response = $this->actingAs($user)->postJson(route('slot.spin'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ $this->assertDatabaseHas('slot_machine_logs', [
+ 'user_id' => $user->id,
+ 'cost' => 100,
+ ]);
+ }
+
+ public function test_cannot_spin_without_enough_gold()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 50]); // Need 100
+
+ $response = $this->actingAs($user)->postJson(route('slot.spin'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false]);
+ }
+
+ public function test_cannot_spin_exceed_daily_limit()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 2000]);
+
+ // Mock daily limit 1
+ GameConfig::updateOrCreate(
+ ['game_key' => 'slot_machine'],
+ [
+ 'name' => 'Slot Machine',
+ 'icon' => 'slot',
+ 'description' => 'Slot Mac',
+ 'enabled' => true,
+ 'params' => [
+ 'cost_per_spin' => 100,
+ 'daily_limit' => 1,
+ ],
+ ]
+ );
+
+ SlotMachineLog::create([
+ 'user_id' => $user->id,
+ 'reel1' => 'cherry',
+ 'reel2' => 'lemon',
+ 'reel3' => 'orange',
+ 'result_type' => 'miss',
+ 'cost' => 100,
+ 'payout' => 0,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('slot.spin'));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => false]);
+ }
+
+ public function test_can_get_history()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ SlotMachineLog::create([
+ 'user_id' => $user->id,
+ 'reel1' => 'cherry',
+ 'reel2' => 'cherry',
+ 'reel3' => 'cherry',
+ 'result_type' => 'triple',
+ 'cost' => 100,
+ 'payout' => 1000,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('slot.history'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['history']);
+ $this->assertCount(1, $response->json('history'));
+ }
+}
diff --git a/tests/Feature/UserControllerTest.php b/tests/Feature/UserControllerTest.php
new file mode 100644
index 0000000..5dc0145
--- /dev/null
+++ b/tests/Feature/UserControllerTest.php
@@ -0,0 +1,255 @@
+ 'superlevel'], ['body' => '100']);
+ Sysparam::updateOrCreate(['alias' => 'level_kick'], ['body' => '15']);
+ Sysparam::updateOrCreate(['alias' => 'level_mute'], ['body' => '15']);
+ Sysparam::updateOrCreate(['alias' => 'level_ban'], ['body' => '15']);
+ Sysparam::updateOrCreate(['alias' => 'level_banip'], ['body' => '15']);
+ Sysparam::updateOrCreate(['alias' => 'smtp_enabled'], ['body' => '1']); // Allow email changing in tests
+ }
+
+ public function test_can_view_user_profile()
+ {
+ $user = User::factory()->create([
+ 'username' => 'testuser',
+ 'user_level' => 10,
+ ]);
+
+ $this->actingAs($user);
+
+ $response = $this->getJson("/user/{$user->username}");
+
+ $response->assertStatus(200)
+ ->assertJsonPath('data.username', 'testuser')
+ ->assertJsonPath('data.user_level', 10);
+ }
+
+ public function test_can_update_profile_without_email_change()
+ {
+ $user = User::factory()->create([
+ 'username' => 'testuser',
+ 'email' => 'old@example.com',
+ 'sign' => 'old sign',
+ ]);
+
+ $this->actingAs($user);
+
+ $response = $this->putJson('/user/profile', [
+ 'email' => 'old@example.com',
+ 'sign' => 'new sign',
+ 'sex' => 1,
+ 'headface' => 'avatar1.png',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJsonPath('status', 'success');
+
+ $user->refresh();
+ $this->assertEquals('new sign', $user->sign);
+ }
+
+ public function test_cannot_update_email_without_verification_code()
+ {
+ $user = User::factory()->create([
+ 'username' => 'testuser',
+ 'email' => 'old@example.com',
+ ]);
+
+ $this->actingAs($user);
+
+ $response = $this->putJson('/user/profile', [
+ 'email' => 'new@example.com',
+ 'sex' => 1,
+ 'headface' => 'avatar1.png',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonPath('status', 'error')
+ ->assertJsonPath('message', '新邮箱需要验证码,请先获取并填写验证码。');
+ }
+
+ public function test_can_update_email_with_valid_code()
+ {
+ $user = User::factory()->create([
+ 'username' => 'testuser',
+ 'email' => 'old@example.com',
+ ]);
+
+ Cache::put("email_verify_code_{$user->id}_new@example.com", '123456', 5);
+
+ $this->actingAs($user);
+
+ $response = $this->putJson('/user/profile', [
+ 'email' => 'new@example.com',
+ 'email_code' => '123456',
+ 'sex' => 1,
+ 'headface' => 'avatar1.png',
+ ]);
+
+ $response->assertStatus(200);
+
+ $user->refresh();
+ $this->assertEquals('new@example.com', $user->email);
+ }
+
+ public function test_can_change_password()
+ {
+ $user = User::factory()->create([
+ 'username' => 'testuser',
+ 'password' => Hash::make('oldpassword'),
+ ]);
+
+ $this->actingAs($user);
+
+ $response = $this->putJson('/user/password', [
+ 'old_password' => 'oldpassword',
+ 'new_password' => 'newpassword123',
+ 'new_password_confirmation' => 'newpassword123',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJsonPath('status', 'success');
+
+ $user->refresh();
+ $this->assertTrue(Hash::check('newpassword123', $user->password));
+ }
+
+ public function test_admin_can_kick_user()
+ {
+ $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
+ $target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
+ $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
+
+ $this->actingAs($admin);
+
+ $response = $this->postJson("/user/{$target->username}/kick", [
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJsonPath('status', 'success');
+ }
+
+ public function test_low_level_user_cannot_kick()
+ {
+ $user = User::factory()->create(['username' => 'user', 'user_level' => 1]);
+ $target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
+ $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
+
+ $this->actingAs($user);
+
+ $response = $this->postJson("/user/{$target->username}/kick", [
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertStatus(403);
+ }
+
+ public function test_room_master_can_kick()
+ {
+ $user = User::factory()->create(['username' => 'user', 'user_level' => 2]);
+ $target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
+ $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'user']); // Master is 'user'
+
+ $this->actingAs($user);
+
+ $response = $this->postJson("/user/{$target->username}/kick", [
+ 'room_id' => $room->id,
+ ]);
+
+ if ($response->status() !== 200) {
+ dump($response->json());
+ }
+ $response->assertStatus(200);
+ }
+
+ public function test_cannot_kick_higher_level()
+ {
+ $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
+ $superadmin = User::factory()->create(['username' => 'superadmin', 'user_level' => 100]);
+ $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
+
+ $this->actingAs($admin);
+
+ $response = $this->postJson("/user/{$superadmin->username}/kick", [
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertStatus(403);
+ }
+
+ public function test_admin_can_mute_user()
+ {
+ $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
+ $target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
+ $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
+
+ Redis::shouldReceive('setex')->once();
+
+ $this->actingAs($admin);
+
+ $response = $this->postJson("/user/{$target->username}/mute", [
+ 'room_id' => $room->id,
+ 'duration' => 10,
+ ]);
+
+ $response->assertStatus(200);
+ }
+
+ public function test_admin_can_ban_user()
+ {
+ $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
+ $target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
+ $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
+
+ $this->actingAs($admin);
+
+ $response = $this->postJson("/user/{$target->username}/ban", [
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertStatus(200);
+
+ $target->refresh();
+ $this->assertEquals(-1, $target->user_level);
+ }
+
+ public function test_admin_can_ban_ip()
+ {
+ $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
+ $target = User::factory()->create(['username' => 'target', 'user_level' => 1, 'last_ip' => '192.168.1.100']);
+ $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
+
+ Redis::shouldReceive('sadd')->with('banned_ips', '192.168.1.100')->once();
+
+ $this->actingAs($admin);
+
+ $response = $this->postJson("/user/{$target->username}/banip", [
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertStatus(200);
+
+ $target->refresh();
+ $this->assertEquals(-1, $target->user_level);
+ }
+}
diff --git a/tests/Feature/WeddingControllerTest.php b/tests/Feature/WeddingControllerTest.php
new file mode 100644
index 0000000..229ff07
--- /dev/null
+++ b/tests/Feature/WeddingControllerTest.php
@@ -0,0 +1,244 @@
+ 'test',
+ 'hyname1' => 'test1',
+ 'hytime' => now(),
+ 'hygb' => 'test',
+ 'hyjb' => 'test',
+ 'i' => 0,
+ ];
+ }
+
+ public function test_can_get_tiers()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+
+ WeddingTier::forceCreate([
+ 'tier' => 1,
+ 'name' => 'Basic Wedding',
+ 'icon' => 'basic',
+ 'amount' => 1000,
+ 'description' => 'A basic wedding',
+ 'is_active' => true,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('wedding.tiers'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['tiers']);
+ $this->assertCount(1, $response->json('tiers'));
+ }
+
+ public function test_can_setup_wedding()
+ {
+ Event::fake();
+
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 10000]);
+ /** @var \App\Models\User $partner */
+ $partner = User::factory()->create();
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $user->id,
+ 'partner_id' => $partner->id,
+ 'status' => 'married',
+ 'married_at' => now(),
+ ], $this->createLegacyMarriageData()));
+
+ $tier = WeddingTier::forceCreate([
+ 'tier' => 1,
+ 'name' => 'Basic Wedding',
+ 'icon' => 'basic',
+ 'amount' => 1000,
+ 'description' => 'A basic wedding',
+ 'is_active' => true,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('wedding.setup', ['marriage' => $marriage->id]), [
+ 'tier_id' => $tier->id,
+ 'payer_type' => 'groom',
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ Event::assertDispatched(\App\Events\WeddingCelebration::class);
+ }
+
+ public function test_can_claim_envelope()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create(['jjb' => 0]);
+ /** @var \App\Models\User $groom */
+ $groom = User::factory()->create();
+ /** @var \App\Models\User $bride */
+ $bride = User::factory()->create();
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $groom->id,
+ 'partner_id' => $bride->id,
+ 'status' => 'married',
+ 'married_at' => now(),
+ ], $this->createLegacyMarriageData()));
+
+ $tier = WeddingTier::forceCreate([
+ 'tier' => 1,
+ 'name' => 'Basic Wedding',
+ 'icon' => 'basic',
+ 'amount' => 1000,
+ 'description' => 'A basic wedding',
+ 'is_active' => true,
+ ]);
+
+ $ceremony = WeddingCeremony::forceCreate([
+ 'marriage_id' => $marriage->id,
+ 'tier_id' => $tier->id,
+ 'total_amount' => 1000,
+ 'payer_type' => 'groom',
+ 'groom_amount' => 1000,
+ 'partner_amount' => 0,
+ 'ceremony_type' => 'immediate',
+ 'status' => 'active',
+ 'expires_at' => now()->addHour(),
+ ]);
+
+ // Mock existing envelope claim waiting to be claimed
+ WeddingEnvelopeClaim::forceCreate([
+ 'ceremony_id' => $ceremony->id,
+ 'user_id' => $user->id,
+ 'amount' => 100,
+ 'claimed' => false,
+ ]);
+
+ $response = $this->actingAs($user)->postJson(route('wedding.claim', ['ceremony' => $ceremony->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson(['ok' => true]);
+
+ $this->assertEquals(100, $user->fresh()->jjb);
+ }
+
+ public function test_can_get_envelope_status()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ /** @var \App\Models\User $groom */
+ $groom = User::factory()->create();
+ /** @var \App\Models\User $bride */
+ $bride = User::factory()->create();
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $groom->id,
+ 'partner_id' => $bride->id,
+ 'status' => 'married',
+ 'married_at' => now(),
+ ], $this->createLegacyMarriageData()));
+
+ $tier = WeddingTier::forceCreate([
+ 'tier' => 1,
+ 'name' => 'Basic',
+ 'icon' => 'basic',
+ 'amount' => 1000,
+ 'description' => 'basic',
+ 'is_active' => true,
+ ]);
+
+ $ceremony = WeddingCeremony::forceCreate([
+ 'marriage_id' => $marriage->id,
+ 'tier_id' => $tier->id,
+ 'total_amount' => 1000,
+ 'payer_type' => 'groom',
+ 'groom_amount' => 1000,
+ 'partner_amount' => 0,
+ 'ceremony_type' => 'immediate',
+ 'status' => 'active',
+ 'expires_at' => now()->addHour(),
+ ]);
+
+ WeddingEnvelopeClaim::forceCreate([
+ 'ceremony_id' => $ceremony->id,
+ 'user_id' => $user->id,
+ 'amount' => 100,
+ 'claimed' => false,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('wedding.envelope-status', ['ceremony' => $ceremony->id]));
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'has_envelope' => true,
+ 'amount' => 100,
+ ]);
+ }
+
+ public function test_can_get_pending_envelopes()
+ {
+ /** @var \App\Models\User $user */
+ $user = User::factory()->create();
+ /** @var \App\Models\User $groom */
+ $groom = User::factory()->create();
+ /** @var \App\Models\User $bride */
+ $bride = User::factory()->create();
+
+ $marriage = Marriage::create(array_merge([
+ 'user_id' => $groom->id,
+ 'partner_id' => $bride->id,
+ 'status' => 'married',
+ 'married_at' => now(),
+ ], $this->createLegacyMarriageData()));
+
+ $tier = WeddingTier::forceCreate([
+ 'tier' => 1,
+ 'name' => 'Basic',
+ 'icon' => 'basic',
+ 'amount' => 1000,
+ 'description' => 'basic',
+ 'is_active' => true,
+ ]);
+
+ $ceremony = WeddingCeremony::forceCreate([
+ 'marriage_id' => $marriage->id,
+ 'tier_id' => $tier->id,
+ 'total_amount' => 1000,
+ 'payer_type' => 'groom',
+ 'groom_amount' => 1000,
+ 'partner_amount' => 0,
+ 'ceremony_type' => 'immediate',
+ 'status' => 'active',
+ 'expires_at' => now()->addHour(),
+ ]);
+
+ WeddingEnvelopeClaim::forceCreate([
+ 'ceremony_id' => $ceremony->id,
+ 'user_id' => $user->id,
+ 'amount' => 100,
+ 'claimed' => false,
+ ]);
+
+ $response = $this->actingAs($user)->getJson(route('wedding.pending-envelopes'));
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['envelopes']);
+ $this->assertCount(1, $response->json('envelopes'));
+ $this->assertEquals($ceremony->id, $response->json('envelopes.0.ceremony_id'));
+ }
+}