From 9b8d5dfd1e60d78e953e228b1dfc6666c21b096e Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 17:55:49 +0200 Subject: [PATCH] Editing in modals, an update button that is not gated on a stale reading, and a support page that is real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things reported together. ── Editing belongs in a modal (R20) The seats table grew its input fields into the row. It worked and it looked broken: the row grew, the columns beside it jumped, and a table half in edit mode reads as a rendering fault rather than as a form. The project already had the answer — EditDatacenter, whose own header comment says it avoids exactly that row-height jump — and the seats table simply did not use it. EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route middleware, so it resolves the customer itself and re-reads the record rather than trusting a hydrated property: a forged addressEditable would otherwise open the address of an accepted seat, and the address is the person — editing it hands one employee's access to another with nobody told. Only an invitation still in flight can have its address corrected, which is the case that actually comes up. The actions column is now always drawn. It used to disappear when the only seat was the owner, on the reasoning that there was nothing to act on. But every seat can be renamed, and a column that vanishes does not read as "not applicable here" — it reads as "this product cannot do that", which is how it was reported, three times. The owner's row says "Geschützt" rather than leaving an empty cell. Also caught here: the edit fields carried class-wide #[Validate] attributes, so an empty edit form made the INVITE button fail on a field the invite form does not have. Rules for an action belong to the action. ── The update button "I cannot run an update, it says everything is current." `behind` is a READING taken by the agent every five minutes, not the state of the world — push a commit and the console insists it is up to date and refuses to act. The agent does its own fetch before deciding, so asking against a stale reading costs one fetch and finds nothing; being locked out costs the deployment. The button is now offered whenever an agent is alive and no run is in flight, labelled for what it does rather than for what the last reading said. ── The update never announced that it had finished Because the thing being watched restarts the thing doing the watching. Mid-run every wire:poll request fails, and what answers afterwards is a new build being questioned by the old page's JavaScript — so the card sat on "läuft" until somebody reloaded by hand. A small Alpine watcher now asks a plain JSON endpoint (no Livewire, no component state, no assets), treats a failed request as the restart rather than as a fault worth giving up over, and reloads once the build it is looking at is no longer the build it started with. ── Support The page was a decorated placeholder: a button that raised a toast saying the form was "only hinted at in the prototype", three invented ticket titles living in the translation file, and no way to see what became of anything. It looked thin because nothing on it was real. It now leads with the customer's own requests — what somebody arriving here wants to know is what they asked and whether anyone answered — with contact details moved to the side where they belong. The form attaches the plan, the instance and who is asking automatically: making a customer describe their own server back to the people who built it is the part of support people hate. FAQ answers end where the thing can actually be done, and "is it me or is it you" is answered by a link to the status page. 637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page view may grow an input field inside a . Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 44 +++++ app/Livewire/EditSeat.php | 108 +++++++++++ app/Livewire/NewSupportRequest.php | 72 ++++++++ app/Livewire/Support.php | 26 ++- app/Livewire/Users.php | 13 +- app/Models/SupportRequest.php | 57 ++++++ ...7_180000_create_support_requests_table.php | 61 +++++++ lang/de/admin_settings.php | 1 + lang/de/support.php | 56 ++++-- lang/de/users.php | 11 ++ lang/en/admin_settings.php | 1 + lang/en/support.php | 66 +++++-- lang/en/users.php | 11 ++ resources/js/app.js | 76 ++++++++ resources/views/components/ui/icon.blade.php | 1 + .../views/livewire/admin/settings.blade.php | 45 ++++- resources/views/livewire/edit-seat.blade.php | 38 ++++ .../livewire/new-support-request.blade.php | 39 ++++ resources/views/livewire/support.blade.php | 167 ++++++++++++++---- resources/views/livewire/users.blade.php | 31 +++- routes/admin.php | 25 +++ tests/Feature/Admin/UpdateButtonTest.php | 66 +++++++ tests/Feature/EditInModalTest.php | 78 ++++++++ tests/Feature/SeatsTest.php | 107 +++++++++++ tests/Feature/SupportRequestTest.php | 164 +++++++++++++++++ 25 files changed, 1271 insertions(+), 93 deletions(-) create mode 100644 app/Livewire/EditSeat.php create mode 100644 app/Livewire/NewSupportRequest.php create mode 100644 app/Models/SupportRequest.php create mode 100644 database/migrations/2026_07_27_180000_create_support_requests_table.php create mode 100644 resources/views/livewire/edit-seat.blade.php create mode 100644 resources/views/livewire/new-support-request.blade.php create mode 100644 tests/Feature/EditInModalTest.php create mode 100644 tests/Feature/SupportRequestTest.php diff --git a/CLAUDE.md b/CLAUDE.md index fe05960..57d1e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,3 +106,47 @@ darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten, Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()` verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe Zeitpunkt zurück. + +--- + +## R20 — Bearbeiten passiert im Modal, nie in der Zeile + +**Sobald etwas Eingabefelder hat, geht ein Modal auf.** + +Verboten: + +1. **Inline-Bearbeitung in einer Tabellenzeile.** Kein ``, kein + ` + @error('body')

