Implement Phase 1: Foundation & Core Setup

Complete foundation for the EZSCALE billing platform replacing WHMCS:

- Install Composer deps (Fortify, Passport, Cashier, PayPal, Spatie Permissions, Inertia)
- Install Vue 3 + Inertia.js with Vite, 3 layouts (App, Auth, Admin)
- Configure subdomain routing (marketing, account, admin) with domain-based route files
- Create 30 database migrations (15 custom tables + package defaults)
- Create 14 Eloquent models with relationships, factories, and encrypted casts
- Set up Fortify auth with 7 Vue pages (Login, Register, ForgotPassword, ResetPassword, VerifyEmail, ConfirmPassword, TwoFactorChallenge)
- Add 2FA TOTP setup page with QR code and recovery codes
- Configure middleware (Inertia, Spatie roles/permissions, EnsureUserNotSuspended)
- Create seeders for roles/permissions, sample plans, and admin user
- Build dashboard controllers and Vue pages for customer and admin panels
- Add 4 shared Vue components (Card, Button, NavLink, FlashMessages)
- Generate Passport OAuth2 keys for future SSO/API use
- Write 24 Pest tests (auth, role-based access, models) — all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 02:50:46 -05:00
parent cf7669f270
commit 26704f9721
130 changed files with 6862 additions and 230 deletions

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
});
it('renders the login page', function (): void {
$this->get($this->accountUrl.'/login')
->assertOk();
});
it('authenticates a user with valid credentials', function (): void {
$user = User::factory()->create();
$this->post($this->accountUrl.'/login', [
'email' => $user->email,
'password' => 'password',
])->assertRedirect();
$this->assertAuthenticated();
});
it('rejects invalid credentials', function (): void {
$user = User::factory()->create();
$this->post($this->accountUrl.'/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
});
it('logs a user out', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->post($this->accountUrl.'/logout')
->assertRedirect();
$this->assertGuest();
});

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
});
it('renders the registration page', function (): void {
$this->get($this->accountUrl.'/register')
->assertOk();
});
it('registers a new user', function (): void {
$this->post($this->accountUrl.'/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123!',
'password_confirmation' => 'password123!',
])->assertRedirect();
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
]);
});
it('assigns customer role on registration', function (): void {
$this->post($this->accountUrl.'/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123!',
'password_confirmation' => 'password123!',
]);
$user = User::where('email', 'test@example.com')->first();
expect($user->hasRole('customer'))->toBeTrue();
});
it('creates user profile on registration', function (): void {
$this->post($this->accountUrl.'/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123!',
'password_confirmation' => 'password123!',
]);
$user = User::where('email', 'test@example.com')->first();
expect($user->profile)->not->toBeNull();
});
it('validates registration input', function (): void {
$this->post($this->accountUrl.'/register', [
'name' => '',
'email' => 'not-an-email',
'password' => 'short',
'password_confirmation' => 'mismatch',
])->assertSessionHasErrors(['name', 'email', 'password']);
});
it('prevents duplicate email registration', function (): void {
User::factory()->create(['email' => 'taken@example.com']);
$this->post($this->accountUrl.'/register', [
'name' => 'Test User',
'email' => 'taken@example.com',
'password' => 'password123!',
'password_confirmation' => 'password123!',
])->assertSessionHasErrors('email');
});

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->accountUrl = 'http://'.config('app.domains.account');
});
it('redirects unauthenticated users to login', function (): void {
$this->get($this->accountUrl.'/dashboard')
->assertRedirect($this->accountUrl.'/login');
});
it('identifies admin users correctly', function (): void {
$admin = User::factory()->admin()->create();
expect($admin->isAdmin())->toBeTrue();
expect($admin->isCustomer())->toBeFalse();
});
it('identifies customer users correctly', function (): void {
$customer = User::factory()->customer()->create();
expect($customer->isCustomer())->toBeTrue();
expect($customer->isAdmin())->toBeFalse();
});
it('identifies suspended users correctly', function (): void {
$user = User::factory()->suspended()->create();
expect($user->isSuspended())->toBeTrue();
expect($user->isBanned())->toBeFalse();
});
it('identifies banned users correctly', function (): void {
$user = User::factory()->banned()->create();
expect($user->isBanned())->toBeTrue();
expect($user->isSuspended())->toBeFalse();
});

View File

@@ -1,7 +1,8 @@
<?php
test('the application returns a successful response', function () {
$response = $this->get('/');
declare(strict_types=1);
$response->assertStatus(200);
test('the application redirects to account login', function () {
$this->get('/')
->assertRedirect('https://'.config('app.domains.account').'/login');
});

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Models\Plan;
it('creates a plan with factory', function (): void {
$plan = Plan::factory()->create();
expect($plan)->toBeInstanceOf(Plan::class);
expect($plan->status)->toBe('active');
});
it('casts features to array', function (): void {
$plan = Plan::factory()->create([
'features' => ['cpu' => '2 vCPU', 'ram' => '4GB'],
]);
expect($plan->features)->toBeArray();
expect($plan->features['cpu'])->toBe('2 vCPU');
});
it('checks availability correctly', function (): void {
$plan = Plan::factory()->create(['status' => 'active', 'stock_quantity' => null]);
expect($plan->isAvailable())->toBeTrue();
$plan = Plan::factory()->create(['status' => 'hidden']);
expect($plan->isAvailable())->toBeFalse();
$plan = Plan::factory()->create(['status' => 'active', 'stock_quantity' => 0]);
expect($plan->isAvailable())->toBeFalse();
});

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\UserProfile;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
});
it('has correct fillable attributes', function (): void {
$user = new User;
expect($user->getFillable())->toBe([
'name', 'email', 'password', 'status', 'phone', 'company',
]);
});
it('has correct hidden attributes', function (): void {
$user = new User;
expect($user->getHidden())->toBe([
'password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes',
]);
});
it('has profile relationship', function (): void {
$user = User::factory()->create();
$profile = UserProfile::factory()->create(['user_id' => $user->id]);
expect($user->profile)->toBeInstanceOf(UserProfile::class);
expect($user->profile->id)->toBe($profile->id);
});
it('creates user with factory', function (): void {
$user = User::factory()->create();
expect($user)->toBeInstanceOf(User::class);
expect($user->status)->toBe('active');
});

View File

@@ -1,47 +1,5 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
pest()->extend(Tests\TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}