<?php

use App\Livewire\Admin\Settings;
use App\Livewire\Auth\OperatorLogin;
use App\Livewire\Auth\OperatorSetPassword;
use App\Livewire\Auth\ResetPassword;
use App\Mail\OperatorInvitationMail;
use App\Models\Operator;
use App\Models\User;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;

/**
 * Ein eingeladener Betreiber vergibt sein Passwort selbst.
 *
 * Vorher erzeugte die Konsole eines und zeigte es dem Inhaber einmal an, damit
 * er es „sicher weitergibt" — per Chat, per Zuruf — für einen Zugang, der die
 * ganze Flotte und jede Kundeninstanz verwaltet. Jetzt geht eine Einladung
 * ohne Passwort hinaus, und niemand kennt ein fremdes.
 *
 * Die tragende Regel dabei ist R21: Konsole und Portal teilen keine Identität,
 * also auch keine Token-Tabelle, keinen Broker und keine Seite.
 */

/**
 * Lädt jemanden ein und gibt das angelegte Konto zurück.
 *
 * Meldet den Inhaber danach ab. Das ist keine Kosmetik: alles, was auf eine
 * Einladung folgt, passiert bei einem ANDEREN Menschen an einem anderen
 * Rechner, und ein Test, der die Sitzung des Inhabers stehen lässt, prüft den
 * Weg des Eingeladenen mit fremden Rechten.
 */
function invite(string $email = 'sam@ops.test', string $role = 'Support'): Operator
{
    Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->set('staffName', 'Sam')->set('staffEmail', $email)->set('staffRole', $role)
        ->call('inviteStaff')->assertHasNoErrors();

    auth('operator')->logout();

    return Operator::query()->where('email', $email)->firstOrFail();
}

/**
 * Jede nichtleere Zeichenkette, die unter den öffentlichen Eigenschaften
 * einer Komponente steckt — auch dann, wenn sie in einem Feld liegt statt
 * selbst eine Eigenschaft zu sein.
 *
 * Die naheliegendste Rückkehr des alten Kastens war kein String, sondern
 * genau das: `public array $invited = ['email' => …, 'password' => $temp]`.
 * Eine Prüfung, die nur `is_string($wert)` zulässt und bei allem anderen
 * `continue`t, liefe an so einem Feld lautlos vorbei — sie hätte den
 * historischen Fehler selbst nicht gefunden.
 *
 * @return array<int, string>
 */
function publicStrings(object $instanz): array
{
    $gefunden = [];

    $einsammeln = function ($wert) use (&$gefunden, &$einsammeln) {
        if (is_string($wert) && $wert !== '') {
            $gefunden[] = $wert;
        } elseif (is_array($wert)) {
            foreach ($wert as $teil) {
                $einsammeln($teil);
            }
        }
    };

    foreach ((new ReflectionObject($instanz))->getProperties(ReflectionProperty::IS_PUBLIC) as $eigenschaft) {
        $einsammeln($eigenschaft->getValue($instanz));
    }

    return $gefunden;
}

/**
 * Jedes Wort ab acht Zeichen aus einem gerenderten Text gegen einen
 * Passwort-Hash gehalten — dasselbe Vorgehen, das für die Mail schon gilt
 * (siehe unten, „trägt kein Passwort in der Mail"), hier auf HTML statt auf
 * eine Mailable angewandt. Träfe irgendwo ein Passwort auf den Bildschirm —
 * im Fliesstext, in einem `value`-Attribut, in einem versteckten Feld —,
 * schlüge genau eines dieser Wörter an.
 */
function assertNoPasswordOnScreen(string $html, string $hash): void
{
    preg_match_all('/[^\s<>"\']{8,}/', strip_tags($html), $treffer);

    foreach ($treffer[0] as $wort) {
        expect(Hash::check($wort, $hash))->toBeFalse("[{$wort}] steht als Passwort auf dem Bildschirm");
    }
}

