新增每日签到与补签卡功能

This commit is contained in:
2026-04-24 22:47:27 +08:00
parent 34356a26ae
commit be9fc09d9d
46 changed files with 3934 additions and 55 deletions
+416
View File
@@ -0,0 +1,416 @@
<?php
/**
* 文件功能:每日签到前台接口测试
*
* 覆盖签到领取、重复拦截、连续天数、身份有效期与聊天室播报。
*/
namespace Tests\Feature;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Models\DailySignIn;
use App\Models\Room;
use App\Models\ShopItem;
use App\Models\SignInRewardRule;
use App\Models\User;
use App\Models\UserPurchase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;
/**
* 类功能:验证每日签到前台接口的核心行为。
*/
class DailySignInControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 方法功能:每个用例前清理 Redis 与时间,避免跨测试污染。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
Carbon::setTestNow('2026-04-24 10:00:00');
}
/**
* 方法功能:每个用例后恢复系统时间。
*/
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
/**
* 测试首次签到会发放奖励、写入流水并生成身份。
*/
public function test_user_can_claim_daily_sign_in_reward(): void
{
$user = User::factory()->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',
]);
}
}