{{ $message }}

@enderror + + + {{-- Said once, plainly. The alternative is a form that asks the customer + to describe their own server back to the people who built it. --}} +

{{ __('support.attached_hint') }}

+ + +
+ {{ __('common.cancel') }} + {{ __('support.send') }} +
+ diff --git a/resources/views/livewire/support.blade.php b/resources/views/livewire/support.blade.php index 16c7a82..28871b4 100644 --- a/resources/views/livewire/support.blade.php +++ b/resources/views/livewire/support.blade.php @@ -1,45 +1,138 @@ -
-
-
-

{{ __('support.title') }}

-

{{ __('support.subtitle') }}

+@php + $locale = app()->getLocale(); +@endphp +
+
+
+

{{ __('support.eyebrow') }}

+

+ {{ __('support.title') }} +

- + + {{ __('support.new') }} -
+ - {{-- Contact --}} - @php $delays = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]']; @endphp -
- @foreach ([ - ['life-buoy', __('support.contact_hours'), __('support.contact_hours_val')], - ['external-link', __('support.contact_email'), 'support@clupilot.com'], - ['shield-check', __('support.contact_sla'), __('support.contact_sla_val')], - ] as $i => [$icon, $label, $val]) -
- -
-

{{ $label }}

-

{{ $val }}