// ---- Was das Konto beim Anlegen ist: noch niemandes ----

it('legt ein Konto an, in das sich niemand anmelden kann', function () {
    Mail::fake();

    $sam = invite();

    // Die halbe Zusicherung, und sie muss halten: es gibt kein Passwort, das
    // dieses Konto öffnet — auch keines, das jemand raten würde, weil er den
    // Code gelesen hat.
    foreach (['', 'password', 'Sam', 'sam@ops.test', 'clupilot'] as $versuch) {
        expect(Hash::check($versuch, $sam->password))->toBeFalse("[{$versuch}] öffnet das Konto");
    }

    Livewire::test(OperatorLogin::class)
        ->set('email', 'sam@ops.test')->set('password', 'password')
        ->call('authenticate')->assertHasErrors('email');

    expect(auth('operator')->check())->toBeFalse();
});

it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite und nicht auf dem Bildschirm', function () {
    // Der eigentliche Fehler war nicht das Passwort, sondern dass es auf einem
    // Bildschirm stand. Deshalb wird hier nicht auf die zwei gelöschten
    // Eigenschaften geprüft (die kämen unter anderem Namen wieder), sondern
    // darauf, dass KEINE öffentliche Eigenschaft dieser Seite das Passwort des
    // eben angelegten Kontos ist — auch nicht als Feld in einem Array, so wie
    // es der alte Kasten war (`public array $invited = [..., 'password' => …]`).
    // Und, weil eine Eigenschaftsprüfung allein nichts über den tatsächlichen
    // Bildschirm beweist: dasselbe Vorgehen wie bei der Mail zusätzlich über
    // das gerenderte Ergebnis — genau da stand der historische Fehler.
    Mail::fake();

    $seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support')
        ->call('inviteStaff');

    $ivy = Operator::query()->where('email', 'ivy@ops.test')->firstOrFail();
    $instanz = $seite->instance();

    foreach (publicStrings($instanz) as $wert) {
        expect(Hash::check($wert, $ivy->password))->toBeFalse(
            "Ein Eigenschaftswert der Seite ist das Passwort des neuen Kontos: {$wert}",
        );
    }

    assertNoPasswordOnScreen($seite->html(), $ivy->password);
});

// ---- Was hinausgeht: ein Link, kein Passwort ----

it('schickt eine Einladung mit dem Link und dem Namen des Einladenden', function () {
    Mail::fake();

    $inhaber = operator('Owner');

    Livewire::actingAs($inhaber, 'operator')->test(Settings::class)
        ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support')
        ->call('inviteStaff')->assertHasNoErrors();

    Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($inhaber) {
        $html = $mail->render();

        return $mail->hasTo('sam@ops.test')
            // Der Link zeigt auf die Seite der KONSOLE, nicht auf die des
            // Portals — das ist R21 im Ergebnis statt in der Absicht.
            && str_contains($mail->url, '/admin/invitation/')
            && str_contains($html, $mail->url)
            // Wer eingeladen hat, steht darin: eine Mail, die einem Menschen
            // einen Konsolenzugang anbietet, ist sonst von einem
            // Phishing-Versuch nicht zu unterscheiden.
            && str_contains($html, $inhaber->name);
    });
});

it('trägt kein Passwort in der Mail', function () {
    Mail::fake();

    $sam = invite();

    Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($sam) {
        // Jedes Wort ab acht Zeichen aus der fertigen Mail gegen den Hash des
        // Kontos gehalten. Fiele irgendwo ein Passwort hinein — im Fliesstext,
        // in einem Kasten, im Betreff — schlüge genau eines davon an.
        preg_match_all('/[^\s<>"\']{8,}/', strip_tags($mail->render()), $treffer);

        foreach ($treffer[0] as $wort) {
            expect(Hash::check($wort, $sam->password))->toBeFalse("[{$wort}] ist das Passwort des Kontos");
        }

        return true;
    });
});

// ---- R21: eigene Tabelle, eigener Broker, eigene Seite ----

