create(['email' => 's@set.test', 'is_admin' => false]); $customer = Customer::factory()->create(['email' => 's@set.test', 'user_id' => $user->id, 'name' => 'Acme', 'status' => 'active']); if ($withInstance) { $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']); Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => 'team', 'status' => 'active', 'subdomain' => 'acme']); } return compact('user', 'customer'); } it('gates settings to authenticated users', function () { $this->get(route('settings'))->assertRedirect('/login'); }); it('blocks a suspended customer from the portal', function () { $user = User::factory()->create(['email' => 'sus@set.test', 'is_admin' => false]); Customer::factory()->create(['email' => 'sus@set.test', 'user_id' => $user->id, 'status' => 'suspended']); $this->actingAs($user)->get(route('dashboard'))->assertRedirect('/login'); expect(auth()->check())->toBeFalse(); }); it('blocks a closed customer from the portal', function () { $user = User::factory()->create(['email' => 'cl@set.test', 'is_admin' => false]); Customer::factory()->create(['email' => 'cl@set.test', 'user_id' => $user->id, 'status' => 'closed', 'closed_at' => now()]); $this->actingAs($user)->get(route('dashboard'))->assertRedirect('/login'); }); it('rejects cancelling a package that is not active', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); $instance = $customer->instances()->first(); $instance->update(['status' => 'cancellation_scheduled']); Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'acme') ->call('cancelPackage'); // Still scheduled — not re-transitioned or resurrected. expect($instance->fresh()->status)->toBe('cancellation_scheduled'); }); it('saves the company profile', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); Livewire::actingAs($user)->test(Settings::class) ->set('companyName', 'Acme GmbH') ->set('vatId', 'ATU12345678') ->set('billingStreet', 'Hauptstraße 1') ->set('billingPostcode', '1010') ->set('billingCity', 'Wien') ->set('billingCountry', 'Österreich') ->call('saveProfile') ->assertHasNoErrors(); $customer->refresh(); expect($customer->name)->toBe('Acme GmbH') ->and($customer->vat_id)->toBe('ATU12345678') ->and($customer->billing_city)->toBe('Wien') ->and($customer->billing_postcode)->toBe('1010') // And the composed block every existing reader uses: street, then // postcode and town on one line, then the country. ->and($customer->billing_address)->toBe("Hauptstraße 1\n1010 Wien\nÖsterreich"); }); it('never lets a business turn itself into a consumer', function () { // The fourteen-day right of withdrawal hangs off this field. A business that // could set itself to "Privatperson" here is a business that can withdraw // from a contract it may not withdraw from — which is why registration asks // the question and why this form cannot answer it a second time. ['user' => $user, 'customer' => $customer] = settingsSetup(); $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); Livewire::actingAs($user)->test(Settings::class) ->set('customerType', App\Models\Customer::TYPE_CONSUMER) ->call('saveProfile') ->assertHasNoErrors(); expect($customer->fresh()->customer_type)->toBe(App\Models\Customer::TYPE_BUSINESS); }); it('lets a record created before the question was asked answer it once', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); $customer->update(['customer_type' => null]); Livewire::actingAs($user)->test(Settings::class) ->set('customerType', App\Models\Customer::TYPE_BUSINESS) ->call('saveProfile'); expect($customer->fresh()->customer_type)->toBe(App\Models\Customer::TYPE_BUSINESS); // And then it is fixed like every other. Livewire::actingAs($user)->test(Settings::class) ->set('customerType', App\Models\Customer::TYPE_CONSUMER) ->call('saveProfile'); expect($customer->fresh()->customer_type)->toBe(App\Models\Customer::TYPE_BUSINESS); }); it('shows a recorded type as a fact, with no way to change it', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); $page = Livewire::actingAs($user)->test(Settings::class)->html(); expect($page)->toContain(__('settings.customer_type_locked')) // No radio to press. ->and($page)->not->toContain('wire:model="customerType"'); }); it('saves branding and resolves defaults when unset', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); // Unset → resolver reports defaults. expect($customer->brandingResolved()['is_default'])->toBeTrue(); Livewire::actingAs($user)->test(Settings::class) ->set('brandDisplayName', 'Acme Cloud') ->set('brandPrimary', '#123456') ->call('saveBranding') ->assertHasNoErrors(); $customer->refresh(); expect($customer->brand_display_name)->toBe('Acme Cloud') ->and($customer->brandingResolved()['primary_color'])->toBe('#123456') ->and($customer->brandingResolved()['is_default'])->toBeFalse(); }); it('rejects an invalid brand colour', function () { ['user' => $user] = settingsSetup(); Livewire::actingAs($user)->test(Settings::class) ->set('brandPrimary', 'notacolor') ->call('saveBranding') ->assertHasErrors(['brandPrimary']); }); it('schedules package cancellation only with the correct typed confirmation', function () { $stripe = new FakeStripeClient; app()->instance(StripeClient::class, $stripe); ['user' => $user, 'customer' => $customer] = settingsSetup(); $instance = $customer->instances()->first(); // A YEARLY contract, because the assertion below is a date and a monthly // assumption would land eleven months early. This test used to assert only // that `service_ends_at` was not null — a column the code had just written, // which the broken monthly arithmetic satisfied exactly as well as the right // answer does. $contract = Subscription::factory()->plan('team', Subscription::TERM_YEARLY)->create([ 'customer_id' => $customer->id, 'order_id' => $instance->order_id, 'instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_settings', 'current_period_start' => '2026-01-01 00:00:00', 'current_period_end' => '2027-01-01 00:00:00', ]); // Wrong confirmation → no change, and Stripe is not told anything either. Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'wrong') ->call('cancelPackage') ->assertHasErrors(['confirmName']); expect($instance->fresh()->status)->toBe('active') ->and($stripe->cancellations)->toBeEmpty(); // Correct confirmation and a reason → scheduled. The reason is required: // a departure with none on it loses the one fact about it worth having, // and "we can ask later" means never. Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'acme') ->call('cancelPackage') ->assertHasErrors(['reason']); expect($instance->fresh()->status)->toBe('active'); Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'acme') ->set('reason', 'too_expensive') ->call('cancelPackage'); $instance->refresh(); expect($instance->status)->toBe('cancellation_scheduled') // The end of the term that was PAID FOR, stated as a date somebody chose. ->and($instance->service_ends_at->toDateTimeString())->toBe('2027-01-01 00:00:00'); // The contract knows it was cancelled — and is still active, because until // the first of January this is a paying customer with a running cloud. expect($contract->fresh()->cancel_requested_at)->not->toBeNull() ->and($contract->fresh()->status)->toBe('active'); // And Stripe was told to stop, at the period end and not now. expect($stripe->cancellations)->toHaveCount(1) ->and($stripe->cancellations[0]['subscription'])->toBe('sub_settings') ->and($stripe->cancellations[0]['when'])->toBe(StripeClient::CANCEL_AT_PERIOD_END); }); it('blocks closing the account while a package is active', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); Livewire::actingAs($user)->test(ConfirmCloseAccount::class) ->set('confirmWord', __('settings.close_keyword')) ->call('closeAccount') ->assertHasErrors(['confirmWord']); expect($customer->fresh()->closed_at)->toBeNull(); }); it('closes the account when no package is active', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(withInstance: false); Livewire::actingAs($user)->test(ConfirmCloseAccount::class) ->set('confirmWord', __('settings.close_keyword')) ->call('closeAccount'); expect($customer->fresh()->closed_at)->not->toBeNull(); }); // ---- Four tabs, and the choice in the address ---- it('opens on the customer own details and says so in the address', function () { // The page was one column 768px wide inside a 1240px shell, holding the // company address, the password, two-factor, the devices, the branding and // the contract — two thousand pixels of scroll, and finding a thing meant // knowing how far down it lived. $user = App\Models\User::factory()->create(['email_verified_at' => now()]); Livewire::actingAs($user)->test(App\Livewire\Settings::class) ->assertSet('tab', 'profile') ->assertSee(__('settings.company_title')) // Not the other tabs' content. ->assertDontSee(__('settings.branding_title')) ->assertDontSee(__('settings.package_title')); }); it('lands on the tab the address names', function () { $user = App\Models\User::factory()->create(['email_verified_at' => now()]); Livewire::actingAs($user)->test(App\Livewire\Settings::class, ['tab' => 'branding']) ->assertSet('tab', 'branding') ->assertSee(__('settings.branding_title')) ->assertDontSee(__('settings.company_title')); }); it('falls back to the first tab when the address names one nobody has', function () { // A tab name out of the URL is a string a stranger typed; the alternative // is a settings page with no section on it at all. $user = App\Models\User::factory()->create(['email_verified_at' => now()]); Livewire::actingAs($user)->test(App\Livewire\Settings::class, ['tab' => 'gibt-es-nicht']) ->assertSet('tab', 'profile') ->assertSee(__('settings.company_title')); }); it('writes the tab into the query string, so a link can name one', function () { // history: true and no `except` — the parameter is there from the first // render. Hidden on the default, a link to this page could not say which // tab it meant unless the tab happened not to be the first. $component = Illuminate\Support\Facades\File::get(app_path('Livewire/Settings.php')); expect($component)->toContain('#[Url(history: true)]') // The property and its default, matched without spelling a PHP // variable inside a quoted string. ->and($component)->toContain('public string $tab') ->and($component)->toContain("= 'profile';") ->and($component)->not->toContain('except:'); }); it('keeps every tab reachable from the bar', function () { // The list IS the schema: it validates the query string, builds the bar and // decides which section renders. $user = App\Models\User::factory()->create(['email_verified_at' => now()]); $page = Livewire::actingAs($user)->test(App\Livewire\Settings::class); foreach (App\Livewire\Settings::TABS as $tab) { $page->assertSee(__('settings.tab.'.$tab)); } }); it('uses the width it is given', function () { // 768px in a 1240px shell left a third of the window empty beside a column // nobody could read the end of. expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/settings.blade.php'))) ->not->toContain('max-w-3xl'); }); it('records why, and refuses "other" with nothing beside it', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); $instance = $customer->instances()->where('status', 'active')->first(); $contract = App\Models\Subscription::factory()->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, 'status' => 'active', ]); Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', $instance->subdomain) ->set('reason', 'other') ->call('cancelPackage') ->assertHasErrors(['note']); expect($instance->fresh()->status)->toBe('active'); Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', $instance->subdomain) ->set('reason', 'other') ->set('note', 'Wir ziehen intern um.') ->call('cancelPackage'); expect($contract->fresh()->cancel_reason)->toBe('other') ->and($contract->fresh()->cancel_reason_note)->toBe('Wir ziehen intern um.'); }); it('takes no reason it was not offered', function () { // The select is a select; the request is a request. ['user' => $user, 'customer' => $customer] = settingsSetup(); $instance = $customer->instances()->where('status', 'active')->first(); Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', $instance->subdomain) ->set('reason', 'weil-ich-es-kann') ->call('cancelPackage') ->assertHasErrors(['reason']); expect($instance->fresh()->status)->toBe('active'); }); it('offers the withdrawal as a way out of the cancellation, while it is open', function () { // Withdrawing ends the service the same day and sends the WHOLE amount // back; cancelling keeps the term that was paid for. Somebody entitled to // the first should not lose it by taking the second unasked. ['user' => $user, 'customer' => $customer] = settingsSetup(); $customer->update(['customer_type' => App\Models\Customer::TYPE_CONSUMER]); $instance = $customer->instances()->where('status', 'active')->first(); App\Models\Subscription::factory()->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, 'status' => 'active', 'started_at' => now()->subDay(), 'withdrawal_ends_at' => now()->addDays(13), ]); $page = Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html(); expect($page)->toContain(__('withdrawal.instead_title')) ->and($page)->toContain('confirm-withdraw'); }); it('never offers a business the withdrawal it does not have', function () { ['user' => $user, 'customer' => $customer] = settingsSetup(); $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); $instance = $customer->instances()->where('status', 'active')->first(); App\Models\Subscription::factory()->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, 'status' => 'active', 'started_at' => now()->subDay(), 'withdrawal_ends_at' => now()->addDays(13), ]); expect(Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html()) ->not->toContain(__('withdrawal.instead_title')); });