Ein Knopf, der eine Mail verspricht, die niemand verschicken kann

Die zweite Haelfte von K4. Die Wanderung legt das Versandkonto an; solange es
niemand ausgefuellt hat, bestand die Attrappe unveraendert fort — und sie ist
nicht der Randfall, sondern der Normalfall: der Mailserver dieses Produkts
entsteht gerade erst, das Postfach ist am ersten Tag garantiert leer.

Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das
Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten
Versand kommt dort aber nichts heraus, und `occ user:add --generate-password
--email` gelingt trotzdem: Nextcloud legt das Konto an, versucht die
Willkommensmail, protokolliert intern einen Fehler und beendet mit 0. Der
Auftrag meldete Erfolg, die Zeile sprang auf "Eingeladen", die Meldung versprach
einen Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand
bekam etwas.

sendInvite() fragt jetzt GuestMailConfig::for($instance)->available(): ein
reiner Blick in die Datenbank, kein Tunnel, keine Warteschlange, deshalb darf er
auf der Seite stehen. Steht der Versand nicht, geht kein Auftrag hinaus, der
Sitz bleibt unveraendert — insbesondere ohne Anmeldenamen — und die Meldung sagt,
dass nichts verschickt wurde und woran es liegt.

Drei Entscheidungen dabei:

Der Knopf bleibt stehen, der Hinweis steht ueber der Tabelle. Ihn an jeder Zeile
verschwinden zu lassen liest sich nicht als "geht hier gerade nicht", sondern als
"das kann dieses Produkt nicht" — genau die Beschwerde, die schon einmal dazu
gefuehrt hat, dass die Aktionsspalte immer gezeichnet wird. Und der Zustand ist
voruebergehend: er endet, sobald der Betrieb das Konto ausfuellt.

Die Wache steht VOR dem Ratelimit. Sonst haette ein Inhaber seine Versuche
aufgebraucht, bevor ueberhaupt einer hinausgehen konnte.

Ohne laufende Instanz greift sie gar nicht: dann scheitert der Auftrag ohnehin an
`no_instance` und die Zeile sagt das im Klartext. Diese Wache gilt dem anderen
Fall — die Cloud laeuft, nur der Versand fehlt.

Das Anlegen bleibt offen: es verspricht ausdruecklich keine Mail, und der
Hinweistext sagt das auch. Der Bereitstellungsschritt ist unangetastet und bleibt
bei "nicht eingerichtet ist etwas anderes als kaputt".

Fuenf Zusicherungen, drei davon gegen den zurueckgedrehten Fix rot gesehen; die
uebrigen zwei sind Grenzpruefungen und muessen in beide Richtungen gruen sein.
Acht Bestandspruefungen richten den Versand jetzt ueber eine eigene
Hilfsfunktion ein — zwei davon haetten sonst aus dem falschen Grund bestanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 00:14:27 +02:00
parent 01eff483fa
commit 40f255565b
5 changed files with 244 additions and 17 deletions

View File

