Plan: der Versandtakt in sieben Aufgaben
Beim Lesen des Frameworks fielen zwei Annahmen des Entwurfs um, und beide haetten Tage gekostet: SendQueuedMailable hat KEINE middleware() — die Klasse hat nur handle, backoff, retryUntil, failed, displayName, __clone. Eine middleware() auf der Mailklasse liest also niemand. Der Weg fuehrt ueber einen eigenen Auftrag. Dafuer gibt es einen sauberen Haken: Mailable::newQueuedJob() erzeugt den Auftrag ueber den Container. Eine Bindung tauscht ihn fuer alle Mails aus, ohne dass eine einzige Absendestelle sich aendert — womit die Zusage des Entwurfs haelt. Und die Spur muss vor dem Einreihen feststehen, weil Mailable::queue() den Schlangennamen liest und an pushOn() weiterreicht. Ein Trait, das queue() ueberschreibt, greift rechtzeitig. Zwei Teststellen stehen bewusst als Rumpf statt als fertiger Code: der Nachweis, dass eine gedrosselte Rueckstellung den Versuchszaehler nicht erschoepft, und das Alter des aeltesten Auftrags einer Redis-Schlange. Beide haengen an Umgebungsverhalten, das ich nicht gemessen habe; geratener Testcode waere dort schlimmer als ein benannter Auftrag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/versandtakt
parent
9e890243f6
commit
2ab6bd22a3
|
|
@ -0,0 +1,940 @@
|
|||
# Der Versandtakt — Umsetzungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Ziel:** Ausgehende Mails laufen in drei Spuren mit eigenem Takt, damit ein
|
||||
großer Lauf nie als Schub bei einem Empfängerserver ankommt — und ein
|
||||
Kennwort-Zurücksetzen trotzdem sofort hinausgeht.
|
||||
|
||||
**Architektur:** Alle vierzehn Mailklassen sind bereits `ShouldQueue`. Ein Trait
|
||||
schickt jede in die Schlange ihrer Spur; ein eigener Warteschlangen-Auftrag
|
||||
drosselt zwei der drei Spuren; der Arbeiter liest die Spuren in der Reihenfolge
|
||||
ihrer Dringlichkeit. **Keine Absendestelle ändert sich** — `Mail::to(...)->queue(...)`
|
||||
bleibt überall stehen.
|
||||
|
||||
**Tech-Stack:** Laravel 13.8, Redis-Warteschlange, Livewire 3, Pest, Docker.
|
||||
|
||||
**Entwurf:** `docs/superpowers/specs/2026-08-02-versandtakt-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Tests laufen so:** `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test`
|
||||
(einzelne Datei durch Anhängen des Pfads).
|
||||
- **Die drei Spuren, wörtlich:**
|
||||
| Spur | Schlange | Takt |
|
||||
|---|---|---|
|
||||
| Direkt | `mail-direkt` | ungedrosselt |
|
||||
| Wichtig | `mail-wichtig` | 30 je 5 Minuten |
|
||||
| Zeit lassen | `mail-ruhig` | 20 je 10 Minuten |
|
||||
- **Die Zuordnung, wörtlich:**
|
||||
- `mail-direkt`: `ResetPasswordMail`, `VerifyEmailMail`, `NewDeviceSignInMail`,
|
||||
`SecurityBlockMail`, `ContactRequestMail`, `OrderConfirmationMail`,
|
||||
`OperatorMessageMail`
|
||||
- `mail-wichtig`: `MaintenanceAnnouncementMail`, `MaintenanceCancelledMail`,
|
||||
`CloudSuspendedMail`, `CloudResumedMail`
|
||||
- `mail-ruhig`: `InvoiceMail`, `DunningNoticeMail`, `DormantAccountWarningMail`
|
||||
- **Kein Zeitfenster.** Rund um die Uhr, ausdrücklich so entschieden.
|
||||
- **Die sieben Direkt-Mails lassen sich nicht in eine gedrosselte Spur
|
||||
verschieben** — die Sperre sitzt in der Zuordnung, nicht im Formular.
|
||||
- **Eine unbekannte Mailklasse landet in `mail-ruhig`** (die vorsichtige Richtung).
|
||||
- Regeln aus `CLAUDE.md` gelten: R22 (Aufwand nach Aufgabe), R23 (Bestätigung im
|
||||
eigenen Modal), R19 (Zeiten über `->local()` anzeigen).
|
||||
- **Commit-Nachrichten auf Deutsch**, mit `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||||
- **Nicht pushen.**
|
||||
|
||||
## Was beim Lesen des Frameworks herauskam
|
||||
|
||||
Zwei Annahmen aus dem Entwurf halten der Prüfung **nicht** stand. Wer das nicht
|
||||
weiß, baut zwei Tage am falschen Ende:
|
||||
|
||||
1. **`SendQueuedMailable` hat keine `middleware()`-Methode** (geprüft in
|
||||
`vendor/laravel/framework/src/Illuminate/Mail/SendQueuedMailable.php`: nur
|
||||
`handle`, `backoff`, `retryUntil`, `failed`, `displayName`, `__clone`). Eine
|
||||
`middleware()` **auf der Mailklasse** wird also von niemandem gelesen. Der
|
||||
Weg führt über einen eigenen Auftrag.
|
||||
2. **Der Auftrag wird über den Container erzeugt.** `Mailable::newQueuedJob()`
|
||||
ruft `Container::getInstance()->make(SendQueuedMailable::class, ['mailable' => $this])`.
|
||||
Eine Bindung im Dienstanbieter tauscht die Klasse also für **alle** Mails aus,
|
||||
ohne eine einzige Absendestelle anzufassen.
|
||||
3. **Die Spur muss vor dem Einreihen feststehen.** `Mailable::queue()` liest den
|
||||
Schlangennamen und reicht ihn an `pushOn()` weiter. Ein Trait, das `queue()`
|
||||
überschreibt, greift rechtzeitig — eine Trait-Methode schlägt die geerbte
|
||||
Methode der Elternklasse.
|
||||
4. **`Limit::perMinutes($decayMinutes, $maxAttempts)`** — die Reihenfolge ist
|
||||
Minuten zuerst. `Limit::perMinutes(5, 30)` heißt „30 in 5 Minuten".
|
||||
5. **Alle vierzehn Mailklassen benutzen bereits `SendsFromMailbox`.** Der Trait
|
||||
für die Spur kommt daneben, nicht hinein: die eine Sache ist der Absender,
|
||||
die andere die Spur.
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Verantwortung |
|
||||
|---|---|
|
||||
| `app/Services/Mail/MailLane.php` | Welche Spur gehört zu welcher Mailklasse, und was ist gesperrt |
|
||||
| `app/Mail/Concerns/RidesALane.php` | Reiht eine Mail in die Schlange ihrer Spur ein |
|
||||
| `app/Jobs/PacedMail.php` | Der Auftrag, der drosselt statt sofort zu senden |
|
||||
| `app/Providers/MailPaceServiceProvider.php` | Kontingente anmelden, Auftrag binden |
|
||||
| `app/Support/Readiness/DeliveryChecks.php` | Warnung, wenn eine Spur steht |
|
||||
| `app/Livewire/Admin/MailPace.php` + Blade | Sicht und Schalter |
|
||||
| `docker-compose.yml` | Der Arbeiter liest die drei Spuren |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Die Spuren und ihre Zuordnung
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Services/Mail/MailLane.php`
|
||||
- Test: `tests/Feature/Mail/MailLaneTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `MailLane::DIRECT = 'mail-direkt'`, `MailLane::URGENT = 'mail-wichtig'`,
|
||||
`MailLane::CALM = 'mail-ruhig'`
|
||||
- Produces: `MailLane::for(string $mailableClass): string` — die Schlange
|
||||
- Produces: `MailLane::isLocked(string $mailableClass): bool` — Direkt-Mail?
|
||||
- Produces: `MailLane::assign(string $mailableClass, string $lane): void` — wirft
|
||||
bei einer gesperrten Klasse
|
||||
- Produces: `MailLane::all(): array<string, string>` — Klasse → Spur, für die Konsole
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
`tests/Feature/Mail/MailLaneTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Mail\DunningNoticeMail;
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Mail\MaintenanceAnnouncementMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Services\Mail\MailLane;
|
||||
use App\Support\Settings;
|
||||
|
||||
/**
|
||||
* Welche Mail in welcher Spur fährt.
|
||||
*
|
||||
* Die Vorgaben stehen im Code, die Änderung des Betreibers darüber. Gesperrt
|
||||
* sind die Mails, auf die gerade ein Mensch wartet: ein Kennwort-Zurücksetzen,
|
||||
* das zwanzig Minuten liegt, ist ein Supportfall und kein gespartes Ansehen.
|
||||
*/
|
||||
it('kennt die Vorgabe jeder Mailklasse', function () {
|
||||
expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT)
|
||||
->and(MailLane::for(MaintenanceAnnouncementMail::class))->toBe(MailLane::URGENT)
|
||||
->and(MailLane::for(InvoiceMail::class))->toBe(MailLane::CALM)
|
||||
->and(MailLane::for(DunningNoticeMail::class))->toBe(MailLane::CALM);
|
||||
});
|
||||
|
||||
it('schickt eine unbekannte Mailklasse in die ruhige Spur', function () {
|
||||
expect(MailLane::for('App\\Mail\\GibtEsNichtMail'))->toBe(MailLane::CALM);
|
||||
});
|
||||
|
||||
it('lässt den Betreiber eine Mail verschieben', function () {
|
||||
MailLane::assign(InvoiceMail::class, MailLane::URGENT);
|
||||
|
||||
expect(MailLane::for(InvoiceMail::class))->toBe(MailLane::URGENT);
|
||||
});
|
||||
|
||||
it('sperrt die Mails, auf die jemand wartet', function () {
|
||||
expect(MailLane::isLocked(ResetPasswordMail::class))->toBeTrue()
|
||||
->and(MailLane::isLocked(InvoiceMail::class))->toBeFalse();
|
||||
|
||||
expect(fn () => MailLane::assign(ResetPasswordMail::class, MailLane::CALM))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT);
|
||||
});
|
||||
|
||||
it('lässt eine gespeicherte Zuordnung eine gesperrte Klasse nicht überschreiben', function () {
|
||||
// Nicht über assign(), sondern direkt in die Einstellung geschrieben — so
|
||||
// sähe es aus, wenn jemand an der Prüfung vorbei schreibt.
|
||||
Settings::set('mail.lanes', [ResetPasswordMail::class => MailLane::CALM]);
|
||||
|
||||
expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen**
|
||||
|
||||
Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail/MailLaneTest.php`
|
||||
Erwartet: FEHLSCHLAG — `Class "App\Services\Mail\MailLane" not found`.
|
||||
|
||||
- [ ] **Step 3: Die Zuordnung schreiben**
|
||||
|
||||
`app/Services/Mail/MailLane.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Mail\CloudResumedMail;
|
||||
use App\Mail\CloudSuspendedMail;
|
||||
use App\Mail\ContactRequestMail;
|
||||
use App\Mail\DormantAccountWarningMail;
|
||||
use App\Mail\DunningNoticeMail;
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Mail\MaintenanceAnnouncementMail;
|
||||
use App\Mail\MaintenanceCancelledMail;
|
||||
use App\Mail\NewDeviceSignInMail;
|
||||
use App\Mail\OperatorMessageMail;
|
||||
use App\Mail\OrderConfirmationMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Mail\SecurityBlockMail;
|
||||
use App\Mail\VerifyEmailMail;
|
||||
use App\Support\Settings;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* In welcher Spur eine Mail fährt.
|
||||
*
|
||||
* Drei Spuren, und die Trennung läuft nicht zwischen Massenversand und
|
||||
* Einzelmail, sondern zwischen dringend und nicht dringend. Eine
|
||||
* Ausfallmeldung geht an alle UND eilt — sie bekommt deshalb ein höheres
|
||||
* Kontingent statt gar keines.
|
||||
*
|
||||
* Die Vorgaben stehen hier, die Änderung des Betreibers darüber in den
|
||||
* Einstellungen. Eine unbekannte Klasse fällt in die ruhige Spur: eine zu
|
||||
* langsam verschickte Mail ist ein kleinerer Fehler als ein ungedrosselter
|
||||
* Schub, den niemand vorhergesehen hat.
|
||||
*/
|
||||
final class MailLane
|
||||
{
|
||||
public const DIRECT = 'mail-direkt';
|
||||
|
||||
public const URGENT = 'mail-wichtig';
|
||||
|
||||
public const CALM = 'mail-ruhig';
|
||||
|
||||
/**
|
||||
* Die sieben, auf die gerade jemand wartet.
|
||||
*
|
||||
* Sie entstehen einzeln, weil ein einzelner Mensch geklickt hat, und
|
||||
* können gar keinen Schub bilden. Drosseln nützt dort nichts und kostet
|
||||
* einen Supportfall je verzögertem Kennwort — deshalb sind sie gesperrt
|
||||
* und nicht nur voreingestellt.
|
||||
*/
|
||||
private const LOCKED = [
|
||||
ResetPasswordMail::class,
|
||||
VerifyEmailMail::class,
|
||||
NewDeviceSignInMail::class,
|
||||
SecurityBlockMail::class,
|
||||
ContactRequestMail::class,
|
||||
OrderConfirmationMail::class,
|
||||
OperatorMessageMail::class,
|
||||
];
|
||||
|
||||
/** @var array<class-string, string> */
|
||||
private const DEFAULTS = [
|
||||
MaintenanceAnnouncementMail::class => self::URGENT,
|
||||
MaintenanceCancelledMail::class => self::URGENT,
|
||||
CloudSuspendedMail::class => self::URGENT,
|
||||
CloudResumedMail::class => self::URGENT,
|
||||
InvoiceMail::class => self::CALM,
|
||||
DunningNoticeMail::class => self::CALM,
|
||||
DormantAccountWarningMail::class => self::CALM,
|
||||
];
|
||||
|
||||
/** Die Schlange, in die diese Mailklasse gehört. */
|
||||
public static function for(string $mailableClass): string
|
||||
{
|
||||
if (self::isLocked($mailableClass)) {
|
||||
return self::DIRECT;
|
||||
}
|
||||
|
||||
$stored = (array) Settings::get('mail.lanes', []);
|
||||
$lane = $stored[$mailableClass] ?? self::DEFAULTS[$mailableClass] ?? self::CALM;
|
||||
|
||||
return in_array($lane, [self::DIRECT, self::URGENT, self::CALM], true) ? $lane : self::CALM;
|
||||
}
|
||||
|
||||
public static function isLocked(string $mailableClass): bool
|
||||
{
|
||||
return in_array($mailableClass, self::LOCKED, true);
|
||||
}
|
||||
|
||||
/** Verschiebt eine Mailklasse. Wirft, wenn sie gesperrt ist. */
|
||||
public static function assign(string $mailableClass, string $lane): void
|
||||
{
|
||||
if (self::isLocked($mailableClass)) {
|
||||
throw new RuntimeException(
|
||||
"{$mailableClass} fährt immer direkt: darauf wartet gerade jemand."
|
||||
);
|
||||
}
|
||||
|
||||
$stored = (array) Settings::get('mail.lanes', []);
|
||||
$stored[$mailableClass] = $lane;
|
||||
Settings::set('mail.lanes', $stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jede bekannte Mailklasse mit ihrer Spur, für die Konsole.
|
||||
*
|
||||
* @return array<class-string, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$classes = array_merge(self::LOCKED, array_keys(self::DEFAULTS));
|
||||
|
||||
return collect($classes)
|
||||
->mapWithKeys(fn (string $class) => [$class => self::for($class)])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test laufen lassen**
|
||||
|
||||
Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail/MailLaneTest.php`
|
||||
Erwartet: BESTANDEN.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/Services/Mail/MailLane.php tests/Feature/Mail/MailLaneTest.php
|
||||
git commit -m "Drei Spuren, und welche Mail in welche gehoert
|
||||
|
||||
Die Trennung laeuft zwischen dringend und nicht dringend, nicht zwischen
|
||||
Massenversand und Einzelmail: eine Ausfallmeldung geht an alle UND eilt.
|
||||
|
||||
Die sieben Mails, auf die jemand wartet, sind gesperrt statt nur
|
||||
voreingestellt — auch eine von Hand geschriebene Einstellung verschiebt sie
|
||||
nicht.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Jede Mail fährt in ihrer Spur
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Mail/Concerns/RidesALane.php`
|
||||
- Modify: alle vierzehn Klassen in `app/Mail/` (je ein Wort in der `use`-Zeile)
|
||||
- Test: `tests/Feature/Mail/MailLaneRoutingTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MailLane::for(string $mailableClass): string` aus Task 1
|
||||
- Produces: jede Mailklasse wird auf die Schlange ihrer Spur eingereiht
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
`tests/Feature/Mail/MailLaneRoutingTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Services\Mail\MailLane;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* Die Spur entsteht beim Einreihen, nicht beim Senden.
|
||||
*
|
||||
* Der Arbeiter liest die Schlangen in der Reihenfolge ihrer Dringlichkeit —
|
||||
* deshalb steht ein Kennwort-Zurücksetzen nie hinter zweihundert Rechnungen.
|
||||
* Das trägt nur, wenn die Rechnung wirklich in einer anderen Schlange liegt.
|
||||
*/
|
||||
it('reiht jede Mail in die Schlange ihrer Spur ein', function () {
|
||||
Queue::fake();
|
||||
|
||||
Mail::to('kunde@example.test')->queue(new InvoiceMail(invoiceForTest(), 'Muster'));
|
||||
|
||||
Queue::assertPushedOn(MailLane::CALM, Illuminate\Mail\SendQueuedMailable::class);
|
||||
});
|
||||
|
||||
it('reiht eine Direkt-Mail in die Direkt-Spur ein', function () {
|
||||
Queue::fake();
|
||||
|
||||
Mail::to('kunde@example.test')->queue(new ResetPasswordMail('https://example.test/reset', 'Muster'));
|
||||
|
||||
Queue::assertPushedOn(MailLane::DIRECT, Illuminate\Mail\SendQueuedMailable::class);
|
||||
});
|
||||
|
||||
it('folgt einer verschobenen Zuordnung', function () {
|
||||
Queue::fake();
|
||||
MailLane::assign(InvoiceMail::class, MailLane::URGENT);
|
||||
|
||||
Mail::to('kunde@example.test')->queue(new InvoiceMail(invoiceForTest(), 'Muster'));
|
||||
|
||||
Queue::assertPushedOn(MailLane::URGENT, Illuminate\Mail\SendQueuedMailable::class);
|
||||
});
|
||||
```
|
||||
|
||||
**Hinweis für den Umsetzenden:** `invoiceForTest()` gibt es nicht — bau eine
|
||||
Hilfsfunktion in dieser Datei, die eine Rechnung anlegt, nach dem Muster der
|
||||
Nachbardateien in `tests/Feature/Billing/`. Prüfe die echten
|
||||
Konstruktor-Signaturen von `InvoiceMail` und `ResetPasswordMail`, bevor du sie
|
||||
abtippst. Prüfe außerdem, ob `Queue::assertPushedOn` mit `SendQueuedMailable`
|
||||
den Auftrag wirklich trifft — falls `Mail::fake()` hier das passendere Werkzeug
|
||||
ist, nimm es und begründe es im Bericht.
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen**
|
||||
|
||||
Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail/MailLaneRoutingTest.php`
|
||||
Erwartet: FEHLSCHLAG — die Mail landet auf `default` statt auf `mail-ruhig`.
|
||||
|
||||
- [ ] **Step 3: Den Trait schreiben**
|
||||
|
||||
`app/Mail/Concerns/RidesALane.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Mail\Concerns;
|
||||
|
||||
use App\Services\Mail\MailLane;
|
||||
use Illuminate\Contracts\Queue\Factory as Queue;
|
||||
|
||||
/**
|
||||
* Reiht eine Mail in die Schlange ihrer Spur ein.
|
||||
*
|
||||
* `Mailable::queue()` liest den Schlangennamen und reicht ihn an `pushOn()`
|
||||
* weiter — die Spur muss also VOR dem Einreihen feststehen. Eine Trait-Methode
|
||||
* schlägt die geerbte Methode der Elternklasse, deshalb genügt es, `queue()`
|
||||
* hier zu überschreiben und danach an die Elternklasse weiterzugeben.
|
||||
*
|
||||
* Neben `SendsFromMailbox` statt darin: die eine Sache ist, von welchem
|
||||
* Postfach eine Mail kommt, die andere, wie eilig sie ist.
|
||||
*/
|
||||
trait RidesALane
|
||||
{
|
||||
public function queue(Queue $queue)
|
||||
{
|
||||
$this->onQueue(MailLane::for(static::class));
|
||||
|
||||
return parent::queue($queue);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Den Trait in alle vierzehn Klassen aufnehmen**
|
||||
|
||||
In jeder Datei unter `app/Mail/` die `use`-Zeile im Klassenrumpf ergänzen, in
|
||||
alphabetischer Ordnung:
|
||||
|
||||
```php
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
```
|
||||
|
||||
und den Import `use App\Mail\Concerns\RidesALane;` setzen. Die vierzehn Klassen:
|
||||
`CloudResumedMail`, `CloudSuspendedMail`, `ContactRequestMail`,
|
||||
`DormantAccountWarningMail`, `DunningNoticeMail`, `InvoiceMail`,
|
||||
`MaintenanceAnnouncementMail`, `MaintenanceCancelledMail`,
|
||||
`NewDeviceSignInMail`, `OperatorMessageMail`, `OrderConfirmationMail`,
|
||||
`ResetPasswordMail`, `SecurityBlockMail`, `VerifyEmailMail`.
|
||||
|
||||
- [ ] **Step 5: Ein Test, der keine vergisst**
|
||||
|
||||
In derselben Testdatei ergänzen:
|
||||
|
||||
```php
|
||||
it('lässt keine Mailklasse ohne Spur', function () {
|
||||
$missing = collect(glob(app_path('Mail/*.php')))
|
||||
->map(fn (string $path) => 'App\\Mail\\'.basename($path, '.php'))
|
||||
->reject(fn (string $class) => in_array(App\Mail\Concerns\RidesALane::class, class_uses_recursive($class), true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($missing)->toBe([]);
|
||||
});
|
||||
```
|
||||
|
||||
Dieser Test ist der eigentliche Wächter: er schlägt an, wenn jemand später eine
|
||||
fünfzehnte Mailklasse anlegt und den Trait vergisst.
|
||||
|
||||
- [ ] **Step 6: Tests laufen lassen und committen**
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail
|
||||
git add app/Mail tests/Feature/Mail/MailLaneRoutingTest.php
|
||||
git commit -m "Jede Mail faehrt in der Schlange ihrer Spur
|
||||
|
||||
Die Spur muss vor dem Einreihen feststehen: Mailable::queue() liest den
|
||||
Schlangennamen und reicht ihn an pushOn() weiter. Ein Trait, das queue()
|
||||
ueberschreibt, greift rechtzeitig — eine Trait-Methode schlaegt die geerbte
|
||||
Methode der Elternklasse.
|
||||
|
||||
Der Waechter-Test schlaegt an, wenn spaeter jemand eine Mailklasse anlegt und
|
||||
den Trait vergisst.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Der gedrosselte Auftrag — und die Falle mit den Versuchen
|
||||
|
||||
**Das ist die Aufgabe, an der dieses Vorhaben scheitern kann.** Der Arbeiter
|
||||
läuft mit `--tries=3`, und eine gedrosselte Rückstellung zählt als Versuch. Ohne
|
||||
Vorkehrung wäre jede Rechnung nach dem dritten Drosseln **endgültig gescheitert**
|
||||
statt verschickt — still, im Fehlerprotokoll.
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Jobs/PacedMail.php`
|
||||
- Create: `app/Providers/MailPaceServiceProvider.php`
|
||||
- Modify: `bootstrap/providers.php`
|
||||
- Test: `tests/Feature/Mail/MailPaceTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MailLane::DIRECT|URGENT|CALM` aus Task 1
|
||||
- Produces: Kontingente `mail-wichtig` (30 je 5 Min) und `mail-ruhig` (20 je 10 Min)
|
||||
- Produces: Einstellungen `mail.pace.urgent.count`, `mail.pace.urgent.minutes`,
|
||||
`mail.pace.calm.count`, `mail.pace.calm.minutes`
|
||||
|
||||
- [ ] **Step 1: Den wichtigsten Test dieses Vorhabens schreiben**
|
||||
|
||||
`tests/Feature/Mail/MailPaceTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Jobs\PacedMail;
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Services\Mail\MailLane;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
it('verliert keine Mail, wenn das Kontingent kleiner ist als der Lauf', function () {
|
||||
Settings::set('mail.pace.calm.count', 20);
|
||||
Settings::set('mail.pace.calm.minutes', 10);
|
||||
|
||||
// Fünfzig Rechnungen in eine Spur mit Kontingent zwanzig.
|
||||
// Erwartet: fünfzig verschickt, null gescheitert.
|
||||
});
|
||||
```
|
||||
|
||||
**Hinweis für den Umsetzenden:** Dieser Test ist bewusst als Rumpf notiert, weil
|
||||
sein Aufbau von der Umgebung abhängt und geraten schlimmer wäre als
|
||||
nachgesehen. Er muss belegen, dass eine gedrosselte Rückstellung den
|
||||
Versuchszähler **nicht** erschöpft. Zwei Wege, wähle den, der hier trägt, und
|
||||
begründe die Wahl im Bericht:
|
||||
|
||||
1. **Über den echten Arbeiter:** die Aufträge einreihen und
|
||||
`php artisan queue:work --once` in einer Schleife fahren, bis die Schlange
|
||||
leer ist, danach `failed_jobs` prüfen. Ehrlich, aber langsam.
|
||||
2. **Über den Auftrag selbst:** `PacedMail::retryUntil()` und das Verhalten der
|
||||
Drossel-Zwischenschicht direkt prüfen — dass sie zurücklegt statt zu werfen,
|
||||
und dass `retryUntil` weit genug in der Zukunft liegt, dass `--tries` gar
|
||||
nicht greift.
|
||||
|
||||
**Was der Test beweisen muss, egal welchen Weg du nimmst:** keine Mail landet in
|
||||
`failed_jobs`, und der Grund dafür ist nicht Zufall. Schreib in den Bericht, was
|
||||
er beweist **und was nicht** — Nebenläufigkeit stellt er nicht nach.
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen**
|
||||
|
||||
Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail/MailPaceTest.php`
|
||||
Erwartet: FEHLSCHLAG — `Class "App\Jobs\PacedMail" not found`.
|
||||
|
||||
- [ ] **Step 3: Den Auftrag schreiben**
|
||||
|
||||
`app/Jobs/PacedMail.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Mail\MailLane;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Mail\SendQueuedMailable;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
|
||||
/**
|
||||
* Der Auftrag, der eine Mail im Takt ihrer Spur verschickt.
|
||||
*
|
||||
* `SendQueuedMailable` kennt keine `middleware()` — geprüft im Framework:
|
||||
* die Klasse hat nur handle, backoff, retryUntil, failed, displayName. Eine
|
||||
* `middleware()` auf der MAILKLASSE liest deshalb niemand. Der Weg führt über
|
||||
* diesen eigenen Auftrag, den `Mailable::newQueuedJob()` über den Container
|
||||
* erzeugt — eine Bindung tauscht ihn für alle Mails aus, ohne dass eine
|
||||
* einzige Absendestelle sich ändert.
|
||||
*
|
||||
* Zur Falle mit den Versuchen: der Arbeiter läuft mit --tries=3, und eine
|
||||
* gedrosselte Rückstellung zählt als Versuch. Deshalb `retryUntil()` statt
|
||||
* eines Versuchszählers — eine zeitliche Grenze kennt keine Rückstellungen,
|
||||
* sondern nur ein Ende.
|
||||
*/
|
||||
class PacedMail extends SendQueuedMailable
|
||||
{
|
||||
/** @return array<int, object> */
|
||||
public function middleware(): array
|
||||
{
|
||||
if (! Settings::bool('mail.pace.enabled', true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return match ($this->mailable->queue) {
|
||||
MailLane::URGENT => [new RateLimited(MailLane::URGENT)],
|
||||
MailLane::CALM => [new RateLimited(MailLane::CALM)],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sechs Stunden statt eines Versuchszählers.
|
||||
*
|
||||
* Weit genug, dass auch ein Lauf über die ganze Nacht durchkommt, und eng
|
||||
* genug, dass eine Mail, die nach sechs Stunden noch nicht draußen ist,
|
||||
* nicht am nächsten Tag zwischen den neuen auftaucht.
|
||||
*/
|
||||
public function retryUntil(): \DateTimeInterface
|
||||
{
|
||||
return now()->addHours(6);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig:** Prüfe, ob `$this->mailable->queue` an dieser Stelle den
|
||||
Schlangennamen trägt (Task 2 setzt ihn über `onQueue()`). Falls nicht, nimm
|
||||
`MailLane::for($this->mailable::class)` — dasselbe Ergebnis aus derselben
|
||||
Quelle. Schreib in den Bericht, welchen Weg du genommen hast und warum.
|
||||
|
||||
- [ ] **Step 4: Kontingente anmelden und den Auftrag binden**
|
||||
|
||||
`app/Providers/MailPaceServiceProvider.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Jobs\PacedMail;
|
||||
use App\Services\Mail\MailLane;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Mail\SendQueuedMailable;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* Der Takt der beiden gedrosselten Spuren.
|
||||
*
|
||||
* Die Bindung ist die ganze Verkabelung: `Mailable::newQueuedJob()` erzeugt
|
||||
* den Auftrag über den Container, also fährt ab hier jede Mail über PacedMail
|
||||
* — ohne dass eine Absendestelle davon weiß.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
class MailPaceServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(SendQueuedMailable::class, PacedMail::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for(MailLane::URGENT, fn () => Limit::perMinutes(
|
||||
(int) Settings::get('mail.pace.urgent.minutes', 5),
|
||||
(int) Settings::get('mail.pace.urgent.count', 30),
|
||||
));
|
||||
|
||||
RateLimiter::for(MailLane::CALM, fn () => Limit::perMinutes(
|
||||
(int) Settings::get('mail.pace.calm.minutes', 10),
|
||||
(int) Settings::get('mail.pace.calm.count', 20),
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `bootstrap/providers.php` eintragen (Reihenfolge wie die vorhandenen).
|
||||
|
||||
- [ ] **Step 5: Tests laufen lassen**
|
||||
|
||||
Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail`
|
||||
Erwartet: BESTANDEN.
|
||||
|
||||
- [ ] **Step 6: Die ganze Suite, dann committen**
|
||||
|
||||
Die Bindung greift für **jede** Mail im Projekt — deshalb hier einmal alles:
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test
|
||||
git add app/Jobs/PacedMail.php app/Providers/MailPaceServiceProvider.php bootstrap/providers.php tests/Feature/Mail/MailPaceTest.php
|
||||
git commit -m "Der Takt, und die Falle mit den Versuchen
|
||||
|
||||
SendQueuedMailable kennt keine middleware() — geprueft im Framework. Der Weg
|
||||
fuehrt ueber einen eigenen Auftrag, den Mailable::newQueuedJob() ueber den
|
||||
Container erzeugt; eine Bindung tauscht ihn fuer alle Mails aus, ohne dass eine
|
||||
Absendestelle sich aendert.
|
||||
|
||||
Der Arbeiter laeuft mit --tries=3, und eine gedrosselte Rueckstellung zaehlt
|
||||
als Versuch. Ohne retryUntil() waere jede Rechnung nach dem dritten Drosseln
|
||||
gescheitert statt verschickt.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Der Arbeiter liest die drei Spuren
|
||||
|
||||
Drei Schlangen nützen nichts, wenn niemand sie abholt. Der Arbeiter fährt heute
|
||||
`queue:work redis` ohne Angabe und bedient damit **nur `default`** — nach Task 2
|
||||
und 3 läge jede Mail unverschickt in einer Schlange, die niemand liest.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yml` (Dienst `queue`)
|
||||
- Test: `tests/Feature/Mail/MailWorkerReadsLanesTest.php`
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Der Arbeiter muss die Spuren kennen.
|
||||
*
|
||||
* Die Reihenfolge IST die Priorität: der Arbeiter sieht erst in mail-direkt
|
||||
* nach und geht erst weiter, wenn dort nichts liegt. Deshalb steht ein
|
||||
* Kennwort-Zurücksetzen nie hinter zweihundert Rechnungen — nicht wegen einer
|
||||
* Sortierung, sondern weil es in einer anderen Schlange liegt.
|
||||
*
|
||||
* `default` bleibt am Ende stehen: dort läuft alles, was keine Mail ist, und es
|
||||
* darf durch diese Änderung nicht verhungern.
|
||||
*/
|
||||
it('laesst den Arbeiter die drei Spuren in der richtigen Reihenfolge lesen', function () {
|
||||
$compose = file_get_contents(base_path('docker-compose.yml'));
|
||||
|
||||
expect($compose)->toContain('--queue=mail-direkt,mail-wichtig,mail-ruhig,default');
|
||||
});
|
||||
```
|
||||
|
||||
Das Muster — eine Zusicherung gegen `docker-compose.yml` — gibt es im Repo
|
||||
bereits (`tests/Feature/DeploymentRunsAsTheAppUserTest.php`); sieh es dir an und
|
||||
folge ihm.
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen**
|
||||
|
||||
Erwartet: FEHLSCHLAG — die Zeichenkette steht nicht in der Datei.
|
||||
|
||||
- [ ] **Step 3: Den Arbeiter umstellen**
|
||||
|
||||
In `docker-compose.yml`, Dienst `queue`, Zeile 70:
|
||||
|
||||
```yaml
|
||||
command: php artisan queue:work redis --queue=mail-direkt,mail-wichtig,mail-ruhig,default --tries=3 --timeout=90
|
||||
```
|
||||
|
||||
Mit einem Kommentar darüber, warum die Reihenfolge zählt und warum `default`
|
||||
hinten stehen bleibt.
|
||||
|
||||
- [ ] **Step 4: Test laufen lassen, Arbeiter neu starten, committen**
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail
|
||||
docker compose up -d queue
|
||||
git add docker-compose.yml tests/Feature/Mail/MailWorkerReadsLanesTest.php
|
||||
git commit -m "Der Arbeiter liest die drei Spuren, und die Reihenfolge ist die Prioritaet
|
||||
|
||||
Ohne diese Zeile laege nach den beiden vorigen Aufgaben jede Mail unverschickt
|
||||
in einer Schlange, die niemand abholt — still, ohne Fehlermeldung. Code und
|
||||
Compose gehoeren deshalb zusammen ausgerollt.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
**Für die Auslieferung vermerken:** Diese Änderung wirkt erst, wenn der
|
||||
Arbeiter-Container neu gestartet wurde.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Der Notschalter und die Sicht in der Konsole
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Livewire/Admin/MailPace.php`
|
||||
- Create: `resources/views/livewire/admin/mail-pace.blade.php`
|
||||
- Modify: `routes/web.php` (Route im Konsolenbereich), `lang/de/*`, `lang/en/*`
|
||||
- Test: `tests/Feature/Admin/MailPacePageTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MailLane::all()`, `MailLane::assign()`, `MailLane::isLocked()` aus Task 1
|
||||
- Consumes: die Einstellungen aus Task 3
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Services\Mail\MailLane;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('zeigt jede Spur mit ihrem Takt und dem, was wartet', function () {
|
||||
// Operator anmelden wie in den Nachbartests unter tests/Feature/Admin/.
|
||||
Livewire::test(App\Livewire\Admin\MailPace::class)
|
||||
->assertSee(MailLane::DIRECT)
|
||||
->assertSee(MailLane::URGENT)
|
||||
->assertSee(MailLane::CALM);
|
||||
});
|
||||
|
||||
it('laesst den Betreiber eine Mail verschieben', function () {
|
||||
Livewire::test(App\Livewire\Admin\MailPace::class)
|
||||
->call('move', InvoiceMail::class, MailLane::URGENT);
|
||||
|
||||
expect(MailLane::for(InvoiceMail::class))->toBe(MailLane::URGENT);
|
||||
});
|
||||
|
||||
it('verschiebt eine gesperrte Mail nicht, auch nicht ueber die Komponente', function () {
|
||||
Livewire::test(App\Livewire\Admin\MailPace::class)
|
||||
->call('move', ResetPasswordMail::class, MailLane::CALM);
|
||||
|
||||
expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT);
|
||||
});
|
||||
|
||||
it('schaltet die Drossel ab und wieder an', function () {
|
||||
Livewire::test(App\Livewire\Admin\MailPace::class)
|
||||
->call('togglePace');
|
||||
|
||||
expect(Settings::bool('mail.pace.enabled', true))->toBeFalse();
|
||||
});
|
||||
```
|
||||
|
||||
**Hinweis:** Prüfe an den Nachbardateien in `tests/Feature/Admin/`, wie ein
|
||||
Operator angemeldet und welche Fähigkeit verlangt wird — nimm dieselbe, die die
|
||||
übrigen Maileinstellungen verlangen, und rate sie nicht.
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen**
|
||||
|
||||
Erwartet: FEHLSCHLAG — die Komponente gibt es nicht.
|
||||
|
||||
- [ ] **Step 3: Komponente und Ansicht bauen**
|
||||
|
||||
Die Komponente zeigt je Spur: Name, eingestellten Takt, Zahl der wartenden
|
||||
Mails (`Illuminate\Support\Facades\Queue::size('mail-ruhig')`), und darunter die
|
||||
Zuordnung als Liste mit einem Auswahlfeld je Mailklasse. Gesperrte Klassen
|
||||
zeigen ein Schloss statt eines Auswahlfelds — und der Server lehnt sie ohnehin
|
||||
ab (Task 1), das Schloss ist nur die Höflichkeit davor.
|
||||
|
||||
Der Notschalter ist ein Umschalter nach dem Muster von `toggleSales()` in
|
||||
`app/Livewire/Admin/Plans.php`: eine Methode, eine Rückmeldung, kein
|
||||
Bestätigungsmodal — er ist umkehrbar.
|
||||
|
||||
Gestaltung nach dem, was die Konsole schon benutzt: `x-ui.panel`, `lbl`,
|
||||
`x-ui.switch`, `x-ui.badge`. **Keine eigenen Klassen erfinden.**
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen und committen**
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Admin/MailPacePageTest.php tests/Feature/Mail
|
||||
git add app/Livewire/Admin/MailPace.php resources/views/livewire/admin/mail-pace.blade.php routes/web.php lang tests/Feature/Admin/MailPacePageTest.php
|
||||
git commit -m "Sicht auf die Spuren, und ein Schalter, der die Drossel abstellt
|
||||
|
||||
Wenn die Drossel je klemmt, muss der Ausweg ein Klick sein und kein Deployment.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Die Bereitschaftsprüfung
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Support/Readiness/DeliveryChecks.php`
|
||||
- Test: `tests/Feature/Readiness/MailPaceCheckTest.php`
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Eine Spur, die steht, sieht von außen aus wie eine Spur, die leer ist.
|
||||
*
|
||||
* Die Grenze liegt bei einer Stunde: 20 je 10 Minuten sind 120 in der Stunde,
|
||||
* und ein Lauf dieser Größe steht bei elf Kunden nicht an. Schlägt die Prüfung
|
||||
* trotzdem an, klemmt etwas.
|
||||
*/
|
||||
it('meldet eine Spur, in der etwas laenger als eine Stunde liegt', function () {
|
||||
// Eine Mail mit einem Zeitstempel von vor zwei Stunden in mail-ruhig legen
|
||||
// und erwarten, dass die Prüfung anschlägt.
|
||||
});
|
||||
|
||||
it('meldet eine Spur mit frischer Arbeit nicht', function () {
|
||||
// Dieselbe Mail, gerade eben eingereiht: keine Meldung.
|
||||
});
|
||||
```
|
||||
|
||||
**Hinweis für den Umsetzenden:** Wie das Alter des ältesten Auftrags einer
|
||||
Redis-Schlange ermittelt wird, ist die eigentliche Arbeit dieser Aufgabe. Sieh
|
||||
dir an, wie `DeliveryChecks` und `ProvisioningChecks` heute prüfen, und folge
|
||||
dem Muster. Findest du keinen ehrlichen Weg an das Alter heranzukommen, **melde
|
||||
das** statt eine Zahl zu erfinden, die nichts misst — eine Prüfung, die immer
|
||||
grün ist, ist schlimmer als keine.
|
||||
|
||||
- [ ] **Step 2 bis 4: Fehlschlag sehen, bauen, committen**
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Readiness
|
||||
git add app/Support/Readiness/DeliveryChecks.php tests/Feature/Readiness/MailPaceCheckTest.php
|
||||
git commit -m "Die Bereitschaftsseite meldet eine Spur, die steht
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Abschluss
|
||||
|
||||
- [ ] **Step 1: Ganze Suite**
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Codex-Durchsicht (R15)**
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export CLAUDE_PLUGIN_ROOT=/home/nexxo/.claude/remote/plugins/e3a3a3c04ad124ae
|
||||
node "$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs" review "--scope branch --base <Commit vor Task 1>"
|
||||
```
|
||||
|
||||
Befunde einarbeiten. Nach R22: höchstens zwei Runden ohne P1.
|
||||
|
||||
- [ ] **Step 3: Ein Lauf im Trockenen**
|
||||
|
||||
Fünfzig Mails in `mail-ruhig` einreihen und zusehen, dass sie im Takt
|
||||
hinausgehen und keine in `failed_jobs` landet. **Am echten Arbeiter, nicht im
|
||||
Test** — das ist der Beweis, den kein Test ersetzt.
|
||||
|
||||
- [ ] **Step 4: Version, Tag, Übergabe**
|
||||
|
||||
`VERSION` erhöhen, Release-Commit, `v*`-Tag — jeder Schritt einzeln, nie mit
|
||||
`&&` verkettet, und mit Blick auf `git branch --show-current` davor. **Der
|
||||
Arbeiter-Container muss beim Ausrollen neu gestartet werden**, sonst liest er
|
||||
die neuen Spuren nicht.
|
||||
|
||||
---
|
||||
|
||||
## Selbstprüfung des Plans
|
||||
|
||||
**Abdeckung des Entwurfs:** Drei Spuren und Zuordnung → Task 1. Einreihen →
|
||||
Task 2. Takt, Bindung, Falle mit den Versuchen → Task 3. Notschalter → Task 3
|
||||
(Prüfung) und Task 5 (Schalter). Arbeiter liest die Spuren → Task 4. Sicht →
|
||||
Task 5. Bereitschaftsprüfung → Task 6. Kein Zeitfenster → nirgends gebaut,
|
||||
richtig so.
|
||||
|
||||
**Bewusst als Rumpf notiert, nicht als fertiger Code:** der Versuchszähler-Test
|
||||
(Task 3, Schritt 1) und die Altersprüfung einer Schlange (Task 6). Beide hängen
|
||||
an Umgebungsverhalten, das ich nicht gemessen habe — geratener Testcode wäre
|
||||
dort schlimmer als ein benannter Auftrag. Beide Stellen sagen ausdrücklich, was
|
||||
bewiesen werden muss.
|
||||
|
||||
**Vermutete Namen, die der Umsetzende prüfen muss:** die Konstruktoren von
|
||||
`InvoiceMail` und `ResetPasswordMail` (Task 2), ob `$this->mailable->queue` den
|
||||
Schlangennamen trägt (Task 3), die Fähigkeit für die Konsolenseite (Task 5), die
|
||||
Zeilennummer des `queue`-Dienstes in `docker-compose.yml` (Task 4).
|
||||
Loading…
Reference in New Issue