presseportale/tests/Feature/Auth/MailAccessTest.php
Kevin Adametz 67016ed549 mail: Prüfmail führt Pressekontakte per Anmeldelink zur Meldung
Die Mail „Ihre Pressemitteilung wird persönlich geprüft“ verlinkte auch
bei API-Meldungen in den Kundenbereich. Empfänger ohne Konto landeten auf
der Passwort-Anmeldung. Wie in der Veröffentlichungs- und Ablehnungsmail
schickt jetzt ein Klick einen Anmeldelink an die Adresse der Mail; die
Zugangsseite übernimmt die Herkunft mail_review für den Funnel.

Changelog: Verbessert
Public: Auch die Mail zur persönlichen Prüfung führt Pressekontakte ohne Passwort zu ihrer Meldung.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:30:04 +00:00

231 lines
10 KiB
PHP

<?php
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Mail\MagicLoginLink;
use App\Mail\PressReleaseHeldForReview;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\Company;
use App\Models\ConversionEvent;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\Auth\MailAccessLink;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Anmeldelink an die Adresse einer Kundenmail (Roadmap 6.17): Empfänger von
* API-Meldungen haben aus ihrer Sicht kein Konto. Ein Klick in der Mail
* schickt den Anmeldelink an genau ihre Adresse, die Anmeldung landet auf
* der Meldung.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
});
function apiRelease(array $attributes = []): PressRelease
{
$company = Company::factory()->businessportal24()->create([
'name' => 'Firma GmbH', 'slug' => 'firma-gmbh', 'email' => 'presse@firma.de', 'is_active' => true,
]);
return PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(array_merge([
'company_id' => $company->id, 'language' => 'de', 'title' => 'Firma GmbH eröffnet Standort',
], $attributes));
}
/**
* @return array{t: string, src: string}
*/
function accessQuery(string $url): array
{
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
return ['t' => (string) $query['t'], 'src' => (string) $query['src']];
}
// ------------------------------------------------------------------ Mails
test('the publication mail offers a sign-in link to the address it went to when the recipient has no account', function () {
/** @var TestCase $this */
$release = apiRelease();
$mail = new PressReleasePublished($release->user, $release, 'Frau Muster', 'presse@firma.de', true);
$html = $mail->render();
expect($mail->accessEmail)->toBe('presse@firma.de')
->and($mail->accessUrl)->toContain('/zugang?')
->and($mail->accessUrl)->not->toContain('presse@firma.de')
->and($mail->accessUrl)->not->toContain('presse%40firma.de')
->and($html)->toContain('Etwas ändern oder die Aufrufe sehen?')
->and($html)->toContain('weder ein Konto noch ein Passwort')
->and($html)->toContain('presse@firma.de')
->and($html)->toContain('den Newsroom von Firma GmbH pflegen')
->and($html)->toContain('Anmeldelink anfordern')
->and($html)->not->toContain('Im pressekonto öffnen')
->and($html)->not->toContain('mit Ihrem Passwort oder per Magic-Link');
});
test('the rejection mail tells recipients without an account that they can revise and resubmit free of charge', function () {
/** @var TestCase $this */
$release = apiRelease(['status' => 'rejected']);
$html = (new PressReleaseRejected($release->user, $release, 'Kein Anlass erkennbar.', 'Frau Muster', 'presse@firma.de'))->render();
expect($html)->toContain('selbst überarbeiten und kostenlos erneut einreichen')
->and($html)->toContain('weder ein Konto noch ein Passwort')
->and($html)->toContain('presse@firma.de')
->and($html)->toContain('Anmeldelink anfordern')
->and($html)->not->toContain('Pressemitteilung bearbeiten');
// Wer selbst eingereicht hat, überarbeitet wie bisher im Konto.
$own = (new PressReleaseRejected($release->user, $release, 'Kein Anlass erkennbar.'))->render();
expect($own)->toContain('Pressemitteilung bearbeiten')
->and($own)->not->toContain('Anmeldelink anfordern');
});
test('the held-for-review mail lets recipients without an account see the status via a sign-in link', function () {
/** @var TestCase $this */
$release = apiRelease(['status' => PressReleaseStatus::Review->value]);
$mail = new PressReleaseHeldForReview($release->user, $release, false, 'Frau Muster', 'presse@firma.de');
$html = $mail->render();
expect($mail->accessUrl)->toContain('/zugang?')
->and(accessQuery($mail->accessUrl)['src'])->toBe(ConversionSource::MailReview->value)
->and($html)->toContain('Sie müssen nichts tun')
->and($html)->toContain('Den Stand der Prüfung sehen?')
->and($html)->toContain('weder ein Konto noch ein Passwort')
->and($html)->toContain('presse@firma.de')
->and($html)->toContain('Anmeldelink anfordern')
->and($html)->not->toContain('Stand der Prüfung ansehen');
// Die Zugangsseite übernimmt die Herkunft aus der Prüfmail.
$this->get($mail->accessUrl)->assertOk();
expect(ConversionEvent::query()->where('type', ConversionEventType::ManagePageOpened->value)->sole()->source)
->toBe(ConversionSource::MailReview);
// Wer selbst eingereicht hat, öffnet die Meldung wie bisher im Konto.
$own = (new PressReleaseHeldForReview($release->user, $release, false))->render();
expect($own)->toContain('Stand der Prüfung ansehen')
->and($own)->not->toContain('Anmeldelink anfordern');
});
// --------------------------------------------------------- Zugangsseite
test('the access page only opens with a valid signature and shows the address masked', function () {
/** @var TestCase $this */
$release = apiRelease();
$url = app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish);
$this->get($url)
->assertOk()
->assertSeeText('p***@firma.de')
->assertSeeText('Firma GmbH eröffnet Standort')
->assertDontSeeText('presse@firma.de');
$this->get(str_replace('src=mail_publish', 'src=mail_reject', $url))->assertForbidden();
$opened = ConversionEvent::query()->where('type', ConversionEventType::ManagePageOpened->value)->sole();
expect($opened->source)->toBe(ConversionSource::MailPublish)
->and($opened->press_release_id)->toBe($release->id);
});
test('one click sends the sign-in link to that address and the login lands on the release', function () {
/** @var TestCase $this */
$release = apiRelease();
$url = app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish);
Livewire::withQueryParams(accessQuery($url))
->test('auth.mail-access')
->call('requestLink')
->assertSet('sent', true)
->assertSee('p***@firma.de');
$user = User::query()->where('email', 'presse@firma.de')->firstOrFail();
Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail): bool => $mail->user->is($user));
$link = MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail();
expect($link->source)->toBe(ConversionSource::MailPublish->value)
->and($link->payload)->toBe(['company_id' => $release->company_id, 'press_release_id' => $release->id])
->and(ConversionEvent::query()->where('type', ConversionEventType::AccessRequested->value)->sole()->company_id)->toBe($release->company_id);
$plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
->assertRedirect(route('me.press-releases.show', ['id' => $release->id], absolute: false));
});
test('requests per address are limited', function () {
/** @var TestCase $this */
$release = apiRelease();
$query = accessQuery(app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailReject));
foreach (range(1, 3) as $i) {
Livewire::withQueryParams($query)->test('auth.mail-access')->call('requestLink')->assertHasNoErrors();
}
Livewire::withQueryParams($query)->test('auth.mail-access')->call('requestLink')->assertHasErrors('link');
Mail::assertQueuedCount(3);
});
test('a signed-in recipient goes straight to the release', function () {
/** @var TestCase $this */
$release = apiRelease();
$user = User::factory()->create(['email' => 'presse@firma.de', 'is_active' => true]);
$user->assignRole('customer');
$user->companies()->attach($release->company_id, ['role' => 'responsible']);
$this->actingAs($user)
->get(app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish))
->assertRedirect(route('me.press-releases.show', ['id' => $release->id]));
});
// ------------------------------------------- Wiedereinreichung (6.18)
test('a press contact revises a rejected api release, resubmits it free of charge and gets the result', function () {
/** @var TestCase $this */
Queue::fake();
$release = apiRelease(['status' => PressReleaseStatus::Rejected->value]);
PressReleaseStatusLog::query()->create([
'press_release_id' => $release->id,
'from_status' => PressReleaseStatus::Draft->value,
'to_status' => PressReleaseStatus::Review->value,
'source' => 'api',
]);
// So steht der Kontakt nach dem Anmeldelink da (ContactAccessService).
$contact = User::factory()->create(['email' => 'presse@firma.de', 'is_active' => true]);
$contact->assignRole('customer');
$contact->companies()->attach($release->company_id, ['role' => 'responsible']);
$quotaBefore = $release->user->pressReleaseQuotaRemaining();
$this->actingAs($contact);
expect(Gate::allows('update', $release))->toBeTrue()
->and(Gate::allows('submitForReview', $release))->toBeTrue();
$release->update(['title' => 'Firma GmbH eröffnet Standort in Bielefeld']);
app(PressReleaseService::class)->submitForReview($release->fresh());
$resubmitted = $release->fresh();
expect($resubmitted->status)->toBe(PressReleaseStatus::Review)
// Kostenlos: Ein Slot wird erst bei der Veröffentlichung verbraucht,
// die Wiedereinreichung selbst bucht nichts.
->and($release->user->fresh()->pressReleaseQuotaRemaining())->toBe($quotaBefore);
// Bleibt eine API-Meldung: Das Ergebnis geht an den Kontakt, nicht an das
// Konto des Einlieferers.
app(PressReleaseService::class)->publish($resubmitted);
Mail::assertQueued(PressReleasePublished::class, fn (PressReleasePublished $mail): bool => $mail->hasTo('presse@firma.de') && $mail->accessEmail === 'presse@firma.de');
Mail::assertNotQueued(PressReleasePublished::class, fn (PressReleasePublished $mail): bool => $mail->hasTo($release->user->email));
});