From 12fbd22459d3db1d6f08ccce48c56c1e756d789d Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 1 Jul 2026 11:18:10 +0200 Subject: [PATCH 1/2] fix: send trial emails synchronously so sent flag reflects delivery --- .../Trial/ProcessMidtrialEmailJob.php | 2 +- .../Trial/ProcessOneDayLeftEmailJob.php | 2 +- .../Trial/ProcessTrialCompleteEmailJob.php | 2 +- .../ProcessTrialCompleteEmailJobTest.php | 97 +++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Jobs/Trial/ProcessTrialCompleteEmailJobTest.php diff --git a/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessMidtrialEmailJob.php b/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessMidtrialEmailJob.php index f61fd2ed..5a68a170 100644 --- a/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessMidtrialEmailJob.php +++ b/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessMidtrialEmailJob.php @@ -77,7 +77,7 @@ public function handle() 'app_instance_id' => $this->appInstance->id, ]); - $mail->queue(new AppInstanceMidtrialMail($this->appInstance, $owner)); + $mail->send(new AppInstanceMidtrialMail($this->appInstance, $owner)); } } else { $this->appInstance->info('Trial expired, skipping midtrial email but marking as sent', [ diff --git a/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessOneDayLeftEmailJob.php b/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessOneDayLeftEmailJob.php index 150cb1fd..e5b9dc00 100644 --- a/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessOneDayLeftEmailJob.php +++ b/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessOneDayLeftEmailJob.php @@ -65,7 +65,7 @@ public function handle() 'app_instance_id' => $this->appInstance->id, ]); - $mail->queue(new AppInstanceOneDayLeftMail($this->appInstance, $owner)); + $mail->send(new AppInstanceOneDayLeftMail($this->appInstance, $owner)); } } else { $this->appInstance->info('Trial expired, skipping one day left email but marking as sent', [ diff --git a/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessTrialCompleteEmailJob.php b/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessTrialCompleteEmailJob.php index 654ea4e7..5b79ad2c 100644 --- a/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessTrialCompleteEmailJob.php +++ b/app/Jobs/ProcessPolydockAppInstanceJobs/Trial/ProcessTrialCompleteEmailJob.php @@ -53,7 +53,7 @@ public function handle() 'owner_email' => $owner->email, ]); - $mail->queue(new AppInstanceTrialCompleteMail($this->appInstance, $owner)); + $mail->send(new AppInstanceTrialCompleteMail($this->appInstance, $owner)); } // Update the sent flag diff --git a/tests/Feature/Jobs/Trial/ProcessTrialCompleteEmailJobTest.php b/tests/Feature/Jobs/Trial/ProcessTrialCompleteEmailJobTest.php new file mode 100644 index 00000000..2c1b36bb --- /dev/null +++ b/tests/Feature/Jobs/Trial/ProcessTrialCompleteEmailJobTest.php @@ -0,0 +1,97 @@ +create(); + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'send_trial_complete_email' => true, + ]); + + $group = UserGroup::factory()->create(); + $owner = User::factory()->create(['email' => 'owner@example.com']); + $group->users()->attach($owner->id, ['role' => UserGroupRoleEnum::OWNER->value]); + + $this->instance = new PolydockAppInstance; + $this->instance->polydock_store_app_id = $storeApp->id; + $this->instance->user_group_id = $group->id; + $this->instance->name = 'trial-complete-test'; + $this->instance->app_type = PolydockAiApp::class; + $this->instance->status = PolydockAppInstanceStatus::NEW; + $this->instance->is_trial = true; + $this->instance->trial_ends_at = now()->subDay(); + $this->instance->trial_complete_email_sent = false; + $this->instance->save(); + } + + public function test_trial_complete_email_is_sent_and_flag_is_set(): void + { + Mail::fake(); + + (new ProcessTrialCompleteEmailJob($this->instance->id))->handle(); + + Mail::assertSent(AppInstanceTrialCompleteMail::class); + + $this->assertTrue($this->instance->fresh()->trial_complete_email_sent); + } + + public function test_flag_stays_false_when_send_throws(): void + { + // Force the synchronous send to fail so the job should throw before + // the sent flag is updated. + $pendingMail = Mockery::mock(); + $pendingMail->shouldReceive('cc')->andReturnSelf(); + $pendingMail->shouldReceive('send') + ->andThrow(new RuntimeException('SMTP failure')); + + Mail::shouldReceive('to')->andReturn($pendingMail); + + try { + (new ProcessTrialCompleteEmailJob($this->instance->id))->handle(); + $this->fail('Expected the job to propagate the send exception.'); + } catch (RuntimeException $e) { + $this->assertSame('SMTP failure', $e->getMessage()); + } + + $this->assertFalse( + $this->instance->fresh()->trial_complete_email_sent, + 'The sent flag must remain false when delivery fails so the job can retry.' + ); + } +} From 6843faf3a15a3a38d9c9eb802cf8f8b634b111ef Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 1 Jul 2026 15:46:00 +0200 Subject: [PATCH 2/2] test: cover midtrial and one-day-left email jobs for the send/flag change --- .../Trial/ProcessMidtrialEmailJobTest.php | 99 +++++++++++++++++++ .../Trial/ProcessOneDayLeftEmailJobTest.php | 99 +++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/Feature/Jobs/Trial/ProcessMidtrialEmailJobTest.php create mode 100644 tests/Feature/Jobs/Trial/ProcessOneDayLeftEmailJobTest.php diff --git a/tests/Feature/Jobs/Trial/ProcessMidtrialEmailJobTest.php b/tests/Feature/Jobs/Trial/ProcessMidtrialEmailJobTest.php new file mode 100644 index 00000000..2ad7efa5 --- /dev/null +++ b/tests/Feature/Jobs/Trial/ProcessMidtrialEmailJobTest.php @@ -0,0 +1,99 @@ +create(); + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'send_midtrial_email' => true, + ]); + + $group = UserGroup::factory()->create(); + $owner = User::factory()->create(['email' => 'owner@example.com']); + $group->users()->attach($owner->id, ['role' => UserGroupRoleEnum::OWNER->value]); + + $this->instance = new PolydockAppInstance; + $this->instance->polydock_store_app_id = $storeApp->id; + $this->instance->user_group_id = $group->id; + $this->instance->name = 'midtrial-test'; + $this->instance->app_type = PolydockAiApp::class; + $this->instance->status = PolydockAppInstanceStatus::NEW; + $this->instance->is_trial = true; + // Reminder is due (past) but the trial itself has not expired (future). + $this->instance->send_midtrial_email_at = now()->subHour(); + $this->instance->trial_ends_at = now()->addDays(3); + $this->instance->midtrial_email_sent = false; + $this->instance->save(); + } + + public function test_midtrial_email_is_sent_and_flag_is_set(): void + { + Mail::fake(); + + (new ProcessMidtrialEmailJob($this->instance->id))->handle(); + + Mail::assertSent(AppInstanceMidtrialMail::class); + + $this->assertTrue($this->instance->fresh()->midtrial_email_sent); + } + + public function test_flag_stays_false_when_send_throws(): void + { + // Force the synchronous send to fail so the job should throw before + // the sent flag is updated. + $pendingMail = Mockery::mock(); + $pendingMail->shouldReceive('cc')->andReturnSelf(); + $pendingMail->shouldReceive('send') + ->andThrow(new RuntimeException('SMTP failure')); + + Mail::shouldReceive('to')->andReturn($pendingMail); + + try { + (new ProcessMidtrialEmailJob($this->instance->id))->handle(); + $this->fail('Expected the job to propagate the send exception.'); + } catch (RuntimeException $e) { + $this->assertSame('SMTP failure', $e->getMessage()); + } + + $this->assertFalse( + $this->instance->fresh()->midtrial_email_sent, + 'The sent flag must remain false when delivery fails so the job can retry.' + ); + } +} diff --git a/tests/Feature/Jobs/Trial/ProcessOneDayLeftEmailJobTest.php b/tests/Feature/Jobs/Trial/ProcessOneDayLeftEmailJobTest.php new file mode 100644 index 00000000..1f335a05 --- /dev/null +++ b/tests/Feature/Jobs/Trial/ProcessOneDayLeftEmailJobTest.php @@ -0,0 +1,99 @@ +create(); + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'send_one_day_left_email' => true, + ]); + + $group = UserGroup::factory()->create(); + $owner = User::factory()->create(['email' => 'owner@example.com']); + $group->users()->attach($owner->id, ['role' => UserGroupRoleEnum::OWNER->value]); + + $this->instance = new PolydockAppInstance; + $this->instance->polydock_store_app_id = $storeApp->id; + $this->instance->user_group_id = $group->id; + $this->instance->name = 'one-day-left-test'; + $this->instance->app_type = PolydockAiApp::class; + $this->instance->status = PolydockAppInstanceStatus::NEW; + $this->instance->is_trial = true; + // Reminder is due (past) but the trial itself has not expired (future). + $this->instance->send_one_day_left_email_at = now()->subHour(); + $this->instance->trial_ends_at = now()->addDay(); + $this->instance->one_day_left_email_sent = false; + $this->instance->save(); + } + + public function test_one_day_left_email_is_sent_and_flag_is_set(): void + { + Mail::fake(); + + (new ProcessOneDayLeftEmailJob($this->instance->id))->handle(); + + Mail::assertSent(AppInstanceOneDayLeftMail::class); + + $this->assertTrue($this->instance->fresh()->one_day_left_email_sent); + } + + public function test_flag_stays_false_when_send_throws(): void + { + // Force the synchronous send to fail so the job should throw before + // the sent flag is updated. + $pendingMail = Mockery::mock(); + $pendingMail->shouldReceive('cc')->andReturnSelf(); + $pendingMail->shouldReceive('send') + ->andThrow(new RuntimeException('SMTP failure')); + + Mail::shouldReceive('to')->andReturn($pendingMail); + + try { + (new ProcessOneDayLeftEmailJob($this->instance->id))->handle(); + $this->fail('Expected the job to propagate the send exception.'); + } catch (RuntimeException $e) { + $this->assertSame('SMTP failure', $e->getMessage()); + } + + $this->assertFalse( + $this->instance->fresh()->one_day_left_email_sent, + 'The sent flag must remain false when delivery fails so the job can retry.' + ); + } +}