create(['jjb' => 10, 'exp_num' => 0, 'meili' => 0]); SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [ 'streak_days' => 1, 'gold_reward' => 100, 'exp_reward' => 20, 'charm_reward' => 3, 'identity_badge_code' => 'sign_1', 'identity_badge_name' => '签到新星', 'identity_badge_icon' => '⭐', 'identity_badge_color' => '#0f766e', 'identity_duration_days' => 7, ]); $response = $this->actingAs($user)->postJson(route('daily-sign-in.claim')); $response->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('data.sign_in.streak_days', 1) ->assertJsonPath('data.user.jjb', 110) ->assertJsonPath('data.identity.label', '签到新星'); $this->assertDatabaseHas('daily_sign_ins', [ 'user_id' => $user->id, 'sign_in_date' => '2026-04-24', 'streak_days' => 1, 'gold_reward' => 100, 'exp_reward' => 20, 'charm_reward' => 3, ]); $this->assertDatabaseHas('user_currency_logs', [ 'user_id' => $user->id, 'currency' => 'gold', 'amount' => 100, 'source' => CurrencySource::SIGN_IN->value, ]); $this->assertDatabaseHas('user_identity_badges', [ 'user_id' => $user->id, 'source' => 'sign_in', 'badge_code' => 'sign_1', 'is_active' => true, ]); } /** * 测试同一天重复签到不会重复发放奖励。 */ public function test_duplicate_daily_sign_in_is_rejected_without_second_reward(): void { $user = User::factory()->create(['jjb' => 0]); SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [ 'streak_days' => 1, 'gold_reward' => 50, ]); $this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk(); $currencyLogCount = \App\Models\UserCurrencyLog::query() ->where('user_id', $user->id) ->where('source', CurrencySource::SIGN_IN->value) ->count(); $response = $this->actingAs($user)->postJson(route('daily-sign-in.claim')); $response->assertStatus(422) ->assertJsonPath('status', 'error') ->assertJsonPath('message', '今日已签到,请明天再来。'); $this->assertSame(1, DailySignIn::query()->where('user_id', $user->id)->count()); $this->assertSame($currencyLogCount, \App\Models\UserCurrencyLog::query()->where('user_id', $user->id)->where('source', CurrencySource::SIGN_IN->value)->count()); $this->assertSame(50, (int) $user->fresh()->jjb); } /** * 测试连续签到会递增,断签后会重新从 1 开始。 */ public function test_streak_increments_and_resets_after_gap(): void { $user = User::factory()->create(); SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], ['streak_days' => 1, 'gold_reward' => 1]); SignInRewardRule::query()->updateOrCreate(['streak_days' => 2], ['streak_days' => 2, 'gold_reward' => 2]); $this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 1); Carbon::setTestNow('2026-04-25 10:00:00'); $this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 2); Carbon::setTestNow('2026-04-27 10:00:00'); $this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 1); } /** * 测试连续签到满一年后下一天会重新从 1 计算。 */ public function test_streak_resets_after_yearly_cycle_is_completed(): void { $user = User::factory()->create(); DailySignIn::factory()->create([ 'user_id' => $user->id, 'sign_in_date' => '2026-04-23', 'streak_days' => 365, ]); $this->actingAs($user) ->postJson(route('daily-sign-in.claim')) ->assertOk() ->assertJsonPath('data.sign_in.streak_days', 1); } /** * 测试闰年需要满 366 天后才重新计算。 */ public function test_leap_year_streak_resets_after_366_days(): void { $user = User::factory()->create(); Carbon::setTestNow('2024-12-31 10:00:00'); DailySignIn::factory()->create([ 'user_id' => $user->id, 'sign_in_date' => '2024-12-30', 'streak_days' => 365, ]); $this->actingAs($user) ->postJson(route('daily-sign-in.claim')) ->assertOk() ->assertJsonPath('data.sign_in.streak_days', 366); Carbon::setTestNow('2025-01-01 10:00:00'); $this->actingAs($user) ->postJson(route('daily-sign-in.claim')) ->assertOk() ->assertJsonPath('data.sign_in.streak_days', 1); } /** * 测试身份有效期过后不会继续出现在状态接口。 */ public function test_expired_identity_is_not_returned_in_status(): void { $user = User::factory()->create(); SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [ 'streak_days' => 1, 'identity_badge_code' => 'short', 'identity_badge_name' => '短期身份', 'identity_badge_icon' => '⏳', 'identity_duration_days' => 1, ]); $this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk(); Carbon::setTestNow('2026-04-26 10:00:00'); $this->actingAs($user) ->getJson(route('daily-sign-in.status')) ->assertOk() ->assertJsonPath('data.identity', null); } /** * 测试当前房间签到会写入聊天室通知并附带快速签到按钮。 */ public function test_claim_with_room_broadcasts_chat_notice_with_quick_button(): void { Event::fake([MessageSent::class, UserStatusUpdated::class]); $room = Room::create(['room_name' => 'testroom', 'door_open' => true]); $user = User::factory()->create(['jjb' => 0]); SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [ 'streak_days' => 1, 'gold_reward' => 100, 'exp_reward' => 20, ]); Redis::hset("room:{$room->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE)); Redis::setex("room:{$room->id}:alive:{$user->username}", 90, 1); $response = $this->actingAs($user)->postJson(route('daily-sign-in.claim'), [ 'room_id' => $room->id, ]); $response->assertOk(); $rawMessage = Redis::lindex("room:{$room->id}:messages", -1); $message = json_decode((string) $rawMessage, true); $this->assertSame('签到播报', $message['from_user']); $this->assertStringContainsString('快速签到', $message['content']); $this->assertStringContainsString('quickDailySignIn', $message['content']); Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($room): bool { return $event->roomId === $room->id && ($event->message['from_user'] ?? null) === '签到播报'; }); } /** * 测试不携带房间 ID 时不会发送聊天室通知。 */ public function test_claim_without_room_does_not_write_chat_notice(): void { Event::fake([MessageSent::class]); $user = User::factory()->create(); SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], ['streak_days' => 1, 'gold_reward' => 1]); $this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk(); $this->assertSame([], Redis::keys('room:*:messages')); Event::assertNotDispatched(MessageSent::class); } /** * 测试签到日历会展示已签、漏签和补签卡数量。 */ public function test_calendar_returns_month_days_and_makeup_card_count(): void { $user = User::factory()->create(); $card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail(); UserPurchase::query()->create([ 'user_id' => $user->id, 'shop_item_id' => $card->id, 'status' => 'active', 'price_paid' => 1200, ]); DailySignIn::factory()->create([ 'user_id' => $user->id, 'sign_in_date' => '2026-04-23', 'streak_days' => 1, ]); $response = $this->actingAs($user)->getJson(route('daily-sign-in.calendar', ['month' => '2026-04'])); $response->assertOk() ->assertJsonPath('data.month', '2026-04') ->assertJsonPath('data.makeup_card_count', 1) ->assertJsonPath('data.sign_repair_card_item.slug', 'sign_repair_card') ->assertJsonPath('data.sign_repair_card_item.price', 10000) ->assertJsonPath('data.reward_rules.0.streak_days', 1); $days = collect($response->json('data.days')); $this->assertTrue((bool) $days->firstWhere('date', '2026-04-23')['signed']); $this->assertTrue((bool) $days->firstWhere('date', '2026-04-22')['can_makeup']); $previousMonthResponse = $this->actingAs($user)->getJson(route('daily-sign-in.calendar', ['month' => '2026-03'])); $previousMonthDays = collect($previousMonthResponse->json('data.days')); $this->assertFalse((bool) $previousMonthDays->firstWhere('date', '2026-03-31')['can_makeup']); } /** * 测试用户可以消耗补签卡补签历史漏签日期。 */ public function test_user_can_makeup_missed_day_with_sign_repair_card(): void { $user = User::factory()->create(['jjb' => 0]); SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [ 'streak_days' => 1, 'gold_reward' => 10, ]); SignInRewardRule::query()->updateOrCreate(['streak_days' => 2], [ 'streak_days' => 2, 'gold_reward' => 20, ]); SignInRewardRule::query()->updateOrCreate(['streak_days' => 3], [ 'streak_days' => 3, 'gold_reward' => 30, ]); $card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail(); $purchase = UserPurchase::query()->create([ 'user_id' => $user->id, 'shop_item_id' => $card->id, 'status' => 'active', 'price_paid' => 1200, ]); DailySignIn::factory()->create([ 'user_id' => $user->id, 'sign_in_date' => '2026-04-22', 'streak_days' => 1, ]); DailySignIn::factory()->create([ 'user_id' => $user->id, 'sign_in_date' => '2026-04-24', 'streak_days' => 1, ]); $response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [ 'target_date' => '2026-04-23', ]); $response->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('data.sign_in.is_makeup', true) ->assertJsonPath('data.sign_in.streak_days', 2) ->assertJsonPath('data.current_streak_days', 3); $this->assertStringContainsString('当前连续签到 3 天', (string) $response->json('message')); $this->assertDatabaseHas('daily_sign_ins', [ 'user_id' => $user->id, 'sign_in_date' => '2026-04-23', 'is_makeup' => true, 'makeup_purchase_id' => $purchase->id, 'streak_days' => 2, 'gold_reward' => 30, ]); $this->assertDatabaseHas('daily_sign_ins', [ 'user_id' => $user->id, 'sign_in_date' => '2026-04-24', 'streak_days' => 3, ]); $this->assertSame('used', $purchase->fresh()->status); $this->assertSame(30, (int) $user->fresh()->jjb); $this->assertDatabaseHas('user_currency_logs', [ 'user_id' => $user->id, 'currency' => 'gold', 'amount' => 30, 'source' => CurrencySource::SIGN_IN->value, 'remark' => '补签 2026-04-23:当前连续签到 3 天', ]); } /** * 测试没有补签卡时不能补签。 */ public function test_makeup_requires_available_sign_repair_card(): void { $user = User::factory()->create(); $response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [ 'target_date' => '2026-04-23', ]); $response->assertStatus(422) ->assertJsonValidationErrors('target_date'); } /** * 测试补签卡不能补签本月之外的漏签日期。 */ public function test_makeup_only_allows_current_month_missed_days(): void { $user = User::factory()->create(); $card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail(); $purchase = UserPurchase::query()->create([ 'user_id' => $user->id, 'shop_item_id' => $card->id, 'status' => 'active', 'price_paid' => 1200, ]); $response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [ 'target_date' => '2026-03-31', ]); $response->assertStatus(422) ->assertJsonValidationErrors('target_date') ->assertJsonPath('errors.target_date.0', '补签卡只能补签本月的未签到日期。'); $this->assertSame('active', $purchase->fresh()->status); $this->assertDatabaseMissing('daily_sign_ins', [ 'user_id' => $user->id, 'sign_in_date' => '2026-03-31', ]); } }