272 lines
15 KiB
PHP
272 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\MailFlow;
|
|
|
|
use App\Jobs\SendEmailJob;
|
|
use App\Jobs\SendMailMessageJob;
|
|
use App\Models\MailMessage;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Services\MailFlow\AutomaticMailPolicy;
|
|
use App\Services\MailFlow\ConventionalTransport;
|
|
use App\Services\MailFlow\DailyReportService;
|
|
use App\Services\MailFlow\Dispatcher;
|
|
use App\Services\MailFlow\ExpiredSubscriptionNotifier;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
class AutomaticMailPolicyTest extends TestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config(['database.default' => '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', '<p>Test</p>');
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|