242 lines
9.4 KiB
PHP
242 lines
9.4 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\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>');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
Artisan::call('queue:work', [
|
|
'--once' => true,
|
|
'--tries' => 3,
|
|
'--sleep' => 0,
|
|
'--queue' => implode(',', [MailLane::DIRECT, MailLane::URGENT, MailLane::CALM]),
|
|
]);
|
|
}
|
|
|
|
expect(DB::table('failed_jobs')->count())->toBe(0)
|
|
->and(Mail::mailer()->getSymfonyTransport()->messages())->toHaveCount(8)
|
|
->and(max($versuche))->toBeGreaterThan(3);
|
|
});
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
|
|
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([]);
|
|
});
|