'sqlite', 'database.connections.sqlite.database' => ':memory:', 'cache.default' => 'array', 'cache.stores.redis' => ['driver' => 'array'], 'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), 'queue.default' => 'sync', 'mailflow.enabled' => true, 'mailflow.resend_enabled' => false]); DB::purge('sqlite'); Cache::forgetDriver(['redis', 'array']); $this->app->forgetInstance(\App\Support\Setting::class); Schema::create('v2_settings', fn (Blueprint $t) => [$t->id(), $t->string('name'), $t->text('value')->nullable(), $t->timestamps()]); Schema::create('v2_user', function (Blueprint $t) { $t->id(); $t->string('email'); $t->boolean('banned')->default(false); $t->boolean('remind_expire')->default(true); $t->integer('plan_id')->nullable(); $t->integer('expired_at')->nullable(); $t->integer('created_at')->nullable(); $t->integer('updated_at')->nullable(); }); Schema::create('v2_mail_templates', fn (Blueprint $t) => [$t->id(), $t->string('name'), $t->string('subject'), $t->text('content'), $t->timestamps()]); Schema::create('v2_mail_log', fn (Blueprint $t) => [$t->id(), $t->string('email'), $t->string('subject'), $t->string('template_name'), $t->string('error')->nullable(), $t->integer('created_at')->nullable(), $t->integer('updated_at')->nullable()]); (require base_path('database/migrations/2026_10_09_170000_create_mail_flow_tables.php'))->up(); Http::preventStrayRequests(); Bus::fake([SendMailMessageJob::class, SendEmailJob::class]); $this->travelTo(\Carbon\Carbon::parse('2026-10-10 12:00:00', 'Asia/Shanghai')); admin_setting(['remind_mail_enable' => 1, 'app_url' => 'https://example.com']); } private function transport(int $times): void { $transport = Mockery::mock(ConventionalTransport::class); $transport->shouldReceive('send')->times($times)->andReturn(['status' => 'submitted', 'provider_id' => 'smtp-test']); $this->app->instance(ConventionalTransport::class, $transport); } private function send(string $purpose, string $key = 'test'): array { return app(Dispatcher::class)->send(['purpose' => $purpose, 'message_key' => $key, 'email' => 'test@example.com'], 'Test', '

Test

