template = [ 'subject' => 'Test Subject', 'heading' => 'Test Heading', 'body_text' => 'Test body text', 'button_text' => 'Click me', 'is_custom' => false, ]; $this->branding = [ 'logo_url' => null, 'primary_color' => '#6366F1', 'secondary_color' => '#4F46E5', 'footer_text' => '© 2026 Crewli', 'reply_to_email' => null, 'reply_to_name' => null, ]; } public function test_job_sends_email_and_updates_log_to_sent(): void { Mail::fake(); $org = Organisation::factory()->create(); $log = EmailLog::factory()->create([ 'organisation_id' => $org->id, 'status' => EmailLogStatus::QUEUED->value, ]); $job = new SendTransactionalEmail( emailLogId: $log->id, type: EmailTemplateType::INVITATION, recipientEmail: 'test@example.com', recipientName: 'Test User', template: $this->template, branding: $this->branding, actionUrl: 'https://example.com/action', ); $job->handle(); Mail::assertSent(TransactionalMail::class, function ($mailable) { return $mailable->template['subject'] === 'Test Subject'; }); $log->refresh(); $this->assertEquals(EmailLogStatus::SENT, $log->status); $this->assertNotNull($log->sent_at); } public function test_job_skips_already_sent_emails(): void { Mail::fake(); $org = Organisation::factory()->create(); $log = EmailLog::factory()->sent()->create([ 'organisation_id' => $org->id, ]); $job = new SendTransactionalEmail( emailLogId: $log->id, type: EmailTemplateType::INVITATION, recipientEmail: 'test@example.com', recipientName: 'Test User', template: $this->template, branding: $this->branding, ); $job->handle(); Mail::assertNothingSent(); } public function test_job_updates_log_to_failed_on_exception(): void { Mail::fake(); Mail::shouldReceive('to')->andThrow(new \RuntimeException('SMTP error')); $org = Organisation::factory()->create(); $log = EmailLog::factory()->create([ 'organisation_id' => $org->id, 'status' => EmailLogStatus::QUEUED->value, ]); $job = new SendTransactionalEmail( emailLogId: $log->id, type: EmailTemplateType::INVITATION, recipientEmail: 'test@example.com', recipientName: 'Test User', template: $this->template, branding: $this->branding, ); try { $job->handle(); } catch (\RuntimeException) { // Expected } $log->refresh(); $this->assertEquals(EmailLogStatus::FAILED, $log->status); $this->assertNotNull($log->failed_at); $this->assertStringContainsString('SMTP error', $log->error_message); } public function test_job_retries_on_failure(): void { $job = new SendTransactionalEmail( emailLogId: 'fake-id', type: EmailTemplateType::INVITATION, recipientEmail: 'test@example.com', recipientName: 'Test User', template: $this->template, branding: $this->branding, ); $this->assertEquals(3, $job->tries); $this->assertEquals([30, 120, 300], $job->backoff); $this->assertEquals('emails', $job->queue); } public function test_job_sets_reply_to_when_configured(): void { Mail::fake(); $org = Organisation::factory()->create(); $log = EmailLog::factory()->create([ 'organisation_id' => $org->id, 'status' => EmailLogStatus::QUEUED->value, ]); $brandingWithReplyTo = array_merge($this->branding, [ 'reply_to_email' => 'reply@example.com', 'reply_to_name' => 'Reply User', ]); $job = new SendTransactionalEmail( emailLogId: $log->id, type: EmailTemplateType::INVITATION, recipientEmail: 'test@example.com', recipientName: 'Test User', template: $this->template, branding: $brandingWithReplyTo, ); $job->handle(); Mail::assertSent(TransactionalMail::class, function ($mailable) { return $mailable->hasReplyTo('reply@example.com', 'Reply User'); }); } }