it('legt den Token in die Tabelle der Betreiber und nicht in die der Kunden', function () {
    Mail::fake();

    invite();

    expect(DB::table('operator_password_reset_tokens')->count())->toBe(1)
        // Ein Token ist nur ein Zeilenschlüssel. Läge er in der Kundentabelle,
        // wäre die Trennung der beiden Personengruppen genau hier aufgehoben.
        ->and(DB::table('password_reset_tokens')->count())->toBe(0);
});

it('löst einen Kunden-Token nicht auf der Konsolenseite ein', function () {
    // Die Adressen dürfen sich heute nicht überschneiden — Admin\Settings weist
    // eine Kundenadresse ab. Das ist eine Prüfung an EINER Stelle, keine
    // Struktur, also wird sie hier absichtlich umgangen: beide Datensätze
    // entstehen direkt.
    $betreiber = Operator::factory()->role('Support')->create([
        'email' => 'doppelt@ops.test', 'password' => 'passwort-des-betreibers',
    ]);
    $kunde = User::factory()->create([
        'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden',
    ]);

    $kundenToken = Password::broker()->createToken($kunde);

    Livewire::test(OperatorSetPassword::class, ['token' => $kundenToken])
        ->set('email', 'doppelt@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save')
        ->assertHasErrors('email')
        ->assertSet('done', false);

    expect(Hash::check('passwort-des-betreibers', $betreiber->fresh()->password))->toBeTrue()
        ->and(Hash::check('ein-langes-neues-passwort', $betreiber->fresh()->password))->toBeFalse();
});

it('löst einen Betreiber-Token nicht auf der Portalseite ein', function () {
    // Die Gegenrichtung. Sie ist die gefährlichere: ein Token aus der Konsole,
    // eingelöst im Portal, setzte das Passwort eines KUNDEN.
    $betreiber = Operator::factory()->role('Support')->create(['email' => 'doppelt@ops.test']);
    $kunde = User::factory()->create([
        'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden',
    ]);

    $betreiberToken = Password::broker('operators')->createToken($betreiber);

    Livewire::test(ResetPassword::class, ['token' => $betreiberToken])
        ->set('email', 'doppelt@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save')
        ->assertHasErrors('email')
        ->assertSet('done', false);

    expect(Hash::check('passwort-des-kunden', $kunde->fresh()->password))->toBeTrue();
});

it('schickt einem Betreiber nicht Laravels eigene Rücksetzmail', function () {
    // Die Vorgabe aus CanResetPassword verlinkt auf `password.reset` — die
    // Seite des PORTALS. Ein Betreiber-Token landete damit auf der Kundenseite.
    // Konkrete Klasse, nicht Throwable: eine Zusicherung auf eine Schnittstelle
    // beweist nichts.
    $betreiber = Operator::factory()->role('Support')->create();

    expect(fn () => $betreiber->sendPasswordResetNotification('irgendein-token'))
        ->toThrow(RuntimeException::class);
});

// ---- Der Link: einlösen, verbrauchen, ablaufen ----

it('setzt das Passwort und lässt den Eingeladenen sich damit anmelden', function () {
    Mail::fake();

    $sam = invite();
    $token = Password::broker('operators')->createToken($sam);

    Livewire::test(OperatorSetPassword::class, ['token' => $token])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save')
        ->assertHasNoErrors()
        ->assertSet('done', true);

    expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeTrue();

    Livewire::test(OperatorLogin::class)
        ->set('email', 'sam@ops.test')->set('password', 'ein-langes-neues-passwort')
        ->call('authenticate')->assertHasNoErrors();

    expect(auth('operator')->id())->toBe($sam->id);
});

it('meldet niemanden an, nur weil er den Link geöffnet hat', function () {
    // Der Link kam per E-Mail. Wer dieses Postfach lesen kann, bekäme sonst die
    // Sitzung in der Konsole gleich dazu.
    Mail::fake();

    $sam = invite();

    Livewire::test(OperatorSetPassword::class, ['token' => Password::broker('operators')->createToken($sam)])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save');

    expect(auth('operator')->check())->toBeFalse();
});

