nummer); } public function content(): Content { return new Content(htmlString: '
Takt
'); } } /** * 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([]); });