+
+ {{-- ── The customer's own requests ────────────────────────────────── + The page used to lead with three cards about opening hours. What + somebody arriving here actually wants to know is what they already + asked and whether anybody has answered — so that goes first, and + the contact details move to the side where they belong. --}} +
+
+
+

{{ __('support.tickets') }}

+ @if ($openCount > 0) + + {{ trans_choice('support.open_count', $openCount, ['count' => $openCount]) }} + + @endif
-
- @endforeach -
- {{-- Tickets --}} -
-

{{ __('support.faq') }}

-
    - @foreach ($faqs as $i => $f) -
  • - -
    {{ $f['a'] }}
    -
  • - @endforeach -
+ @forelse ($requests as $r) +
+
+

{{ $r->subject }}

+

+ {{ __('support.category_'.$r->category) }} + · {{ $r->created_at->local()->locale($locale)->isoFormat('LL, LT') }} + @if ($r->reported_by) · {{ $r->reported_by }} @endif +

+ @if ($r->answered_at) +

+ {{ __('support.answered_at', ['when' => $r->answered_at->local()->locale($locale)->isoFormat('LL, LT')]) }} +

+ @endif +
+ + @php $tone = ['open' => 'info', 'in_progress' => 'provisioning', 'answered' => 'active', 'closed' => 'suspended'][$r->status] ?? 'info'; @endphp + {{ __('support.status_'.$r->status) }} +
+ @empty + {{-- An empty state that says what to do, not "no data". --}} +
+ + + +

{{ __('support.empty_title') }}

+

{{ __('support.empty_body') }}

+ + {{ __('support.new') }} + +
+ @endforelse + + + {{-- ── Frequent questions ─────────────────────────────────────── + Each answer ends where the thing can actually be done. An FAQ + that only describes a button leaves the reader hunting for it. --}} +
+
+

{{ __('support.faq') }}

+
+
    + @foreach ($faqs as $i => $f) +
  • + +
    +

    {{ $f['a'] }}

    + @if ($f['to']) + + {{ $f['cta'] }} + + + @endif +
    +
  • + @endforeach +
+
+
+ + {{-- ── Contact, and the one question an outage raises ─────────────── + "Is it me or is it you" is what a customer asks first when + something is down, and it is answered by a page rather than by us. --}} +
diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 3d7e2c6..667fddf 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -44,9 +44,7 @@ {{ __('users.col_person') }} {{ __('users.role') }} {{ __('users.col_status') }} - @if ($hasActions) - {{ __('users.col_actions') }} - @endif + {{ __('users.col_actions') }} @@ -71,16 +69,31 @@ @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp {{ __('users.status_'.$seat->status) }} - @if ($hasActions)
- @if ($seat->role === 'owner') - - @endif @if ($seat->status === 'invited') @endif - @if ($seat->role !== 'owner') + + {{-- Rename is offered on every row, the owner + included: a row you can do nothing to reads + as a broken table, not as a protected one. + It opens a modal, never the row itself — + see R20. --}} + + + @if ($seat->role === 'owner') + {{-- Said, not hidden. The owner keeps their + own access; an empty cell would leave + somebody hunting for a button that is + deliberately absent. --}} + {{ __('users.owner_protected') }} + @else {{-- Pause first, remove second. Somebody leaving needs their access stopped today; deleting the seat also throws @@ -93,6 +106,7 @@
- @endif @endforeach diff --git a/routes/admin.php b/routes/admin.php index f7ee72d..79bfce7 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -38,3 +38,28 @@ Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/secrets', Admin\Secrets::class)->name('secrets'); Route::get('/settings', Admin\Settings::class)->name('settings'); + +/* + * A deployment restarts the very application that is watching it. + * + * The settings card polls itself with wire:poll, which is fine until the run + * reaches the restart: the requests fail, and what comes back afterwards is a + * new build being asked questions by the old page's JavaScript. So the card + * never learned that the update had finished and an operator had to reload to + * find out — reported exactly that way. + * + * This is the smallest thing that can answer "is it done, and is it still the + * same build" across that gap: no Livewire, no component state, no assets. A + * failed request here is expected (it IS the restart) and the poller keeps + * trying rather than giving up. + */ +Route::get('/update/state', function (\App\Services\Deployment\UpdateChannel $channel) { + $state = $channel->state(); + + return response()->json([ + 'running' => $state['running'], + 'commit' => $state['commit'], + 'behind' => $state['behind'], + 'last_finished_at' => $state['last_finished_at']?->toIso8601String(), + ])->header('Cache-Control', 'no-store'); +})->name('update.state'); diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index c01b0c6..06fa102 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -366,3 +366,69 @@ it('refuses a second request while the agent is mid-run', function () { expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); }); + +it('offers the update button even when the last check found nothing', function () { + // Reported as "I cannot run an update, it says everything is current". + // `behind` is a READING taken every few minutes, not the state of the + // world: push a commit and the console insisted it was up to date and + // refused to act on it. The agent fetches before deciding, so asking + // against a stale reading costs one fetch — being locked out costs the + // deployment. + writeStatus([ + 'checked_at' => now()->toIso8601String(), + 'state' => 'idle', + 'behind' => 0, + ]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_recheck')) + ->call('requestUpdate'); + + expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull(); +}); + +it('still refuses when there is no agent to pick the request up', function () { + // The one case where the button really would go nowhere: nothing is + // running that would ever read the request file. + writeStatus([ + 'checked_at' => now()->subDay()->toIso8601String(), + 'state' => 'idle', + 'behind' => 0, + ]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_unknown')) + ->assertDontSee(__('admin_settings.update_recheck')); +}); + +it('watches the deployment in a way that survives the restart', function () { + // wire:poll cannot see the run through to the end, because the run + // restarts the containers answering the poll. + writeStatus([ + 'checked_at' => now()->toIso8601String(), + 'state' => 'idle', + 'behind' => 2, + ]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee('updateWatcher', escape: false); +}); + +it('answers the watcher without Livewire in the way', function () { + writeStatus(['checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0]); + + $this->actingAs(operator('Owner')) + ->get(route('admin.update.state')) + ->assertOk() + ->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at']) + ->assertHeader('Cache-Control', 'no-store, private'); +}); + +it('does not answer the watcher for somebody who is not an operator', function () { + $this->actingAs(\App\Models\User::factory()->create()) + ->get(route('admin.update.state')) + ->assertForbidden(); +}); diff --git a/tests/Feature/EditInModalTest.php b/tests/Feature/EditInModalTest.php new file mode 100644 index 0000000..2b7187a --- /dev/null +++ b/tests/Feature/EditInModalTest.php @@ -0,0 +1,78 @@ +getPathname()); + + if (isModalOrForm($path)) { + continue; + } + + $source = $file->getContents(); + + // Look only INSIDE …: a field in a table cell is an inline + // editor by definition. Fields elsewhere on the page are a form. + foreach (preg_split('##', $source) as $segment) { + $open = strrpos($segment, ' is page, not row. Without this the whole file counted as + // one cell and every ordinary form failed. + if ($open === false) { + continue; + } + + $cell = substr($segment, $open); + + if (preg_match('#<(input|textarea)\b(?![^>]*type="(checkbox|radio|hidden)")#', $cell, $m)) { + $offenders[$path][] = trim(strtok(ltrim($cell), "\n") ?: $m[0]); + } + } + } + + expect($offenders)->toBe([]); +}); + +it('reaches every edit modal through openModal', function () { + // An edit button that calls a method on the page component instead of + // dispatching openModal is the inline editor coming back under a new name. + $modals = collect(File::allFiles(app_path('Livewire'))) + ->filter(fn ($f) => str_contains($f->getContents(), 'extends ModalComponent')) + ->map(fn ($f) => $f->getFilenameWithoutExtension()) + ->values(); + + expect($modals)->toContain('EditSeat') + ->and($modals)->toContain('EditDatacenter'); + + // And the seats table actually opens it. + $users = File::get(resource_path('views/livewire/users.blade.php')); + + expect($users)->toContain("component: 'edit-seat'"); +}); diff --git a/tests/Feature/SeatsTest.php b/tests/Feature/SeatsTest.php index ed0a48e..78790cf 100644 --- a/tests/Feature/SeatsTest.php +++ b/tests/Feature/SeatsTest.php @@ -1,5 +1,6 @@ test(Users::class)->call('revoke', $seat->uuid); expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse(); }); + +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 still in flight', function () { + $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 an accepted seat to a different address', function () { + // The address IS the person once they are in. Editing it would hand one + // employee's access to another with nobody told — a transfer of access + // wearing the clothes of a rename. Driven past the form on purpose: the + // property is hydrated from the browser and must not be trusted. + $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' => 'active', + ]); + + 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') + ->and($seat->fresh()->name)->toBe('B'); +}); + +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); +}); diff --git a/tests/Feature/SupportRequestTest.php b/tests/Feature/SupportRequestTest.php new file mode 100644 index 0000000..8bf34c5 --- /dev/null +++ b/tests/Feature/SupportRequestTest.php @@ -0,0 +1,164 @@ +create(['name' => 'Dr. S. Berger']); + $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); + + return [$user, $customer]; +} + +it('records a request and shows it back to the customer', function () { + [$user, $customer] = supportCustomer(); + + Livewire::actingAs($user) + ->test(NewSupportRequest::class) + ->set('category', 'technical') + ->set('subject', 'Kalendersynchronisation mit Outlook') + ->set('body', 'Termine werden bei zwei Mitarbeitern nicht mehr übernommen.') + ->call('save') + ->assertHasNoErrors(); + + $request = SupportRequest::query()->where('customer_id', $customer->id)->sole(); + + expect($request->status)->toBe('open') + // The person, not the account: on a shared login the account name says + // nothing about who is actually asking. + ->and($request->reported_by)->toBe('Dr. S. Berger'); + + Livewire::actingAs($user) + ->test(Support::class) + ->assertSee('Kalendersynchronisation mit Outlook') + ->assertSee(__('support.status_open')); +}); + +it('attaches the instance so nobody has to ask which server', function () { + [$user, $customer] = supportCustomer(); + $instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active']); + + Livewire::actingAs($user) + ->test(NewSupportRequest::class) + ->set('subject', 'Ein Betreff') + ->set('body', 'Eine Nachricht, die lang genug ist.') + ->call('save'); + + expect(SupportRequest::query()->sole()->instance_id)->toBe($instance->id); +}); + +it('still takes a request from somebody whose cloud is not built yet', function () { + // A question can arrive before provisioning finishes, and that is exactly + // when somebody is most likely to have one. + [$user] = supportCustomer(); + + Livewire::actingAs($user) + ->test(NewSupportRequest::class) + ->set('subject', 'Wann geht es los?') + ->set('body', 'Wir haben gestern bestellt und würden gerne planen.') + ->call('save') + ->assertHasNoErrors(); + + expect(SupportRequest::query()->sole()->instance_id)->toBeNull(); +}); + +it('refuses an empty or barely-written request', function () { + [$user] = supportCustomer(); + + Livewire::actingAs($user) + ->test(NewSupportRequest::class) + ->set('subject', '') + ->set('body', 'kurz') + ->call('save') + ->assertHasErrors(['subject', 'body']); + + expect(SupportRequest::query()->count())->toBe(0); +}); + +it('refuses a category that is not on the form', function () { + [$user] = supportCustomer(); + + Livewire::actingAs($user) + ->test(NewSupportRequest::class) + ->set('category', 'urgent-vip') + ->set('subject', 'Ein Betreff') + ->set('body', 'Eine Nachricht, die lang genug ist.') + ->call('save') + ->assertHasErrors('category'); +}); + +it('shows a customer only their own requests', function () { + [$user] = supportCustomer(); + [, $other] = supportCustomer(); + + SupportRequest::create([ + 'customer_id' => $other->id, + 'subject' => 'Fremde Anfrage', + 'category' => 'billing', + 'body' => 'Gehört jemand anderem.', + 'status' => 'open', + ]); + + Livewire::actingAs($user) + ->test(Support::class) + ->assertDontSee('Fremde Anfrage') + ->assertSee(__('support.empty_title')); +}); + +it('counts only what is still open', function () { + [$user, $customer] = supportCustomer(); + + foreach (['open', 'in_progress', 'answered', 'closed'] as $status) { + SupportRequest::create([ + 'customer_id' => $customer->id, + 'subject' => 'Anfrage '.$status, + 'category' => 'other', + 'body' => 'Eine Nachricht, die lang genug ist.', + 'status' => $status, + ]); + } + + Livewire::actingAs($user) + ->test(Support::class) + // open + in_progress. Answered and closed are done with. + ->assertViewHas('openCount', 2); +}); + +it('says something useful when there is nothing to show', function () { + // An empty state that names the next step, rather than "no data". + [$user] = supportCustomer(); + + Livewire::actingAs($user) + ->test(Support::class) + ->assertSee(__('support.empty_title')) + ->assertSee(__('support.empty_body')) + ->assertSee(__('support.new')); +}); + +it('carries no invented ticket titles', function () { + // Three fictional tickets used to live in the translation file and render + // as though they were the customer's own. + foreach (['de', 'en'] as $locale) { + $keys = array_keys(include base_path("lang/{$locale}/support.php")); + + expect($keys)->not->toContain('ticket_migration') + ->and($keys)->not->toContain('ticket_training') + ->and($keys)->not->toContain('ticket_smtp') + ->and($keys)->not->toContain('new_toast'); + } +});