@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Seat;
use App\Provisioning\Jobs\SyncSeatToNextcloud;
use App\Services\Mail\GuestMailConfig;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
@ -226,6 +227,28 @@ class Users extends Component
return;
}
// Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht
// das Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne
// eingerichteten Mailversand kommt dort aber nichts heraus, und
// `occ user:add --generate-password --email` GELINGT trotzdem:
// Nextcloud legt das Konto an, versucht die Willkommensmail,
// protokolliert intern einen Fehler und beendet mit 0. Der Auftrag
// meldete Erfolg, die Zeile sprang auf „Eingeladen", die Meldung
// versprach einen Link — und niemand bekam etwas.
//
// Am Tag der Auslieferung ist das kein Randfall, sondern der
// Normalzustand: der Mailserver dieses Produkts entsteht gerade erst,
// und das Postfach steht bis dahin leer.
//
// VOR dem Ratelimit, damit ein abgewiesener Versuch kein Kontingent
// verbraucht: sonst hätte der Inhaber seine Versuche aufgebraucht,
// bevor überhaupt einer hinausgehen konnte.
if (! $this->mailversandBereit($customer)) {
$this->dispatch('notify', message: __('users.invite_no_mail'));
return;
}
if (($warten = $this->rateLimited($customer, $seat)) !== null) {
$this->dispatch('notify', message: __('users.too_many_invites', ['minutes' => $warten]));
@ -550,6 +573,36 @@ class Users extends Component
$this->revoke($uuid);
}
/**
* Ob eine Einladung dieses Kunden überhaupt ankommen könnte.
*
* Ein reiner Blick in die Datenbank Servereinstellung und
* Versandkonto kein Tunnel, keine Warteschlange. Deshalb darf er auf der
* Seite stehen und nicht erst im Auftrag.
*
* OHNE Instanz gibt es hier nichts zu entscheiden: dann scheitert der
* Auftrag ohnehin an `no_instance`, und die Zeile sagt das im Klartext.
* Diese Wache gilt dem anderen Fall die Cloud LÄUFT, nur der Versand
* fehlt. Genau dort meldet occ Erfolg, und niemand bekommt eine Mail.
*
* Dieselbe Auswahl wie im Auftrag (SyncSeatToNextcloud): die Instanz, in
* der die Einladung tatsächlich landen würde.
*
* Der Bereitstellungsschritt bleibt davon unberührt. Dort gilt weiter
* „nicht eingerichtet ist etwas anderes als kaputt", und eine bezahlte
* Bestellung läuft durch eine Cloud ohne Mailversand funktioniert
* vollständig. Was sie nicht kann, ist eine Einladung verschicken, und nur
* darum geht es hier.
*/
private function mailversandBereit(Customer $customer): bool
{
$instance = $customer->instances()
->whereIn('status', ['active', 'cancellation_scheduled'])
->latest('id')->first();
return $instance === null || GuestMailConfig::for($instance)->available();
}
private function usedSeats(Customer $customer): int
{
return $customer->seats()->where('status', '!=', 'revoked')->count();
@ -583,6 +636,9 @@ class Users extends Component
'used' => $customer ? $this->usedSeats($customer) : 0,
'limit' => $customer ? $this->seatLimit($customer) : 0,
'roles' => Seat::ROLES,
// Der Hinweis steht dort, wo der Inhaber ihn liest, bevor er
// drückt. Der Knopf bleibt trotzdem stehen — siehe das Blade.
'versandBereit' => $customer === null || $this->mailversandBereit($customer),
]);
}
}

View File