it('verbraucht den Link, damit eine weitergeleitete Mail ihn nicht zweimal öffnet', function () {
    Mail::fake();

    $sam = invite();
    $token = Password::broker('operators')->createToken($sam);

    $einlösen = fn () => Livewire::test(OperatorSetPassword::class, ['token' => $token])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save');

    $einlösen()->assertSet('done', true);
    $einlösen()->assertSet('done', false)->assertHasErrors('email');
});

it('lässt den Link ablaufen, statt ihn in einem alten Postfach liegen zu lassen', function () {
    // Ein Einladungslink, der ein Jahr später noch gilt, ist ein Schlüssel zur
    // Betreiberkonsole in einem Postfach, an das niemand mehr denkt.
    Mail::fake();

    $sam = invite();
    $token = Password::broker('operators')->createToken($sam);

    $stunden = OperatorInvitationMail::validHours();

    // Eine Stunde vor Ablauf: gilt noch.
    $this->travel($stunden - 1)->hours();

    Livewire::test(OperatorSetPassword::class, ['token' => $token])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save')
        ->assertSet('done', true);

    // Und derselbe Vorgang jenseits der Frist: ein frischer Token, eine Stunde
    // nach Ablauf.
    $frisch = Password::broker('operators')->createToken($sam);
    $this->travel($stunden + 1)->hours();

    Livewire::test(OperatorSetPassword::class, ['token' => $frisch])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-noch-viel-neueres-passwort')
        ->set('password_confirmation', 'ein-noch-viel-neueres-passwort')
        ->call('save')
        ->assertSet('done', false)
        ->assertHasErrors('email');

    expect(Hash::check('ein-noch-viel-neueres-passwort', $sam->fresh()->password))->toBeFalse();
});

it('hält die Frist in Tagen, nicht in Monaten', function () {
    // Der Test darüber prüft den MECHANISMUS: was auch immer eingestellt ist,
    // läuft ab. Er würde eine Frist von einem Jahr klaglos mittragen, weil er
    // die Zahl aus derselben Einstellung liest. Die Grössenordnung ist aber
    // keine Geschmacksfrage: nach oben ist ein Link, der Wochen später noch
    // gilt, ein Schlüssel zur Betreiberkonsole in einem Postfach, an das
    // niemand mehr denkt; nach unten wird eine Einladung, die vor dem
    // Wochenende abläuft, nie geöffnet. Die Zahl dazwischen ist in
    // config/auth.php begründet.
    expect(OperatorInvitationMail::validHours())->toBeGreaterThanOrEqual(24)
        ->and(OperatorInvitationMail::validHours())->toBeLessThanOrEqual(7 * 24);
});

it('nennt keinen rohen Statusschlüssel, wenn der Link nicht gilt', function () {
    // Nicht weil `passwords.token` roh auf der Seite stünde — Laravel liefert
    // dazu eine eigene Übersetzung mit (siehe OperatorSetPassword::save()) —,
    // sondern weil EINE Meldung für alle Fehlschläge gilt: sie zu
    // unterscheiden verriete einem Fremden, wer Betreiber ist.
    Mail::fake();

    invite();

    Livewire::test(OperatorSetPassword::class, ['token' => 'gibt-es-nicht'])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save')
        ->assertHasErrors('email')
        ->assertSee(__('auth.console_invite_invalid'))
        // Die Zusicherung, die der Name tatsächlich verspricht: stünde
        // `passwords.token` daneben, bliebe der Test oben trotzdem grün.
        ->assertDontSee('passwords.');
});

// ---- Wo der Link hinführt: in die Konsole, hinter ihre Schranken ----