'); } private function dueUser(int $offset = 10, array $extra = []): User { return User::withoutEvents(fn () => User::create($extra + ['email' => 'test@example.com', 'plan_id' => 1, 'expired_at' => now()->timestamp + $offset])); } public function test_unconfigured_is_tier_one_and_does_not_write_default(): void { $this->assertSame(['level' => 1, 'since' => null], AutomaticMailPolicy::state()); $this->assertNull(AutomaticMailPolicy::decision('expiry_reminder')); $this->assertSame(0, Setting::where('name', 'automatic_mail_policy')->count()); } public function test_tier_matrix_preserves_manual_and_responses(): void { $this->transport(19); foreach ([1, 2, 3] as $level) { AutomaticMailPolicy::save($level); foreach (['verification', 'login_link', 'ticket_reply', 'broadcast', 'auto_reply', 'admin_test'] as $purpose) { $this->assertSame('submitted', $this->send($purpose, "$level-$purpose")['status']); } $this->assertSame($level === 1 ? 'submitted' : 'policy_blocked', $this->send('traffic_reminder', "$level-traffic")['status']); } Http::assertNothingSent(); } public function test_legacy_channel_also_obeys_tier(): void { config(['mailflow.enabled' => false]); AutomaticMailPolicy::save(3); $this->transport(1); $this->assertSame('policy_blocked', $this->send('expiry_reminder')['status']); $this->assertSame('submitted', $this->send('verification')['status']); } public function test_already_queued_job_is_cancelled_and_logged_after_tier_change(): void { $job = new SendEmailJob(['email' => 'test@example.com', 'subject' => 'Test', 'template_name' => 'remindExpire', 'template_value' => ['name' => 'Test', 'url' => 'https://example.com']]); AutomaticMailPolicy::save(3); $this->transport(0); $job->handle(); $this->assertSame('policy_blocked', MailMessage::first()->status); $this->assertSame('automatic_mail_level_blocked', DB::table('v2_mail_log')->value('error')); } public function test_persisted_retry_is_cancelled_even_when_flow_is_disabled(): void { $m = MailMessage::create(['message_key' => 'old', 'purpose' => 'traffic_reminder', 'email' => 'test@example.com', 'subject' => 'Test', 'payload' => ['html' => 'Test']]); AutomaticMailPolicy::save(3); config(['mailflow.enabled' => false]); $this->transport(0); (new SendMailMessageJob($m->id))->handle(app(Dispatcher::class)); $this->assertSame('policy_blocked', $m->fresh()->status); AutomaticMailPolicy::save(1); $this->assertSame('policy_blocked', $this->send('traffic_reminder', 'old')['status']); } public function test_unknown_and_submitted_results_are_not_rewritten_as_unsent(): void { AutomaticMailPolicy::save(3); $this->transport(0); foreach (['unknown', 'submitted'] as $status) { $m = MailMessage::create(['message_key' => $status, 'purpose' => 'expiry_reminder', 'email' => 'test@example.com', 'subject' => 'Test', 'channel' => 'resend', 'status' => $status, 'reservation' => [999], 'payload' => ['html' => 'Test']]); $this->assertSame($status, $this->send('expiry_reminder', $status)['status']); $this->assertSame([999], $m->fresh()->reservation); } } public function test_tier_reads_are_fresh_within_same_worker_scope(): void { AutomaticMailPolicy::save(1); $this->assertNull(AutomaticMailPolicy::decision('traffic_reminder')); // Simulate another process updating the setting without invalidating this scope's cache. Setting::createOrUpdate('automatic_mail_policy', ['level' => 3, 'since' => null]); $this->assertSame('policy_blocked', AutomaticMailPolicy::decision('traffic_reminder')['status']); } public function test_unavailable_policy_retries_only_automatic_messages(): void { Schema::drop('v2_settings'); $this->assertTrue(AutomaticMailPolicy::decision('traffic_reminder')['retryable']); $this->assertNull(AutomaticMailPolicy::decision('verification')); $this->assertNull(AutomaticMailPolicy::decision('broadcast')); } public function test_no_historical_expiry_and_exact_due_is_queued_once(): void { $this->dueUser(-1); $due = $this->dueUser(10); AutomaticMailPolicy::save(2); $notifier = app(ExpiredSubscriptionNotifier::class); $this->assertSame(0, $notifier->queueDue()); $this->travel(10)->seconds(); $this->assertSame(1, $notifier->queueDue()); $this->assertSame(0, $notifier->queueDue()); $this->assertSame('subscription-expired:' . $due->id . ':' . $due->expired_at, MailMessage::first()->message_key); Bus::assertDispatchedTimes(SendMailMessageJob::class, 1); } public function test_renewal_cancels_old_event_and_later_expiry_is_distinct(): void { AutomaticMailPolicy::save(2); $user = $this->dueUser(); $this->travel(10)->seconds(); $notifier = app(ExpiredSubscriptionNotifier::class); $this->assertSame(1, $notifier->queueDue()); $old = MailMessage::first(); User::withoutEvents(fn () => $user->update(['expired_at' => now()->timestamp + 10])); $this->transport(0); (new SendMailMessageJob($old->id))->handle(app(Dispatcher::class)); $this->assertSame('policy_blocked', $old->fresh()->status); $this->travel(10)->seconds(); $this->assertSame(1, $notifier->queueDue()); $this->assertSame(2, MailMessage::count()); } public function test_expiry_sends_in_legacy_mode_and_keeps_original_template_separate(): void { config(['mailflow.enabled' => false]); AutomaticMailPolicy::save(2); $this->dueUser(); $this->travel(10)->seconds(); app(ExpiredSubscriptionNotifier::class)->queueDue(); $m = MailMessage::first(); $this->transport(1); (new SendMailMessageJob($m->id))->handle(app(Dispatcher::class)); $this->assertSame('submitted', $m->fresh()->status); $this->assertSame('conventional', $m->fresh()->channel); $this->assertSame('mail.default.serviceExpired', DB::table('v2_mail_log')->value('template_name')); $html = view('mail.default.serviceExpired', ['name' => 'Test', 'url' => 'https://example.com'])->render(); $this->assertStringContainsString('您的套餐已到期', $html); $this->assertStringNotContainsString('24 小时', $html); $this->assertSame('submitted', $m->fresh()->status); } public function test_user_preferences_and_banned_or_no_plan_users_are_excluded(): void { AutomaticMailPolicy::save(2); $this->dueUser(10, ['remind_expire' => false]); $this->dueUser(10, ['banned' => true]); $this->dueUser(10, ['plan_id' => null]); $this->travel(10)->seconds(); $this->assertSame(0, app(ExpiredSubscriptionNotifier::class)->queueDue()); Bus::assertNothingDispatched(); } public function test_resaving_tier_two_keeps_start_and_reentering_does_not_backfill(): void { AutomaticMailPolicy::save(2); $since = AutomaticMailPolicy::state()['since']; $this->dueUser(); $this->travel(10)->seconds(); AutomaticMailPolicy::save(2); $this->assertSame($since, AutomaticMailPolicy::state()['since']); AutomaticMailPolicy::save(3); AutomaticMailPolicy::save(2); $this->assertSame(0, app(ExpiredSubscriptionNotifier::class)->queueDue()); } public function test_reconcile_recovers_expiry_without_enabling_resend(): void { config(['mailflow.enabled' => false]); AutomaticMailPolicy::save(2); $this->dueUser(); $this->travel(10)->seconds(); app(ExpiredSubscriptionNotifier::class)->queueDue(); $this->travel(3)->minutes(); $this->artisan('mail:reconcile')->assertSuccessful(); Bus::assertDispatchedTimes(SendMailMessageJob::class, 2); Http::assertNothingSent(); } public function test_policy_cancellations_are_not_reported_as_pending_or_fallback(): void { AutomaticMailPolicy::save(3); $this->transport(0); $this->send('traffic_reminder'); $this->travel(1)->seconds(); $report = app(DailyReportService::class)->snapshot(now()); $this->assertSame(['out/policy/policy_blocked' => 1], $report->summary['counts']); $this->assertSame(0, $report->summary['backlog']); $this->assertSame(0, $report->summary['fallbacks']); } public function test_blocked_pending_releases_reservation_without_consuming_quota(): void { $bucket = \App\Models\MailQuotaBucket::create(['period_key' => 'daily-test', 'period' => 'daily', 'resets_at' => now()->addDay(), 'limit' => 100, 'used' => 7, 'reserved' => 1]); MailMessage::create(['message_key' => 'reserved', 'purpose' => 'traffic_reminder', 'email' => 'test@example.com', 'subject' => 'Test', 'reservation' => [$bucket->id], 'payload' => ['html' => 'Test']]); AutomaticMailPolicy::save(3); $this->transport(0); $this->assertSame('policy_blocked', $this->send('traffic_reminder', 'reserved')['status']); $this->assertSame(7, $bucket->fresh()->used); $this->assertSame(0, $bucket->fresh()->reserved); Http::assertNothingSent(); } public function test_protected_mail_keeps_resend_route_and_automatic_block_never_reserves(): void { AutomaticMailPolicy::save(3); config(['mailflow.resend_enabled' => true, 'mailflow.resend_key' => 'fake-key', 'mailflow.resend_from' => 'sender@example.com']); $this->transport(0); $quota = Mockery::mock(\App\Services\MailFlow\QuotaService::class); $quota->shouldReceive('reserve')->once()->andReturn(true); $quota->shouldReceive('settle')->once()->with(Mockery::type(MailMessage::class), false); $quota->shouldReceive('settle')->once()->with(Mockery::type(MailMessage::class), true); $this->app->instance(\App\Services\MailFlow\QuotaService::class, $quota); Http::fake(['*/emails' => Http::response(['id' => 'fake-id'])]); $this->assertSame('policy_blocked', $this->send('expiry_reminder', 'blocked')['status']); $this->assertSame('resend', $this->send('verification', 'protected')['channel']); Http::assertSentCount(1); } public function test_config_validation_and_atomic_state_preserve_mailflow_settings(): void { $rules = (new \App\Http\Requests\Admin\ConfigSave())->rules(); foreach ([0, 4, 'invalid', null] as $invalid) { $this->assertTrue(\Illuminate\Support\Facades\Validator::make(['automatic_mail_level' => $invalid], $rules)->fails()); } admin_setting(['mail_flow' => ['enabled' => false, 'resend_key' => 'unchanged-test']]); AutomaticMailPolicy::save(2); $this->assertSame(['enabled' => false, 'resend_key' => 'unchanged-test'], admin_setting('mail_flow')); $this->assertSame(2, AutomaticMailPolicy::state()['level']); } public function test_actual_config_save_and_status_return_tier_without_overwriting_mail_settings(): void { admin_setting(['mail_flow' => ['enabled' => false, 'resend_key' => 'unchanged-test']]); $request = \App\Http\Requests\Admin\ConfigSave::create('/config/save', 'POST', ['automatic_mail_level' => 2]); $request->setContainer($this->app); $request->setRedirector(app('redirect')); $request->validateResolved(); (new \App\Http\Controllers\V2\Admin\ConfigController())->save($request); $this->assertSame(2, AutomaticMailPolicy::state()['level']); $response = (new \App\Http\Controllers\V2\Admin\MailFlowController())->status(); $this->assertSame(2, $response->getData(true)['data']['automatic_mail_level']); $this->assertSame(['enabled' => false, 'resend_key' => 'unchanged-test'], admin_setting('mail_flow')); } public function test_changed_reminder_preference_cancels_pending_expiry(): void { AutomaticMailPolicy::save(2); $user = $this->dueUser(); $this->travel(10)->seconds(); app(ExpiredSubscriptionNotifier::class)->queueDue(); $m = MailMessage::first(); User::withoutEvents(fn () => $user->update(['remind_expire' => false])); $this->transport(0); (new SendMailMessageJob($m->id))->handle(app(Dispatcher::class)); $this->assertSame('policy_blocked', $m->fresh()->status); } }