@ -90,6 +90,12 @@ return [
'added' => 'Benutzer angelegt. Die Einladung verschicken Sie mit „Einladen".',
'reinstated' => 'Zugang wieder aufgenommen. Die Einladung verschicken Sie mit „Einladen".',
'invite_sent' => 'Einladung verschickt. Der Mitarbeiter bekommt einen Link, an dem er sein Passwort selbst setzt.',
// Die Einladung verschickt die Nextcloud des Kunden selbst — ohne
// eingerichteten Mailversand kommt dort nichts heraus, und `occ user:add`
// meldet trotzdem Erfolg. Der Satz sagt deshalb beides: dass nichts
// verschickt wurde, und woran es liegt.
'mail_not_ready' => 'Einladungen sind noch nicht möglich: Der Mailversand für Ihre Cloud ist noch nicht eingerichtet. Die Einladung verschickt Ihre Cloud selbst — ohne Versand bekäme niemand eine Mail. Bitte wenden Sie sich an den Support. Anlegen können Sie Ihre Leute schon jetzt.',
'invite_no_mail' => 'Es wurde nichts verschickt: Der Mailversand für Ihre Cloud ist noch nicht eingerichtet. Bitte wenden Sie sich an den Support.',
'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.',
'revoked' => 'Benutzer entfernt.',
'resent' => 'Einladung erneut gesendet.',

View File

@ -89,6 +89,12 @@ return [
'added' => 'User added. Send the invitation with "Invite".',
'reinstated' => 'Access taken back on. Send the invitation with "Invite".',
'invite_sent' => 'Invitation sent. They get a link to set their own password.',
// The invitation is sent by the customer's own Nextcloud — with no mail
// delivery configured, nothing leaves it, and `occ user:add` reports
// success all the same. The sentence therefore says both: that nothing
// was sent, and why.
'mail_not_ready' => 'Invitations are not possible yet: mail delivery for your cloud is not set up. The invitation is sent by your own cloud — without delivery, nobody would receive anything. Please contact support. You can already add your people.',
'invite_no_mail' => 'Nothing was sent: mail delivery for your cloud is not set up yet. Please contact support.',
'too_many_invites' => 'Too many invitations — possible again in :minutes minutes.',
'revoked' => 'User removed.',
'resent' => 'Invitation resent.',

View File

@ -11,9 +11,9 @@
</div>
</div>
{{-- Anlegen. Ausdruecklich NICHT einladen: hier entsteht nur die Zeile,
{{-- Anlegen. Ausdrücklich NICHT einladen: hier entsteht nur die Zeile,
die Einladung verschickt der Knopf in der Tabelle. Ein Inhaber soll
sein Team vorbereiten koennen, ohne dass jemand eine Mail bekommt. --}}
sein Team vorbereiten können, ohne dass jemand eine Mail bekommt. --}}
<form wire:submit="addSeat" class="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
<div>
<label class="text-sm font-medium text-body" for="inviteEmail">{{ __('users.invite_email') }}</label>
@ -40,6 +40,25 @@
</x-ui.button>
</form>
{{-- Der Mailversand fehlt: gesagt, bevor jemand drückt.
Der Einladen-Knopf bleibt trotzdem stehen. Ihn an jeder Zeile
verschwinden zu lassen liest sich nicht als „geht hier gerade nicht",
sondern als „das kann dieses Produkt nicht" — genau die Beschwerde,
die schon einmal dazu geführt hat, dass die Aktionsspalte IMMER
gezeichnet wird (siehe Users::render()). Und der Zustand ist
vorübergehend: er endet, sobald der Betrieb das Versandkonto
ausfüllt, ohne dass sich an dieser Seite etwas ändert.
Einmal über der Tabelle statt einmal je Zeile: der Grund ist für
alle derselbe, und an jeder Zeile stünde derselbe Satz noch einmal.
Der Server weist die Einladung mit derselben Begründung ab der
Knopf verspricht also nichts, was der Hinweis nicht schon
zurücknimmt. --}}
@unless ($versandBereit)
<x-ui.alert variant="warning" class="animate-rise [animation-delay:90ms]">{{ __('users.mail_not_ready') }}</x-ui.alert>
@endunless
{{-- Seats --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="overflow-x-auto">
@ -73,10 +92,10 @@
{{-- Was der Inhaber WILL (`status`) und was in der
Nextcloud WIRKLICH ist (`nc_state`) stehen in
einer Zelle, und der wahre Zustand geht vor:
solange der Auftrag laeuft oder gescheitert
ist, waere „Eingeladen" eine Behauptung ueber
solange der Auftrag läuft oder gescheitert
ist, wäre „Eingeladen" eine Behauptung über
etwas, das die Warteschlange noch gar nicht
erledigt hat. Ohne diese Spalte drueckt der
erledigt hat. Ohne diese Spalte drückt der
Inhaber wieder und wieder, weil nichts
sichtbar geschieht. --}}
<td class="px-4 py-3">
@ -84,7 +103,7 @@
{{-- Entzogen geht dem Zustand vor: ein
„wird eingerichtet " an einer
geschlossenen Zeile las sich wie ein
Weg zurueck, den es an DIESER Zeile
Weg zurück, den es an DIESER Zeile
nicht gibt. --}}
<x-ui.badge status="suspended">{{ __('users.status_revoked') }}</x-ui.badge>
<p class="mt-1 text-xs text-muted">{{ __('users.revoked_closed') }}</p>
@ -98,15 +117,15 @@
@endif
{{-- Der Fehlschlag steht NEBEN dem Entzogen,
nicht dahinter. Sonst verschwaende genau
dort die Rueckfahrkarte, wo sie am
noetigsten ist: scheitert das `disable`,
nicht dahinter. Sonst verschwände genau
dort die Rückfahrkarte, wo sie am
nötigsten ist: scheitert das `disable`,
ist das Konto im Gast weiter offen,
waehrend die Zeile „entzogen" sagt — kein
während die Zeile „entzogen" sagt — kein
Wiederholungslauf (tries = 1), kein
Abgleich im Zeitplan, und diese Seite ist
die einzige Stelle im Repo, die nc_state
ueberhaupt anzeigt. --}}
überhaupt anzeigt. --}}
@if ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
<div @class(['mt-1' => $seat->status === 'revoked'])>
<x-ui.badge status="failed">{{ __('users.state_failed') }}</x-ui.badge>
@ -117,10 +136,10 @@
<p class="mt-1 text-xs text-muted">{{ __('users.error_'.($seat->nc_error ?: 'unexpected')) }}</p>
@php
// Dieselbe Bedingung wie in retry(): ein Knopf, der
// nur wieder rot zurueckkommt, ist schlimmer als
// nur wieder rot zurückkommt, ist schlimmer als
// keiner. Eine entzogene oder gesperrte Zeile, die
// nie in der Nextcloud ankam, hat dort nichts zu
// sperren — da gaebe es nichts zu wiederholen.
// sperren — da gäbe es nichts zu wiederholen.
$wiederholbar = $seat->role !== 'owner'
&& ($seat->nc_synced_at !== null
|| ! in_array($seat->status, ['revoked', 'suspended'], true));
@ -137,11 +156,11 @@
<div class="flex items-center justify-end gap-1.5">
@if ($seat->status === 'revoked')
{{-- Kein Knopf an der Zeile. Der Weg
zurueck geht durch die Vordertuer: das
zurück geht durch die Vordertür: das
Anlegen-Formular oben, mit derselben
Adresse und damit durch die
Platzpruefung, die es an der Zeile
nicht gaebe. Gesagt statt leer
Platzprüfung, die es an der Zeile
nicht gäbe. Gesagt statt leer
gelassen, wie bei der Inhaber-Zeile
eine leere Zelle liesse jemanden nach
einem Knopf suchen, der absichtlich
@ -153,7 +172,7 @@
steht nur an einer Zeile, die noch
nichts in der Nextcloud hat; ist der
Auftrag unterwegs, gibt es nichts zu
druecken, und bei einem Fehlschlag
drücken, und bei einem Fehlschlag
steht „Nochmal versuchen" schon in der
Zustandsspalte. Nie an der
Inhaber-Zeile: dieses Konto legt die

View File

@ -6,15 +6,37 @@ use App\Livewire\Users;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Mailbox;
use App\Models\Seat;
use App\Provisioning\Jobs\SyncSeatToNextcloud;
use App\Services\Mail\GuestMailConfig;
use App\Services\Nextcloud\NextcloudUsers;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Symfony\Component\Finder\Finder;
/**
* Der Mailversand, eingerichtet Server und ausgefülltes Versandkonto.
*
* Steht jetzt in jedem Test, der wirklich einladen will: die Einladung
* verschickt die Nextcloud des Kunden selbst, und ohne Versand käme dort nie
* eine Mail heraus. Angelegt ist das Postfach ab der eigenen Wanderung aber
* inaktiv und ohne Zugangsdaten, also genau der Zustand, den „Einladen" jetzt
* abweist.
*/
function mailversandEingerichtet(): void
{
Settings::set('mail.host', 'mail.clupilot.cloud');
Settings::set('mail.port', 587);
Mailbox::findByKey(GuestMailConfig::RELAY_KEY)->update([
'address' => 'noreply@clupilot.cloud', 'password' => 'geheim', 'active' => true,
]);
}
it('schreibt den Erfolg an den Sitz', function () {
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
$customer = Customer::factory()->create();
@ -92,6 +114,7 @@ it('legt beim Anlegen KEINEN Nextcloud-Benutzer an', function () {
it('schickt erst beim Einladen einen Auftrag los', function () {
Queue::fake();
mailversandEingerichtet();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -108,6 +131,7 @@ it('hält den Benutzernamen fest, auch wenn die Mailadresse sich ändert', funct
// Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich
// später ändert, behält seinen Anmeldenamen.
Queue::fake();
mailversandEingerichtet();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -121,6 +145,7 @@ it('hält den Benutzernamen fest, auch wenn die Mailadresse sich ändert', funct
it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', function () {
Queue::fake();
mailversandEingerichtet();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -150,6 +175,7 @@ it('weist das vierte Draufdrücken auf denselben Sitz ab', function () {
// Die zweite Grenze: gegen das wiederholte Klicken an EINER Zeile, das
// demselben Menschen sonst vier Willkommensmails schickt.
Queue::fake();
mailversandEingerichtet();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -344,6 +370,7 @@ it('lädt einen entzogenen Sitz nicht erneut ein', function () {
// Derselbe Weg zurück, nur über die andere Tür — und ebenfalls an der
// Platzgrenze vorbei. Der richtige Weg ist ein NEUER Sitz.
Queue::fake();
mailversandEingerichtet();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -396,6 +423,7 @@ it('lädt den Inhaber nicht zu seiner eigenen Cloud ein', function () {
// oder legte, solange die Bereitstellung noch nicht so weit ist, ein
// ZWEITES Konto in der Gruppe `admin` an, neben dem echten.
Queue::fake();
mailversandEingerichtet();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -551,6 +579,7 @@ it('lässt einen wieder Aufgenommenen auch wirklich wieder hinein', function ()
// die revoke() in den Gast geschickt hat, muss beim Einladen wieder
// aufgehen. `user:welcome` allein tut das nicht.
Queue::fake();
mailversandEingerichtet();
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$customer = Customer::factory()->create();
@ -657,6 +686,7 @@ it('sperrt ein Konto, das erst nach dem Entziehen entsteht', function () {
// Ausführen hat sich die Absicht geändert — der Auftrag darf dann nicht
// blind ausführen, was beim Absenden galt.
Queue::fake();
mailversandEingerichtet();
$pve = new FakeProxmoxClient;
// Der Benutzer existiert im Gast noch nicht: es läuft ein `user:add`,
// nicht nur ein `user:welcome` — der beschriebene Ablauf.
@ -842,6 +872,116 @@ it('nimmt dem Inhaber-Sitz seine Rolle auch dann nicht, wenn es einen zweiten gi
Queue::assertNothingPushed();
});
// --- Der Knopf, der eine Mail verspricht, die niemand verschicken kann.
it('verschickt keine Einladung, solange der Mailversand nicht eingerichtet ist', function () {
// Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das
// Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten
// Versand kommt aber keine Mail heraus, und `occ user:add
// --generate-password --email` GELINGT trotzdem: Nextcloud legt das Konto
// an, versucht die Willkommensmail, protokolliert intern einen Fehler und
// beendet mit 0.
//
// Die Zeile stünde also auf „Eingeladen", die Meldung verspräche einen
// Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand
// bekäme etwas. Dieselbe Attrappe, gegen die dieses ganze Feature gebaut
// wurde, eine Schicht tiefer.
//
// Kein ausgefülltes Postfach in dieser Fixture: das ist der Zustand, in
// dem JEDE Instanz am ersten Tag steht, solange der Mailserver dieses
// Produkts noch nicht existiert.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member']);
Livewire::actingAs($user)->test(Users::class)
->call('sendInvite', $sitz->uuid)
// Kein Erfolg gemeldet. Auf den Satz selbst geprüft, nicht auf
// irgendeine Meldung: „Einladung verschickt" ist genau das
// Versprechen, das hier nicht zu halten ist.
->assertDispatched('notify', fn ($ereignis, $daten) => $daten['message'] !== __('users.invite_sent'));
// Kein Auftrag, und der Sitz bleibt, wie er war — insbesondere ohne
// Anmeldenamen, denn den vergibt erst das Einladen.
Queue::assertNothingPushed();
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE)
->and($sitz->fresh()->nc_username)->toBeNull();
});
it('sagt es auf der Seite, statt den Knopf verschwinden zu lassen', function () {
// Der Hinweis steht dort, wo der Inhaber ihn VOR dem Klicken liest. Der
// Knopf bleibt: die Einladen-Spalte an jeder Zeile verschwinden zu lassen
// liest sich nicht als „geht hier gerade nicht", sondern als „das kann
// dieses Produkt nicht" — genau die Beschwerde, die schon einmal zur
// immer sichtbaren Aktionsspalte geführt hat.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member']);
Livewire::actingAs($user)->test(Users::class)
->assertSee(__('users.mail_not_ready'))
->assertSee("sendInvite('{$sitz->uuid}')", escape: false);
});
it('laedt wieder ein, sobald der Versand steht', function () {
// Die Gegenrichtung: die Wache darf nicht mehr abweisen, als sie soll.
Queue::fake();
mailversandEingerichtet();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member']);
Livewire::actingAs($user)->test(Users::class)
->call('sendInvite', $sitz->uuid)
->assertDontSee(__('users.mail_not_ready'));
Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'invite');
});
it('verbraucht fuer eine abgewiesene Einladung kein Kontingent', function () {
// Die Wache steht VOR dem Ratelimit. Sonst hätte ein Inhaber, dessen
// Versand noch nicht steht, seine drei Versuche an dieser Zeile
// verbraucht, bevor überhaupt einer hinausgehen konnte — und müsste
// warten, sobald der Betrieb den Versand einrichtet.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member']);
$seite = Livewire::actingAs($user)->test(Users::class);
foreach (range(1, 3) as $ignoriert) {
$seite->call('sendInvite', $sitz->uuid);
}
mailversandEingerichtet();
$seite->call('sendInvite', $sitz->uuid);
Queue::assertPushed(SyncSeatToNextcloud::class, 1);
});
it('legt weiterhin Sitze an, auch ohne Versand', function () {
// Anlegen verspricht ausdrücklich KEINE Mail — ein Inhaber soll sein Team
// vorbereiten können, ohne dass jemand eine bekommt. Die Wache gehört
// deshalb an „Einladen" und nirgendwo sonst.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'anna@firma.tld')
->call('addSeat')
->assertHasNoErrors();
expect($customer->seats()->where('email', 'anna@firma.tld')->exists())->toBeTrue();
});
it('nennt in keiner Datei unter app/ das Löschen eines Benutzers', function () {
// Testerzwungene Regel: kein Nextcloud-Benutzer wird je gelöscht, und
// keine Datei. Wer das ändern will, muss diese Prüfung anfassen und