CluPilotCloud/tests/Feature/Mail/MailPaceTest.php

408 lines
16 KiB
PHP

<?php
use App\Jobs\PacedMail;
use App\Mail\Concerns\RidesALane;
use App\Mail\ResetPasswordMail;
use App\Models\User;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
/**
* Eine Mail ohne Postfach und ohne Anhang, nur damit ein Lauf zustande kommt.
*
* Die echten Mailklassen hängen alle an einem Postfach — eigener Mailer,
* eigener SMTP-Zugang — und die Rechnung rendert obendrein ein PDF je Stück.
* Beides gehört nicht zu dem, was hier bewiesen wird: die Drossel sieht die
* Spur und sonst nichts vom Inhalt. Der Trait ist derselbe, den alle vierzehn
* benutzen, der Weg in die Schlange also derselbe wie im Betrieb.
*/
class TaktProbeMail extends Mailable implements ShouldQueue
{
use Queueable, RidesALane, SerializesModels;
public function __construct(public int $nummer) {}
public function envelope(): Envelope
{
return new Envelope(from: 'takt@example.test', subject: 'Takt '.$this->nummer);
}
public function content(): Content
{
return new Content(htmlString: '<p>Takt</p>');
}
}
/**
* Eine Mail, die aus echtem Grund kippt — nicht wegen der Drossel.
*
* Der Anhang, weil genau dort der teuerste echte Fehler dieses Projekts sitzt:
* `InvoiceMail::attachments()` rendert das PDF beim Senden. Kippt das, ist der
* Auftrag nicht gedrosselt, sondern kaputt, und gehört nach drei Versuchen ins
* Fehlerprotokoll statt sechs Stunden lang wiederholt.
*/
class GiftigeDirektMail extends Mailable implements ShouldQueue
{
use Queueable, RidesALane, SerializesModels;
public function envelope(): Envelope
{
return new Envelope(from: 'takt@example.test', subject: 'Giftig');
}
public function content(): Content
{
return new Content(htmlString: '<p>Giftig</p>');
}
/** @return array<int, Attachment> */
public function attachments(): array
{
throw new RuntimeException('Das Rendern des Anhangs ist gekippt.');
}
}
/**
* Eine Mailklasse, die ihre eigene zeitliche Grenze mitbringt.
*
* Heute tut das keine der vierzehn. Aber `SendQueuedMailable::retryUntil()`
* reicht an die Mailklasse weiter, und eine Überschreibung, die das still
* wegnimmt, würde die nächste ohne einen Hinweis übergehen.
*/
class EigeneGrenzeMail extends Mailable implements ShouldQueue
{
use Queueable, RidesALane, SerializesModels;
public function retryUntil(): DateTimeInterface
{
return now()->addMinutes(11);
}
public function envelope(): Envelope
{
return new Envelope(from: 'takt@example.test', subject: 'Eigene Grenze');
}
public function content(): Content
{
return new Content(htmlString: '<p>Eigene Grenze</p>');
}
}
/**
* Der Auftrag, so wie ihn das Einreihen erzeugt.
*
* Nicht `new PacedMail(...)`: dass überhaupt ein PacedMail entsteht, ist die
* halbe Aufgabe — die Bindung im Container tauscht ihn für jede Mail aus, ohne
* dass eine Absendestelle davon weiß. Und die Spur steht erst da, weil der
* Trait sie VOR `newQueuedJob()` setzt; ein von Hand gebauter Auftrag hätte sie
* nie gesehen und der Test bewiese die falsche Sache.
*/
function taktAuftrag(Mailable $mailable): object
{
Queue::fake();
Mail::to('kunde@example.test')->queue($mailable);
return Queue::pushed(PacedMail::class)->first()
?? throw new RuntimeException('Es wurde kein PacedMail eingereiht.');
}
/**
* Der Name der Drossel, an der dieser Auftrag hängt.
*
* `RateLimited` hält ihn geschützt und bietet keinen Weg heraus. Die Alternative
* wäre, die Spur am Verhalten abzulesen — zwei Kontingente setzen und schauen,
* welches greift; das prüft aber die Drossel des Frameworks mit und nicht die
* eine Entscheidung, um die es hier geht: welche Spur welche Drossel bekommt.
*/
function drosselName(RateLimited $middleware): string
{
return (new ReflectionProperty($middleware, 'limiterName'))->getValue($middleware);
}
/** Ein Durchlauf des echten Arbeiters, so wie er im Betrieb läuft. */
function einDurchlaufDesArbeiters(): void
{
Artisan::call('queue:work', [
'--once' => true,
'--tries' => 3,
'--sleep' => 0,
'--queue' => implode(',', [MailLane::DIRECT, MailLane::URGENT, MailLane::CALM]),
]);
}
/** Die Nutzlast des einen Auftrags, der gerade in der Schlange liegt. */
function nutzlastDesAuftrags(): array
{
return json_decode((string) DB::table('jobs')->value('payload'), true);
}
/**
* Die Drossel darf keine Mail verlieren.
*
* Der Arbeiter läuft mit --tries=3, und eine zurückgelegte Mail zählt als
* Versuch. Ohne Vorkehrung wäre jede Rechnung nach dem dritten Drosseln
* gescheitert statt verschickt — still, im Fehlerprotokoll, ohne dass jemand
* etwas merkt. Das ist der Test, der vor der ersten Zeile Drossel steht.
*
* Über den ECHTEN Arbeiter, nicht über das Verhalten der Zwischenschicht: die
* Falle sitzt nicht in der Drossel, sondern in `Worker::markJobAsFailedIf…`,
* das `--tries` gegen `attempts()` hält. Nur ein Lauf, der wirklich durch diese
* Stelle geht, kann belegen, dass sie nicht zuschlägt. Die Schlange ist deshalb
* `database` statt `sync` — `sync` legt nicht zurück, sondern lässt eine
* gedrosselte Mail spurlos verschwinden, und der Test wäre grün ohne Grund.
*
* Acht Mails auf zwei je Fenster: die letzten beiden werden dreimal
* zurückgelegt und kommen im vierten Anlauf dran — ein Versuch mehr, als
* `--tries=3` durchgehen ließe. Genau das sichert die letzte Zusicherung ab,
* damit der Test nicht eines Tages grün bleibt, weil gar nicht mehr gedrosselt
* wird.
*/
it('verliert keine Mail, wenn das Kontingent kleiner ist als der Lauf', function () {
Settings::set('mail.pace.calm.count', 2);
Settings::set('mail.pace.calm.minutes', 1);
MailLane::assign(TaktProbeMail::class, MailLane::CALM);
config(['queue.default' => 'database']);
$versuche = [];
Event::listen(JobProcessing::class, function (JobProcessing $event) use (&$versuche) {
$versuche[] = $event->job->attempts();
});
foreach (range(1, 8) as $nummer) {
Mail::to("kunde{$nummer}@example.test")->queue(new TaktProbeMail($nummer));
}
// Der Arbeiter, so wie er im Betrieb läuft: eine Mail je Aufruf, --tries=3.
// Liegt nichts Fälliges mehr da, wird die Uhr auf den nächsten
// Fälligkeitszeitpunkt gestellt — die Drossel legt zurück, sie wirft nicht,
// und ohne Uhrstellen stünde der Test eine Minute lang still.
$notbremse = 100;
while (DB::table('jobs')->count() > 0) {
if (--$notbremse <= 0) {
throw new RuntimeException('Die Schlange leert sich nicht — Abbruch statt Endlosschleife.');
}
if (! DB::table('jobs')->where('available_at', '<=', now()->getTimestamp())->exists()) {
$this->travelTo(Carbon::createFromTimestamp((int) DB::table('jobs')->min('available_at')));
continue;
}
einDurchlaufDesArbeiters();
}
expect(DB::table('failed_jobs')->count())->toBe(0)
->and(Mail::mailer()->getSymfonyTransport()->messages())->toHaveCount(8)
->and(max($versuche))->toBeGreaterThan(3);
});
/**
* Die Kehrseite: wo nicht gedrosselt wird, muss `--tries` weiter greifen.
*
* Eine zeitliche Grenze schaltet den Versuchszähler nicht bloß für die Drossel
* ab, sondern ganz: `Worker::markJobAsFailedIfWillExceedMaxAttempts` prüft die
* Versuche ausdrücklich nur `if (! $job->retryUntil())`. Eine Mail, die aus
* ECHTEM Grund wirft, würde damit sechs Stunden lang wiederholt — ohne Pause,
* weil der Arbeiter ohne `--backoff` läuft und `calculateBackoff` dann auf null
* Sekunden fällt.
*
* Zwei Schäden: der Fehler ist sechs Stunden lang nicht in `failed_jobs`, also
* für jede Alarmierung nicht vorhanden. Und sobald der Arbeiter die Spuren in
* Prioritätsreihenfolge liest, zieht er diese eine giftige Direkt-Mail bei
* JEDEM Durchlauf zuerst — sechs Stunden lang keine Kennwort-Zurücksetzung.
*/
it('lässt eine Direkt-Mail, die aus echtem Grund wirft, nach drei Versuchen scheitern', function () {
MailLane::assign(GiftigeDirektMail::class, MailLane::DIRECT);
config(['queue.default' => 'database']);
Mail::to('kunde@example.test')->queue(new GiftigeDirektMail);
// Ohne Zeitfenster wird der Auftrag beim dritten Versuch abgelegt und die
// Schlange ist leer. Mit Zeitfenster käme er endlos wieder — die Schranke
// bei zehn ist der Unterschied zwischen einem roten Test und einem Test,
// der nie fertig wird.
$durchlaeufe = 0;
while (DB::table('jobs')->where('available_at', '<=', now()->getTimestamp())->exists()) {
if (++$durchlaeufe > 10) {
throw new RuntimeException('Der Auftrag hört nicht auf — die Versuchszahl greift nicht.');
}
einDurchlaufDesArbeiters();
}
expect($durchlaeufe)->toBe(3)
->and(DB::table('failed_jobs')->count())->toBe(1)
->and(DB::table('jobs')->count())->toBe(0);
});
it('gibt einem Auftrag ohne Drossel gar kein Zeitfenster', function (string $aufbau) {
config(['queue.default' => 'database']);
$aufbau === 'direkt'
? MailLane::assign(TaktProbeMail::class, MailLane::DIRECT)
: Settings::set('mail.pace.enabled', false);
Mail::to('kunde@example.test')->queue(new TaktProbeMail(1));
expect(nutzlastDesAuftrags()['retryUntil'])->toBeNull();
})->with(['direkt', 'notschalter aus']);
/**
* Bringt die Mailklasse ihre eigene Grenze mit, gilt ihre.
*
* `SendQueuedMailable::retryUntil()` reicht an `$this->mailable->retryUntil`
* weiter. Eine Überschreibung, die das nicht zurückfragt, nähme der nächsten
* Mailklasse ihre Entscheidung still weg.
*/
it('lässt der Mailklasse ihre eigene zeitliche Grenze', function () {
config(['queue.default' => 'database']);
Mail::to('kunde@example.test')->queue(new EigeneGrenzeMail);
// Auf einer gedrosselten Spur — sonst bewiese der Test nur, dass ohne
// Drossel nichts passiert, und nicht, dass die eigene Grenze vorgeht.
expect(MailLane::for(EigeneGrenzeMail::class))->toBe(MailLane::CALM)
->and(nutzlastDesAuftrags()['retryUntil'])->toBe(now()->addMinutes(11)->getTimestamp());
});
/**
* Was der Arbeiter liest, ist der Auftrag in der Schlange, nicht das Objekt.
*
* `retryUntil` wird beim Einreihen ausgerechnet und in die Nutzlast
* geschrieben; von dort holt `Worker::markJobAsFailedIfAlreadyExceedsMaxAttempts`
* sie und steigt aus, bevor `--tries` überhaupt zur Sprache kommt. Steht die
* Zeile nicht in der Nutzlast, hilft die schönste `retryUntil()`-Methode nichts.
*
* `maxTries` bleibt leer: ein eigener Versuchszähler am Auftrag würde den des
* Arbeiters ersetzen und die Falle nur an eine andere Zahl hängen.
*/
it('schreibt die zeitliche Grenze in den Auftrag, den der Arbeiter liest', function () {
config(['queue.default' => 'database']);
Mail::to('kunde@example.test')->queue(new TaktProbeMail(1));
$nutzlast = json_decode((string) DB::table('jobs')->value('payload'), true);
expect($nutzlast['retryUntil'])->toBe(now()->addHours(6)->getTimestamp())
->and($nutzlast['maxTries'])->toBeNull();
});
it('erzeugt für jede Mail den gedrosselten Auftrag', function () {
expect(taktAuftrag(new TaktProbeMail(1)))->toBeInstanceOf(PacedMail::class);
});
it('hängt jede gedrosselte Spur an ihre eigene Drossel', function (string $spur) {
MailLane::assign(TaktProbeMail::class, $spur);
$middleware = taktAuftrag(new TaktProbeMail(1))->middleware();
expect($middleware)->toHaveCount(1)
->and($middleware[0])->toBeInstanceOf(RateLimited::class)
->and(drosselName($middleware[0]))->toBe($spur);
})->with([MailLane::URGENT, MailLane::CALM]);
/**
* Auf der direkten Spur wartet gerade ein Mensch, der eben geklickt hat.
* Drosseln nützt dort nichts — die Mails entstehen einzeln und können gar
* keinen Schub bilden — und kostet einen Supportfall je verzögertem Kennwort.
*/
it('drosselt die direkte Spur nicht', function () {
$mail = new ResetPasswordMail(User::factory()->create(), 'https://example.test/reset', 60);
expect(taktAuftrag($mail)->middleware())->toBe([]);
});
it('meldet für die direkte Spur gar keine Drossel an', function () {
expect(RateLimiter::limiter(MailLane::DIRECT))->toBeNull();
});
/**
* `Limit::perMinutes($minuten, $anzahl)` — Minuten zuerst. Die Reihenfolge ist
* anders herum, als man sie liest, und ein vertauschtes Paar wäre ein Takt von
* fünf Mails in dreißig Minuten statt dreißig in fünf. Deshalb stehen hier
* beide Zahlen einzeln, und zwar in Sekunden, wie die Drossel sie sieht.
*/
it('nimmt beide Kontingente aus den Einstellungen', function () {
Settings::set('mail.pace.urgent.count', 12);
Settings::set('mail.pace.urgent.minutes', 3);
Settings::set('mail.pace.calm.count', 7);
Settings::set('mail.pace.calm.minutes', 9);
$wichtig = RateLimiter::limiter(MailLane::URGENT)(null);
$ruhig = RateLimiter::limiter(MailLane::CALM)(null);
expect($wichtig->maxAttempts)->toBe(12)
->and($wichtig->decaySeconds)->toBe(180)
->and($ruhig->maxAttempts)->toBe(7)
->and($ruhig->decaySeconds)->toBe(540);
});
/**
* Eine unbrauchbare Einstellung darf kein Kontingent von null ergeben.
*
* Die Vorgabe in `Settings::get()` greift nur bei FEHLENDER Zeile. Eine
* gespeicherte `null` — oder ein Wort statt einer Zahl — kommt als Wert durch
* und wird von `(int)` zu 0. Ein Kontingent von 0 hieße: `tooManyAttempts` ist
* immer wahr, jede Mail wird endlos zurückgelegt und nach sechs Stunden
* gesammelt zu `failed_jobs` — genau der Ausgang, den dieser Takt verhindern
* soll, nur über einen anderen Weg. Ein Fenster von 0 Minuten hieße
* umgekehrt: gar keine Drossel.
*/
it('macht aus einer unbrauchbaren Einstellung kein Kontingent von null', function () {
Settings::set('mail.pace.urgent.count', null);
Settings::set('mail.pace.urgent.minutes', null);
Settings::set('mail.pace.calm.count', 'viele');
Settings::set('mail.pace.calm.minutes', -3);
$wichtig = RateLimiter::limiter(MailLane::URGENT)(null);
$ruhig = RateLimiter::limiter(MailLane::CALM)(null);
expect($wichtig->maxAttempts)->toBe(1)
->and($wichtig->decaySeconds)->toBe(60)
->and($ruhig->maxAttempts)->toBe(1)
->and($ruhig->decaySeconds)->toBe(60);
});
it('taktet ohne Einstellung dreißig je fünf und zwanzig je zehn Minuten', function () {
$wichtig = RateLimiter::limiter(MailLane::URGENT)(null);
$ruhig = RateLimiter::limiter(MailLane::CALM)(null);
expect($wichtig->maxAttempts)->toBe(30)
->and($wichtig->decaySeconds)->toBe(300)
->and($ruhig->maxAttempts)->toBe(20)
->and($ruhig->decaySeconds)->toBe(600);
});
/**
* Der Notschalter für den Abend, an dem etwas anderes klemmt und zweihundert
* Rechnungen trotzdem heute raus müssen.
*/
it('lässt sich mit dem Notschalter ganz abstellen', function () {
Settings::set('mail.pace.enabled', false);
MailLane::assign(TaktProbeMail::class, MailLane::CALM);
expect(taktAuftrag(new TaktProbeMail(1))->middleware())->toBe([]);
});