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('billingAddress', "Neue Gasse 9\n1020 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'"); });