create(['email' => 'o@seat.test', 'is_admin' => false]); $customer = Customer::factory()->create(['email' => 'o@seat.test', 'user_id' => $user->id, 'name' => 'Owner Co']); $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => $plan]); Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active']); return compact('user', 'customer'); } it('creates an owner seat on first visit', function () { ['user' => $user, 'customer' => $customer] = seatSetup(); Livewire::actingAs($user)->test(Users::class)->assertOk(); $owner = $customer->seats()->where('role', 'owner')->first(); expect($owner)->not->toBeNull()->and($owner->email)->toBe('o@seat.test'); }); // invite() heisst seit dem Nextcloud-Anschluss addSeat(), weil sie genau das // tut und nichts weiter: eine Zeile anlegen. Verschickt wird erst in // sendInvite() — siehe SyncSeatToNextcloudTest. Die Sitzplatzgrenze und die // Sperre gegen doppelte Adressen sind dabei unverändert geblieben. it('adds a seat and blocks duplicates', function () { ['user' => $user, 'customer' => $customer] = seatSetup(); Livewire::actingAs($user)->test(Users::class) ->set('inviteEmail', 'new@seat.test')->set('inviteRole', 'member')->call('addSeat') ->assertHasNoErrors(); expect($customer->seats()->where('email', 'new@seat.test')->exists())->toBeTrue(); Livewire::actingAs($user)->test(Users::class) ->set('inviteEmail', 'new@seat.test')->call('addSeat') ->assertHasErrors(['inviteEmail']); expect($customer->seats()->where('email', 'new@seat.test')->count())->toBe(1); }); it('enforces the plan seat limit', function () { ['user' => $user, 'customer' => $customer] = seatSetup('start'); // 5-seat plan // Fill all 5 seats so the plan allowance is exhausted. Seat::factory()->count(5)->create(['customer_id' => $customer->id]); Livewire::actingAs($user)->test(Users::class) ->set('inviteEmail', 'over@seat.test')->call('addSeat') ->assertHasErrors(['inviteEmail']); expect($customer->seats()->where('email', 'over@seat.test')->exists())->toBeFalse(); }); it('will not remove or demote the last owner', function () { ['user' => $user, 'customer' => $customer] = seatSetup(); Livewire::actingAs($user)->test(Users::class); // creates owner $owner = $customer->seats()->where('role', 'owner')->first(); // Drei Handlungen, drei eigene Meldungen (users.owner_locked trug bis vor // kurzem alle drei) — je Knopf ein eigener Satz, der sagt, was HIER // gerade nicht geht. Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid) ->assertDispatched('notify', message: __('users.owner_revoke_locked')); // Auf den STATUS geprüft, nicht mehr nur auf die Zeile: seit revoke() // grundsätzlich nicht mehr löscht, bewiese eine noch vorhandene Zeile // gar nichts — sie bliebe auch dann stehen, wenn die Inhaber-Sperre // versagte. Der Status ist die Zusicherung, die hier gemeint war. expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue() ->and($owner->fresh()->status)->toBe('active'); Livewire::actingAs($user)->test(Users::class)->call('setRole', $owner->uuid, 'member') ->assertDispatched('notify', message: __('users.owner_role_locked')); expect($owner->fresh()->role)->toBe('owner'); Livewire::actingAs($user)->test(Users::class)->call('suspend', $owner->uuid) ->assertDispatched('notify', message: __('users.owner_locked')); expect($owner->fresh()->status)->toBe('active'); }); it('revokes a non-owner seat without deleting the row', function () { // Früher wurde die Zeile gelöscht. Das fällt weg: ein Fehlgriff im // Userpanel darf die Arbeit eines Menschen nicht vernichten, und in einem // Produkt, das mit Nachvollziehbarkeit verkauft wird, gäbe es danach // nichts mehr zu zeigen, wer welchen Zugang hatte. Gelöscht wird, wenn // überhaupt, in der Nextcloud selbst — dort fragt Nextcloud nach, was mit // den Dateien geschehen soll. ['user' => $user, 'customer' => $customer] = seatSetup(); $seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']); Livewire::actingAs($user)->test(Users::class)->call('revoke', $seat->uuid); expect($customer->seats()->whereKey($seat->id)->exists())->toBeTrue() ->and($seat->fresh()->status)->toBe('revoked'); }); it('shows the actions column even when the owner is the only seat', function () { // Reported three times: a customer with one seat saw a table with nothing // to click and concluded the product could not edit users. The column used // to be hidden when there was "nothing to act on" — but a column that // disappears does not read as "not applicable here", it reads as missing. $user = User::factory()->create(); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); Livewire::actingAs($user) ->test(Users::class) ->assertSee(__('users.col_actions')) ->assertSee(__('users.edit')) // And it says why the owner has no destructive actions instead of // leaving an empty cell. ->assertSee(__('users.owner_protected')); expect($customer->seats()->count())->toBe(1); }); it('renames any seat, the owner included', function () { $user = User::factory()->create(); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); $owner = $customer->seats()->create([ 'email' => $user->email, 'name' => 'Falsch Geschrieben', 'role' => 'owner', 'status' => 'active', ]); Livewire::actingAs($user) ->test(EditSeat::class, ['uuid' => $owner->uuid]) ->assertSet('name', 'Falsch Geschrieben') ->assertSet('isOwner', true) ->set('name', 'Dr. M. Muster') ->call('save') ->assertHasNoErrors(); expect($owner->fresh()->name)->toBe('Dr. M. Muster'); }); it('corrects the address of an invitation nobody has sent yet', function () { // Angelegt, noch nicht eingeladen: in der Cloud gibt es dazu nichts, und // ein Tippfehler ist an dieser Stelle folgenlos zu berichtigen. $user = User::factory()->create(); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); $seat = $customer->seats()->create([ 'email' => 'tpyo@example.test', 'role' => 'member', 'status' => 'invited', ]); Livewire::actingAs($user) ->test(EditSeat::class, ['uuid' => $seat->uuid]) ->assertSet('addressEditable', true) ->set('email', 'typo@example.test') ->call('save') ->assertHasNoErrors(); expect($seat->fresh()->email)->toBe('typo@example.test'); }); it('will not move a seat that already has an account in the cloud', function () { // Die Bedingung hing an `status === 'invited'` — und NICHTS im Bestand // setzt `seats.status` je von 'invited' auf 'active'. Ein eingeladener // Sitz blieb damit für immer 'invited', und die Adresse dauerhaft // änderbar: `seats.email` wurde `b@y`, `nc_username` blieb `a@x` // (richtig — Nextcloud kann nicht umbenennen), und kein Befehl trug die // neue Adresse in den Gast. „Erneut senden" schickte danach // `user:welcome --reset-password 'a@x'`: das Portal führte `b@y` als // Inhaberin des Sitzes, tatsächlich hielt `a@x` den Zugang und bekam // jederzeit einen neuen Zurücksetzen-Link. // // Massgeblich ist deshalb `nc_username`: ab da IST die Adresse die Person. // Am Formular vorbei gefahren, mit Absicht — die Eigenschaft kommt aus // dem Browser und darf nicht geglaubt werden. $user = User::factory()->create(); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); $seat = $customer->seats()->create([ 'email' => 'in@example.test', 'name' => 'A', 'role' => 'member', 'status' => 'invited', 'nc_username' => 'in@example.test', ]); Livewire::actingAs($user) ->test(EditSeat::class, ['uuid' => $seat->uuid]) ->assertSet('addressEditable', false) ->set('addressEditable', true) ->set('email', 'someone.else@example.test') ->set('name', 'B') ->call('save'); expect($seat->fresh()->email)->toBe('in@example.test') // Umbenennen bleibt erlaubt — es ist dieselbe Person. ->and($seat->fresh()->name)->toBe('B'); }); it('will not move the owner seat to a different address', function () { // Der Inhaber-Sitz steht immer auf 'active' — an `status === 'invited'` war // seine Adresse deshalb NIE änderbar. An `nc_username` allein wäre sie es, // solange `linkToInstanceAdmin()` noch nicht gegriffen hat, also genau vor // der Bereitstellung. // // Was dabei entstünde, ist kein Umbenennen: läuft die Adresse des // Inhaber-Sitzes von der Kundenadresse weg, legt „Anlegen" mit der echten // Adresse eine ZWEITE Zeile für dieselbe Person an, die gegen die // Platzgrenze zählt. $user = User::factory()->create(); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); // Ohne nc_username: die Bereitstellung hat das Admin-Konto noch nicht // angelegt, der Sitz ist also noch mit nichts im Gast verknüpft. $owner = $customer->seats()->create([ 'email' => $user->email, 'name' => 'Inhaberin', 'role' => 'owner', 'status' => 'active', ]); Livewire::actingAs($user) ->test(EditSeat::class, ['uuid' => $owner->uuid]) ->assertSet('addressEditable', false) ->set('addressEditable', true) ->set('email', 'privat@woanders.test') ->set('name', 'Dr. Inhaberin') ->call('save'); expect($owner->fresh()->email)->toBe($user->email) // Umbenennen bleibt erlaubt, auch am Inhaber-Sitz. ->and($owner->fresh()->name)->toBe('Dr. Inhaberin'); }); it('refuses to open a seat belonging to somebody else', function () { // The uuid comes from the browser, and a modal is reachable without the // page's route middleware. $mine = User::factory()->create(); Customer::factory()->create(['user_id' => $mine->id, 'email' => $mine->email]); $theirs = Customer::factory()->create(['user_id' => User::factory()->create()->id]); $seat = $theirs->seats()->create(['email' => 'x@example.test', 'name' => 'X', 'role' => 'member', 'status' => 'active']); Livewire::actingAs($mine) ->test(EditSeat::class, ['uuid' => $seat->uuid]) ->assertStatus(404); expect($seat->fresh()->name)->toBe('X'); }); it('opens editing in a modal rather than in the row', function () { // R20. Inline fields made the row grow and the columns beside it jump, // which reads as a rendering fault rather than as a form. $user = User::factory()->create(); Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); Livewire::actingAs($user) ->test(Users::class) ->assertSee('openModal', escape: false) ->assertSee('edit-seat', escape: false) ->assertDontSee('wire:model="editName"', escape: false); }); // R23: confirmation moved from wire:confirm to ConfirmRevokeSeat, which // mirrors EditSeat above (a modal is reachable without the page's route // middleware, so the seat is resolved fresh from the signed-in customer) but // dispatches back to Users::revoke() instead of mutating anything itself. it('opens the revoke confirmation in a modal too', function () { // The revoke button only renders for a non-owner seat (the owner row has // no destructive actions at all), so one has to exist first. ['user' => $user, 'customer' => $customer] = seatSetup(); Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']); Livewire::actingAs($user) ->test(Users::class) ->assertSee('confirm-revoke-seat', escape: false); }); it('refuses to open the revoke confirmation for the owner seat', function () { ['user' => $user, 'customer' => $customer] = seatSetup(); Livewire::actingAs($user)->test(Users::class); // creates owner $owner = $customer->seats()->where('role', 'owner')->first(); Livewire::actingAs($user) ->test(ConfirmRevokeSeat::class, ['uuid' => $owner->uuid]) ->assertStatus(404); expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue(); }); it('refuses to open the revoke confirmation for somebody else\'s seat', function () { $mine = User::factory()->create(); Customer::factory()->create(['user_id' => $mine->id, 'email' => $mine->email]); $theirs = Customer::factory()->create(['user_id' => User::factory()->create()->id]); $seat = $theirs->seats()->create(['email' => 'x@example.test', 'name' => 'X', 'role' => 'member', 'status' => 'active']); Livewire::actingAs($mine) ->test(ConfirmRevokeSeat::class, ['uuid' => $seat->uuid]) ->assertStatus(404); expect($seat->fresh()->name)->toBe('X'); }); it('confirms a revoke through the modal without removing the seat itself', function () { ['user' => $user, 'customer' => $customer] = seatSetup(); $seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member', 'name' => 'Weg Damit']); Livewire::actingAs($user) ->test(ConfirmRevokeSeat::class, ['uuid' => $seat->uuid]) ->assertSee('Weg Damit') ->call('confirm') ->assertDispatched('seat-revoke-confirmed', uuid: $seat->uuid); expect($customer->seats()->whereKey($seat->id)->exists())->toBeTrue(); }); it('revokes the seat once the page receives the confirmed event', function () { // Exercises the forwarding listener directly — see the equivalent note in // SecretsPageTest for why the cross-component event round trip itself is // checked manually rather than in a single-component Livewire::test(). ['user' => $user, 'customer' => $customer] = seatSetup(); $seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']); Livewire::actingAs($user)->test(Users::class)->call('onRevokeConfirmed', $seat->uuid); // Wieder der Status statt der Existenz: revoke() löscht nicht mehr (siehe // die Notiz weiter oben), also wäre „die Zeile ist weg" jetzt eine // Zusicherung, die das Produkt bewusst nicht mehr gibt. expect($seat->fresh()->status)->toBe('revoked'); });