it('legt die Einladungsseite hinter dieselbe Netzbeschränkung wie den Rest der Konsole', function () {
    // Der Link führt in ein Netz, das der Eingeladene erreichen können muss —
    // und ein Fremder eben nicht. 404, nie 403: dieselbe Haltung wie überall
    // sonst in der Konsole.
    config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
    AppSettings::set('console.network_restricted', true);
    AppSettings::set('console.allowed_ips', []);

    $ziel = route('admin.invitation', ['token' => 'beispiel-token', 'email' => 'sam@ops.test']);

    $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->get($ziel)->assertNotFound();
    $this->withServerVariables(['REMOTE_ADDR' => '10.66.0.5'])->get($ziel)->assertOk();
});

it('führt die Einladung über eine Route der Konsole, nicht über eine des Portals', function () {
    // Nicht der Pfad, sondern der NAME: er entscheidet in RestrictAdminHost,
    // ob eine Route als Konsolenroute gilt.
    $route = app('router')->getRoutes()->getByName('admin.invitation');

    expect($route)->not->toBeNull()
        ->and($route->getName())->toStartWith('admin.')
        // Im Gast-Teil: wer den Link öffnet, hat noch keinen Zugang, und
        // routes/admin.php liegt hinter `auth:operator,web`.
        ->and($route->gatherMiddleware())->toContain('guest:operator')
        ->and($route->gatherMiddleware())->not->toContain('auth:operator,web');
});

// ---- Was die Seite nicht mehr anbietet ----

it('zeigt auf der Team-Seite keine Zugangsdaten mehr an', function () {
    Mail::fake();

    $seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->set('tab', 'team')
        ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support')
        ->call('inviteStaff');

    $sam = Operator::query()->where('email', 'sam@ops.test')->firstOrFail();
    $html = $seite->html();

    // Die drei Zeilen, die den alten Kasten beschriftet haben, gibt es nicht
    // mehr — in keiner der beiden Sprachen. Das allein beweist aber nichts:
    // ein Kasten unter NEUEN Schlüsseln käme durch drei fehlende alte
    // Schlüssel unbemerkt durch. Der Name dieses Tests verspricht eine
    // Aussage über die SEITE, nicht über drei bestimmte Übersetzungsdateien.
    foreach (['admin_settings.temp_title', 'admin_settings.temp_hint', 'admin_settings.temp_password'] as $schlüssel) {
        expect(Lang::has($schlüssel, 'de'))->toBeFalse("{$schlüssel} steht noch in lang/de")
            ->and(Lang::has($schlüssel, 'en'))->toBeFalse("{$schlüssel} steht noch in lang/en");
    }

    // Die Aussage, die der Name tatsächlich verspricht: der Bildschirm selbst
    // enthält kein Zugangsdatum, gleich unter welchem Schlüssel es aufgetaucht
    // wäre.
    assertNoPasswordOnScreen($html, $sam->password);

    expect($html)->toContain(e(__('admin_settings.invite_hint', ['hours' => OperatorInvitationMail::validHours()])));
});

// ---- Der Rückweg: läuft der Link ab, ist die Adresse nicht verbrannt ----

it('bietet „Einladung erneut senden" nur für jemanden an, der den Link noch nie benutzt hat', function () {
    // Sam hat sich nie angemeldet: last_login_at ist null, wie bei jedem
    // frisch eingeladenen Konto (OperatorLogin::completeLogin ist der einzige
    // Ort, der das Feld setzt). Ivy dagegen ist längst im Team — ein Knopf,
    // der ihr das Passwort neu vergäbe, wäre ein Fehler, kein Komfort.
    Mail::fake();

    $sam = invite();
    $ivy = Operator::factory()->role('Support')->create(['last_login_at' => now()]);

    $html = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->set('tab', 'team')
        ->html();

    expect($html)->toContain("resendInvitation({$sam->id})")
        ->and($html)->not->toContain("resendInvitation({$ivy->id})");
});

