Files
crewli/api/tests/Feature/Api/V1/EmailChangeTest.php
bert.hausmans 65978104d8 feat: complete email infrastructure with queue, templates, logging, and API
Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
  verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:12:21 +02:00

308 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Enums\EmailChangeStatus;
use App\Enums\EmailTemplateType;
use App\Jobs\SendTransactionalEmail;
use App\Mail\EmailChangedConfirmationMail;
use App\Models\EmailChangeRequest;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class EmailChangeTest extends TestCase
{
use RefreshDatabase;
// ─── Self-Service Email Change ──────────────────────────────────────
public function test_user_can_request_email_change(): void
{
Queue::fake();
$user = User::factory()->create([
'email' => 'oud@voorbeeld.nl',
'password' => bcrypt('wachtwoord123'),
]);
$response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
'new_email' => 'nieuw@voorbeeld.nl',
'password' => 'wachtwoord123',
'app' => 'app',
]);
$response->assertOk();
$this->assertDatabaseHas('email_change_requests', [
'user_id' => $user->id,
'current_email' => 'oud@voorbeeld.nl',
'new_email' => 'nieuw@voorbeeld.nl',
'status' => 'pending',
]);
Queue::assertPushed(SendTransactionalEmail::class, function ($job) {
return $job->recipientEmail === 'nieuw@voorbeeld.nl'
&& $job->type === EmailTemplateType::EMAIL_VERIFICATION;
});
}
public function test_email_change_requires_correct_password(): void
{
$user = User::factory()->create([
'password' => bcrypt('wachtwoord123'),
]);
$response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
'new_email' => 'nieuw@voorbeeld.nl',
'password' => 'foutwachtwoord',
'app' => 'app',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('password');
}
public function test_email_change_rejects_already_used_email(): void
{
User::factory()->create(['email' => 'bezet@voorbeeld.nl']);
$user = User::factory()->create([
'password' => bcrypt('wachtwoord123'),
]);
$response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
'new_email' => 'bezet@voorbeeld.nl',
'password' => 'wachtwoord123',
'app' => 'app',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('new_email');
}
public function test_email_change_requires_authentication(): void
{
$response = $this->postJson('/api/v1/me/change-email', [
'new_email' => 'nieuw@voorbeeld.nl',
'password' => 'wachtwoord123',
'app' => 'app',
]);
$response->assertStatus(401);
}
public function test_duplicate_pending_request_cancels_old_one(): void
{
Mail::fake();
$user = User::factory()->create([
'email' => 'oud@voorbeeld.nl',
'password' => bcrypt('wachtwoord123'),
]);
// First request
$this->actingAs($user)->postJson('/api/v1/me/change-email', [
'new_email' => 'eerste@voorbeeld.nl',
'password' => 'wachtwoord123',
'app' => 'app',
]);
$firstRequest = EmailChangeRequest::where('new_email', 'eerste@voorbeeld.nl')->first();
$this->assertEquals('pending', $firstRequest->status->value);
// Second request
$this->actingAs($user)->postJson('/api/v1/me/change-email', [
'new_email' => 'tweede@voorbeeld.nl',
'password' => 'wachtwoord123',
'app' => 'app',
]);
$firstRequest->refresh();
$this->assertEquals('cancelled', $firstRequest->status->value);
$secondRequest = EmailChangeRequest::where('new_email', 'tweede@voorbeeld.nl')->first();
$this->assertEquals('pending', $secondRequest->status->value);
}
// ─── Email Change Verification ──────────────────────────────────────
public function test_verify_email_change_with_valid_token(): void
{
Mail::fake();
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
EmailChangeRequest::create([
'user_id' => $user->id,
'current_email' => 'oud@voorbeeld.nl',
'new_email' => 'nieuw@voorbeeld.nl',
'token' => hash('sha256', $plainToken),
'requested_by_user_id' => $user->id,
'status' => EmailChangeStatus::PENDING,
'expires_at' => now()->addHours(24),
]);
$response = $this->postJson('/api/v1/verify-email-change', [
'token' => $plainToken,
]);
$response->assertOk();
$user->refresh();
$this->assertEquals('nieuw@voorbeeld.nl', $user->email);
Mail::assertQueued(EmailChangedConfirmationMail::class, function ($mail) {
return $mail->hasTo('oud@voorbeeld.nl');
});
}
public function test_verify_email_change_revokes_all_tokens(): void
{
Mail::fake();
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
$user->createToken('test-token');
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
EmailChangeRequest::create([
'user_id' => $user->id,
'current_email' => 'oud@voorbeeld.nl',
'new_email' => 'nieuw@voorbeeld.nl',
'token' => hash('sha256', $plainToken),
'requested_by_user_id' => $user->id,
'status' => EmailChangeStatus::PENDING,
'expires_at' => now()->addHours(24),
]);
$this->postJson('/api/v1/verify-email-change', [
'token' => $plainToken,
]);
$this->assertCount(0, $user->tokens()->get());
}
public function test_verify_email_change_with_expired_token(): void
{
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
EmailChangeRequest::create([
'user_id' => $user->id,
'current_email' => 'oud@voorbeeld.nl',
'new_email' => 'nieuw@voorbeeld.nl',
'token' => hash('sha256', $plainToken),
'requested_by_user_id' => $user->id,
'status' => EmailChangeStatus::PENDING,
'expires_at' => now()->subHour(),
]);
$response = $this->postJson('/api/v1/verify-email-change', [
'token' => $plainToken,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('token');
}
public function test_verify_email_change_with_invalid_token(): void
{
$response = $this->postJson('/api/v1/verify-email-change', [
'token' => 'completely-invalid-token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('token');
}
public function test_verify_email_change_fails_if_email_taken_between_request_and_verify(): void
{
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
EmailChangeRequest::create([
'user_id' => $user->id,
'current_email' => 'oud@voorbeeld.nl',
'new_email' => 'nieuw@voorbeeld.nl',
'token' => hash('sha256', $plainToken),
'requested_by_user_id' => $user->id,
'status' => EmailChangeStatus::PENDING,
'expires_at' => now()->addHours(24),
]);
// Another user takes the email
User::factory()->create(['email' => 'nieuw@voorbeeld.nl']);
$response = $this->postJson('/api/v1/verify-email-change', [
'token' => $plainToken,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('new_email');
}
// ─── Admin Email Change ─────────────────────────────────────────────
public function test_admin_can_change_member_email(): void
{
Queue::fake();
$organisation = Organisation::factory()->create();
$admin = User::factory()->create();
$organisation->users()->attach($admin, ['role' => 'org_admin']);
$member = User::factory()->create(['email' => 'lid@voorbeeld.nl']);
$organisation->users()->attach($member, ['role' => 'org_member']);
$response = $this->actingAs($admin)->postJson(
"/api/v1/organisations/{$organisation->id}/members/{$member->id}/change-email",
['new_email' => 'nieuw-lid@voorbeeld.nl'],
);
$response->assertOk();
Queue::assertPushed(SendTransactionalEmail::class, function ($job) {
return $job->recipientEmail === 'nieuw-lid@voorbeeld.nl'
&& $job->type === EmailTemplateType::EMAIL_VERIFICATION;
});
}
public function test_non_admin_cannot_change_member_email(): void
{
$organisation = Organisation::factory()->create();
$member = User::factory()->create();
$organisation->users()->attach($member, ['role' => 'org_member']);
$otherMember = User::factory()->create(['email' => 'ander@voorbeeld.nl']);
$organisation->users()->attach($otherMember, ['role' => 'org_member']);
$response = $this->actingAs($member)->postJson(
"/api/v1/organisations/{$organisation->id}/members/{$otherMember->id}/change-email",
['new_email' => 'nieuw@voorbeeld.nl'],
);
$response->assertStatus(403);
}
public function test_unauthenticated_cannot_change_member_email(): void
{
$organisation = Organisation::factory()->create();
$member = User::factory()->create();
$organisation->users()->attach($member, ['role' => 'org_member']);
$response = $this->postJson(
"/api/v1/organisations/{$organisation->id}/members/{$member->id}/change-email",
['new_email' => 'nieuw@voorbeeld.nl'],
);
$response->assertStatus(401);
}
}