CluPilotCloud/tests/Feature/Admin/EditCustomerTest.php

210 lines
8.3 KiB
PHP

<?php
use App\Livewire\Admin\CustomerDetail;
use App\Livewire\Admin\EditCustomer;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Correcting a customer's own details from the console.
*
* It could show them and not touch them, so a moved office meant impersonating
* the customer or editing the database by hand. Most of what is tested here is
* not the saving — it is what saving must NOT quietly do.
*/
beforeEach(function () {
$this->customer = Customer::factory()->create([
'name' => 'Beispiel GmbH',
'contact_name' => 'Bea Berger',
'email' => 'bea@beispiel.test',
'phone' => '+43 1 111',
'customer_type' => Customer::TYPE_BUSINESS,
'vat_id' => 'ATU11111111',
'vat_id_verified_at' => now(),
'vat_id_verified_value' => 'ATU11111111',
'billing_address' => "Alte Gasse 1\n1010 Wien",
]);
});
/** The modal, mounted on this customer. */
function editing(Customer $customer, string $role = 'Owner')
{
return Livewire::actingAs(operator($role), 'operator')
->test(EditCustomer::class, ['uuid' => $customer->uuid]);
}
it('saves the details an operator corrected', function () {
editing($this->customer)
->set('name', 'Beispiel GmbH & Co KG')
->set('contactName', 'Bernd Berger')
->set('phone', '+43 1 999')
->set('billingStreet', 'Neue Gasse 9')
->set('billingPostcode', '1020')
->set('billingCity', 'Wien')
->set('locale', 'en')
->call('save')
->assertHasNoErrors();
$fresh = $this->customer->fresh();
expect($fresh->name)->toBe('Beispiel GmbH & Co KG')
->and($fresh->contact_name)->toBe('Bernd Berger')
->and($fresh->phone)->toBe('+43 1 999')
->and($fresh->billing_address)->toContain('1020 Wien')
->and($fresh->locale)->toBe('en');
});
it('never resets a recorded customer type back to nobody asked', function () {
// That answer decides whether a withdrawal right exists. A form saved with
// the field untouched must not quietly undo it — and the field opens on
// "leave unchanged", so saving anything else would do exactly that.
editing($this->customer)->set('customerType', '')->call('save');
expect($this->customer->fresh()->customer_type)->toBe(Customer::TYPE_BUSINESS);
});
it('records the other answer when that is what was chosen', function () {
editing($this->customer)->set('customerType', Customer::TYPE_CONSUMER)->call('save');
expect($this->customer->fresh()->isConsumer())->toBeTrue();
});
it('treats a changed VAT number as unverified, and keeps what was checked', function () {
// The verification is bound to the VALUE, so a new number is unverified the
// moment it is stored. The record of what WAS checked stays: clearing it
// would destroy the evidence rather than the claim.
editing($this->customer)->set('vatId', 'ATU22222222')->call('save');
$fresh = $this->customer->fresh();
expect($fresh->vat_id)->toBe('ATU22222222')
->and($fresh->hasVerifiedVatId())->toBeFalse()
->and($fresh->vat_id_verified_value)->toBe('ATU11111111');
});
it('leaves a verification alone when the number was not touched', function () {
editing($this->customer)->set('phone', '+43 1 222')->call('save');
expect($this->customer->fresh()->hasVerifiedVatId())->toBeTrue();
});
// ---- The address, which is also the login ----
it('moves the portal login with the address and asks for it to be confirmed', function () {
// Otherwise the customer is locked out of an account that no longer matches
// their record. And the new address is unconfirmed by definition — nobody
// has shown they can read it.
$user = User::factory()->create(['email' => 'bea@beispiel.test', 'email_verified_at' => now()]);
$this->customer->update(['user_id' => $user->id]);
editing($this->customer->fresh())->set('email', 'neu@beispiel.test')->call('save')->assertHasNoErrors();
expect($this->customer->fresh()->email)->toBe('neu@beispiel.test')
->and($user->fresh()->email)->toBe('neu@beispiel.test')
->and($user->fresh()->email_verified_at)->toBeNull();
});
it('refuses an address that belongs to an operator', function () {
// R21: a customer and an operator must never share an address. No index
// spans the two tables, so this is checked rather than left to the database.
$operator = operator('Support');
editing($this->customer)
->set('email', $operator->email)
->call('save')
->assertHasErrors('email');
expect($this->customer->fresh()->email)->toBe('bea@beispiel.test');
});
it('refuses an address that belongs to another customer', function () {
// Two customers on one address makes every lookup that matches by address —
// inbound mail, the portal user, the register — ambiguous.
Customer::factory()->create(['email' => 'schon@da.test']);
editing($this->customer)
->set('email', 'schon@da.test')
->call('save')
->assertHasErrors('email');
expect($this->customer->fresh()->email)->toBe('bea@beispiel.test');
});
it('refuses an address that belongs to somebody else user account', function () {
User::factory()->create(['email' => 'fremd@example.test']);
editing($this->customer)
->set('email', 'fremd@example.test')
->call('save')
->assertHasErrors('email');
});
it('lets a customer keep their own address while something else is corrected', function () {
// The unique rule has to ignore this record, or nothing else on the form can
// ever be saved.
editing($this->customer)->set('name', 'Anderer Name')->call('save')->assertHasNoErrors();
expect($this->customer->fresh()->name)->toBe('Anderer Name');
});
it('lowercases the address, because that is how every lookup asks for it', function () {
editing($this->customer)->set('email', 'GROSS@Beispiel.test')->call('save');
expect($this->customer->fresh()->email)->toBe('gross@beispiel.test');
});
// ---- The rules around it ----
it('refuses an empty company name rather than storing one', function () {
editing($this->customer)->set('name', '')->call('save')->assertHasErrors('name');
expect($this->customer->fresh()->name)->toBe('Beispiel GmbH');
});
it('takes no language this application does not have', function () {
editing($this->customer)->set('locale', 'fr')->call('save')->assertHasErrors('locale');
});
it('keeps the form to operators who may manage customers', function () {
Livewire::actingAs(operator('Read-only'), 'operator')
->test(EditCustomer::class, ['uuid' => $this->customer->uuid])
->assertForbidden();
});
it('is reached from the customer page through openModal, not a page method', function () {
// R20: an edit button that calls a method on the page component is the inline
// editor coming back under a new name.
$page = File::get(resource_path('views/livewire/admin/customer-detail.blade.php'));
expect($page)->toContain("component: 'admin.edit-customer'");
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSee(__('customer_detail.edit'));
});
it('does not offer to change what an operator must not change here', function () {
// status has its own action with its own consequences for access;
// stripe_customer_id points at somebody else's payments if it is wrong; the
// brand fields are the customer's own.
// Comments stripped first. The class documents at length WHY each of these
// is absent, and reading that documentation as the offence is a trap this
// repository has fallen into three times — see the @vite, prefers-color-scheme
// and clupilot.cloud scans.
$code = implode('', array_map(
fn ($token) => is_array($token)
? (in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true) ? '' : $token[1])
: $token,
token_get_all(File::get(app_path('Livewire/Admin/EditCustomer.php'))),
));
expect($code)->not->toContain("'status'")
->not->toContain('stripe_customer_id')
->not->toContain('brand_logo_path')
// And the fields it DOES write are the ones it is meant to.
->toContain("'billing_address'")
->toContain("'customer_type'");
});