it('stellt beim erneuten Senden ein neues Token aus und macht das alte damit ungültig', function () {
    Mail::fake();

    $sam = invite();
    $altesToken = Password::broker('operators')->createToken($sam);

    Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->call('resendInvitation', $sam->id)
        ->assertHasNoErrors();

    // Der Broker löscht die bestehende Token-Zeile beim Ausstellen einer neuen
    // von selbst — operator_password_reset_tokens hat die Adresse als
    // Primärschlüssel (DatabaseTokenRepository::create() ruft deleteExisting()
    // vor dem Insert). Das alte Token verliert dadurch von selbst seine
    // Gültigkeit, ohne dass Admin\Settings hier extra aufräumen müsste.
    expect(DB::table('operator_password_reset_tokens')->where('email', 'sam@ops.test')->count())->toBe(1);

    Livewire::test(OperatorSetPassword::class, ['token' => $altesToken])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save')
        ->assertSet('done', false)
        ->assertHasErrors('email');

    expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeFalse();
});

it('lässt sich mit dem frischen Token aus einer erneut gesendeten Einladung anmelden', function () {
    Mail::fake();

    $sam = invite();

    Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->call('resendInvitation', $sam->id)
        ->assertHasNoErrors();

    Mail::assertQueued(OperatorInvitationMail::class, fn (OperatorInvitationMail $mail) => $mail->hasTo('sam@ops.test'));

    $frisch = Password::broker('operators')->createToken($sam);

    Livewire::test(OperatorSetPassword::class, ['token' => $frisch])
        ->set('email', 'sam@ops.test')
        ->set('password', 'ein-langes-neues-passwort')
        ->set('password_confirmation', 'ein-langes-neues-passwort')
        ->call('save')
        ->assertHasNoErrors()
        ->assertSet('done', true);

    expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeTrue();
});

it('bietet den Rückweg nicht für jemanden an, der sich längst angemeldet hat', function () {
    // Ein direkter Aufruf, an der Oberfläche vorbei: der Knopf ist zwar
    // ausgeblendet, aber die Livewire-Methode ist ein öffentlicher Endpunkt
    // wie jede andere. Ein bereits benutztes Konto darf sein Passwort nicht
    // über diesen Weg neu bekommen.
    Mail::fake();

    $ivy = Operator::factory()->role('Support')->create(['last_login_at' => now()]);
    $vorherigesPasswort = $ivy->password;

    Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
        ->call('resendInvitation', $ivy->id);

    Mail::assertNothingQueued();
    expect(DB::table('operator_password_reset_tokens')->where('email', $ivy->email)->exists())->toBeFalse()
        ->and($ivy->fresh()->password)->toBe($vorherigesPasswort);
});

it('lässt beim Einladen kein halbes Konto zurück, wenn der Mailversand scheitert', function () {
    // Vorher liefen Operator::create() und assignRole() fest, bevor der
    // Broker den Token zog und die Mail hinausging. Geht die
    // Mail-Warteschlange GENAU dazwischen kurz weg — Redis kurz nicht
    // erreichbar —, blieb bisher ein Konto stehen, das nie eine Einladung
    // bekommen hatte: unique:operators,email hätte jede erneute Einladung an
    // dieselbe Adresse für immer abgewiesen.
    Mail::shouldReceive('mailer')->once()->andThrow(new RuntimeException('Warteschlange nicht erreichbar'));

    $inhaber = operator('Owner');

    expect(fn () => Livewire::actingAs($inhaber, 'operator')->test(Settings::class)
        ->set('staffName', 'Theo')->set('staffEmail', 'theo@ops.test')->set('staffRole', 'Support')
        ->call('inviteStaff'))->toThrow(RuntimeException::class);

    expect(Operator::query()->where('email', 'theo@ops.test')->exists())->toBeFalse(
        'Das Konto blieb stehen, obwohl nie eine Einladung verschickt wurde — die Adresse wäre für immer blockiert.',
    )->and(DB::table('operator_password_reset_tokens')->where('email', 'theo@ops.test')->exists())->toBeFalse();
});
