createYearlyEvent([ 'schedule_month' => 1, 'schedule_day' => 1, 'schedule_time' => '09:00', 'duration_days' => 3, 'daily_occurrences' => 1, 'occurrence_interval_minutes' => null, 'send_at' => '2026-01-01 09:00:00', ]); $nextDay = $service->advanceAfterTrigger($event); $this->assertSame('2026-01-02 09:00:00', $nextDay?->format('Y-m-d H:i:s')); $event->forceFill(['send_at' => CarbonImmutable::parse('2026-01-03 09:00:00')]); $nextYear = $service->advanceAfterTrigger($event); $this->assertSame('2027-01-01 09:00:00', $nextYear?->format('Y-m-d H:i:s')); } /** * 方法功能:验证年度节日支持同一天多轮发送并在最后一轮后切到下一年。 */ public function test_yearly_schedule_advances_across_same_day_multiple_occurrences(): void { $service = app(HolidayEventScheduleService::class); $event = $this->createYearlyEvent([ 'schedule_month' => 8, 'schedule_day' => 1, 'schedule_time' => '09:00', 'duration_days' => 1, 'daily_occurrences' => 3, 'occurrence_interval_minutes' => 180, 'send_at' => '2026-08-01 09:00:00', ]); $secondRound = $service->advanceAfterTrigger($event); $this->assertSame('2026-08-01 12:00:00', $secondRound?->format('Y-m-d H:i:s')); $event->forceFill(['send_at' => CarbonImmutable::parse('2026-08-01 12:00:00')]); $thirdRound = $service->advanceAfterTrigger($event); $this->assertSame('2026-08-01 15:00:00', $thirdRound?->format('Y-m-d H:i:s')); $event->forceFill(['send_at' => CarbonImmutable::parse('2026-08-01 15:00:00')]); $nextYear = $service->advanceAfterTrigger($event); $this->assertSame('2027-08-01 09:00:00', $nextYear?->format('Y-m-d H:i:s')); } /** * 方法功能:验证手动立即触发的每一轮都会重新按当时在线用户生成名单。 */ public function test_manual_trigger_recomputes_audience_for_each_run(): void { Event::fake([HolidayEventStarted::class, MessageSent::class]); Queue::fake([SaveMessageJob::class]); $firstUser = User::factory()->create(['username' => 'holiday-a']); $secondUser = User::factory()->create(['username' => 'holiday-b']); Redis::shouldReceive('hgetall') ->twice() ->andReturn( ['holiday-a' => json_encode(['user_id' => $firstUser->id], JSON_UNESCAPED_UNICODE)], ['holiday-b' => json_encode(['user_id' => $secondUser->id], JSON_UNESCAPED_UNICODE)], ); $chatState = $this->createMock(ChatStateService::class); $chatState->expects($this->exactly(2))->method('nextMessageId')->with(1)->willReturnOnConsecutiveCalls(1, 2); $chatState->expects($this->exactly(2))->method('pushMessage'); $event = $this->createYearlyEvent([ 'send_at' => now()->addDay()->toDateTimeString(), ]); (new TriggerHolidayEventJob($event, true))->handle($chatState, app(HolidayEventScheduleService::class)); (new TriggerHolidayEventJob($event, true))->handle($chatState, app(HolidayEventScheduleService::class)); $runs = HolidayEventRun::query()->orderBy('id')->get(); $this->assertCount(2, $runs); $this->assertDatabaseHas('holiday_claims', ['run_id' => $runs[0]->id, 'user_id' => $firstUser->id]); $this->assertDatabaseHas('holiday_claims', ['run_id' => $runs[1]->id, 'user_id' => $secondUser->id]); $this->assertDatabaseMissing('holiday_claims', ['run_id' => $runs[0]->id, 'user_id' => $secondUser->id]); $this->assertDatabaseMissing('holiday_claims', ['run_id' => $runs[1]->id, 'user_id' => $firstUser->id]); } /** * 方法功能:验证手动立即触发不会破坏年度模板原本的 send_at 锚点。 */ public function test_manual_trigger_does_not_change_yearly_anchor_send_at(): void { Event::fake([HolidayEventStarted::class, MessageSent::class]); Queue::fake([SaveMessageJob::class]); Redis::shouldReceive('hgetall')->once()->andReturn([]); $chatState = $this->createMock(ChatStateService::class); $event = $this->createYearlyEvent([ 'send_at' => '2026-08-01 09:00:00', ]); (new TriggerHolidayEventJob($event, true))->handle($chatState, app(HolidayEventScheduleService::class)); $this->assertSame('2026-08-01 09:00:00', $event->fresh()?->send_at?->format('Y-m-d H:i:s')); } /** * 方法功能:验证旧的 daily 重复模式仍按原逻辑继续推进。 */ public function test_daily_repeat_mode_still_advances_normally(): void { $service = app(HolidayEventScheduleService::class); $event = HolidayEvent::create([ 'name' => '普通重复福利', 'description' => 'daily 兼容测试', 'total_amount' => 5000, 'max_claimants' => 20, 'distribute_type' => 'fixed', 'min_amount' => 100, 'max_amount' => null, 'fixed_amount' => 500, 'send_at' => CarbonImmutable::parse('2026-05-01 09:00:00'), 'expire_minutes' => 30, 'repeat_type' => 'daily', 'target_type' => 'all', 'status' => 'pending', 'enabled' => true, 'claimed_count' => 0, 'claimed_amount' => 0, 'duration_days' => 1, 'daily_occurrences' => 1, ]); $nextSendAt = $service->advanceAfterTrigger($event); $this->assertSame('2026-05-02 09:00:00', $nextSendAt?->format('Y-m-d H:i:s')); } /** * 方法功能:创建一个年度节日模板。 * * @param array $overrides */ private function createYearlyEvent(array $overrides = []): HolidayEvent { return HolidayEvent::create(array_merge([ 'name' => '建军节福利', 'description' => '年度节日调度测试', 'total_amount' => 9000, 'max_claimants' => 30, 'distribute_type' => 'fixed', 'min_amount' => 100, 'max_amount' => null, 'fixed_amount' => 300, 'send_at' => '2026-08-01 09:00:00', 'expire_minutes' => 30, 'repeat_type' => 'yearly', 'schedule_month' => 8, 'schedule_day' => 1, 'schedule_time' => '09:00', 'duration_days' => 1, 'daily_occurrences' => 1, 'occurrence_interval_minutes' => null, 'target_type' => 'all', 'target_value' => null, 'status' => 'pending', 'enabled' => true, 'claimed_count' => 0, 'claimed_amount' => 0, ], $overrides)); } }