240 lines
10 KiB
PHP
240 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Livewire\ConfirmCancelPackage;
|
|
use App\Livewire\ConfirmCloseAccount;
|
|
use App\Livewire\Settings;
|
|
use App\Models\Customer;
|
|
use App\Models\Instance;
|
|
use App\Models\Order;
|
|
use App\Models\Subscription;
|
|
use App\Models\User;
|
|
use App\Services\Stripe\FakeStripeClient;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Livewire\Livewire;
|
|
|
|
function settingsSetup(bool $withInstance = true): array
|
|
{
|
|
$user = User::factory()->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('billingAddress', "Hauptstraße 1\n1010 Wien")
|
|
->call('saveProfile')
|
|
->assertHasNoErrors();
|
|
|
|
$customer->refresh();
|
|
expect($customer->name)->toBe('Acme GmbH')
|
|
->and($customer->vat_id)->toBe('ATU12345678')
|
|
->and($customer->billing_address)->toContain('Wien');
|
|
});
|
|
|
|
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 → scheduled.
|
|
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
|
|
->set('confirmName', 'acme')
|
|
->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');
|
|
});
|