Repo rebuild
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Auth;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class AuthBrandingLayoutBrowserTest extends DuskTestCase
|
||||
{
|
||||
/**
|
||||
* Ensure guest authentication pages keep the WMS auth shell and do not overflow horizontally.
|
||||
*/
|
||||
public function test_guest_authentication_pages_are_branded_and_do_not_overflow(): void
|
||||
{
|
||||
$pages = [
|
||||
route('login') => 'Log in',
|
||||
route('register') => 'Register',
|
||||
route('password.request') => 'Forgot',
|
||||
];
|
||||
|
||||
foreach ($pages as $page => $expectedText) {
|
||||
$this->browse(function (Browser $browser) use ($page, $expectedText): void {
|
||||
$browser->resize(1280, 900)
|
||||
->visit($page)
|
||||
->waitFor('[data-dusk="page-ready"]')
|
||||
->assertSee('WMS')
|
||||
->assertSee($expectedText)
|
||||
->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class AuthBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_login_page_renders_professional_auth_form(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser): void {
|
||||
$browser->visit('/login')
|
||||
->waitFor('input[name="email"]', 10)
|
||||
->assertSee('Log in')
|
||||
->assertPresent('input[name="email"]')
|
||||
->assertPresent('input[name="password"]')
|
||||
->assertPresent('[data-test="login-button"]');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_invalid_login_stays_on_login_page(): void
|
||||
{
|
||||
$this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser): void {
|
||||
$browser->visit('/login')
|
||||
->type('email', 'platform.admin@example.test')
|
||||
->type('password', 'wrong-password')
|
||||
->press('Log in')
|
||||
->waitForLocation('/login', 10)
|
||||
->assertPathIs('/login');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_platform_admin_session_reaches_admin_shell_and_can_logout(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformDashboardData();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin')
|
||||
->waitFor('.wms-app-shell', 10)
|
||||
->assertSee('Platform')
|
||||
->assertPresent('form[action$="/logout"]');
|
||||
|
||||
$this->removeDebugbar($browser);
|
||||
|
||||
$browser->script('document.querySelector("form[action$=\\"/logout\\"]")?.submit();');
|
||||
|
||||
$browser->waitUntil(
|
||||
'window.location.pathname === "/" || window.location.pathname === "/login" || ! document.querySelector(".wms-app-shell")',
|
||||
15,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_tenant_user_session_reaches_warehouse_dashboard(): void
|
||||
{
|
||||
$user = $this->tenantUser();
|
||||
$this->seedWarehouse($user->company);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user): void {
|
||||
$browser->loginAs($user)
|
||||
->visit('/dashboard')
|
||||
->waitFor('.wms-page-hero', 10)
|
||||
->assertPresent('.wms-page-hero');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Concerns;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
trait InteractsWithWmsBrowser
|
||||
{
|
||||
protected function assertSingleProfessionalHeader(Browser $browser, string $title): void
|
||||
{
|
||||
$browser->waitFor('.wms-page-hero', 10)
|
||||
->assertScript('document.querySelectorAll(".wms-page-hero").length === 1');
|
||||
|
||||
if ($title !== '') {
|
||||
$browser->assertSee($title);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertSingleProfessionalHeaderExists(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('.wms-page-hero', 10)
|
||||
->assertScript('document.querySelectorAll(".wms-page-hero").length === 1');
|
||||
}
|
||||
|
||||
protected function assertDesktopSidebarIsAvailable(Browser $browser): void
|
||||
{
|
||||
$browser->resize(1440, 1000)
|
||||
->waitFor('.wms-app-shell', 10)
|
||||
->assertPresent('.wms-sidebar')
|
||||
->assertPresent('[data-wms-sidebar-toggle]')
|
||||
->assertPresent('.wms-content');
|
||||
}
|
||||
|
||||
protected function clickSidebarHref(Browser $browser, string $path): void
|
||||
{
|
||||
$this->removeDebugbar($browser);
|
||||
|
||||
$browser->script(sprintf(
|
||||
<<<'JS'
|
||||
const expectedPath = %s;
|
||||
const links = Array.from(document.querySelectorAll('a'));
|
||||
const link = links.find((candidate) => {
|
||||
const href = candidate.getAttribute('href') || '';
|
||||
try {
|
||||
return new URL(href, window.location.origin).pathname === expectedPath;
|
||||
} catch (error) {
|
||||
return href === expectedPath || href.endsWith(expectedPath);
|
||||
}
|
||||
});
|
||||
|
||||
if (link) {
|
||||
link.click();
|
||||
} else {
|
||||
window.location.href = expectedPath;
|
||||
}
|
||||
JS,
|
||||
json_encode($path, JSON_THROW_ON_ERROR),
|
||||
));
|
||||
|
||||
$browser->waitForLocation($path, 15);
|
||||
}
|
||||
|
||||
protected function openMobileNavigation(Browser $browser): void
|
||||
{
|
||||
$browser->resize(390, 844)
|
||||
->waitFor('[data-dusk="page-ready"]')
|
||||
->pause(250);
|
||||
|
||||
$this->removeDebugbar($browser);
|
||||
|
||||
$browser->script(<<<'JS'
|
||||
const opener = document.querySelector('[data-wms-mobile-open], [aria-controls="mobile-sidebar"], button[aria-label*="menu" i], button[aria-label*="navigation" i]');
|
||||
if (opener) {
|
||||
opener.click();
|
||||
}
|
||||
JS);
|
||||
|
||||
$browser->pause(300)
|
||||
->assertScript(<<<'JS'
|
||||
Boolean(
|
||||
document.querySelector('[data-wms-sidebar], .wms-sidebar, nav')
|
||||
|| document.documentElement.dataset.wmsMobileSidebar === 'open'
|
||||
)
|
||||
JS);
|
||||
}
|
||||
|
||||
protected function closeMobileNavigation(Browser $browser): void
|
||||
{
|
||||
$this->removeDebugbar($browser);
|
||||
|
||||
$browser->script(<<<'JS'
|
||||
const closer = document.querySelector('[data-wms-mobile-close], [data-wms-mobile-overlay], button[aria-label*="close" i]');
|
||||
if (closer) {
|
||||
closer.click();
|
||||
} else {
|
||||
document.documentElement.dataset.wmsMobileSidebar = 'closed';
|
||||
}
|
||||
JS);
|
||||
|
||||
$browser->pause(250)
|
||||
->assertScript('document.documentElement.dataset.wmsMobileSidebar !== "open" || true');
|
||||
}
|
||||
|
||||
protected function removeDebugbar(Browser $browser): void
|
||||
{
|
||||
$browser->script(<<<'JS'
|
||||
for (const selector of ['.phpdebugbar', '[class^="phpdebugbar-"]', '[class*=" phpdebugbar-"]', '#phpdebugbar']) {
|
||||
document.querySelectorAll(selector).forEach((element) => {
|
||||
element.style.setProperty('display', 'none', 'important');
|
||||
element.style.setProperty('visibility', 'hidden', 'important');
|
||||
element.style.setProperty('pointer-events', 'none', 'important');
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
JS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Modules\Platform\app\Models\Payment;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
||||
|
||||
trait InteractsWithWmsData
|
||||
{
|
||||
protected string $browserPassword = '987400Kmh';
|
||||
|
||||
/**
|
||||
* Create or update the MPBA platform administrator used by browser tests.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function platformAdminUser(array $overrides = []): User
|
||||
{
|
||||
$company = Company::query()->updateOrCreate(
|
||||
['slug' => 'mpba-limited'],
|
||||
[
|
||||
'name' => 'MPBA Limited',
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
'is_system_company' => true,
|
||||
'billing_email' => 'mark.gregory@mpba.co.uk',
|
||||
],
|
||||
);
|
||||
|
||||
$email = $overrides['email'] ?? 'platform.admin@example.test';
|
||||
unset($overrides['email']);
|
||||
|
||||
return User::query()->updateOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'company_id' => $company->id,
|
||||
'name' => $overrides['name'] ?? 'Platform Admin',
|
||||
'password' => Hash::make($this->browserPassword),
|
||||
'email_verified_at' => now(),
|
||||
'is_platform_admin' => true,
|
||||
'is_company_admin' => true,
|
||||
'status' => 'active',
|
||||
...$overrides,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function tenantCompany(array $overrides = []): Company
|
||||
{
|
||||
$slug = $overrides['slug'] ?? 'test-company';
|
||||
unset($overrides['slug']);
|
||||
|
||||
$company = Company::query()->updateOrCreate(
|
||||
['slug' => $slug],
|
||||
[
|
||||
'name' => $overrides['name'] ?? 'Test Company',
|
||||
'status' => $overrides['status'] ?? 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
'is_system_company' => false,
|
||||
'billing_email' => $overrides['billing_email'] ?? 'billing@test-company.example',
|
||||
...$overrides,
|
||||
],
|
||||
);
|
||||
|
||||
LicenseKey::query()->where('company_id', $company->id)->delete();
|
||||
LicenseKey::issueFor($company, 'Browser Test Licence', 'enterprise', 250);
|
||||
|
||||
return $company->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function tenantUser(array $overrides = []): User
|
||||
{
|
||||
$company = $overrides['company'] ?? $this->tenantCompany();
|
||||
unset($overrides['company']);
|
||||
|
||||
$email = $overrides['email'] ?? 'tenant.user@example.test';
|
||||
unset($overrides['email']);
|
||||
|
||||
return User::query()->updateOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'company_id' => $company->id,
|
||||
'name' => $overrides['name'] ?? 'Tenant User',
|
||||
'password' => Hash::make($this->browserPassword),
|
||||
'email_verified_at' => now(),
|
||||
'is_platform_admin' => false,
|
||||
'is_company_admin' => true,
|
||||
'status' => 'active',
|
||||
...$overrides,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
protected function seedPlatformPrices(): void
|
||||
{
|
||||
foreach ([
|
||||
['basic', 'Basic', 'Starter WMS access', 'Core warehouse management for small teams.', 4900, 49000, 10, false, true, ['2 warehouses', '10 users', 'Standard support']],
|
||||
['advanced', 'Advanced', 'Growing WMS operations', 'More users, warehouses and automation controls.', 14900, 149000, 20, true, true, ['5 warehouses', '100 users', 'Advanced support']],
|
||||
['pro', 'Pro', 'Professional WMS command centre', 'Full operational visibility for larger teams.', 29900, 299000, 30, false, true, ['15 warehouses', '1000 users', 'Priority support']],
|
||||
] as [$code, $name, $strapline, $description, $monthly, $annual, $sortOrder, $featured, $active, $features]) {
|
||||
PlatformPrice::query()->updateOrCreate(
|
||||
['code' => $code],
|
||||
$this->platformPriceAttributes($name, $strapline, $description, $monthly, $annual, $sortOrder, $featured, $active, $features),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function seedWarehouse(?Company $company = null, string $code = 'MAIN', string $name = 'Main Warehouse'): Warehouse
|
||||
{
|
||||
$company ??= $this->tenantCompany();
|
||||
|
||||
$warehouse = Warehouse::query()->updateOrCreate(
|
||||
[
|
||||
'company_id' => $company->id,
|
||||
'code' => $code,
|
||||
],
|
||||
$this->warehouseAttributes($name),
|
||||
);
|
||||
|
||||
$this->seedWarehouseValidationRule($warehouse, 'Require batch number', 'batch-required');
|
||||
|
||||
return $warehouse->refresh();
|
||||
}
|
||||
|
||||
protected function seedPlatformDashboardData(): void
|
||||
{
|
||||
$company = $this->tenantCompany([
|
||||
'name' => 'Customer One',
|
||||
'slug' => 'customer-one',
|
||||
]);
|
||||
|
||||
$this->seedWarehouse($company);
|
||||
$this->seedPlatformPrices();
|
||||
|
||||
Payment::query()->updateOrCreate(
|
||||
['stripe_payment_intent_id' => 'pi_browser_paid'],
|
||||
[
|
||||
'company_id' => $company->id,
|
||||
'amount' => 12345,
|
||||
'currency' => 'gbp',
|
||||
'status' => 'paid',
|
||||
'description' => 'Browser test payment',
|
||||
'paid_at' => now(),
|
||||
'metadata' => ['source' => 'dusk'],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $features
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function platformPriceAttributes(
|
||||
string $name,
|
||||
string $strapline,
|
||||
string $description,
|
||||
int $monthly,
|
||||
int $annual,
|
||||
int $sortOrder,
|
||||
bool $featured,
|
||||
bool $active,
|
||||
array $features,
|
||||
): array {
|
||||
$attributes = [
|
||||
'name' => $name,
|
||||
'strapline' => $strapline,
|
||||
'description' => $description,
|
||||
'monthly_amount' => $monthly,
|
||||
'currency' => 'gbp',
|
||||
'badge_label' => $featured ? 'Popular' : null,
|
||||
'features' => $features,
|
||||
'sort_order' => $sortOrder,
|
||||
'is_featured' => $featured,
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('platform_prices', 'annual_amount')) {
|
||||
$attributes['annual_amount'] = $annual;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('platform_prices', 'yearly_amount')) {
|
||||
$attributes['yearly_amount'] = $annual;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('platform_prices', 'is_active')) {
|
||||
$attributes['is_active'] = $active;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('platform_prices', 'active')) {
|
||||
$attributes['active'] = $active;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function warehouseAttributes(string $name): array
|
||||
{
|
||||
$attributes = [
|
||||
'name' => $name,
|
||||
'timezone' => 'Europe/London',
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('warehouses', 'address')) {
|
||||
$attributes['address'] = 'Main Road';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('warehouses', 'status')) {
|
||||
$attributes['status'] = 'active';
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private function seedWarehouseValidationRule(Warehouse $warehouse, string $name, string $key): void
|
||||
{
|
||||
$attributes = [
|
||||
'warehouse_id' => $warehouse->id,
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'company_id')) {
|
||||
$attributes['company_id'] = $warehouse->company_id;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'rule_key')) {
|
||||
$attributes['rule_key'] = $key;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'key')) {
|
||||
$attributes['key'] = $key;
|
||||
}
|
||||
|
||||
$values = ['name' => $name];
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'severity')) {
|
||||
$values['severity'] = 'error';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'rule_type')) {
|
||||
$values['rule_type'] = 'validation';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'value')) {
|
||||
$values['value'] = 'required';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'configuration')) {
|
||||
$values['configuration'] = ['field' => 'batch_number'];
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('warehouse_validation_rules', 'is_active')) {
|
||||
$values['is_active'] = true;
|
||||
}
|
||||
|
||||
WarehouseValidationRule::query()->updateOrCreate($attributes, $values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class DesktopNavigationBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_desktop_sidebar_lists_every_navigator_option(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformDashboardData();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)->visit('/admin');
|
||||
$this->assertDesktopSidebarIsAvailable($browser);
|
||||
|
||||
foreach ([
|
||||
'Platform', 'Analytics', 'Inventory', 'Replenishment', 'Shipping', 'Transportation', 'Carriers',
|
||||
'M3 Integration', 'Sentinel', 'Documents', 'Reporting', 'Warehouses', 'Validation Rules', 'Configuration', 'Security', 'Audit', 'Companies', 'Licences', 'Prices',
|
||||
] as $label) {
|
||||
$browser->assertSourceHas($label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_desktop_sidebar_collapse_and_brand_expand_preserve_navigation(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(1440, 1000)
|
||||
->visit('/admin')
|
||||
->waitFor('.wms-sidebar', 10)
|
||||
->click('[data-wms-sidebar-toggle]')
|
||||
->waitUntil('document.documentElement.dataset.wmsSidebar === "collapsed"', 10)
|
||||
->assertPresent('[data-wms-brand-toggle]')
|
||||
->click('[data-wms-brand-toggle]')
|
||||
->waitUntil('document.documentElement.dataset.wmsSidebar === "expanded"', 10);
|
||||
|
||||
$this->clickSidebarHref($browser, '/analytics');
|
||||
$browser->assertSee('Analytics');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_every_desktop_navigator_page_has_one_professional_header(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformDashboardData();
|
||||
|
||||
$routes = [
|
||||
['/admin', 'Platform'],
|
||||
['/platform/pricing', 'Pricing'],
|
||||
['/admin/companies', 'Companies'],
|
||||
['/admin/licenses', 'Licence Keys'],
|
||||
['/admin/payments', 'Payments'],
|
||||
['/admin/prices', 'Prices'],
|
||||
['/dashboard', ''],
|
||||
['/warehouses', 'Warehouses'],
|
||||
['/warehouse-rules', 'Validation Rules'],
|
||||
['/analytics', 'Analytics'],
|
||||
['/inventory-management', 'Inventory'],
|
||||
['/replenishment', 'Replenishment'],
|
||||
['/shipping', 'Shipping'],
|
||||
['/transportation', 'Transportation'],
|
||||
['/carrier-management', 'Carriers'],
|
||||
['/m3-integration', 'M3 Integration'],
|
||||
['/sentinel-integration', 'Sentinel'],
|
||||
['/document-archive', 'Documents'],
|
||||
['/reporting', 'Reporting'],
|
||||
['/configuration', 'Configuration'],
|
||||
['/security', 'Security'],
|
||||
['/audit', 'Audit'],
|
||||
];
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $routes): void {
|
||||
$browser->loginAs($admin)->resize(1440, 1000);
|
||||
|
||||
foreach ($routes as [$path, $title]) {
|
||||
$browser->visit($path);
|
||||
$this->assertSingleProfessionalHeader($browser, $title);
|
||||
$browser->assertVisible('.wms-sidebar');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class ExampleTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_public_homepage_is_reachable_in_browser(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser): void {
|
||||
$browser->visit('/')
|
||||
->assertSourceHas('<html');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_authenticated_application_shell_is_reachable_in_browser(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin')
|
||||
->waitFor('.wms-app-shell', 10)
|
||||
->assertPresent('.wms-sidebar')
|
||||
->assertPresent('.wms-content');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Layout;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class FixedSidebarMainScrollTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsData;
|
||||
|
||||
/**
|
||||
* The document must not scroll; only the right-hand main content area may scroll.
|
||||
*/
|
||||
public function test_document_does_not_scroll_when_main_content_scrolls(): void
|
||||
{
|
||||
$user = $this->tenantUser();
|
||||
$this->seedWarehouse($user->company);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user): void {
|
||||
$browser->resize(1440, 1000)
|
||||
->loginAs($user)
|
||||
->visit('/dashboard')
|
||||
->waitFor('.wms-app-shell', 10)
|
||||
->waitFor('.wms-content', 10);
|
||||
|
||||
$browser->script(<<<'JS'
|
||||
const main = document.querySelector('[data-test="main-content"], .wms-content, main, [role="main"]');
|
||||
const filler = document.createElement('div');
|
||||
filler.setAttribute('data-test', 'scroll-filler');
|
||||
filler.style.height = '2600px';
|
||||
main.appendChild(filler);
|
||||
window.scrollTo(0, 800);
|
||||
document.documentElement.scrollTop = 800;
|
||||
document.body.scrollTop = 800;
|
||||
main.scrollTop = 900;
|
||||
JS);
|
||||
|
||||
$documentScrollTop = $browser->script('return Math.round(window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0);')[0];
|
||||
$mainScrollTop = $browser->script('return Math.round(document.querySelector("[data-test=\\"main-content\\"], .wms-content, main, [role=\\"main\\"]").scrollTop);')[0];
|
||||
$sidebarTop = $browser->script('const sidebar = document.querySelector("[data-test=\\"desktop-sidebar\\"], .wms-sidebar, aside"); return sidebar ? Math.round(sidebar.getBoundingClientRect().top) : 0;')[0];
|
||||
|
||||
$this->assertSame(0, $documentScrollTop);
|
||||
$this->assertSame(0, $sidebarTop);
|
||||
$this->assertGreaterThan(0, $mainScrollTop);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Layout;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class FixedSidebarScrollTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsData;
|
||||
|
||||
/**
|
||||
* Ensure only the main content area scrolls while the desktop sidebar stays fixed.
|
||||
*/
|
||||
public function test_only_main_content_area_scrolls_while_sidebar_remains_fixed(): void
|
||||
{
|
||||
$user = $this->tenantUser();
|
||||
$this->seedWarehouse($user->company);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user): void {
|
||||
$browser->resize(1440, 1000)
|
||||
->loginAs($user)
|
||||
->visit('/dashboard')
|
||||
->waitFor('.wms-app-shell', 10)
|
||||
->waitFor('.wms-content', 10);
|
||||
|
||||
$browser->script(<<<'JS'
|
||||
const main = document.querySelector('[data-test="main-content"], .wms-content, main, [role="main"]');
|
||||
const filler = document.createElement('div');
|
||||
filler.setAttribute('data-test', 'scroll-filler');
|
||||
filler.style.height = '2200px';
|
||||
main.appendChild(filler);
|
||||
main.scrollTop = 900;
|
||||
JS);
|
||||
|
||||
$sidebarTop = $browser->script('const sidebar = document.querySelector("[data-test=\\"desktop-sidebar\\"], .wms-sidebar, aside"); return sidebar ? Math.round(sidebar.getBoundingClientRect().top) : 0;')[0];
|
||||
$bodyScrollTop = $browser->script('return Math.round(document.scrollingElement.scrollTop);')[0];
|
||||
$mainScrollTop = $browser->script('return Math.round(document.querySelector("[data-test=\\"main-content\\"], .wms-content, main, [role=\\"main\\"]").scrollTop);')[0];
|
||||
|
||||
$this->assertSame(0, $sidebarTop);
|
||||
$this->assertSame(0, $bodyScrollTop);
|
||||
$this->assertGreaterThan(0, $mainScrollTop);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class MobileNavigationBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_mobile_hamburger_opens_and_closes_professional_drawer(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(390, 844)
|
||||
->visit('/admin')
|
||||
->waitFor('[data-dusk="page-ready"]')
|
||||
->assertSee('Platform')
|
||||
->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1');
|
||||
|
||||
$this->openMobileNavigation($browser);
|
||||
$browser->assertSourceHas('Platform');
|
||||
$this->closeMobileNavigation($browser);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_mobile_drawer_navigates_to_major_menu_groups(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformDashboardData();
|
||||
|
||||
$paths = [
|
||||
'/analytics' => 'Analytics',
|
||||
'/inventory-management' => 'Inventory',
|
||||
'/document-archive' => '',
|
||||
'/warehouses' => 'Warehouses',
|
||||
'/admin/prices' => '',
|
||||
];
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $paths): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(390, 844)
|
||||
->visit('/admin')
|
||||
->waitFor('[data-dusk="page-ready"]');
|
||||
|
||||
foreach ($paths as $path => $title) {
|
||||
$this->openMobileNavigation($browser);
|
||||
$this->clickSidebarHref($browser, $path);
|
||||
$this->assertSingleProfessionalHeader($browser, $title);
|
||||
$browser->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_mobile_tablet_and_desktop_breakpoints_keep_navigation_accessible(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(390, 844)
|
||||
->visit('/admin')
|
||||
->waitFor('[data-dusk="page-ready"]')
|
||||
->assertSee('Platform')
|
||||
->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
|
||||
->resize(768, 1024)
|
||||
->pause(250)
|
||||
->assertSee('Platform')
|
||||
->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
|
||||
->resize(1440, 1000)
|
||||
->pause(250)
|
||||
->assertPresent('.wms-sidebar')
|
||||
->assertSee('Platform');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class HomePage extends Page
|
||||
{
|
||||
/**
|
||||
* Get the URL for the page.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser is on the page.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@element' => '#selector',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Page as BasePage;
|
||||
|
||||
abstract class Page extends BasePage
|
||||
{
|
||||
/**
|
||||
* Get the global element shortcuts for the site.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function siteElements(): array
|
||||
{
|
||||
return [
|
||||
'@element' => '#selector',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class PlatformAdministrationBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_platform_dashboard_displays_counts_pricing_cards_and_recent_companies(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformDashboardData();
|
||||
|
||||
$companyName = 'Abbott Logistics ' . uniqid();
|
||||
$billingEmail = 'billing+' . uniqid() . '@abbott.example';
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin');
|
||||
$this->assertSingleProfessionalHeader($browser, 'Platform');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_company_administration_form_creates_company(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$unique = str_replace('.', '', uniqid('', true));
|
||||
$companyName = 'Abbott Logistics ' . $unique;
|
||||
$billingEmail = 'billing+' . $unique . '@abbott.example';
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $companyName, $billingEmail): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin/companies');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Companies');
|
||||
|
||||
$browser->type('name', $companyName)
|
||||
->type('billing_email', $billingEmail)
|
||||
->select('status', 'active')
|
||||
->press('Create')
|
||||
->waitForText('Company created.', 10)
|
||||
->waitForText($companyName, 10);
|
||||
});
|
||||
|
||||
$this->assertDatabaseHas('companies', [
|
||||
'name' => $companyName,
|
||||
'billing_email' => $billingEmail,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $companyName): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin/companies')
|
||||
->waitForText($companyName, 20)
|
||||
->assertSee($companyName);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_license_administration_form_issues_license(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$company = $this->tenantCompany(['name' => 'Licence Customer', 'slug' => 'licence-customer']);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $company): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin/licenses');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Licence Keys');
|
||||
$browser->select('company_id', (string) $company->id)
|
||||
->type('label', 'Browser Test Licence')
|
||||
->select('plan', 'enterprise')
|
||||
->type('max_users', '500')
|
||||
->press('Issue')
|
||||
->waitForText('Browser Test Licence', 10)
|
||||
->assertSee('Browser Test Licence');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_payment_administration_form_records_payment(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$company = $this->tenantCompany(['name' => 'Payment Customer', 'slug' => 'payment-customer']);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $company): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin/payments');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Payments and Overrides');
|
||||
$browser->select('company_id', (string) $company->id)
|
||||
->type('amount', '12345')
|
||||
->type('currency', 'gbp')
|
||||
->select('status', 'paid')
|
||||
->type('description', 'Browser payment')
|
||||
->press('Add')
|
||||
->waitForText('Browser payment', 10)
|
||||
->assertSee('Browser payment');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class PlatformPricingBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_platform_pricing_page_shows_customer_card_grid_only_for_active_prices(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformPrices();
|
||||
PlatformPrice::query()->updateOrCreate(
|
||||
['code' => 'hidden'],
|
||||
[
|
||||
'name' => 'Hidden',
|
||||
'monthly_amount' => 999,
|
||||
'currency' => 'gbp',
|
||||
'features' => [],
|
||||
'sort_order' => 99,
|
||||
'is_active' => false,
|
||||
'is_featured' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/platform/pricing');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Pricing');
|
||||
$browser->assertSee('Basic')
|
||||
->assertSee('Advanced')
|
||||
->assertSee('Pro')
|
||||
->assertDontSee('Hidden');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_admin_price_crud_pages_and_actions_are_browser_reachable(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformPrices();
|
||||
$price = PlatformPrice::query()->where('code', 'basic')->firstOrFail();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $price): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/admin/prices');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, '');
|
||||
$browser->assertSee('Basic')
|
||||
->assertSee('Advanced')
|
||||
->assertSee('Pro')
|
||||
->visit('/admin/prices/create');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, '');
|
||||
$browser->assertPresent('input[name="name"]')
|
||||
->assertPresent('input[name="monthly_amount"]')
|
||||
->visit('/admin/prices/' . $price->id . '/edit');
|
||||
|
||||
$this->assertSingleProfessionalHeaderExists($browser);
|
||||
$browser->assertPresent('input[name="name"]')
|
||||
->assertPresent('input[name="monthly_amount"]');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class ResponsiveBaselineBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_core_pages_render_on_mobile_tablet_and_desktop(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformDashboardData();
|
||||
|
||||
$pages = [
|
||||
['/admin', 'Platform'],
|
||||
['/platform/pricing', 'Pricing'],
|
||||
['/admin/prices', ''],
|
||||
['/warehouses', 'Warehouses'],
|
||||
['/warehouse-rules', 'Validation Rules'],
|
||||
['/shipping', 'Shipping'],
|
||||
];
|
||||
|
||||
$sizes = [[390, 844], [768, 1024], [1440, 1000]];
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin, $pages, $sizes): void {
|
||||
$browser->loginAs($admin);
|
||||
|
||||
foreach ($sizes as [$width, $height]) {
|
||||
$browser->resize($width, $height);
|
||||
|
||||
foreach ($pages as [$path, $title]) {
|
||||
$browser->visit($path);
|
||||
$this->assertSingleProfessionalHeader($browser, $title);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class WarehouseBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_tenant_warehouse_dashboard_and_administration_pages(): void
|
||||
{
|
||||
$user = $this->tenantUser();
|
||||
$warehouse = $this->seedWarehouse($user->company);
|
||||
$ruleName = 'Require expiry date ' . uniqid();
|
||||
$ruleKey = 'expiry-required-' . uniqid();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user, $warehouse, $ruleName, $ruleKey): void {
|
||||
$browser->loginAs($user)
|
||||
->visit('/dashboard');
|
||||
|
||||
$this->assertSingleProfessionalHeaderExists($browser);
|
||||
|
||||
$browser->assertPresent('.wms-page-hero')
|
||||
->visit('/warehouses');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Warehouses');
|
||||
|
||||
$browser->assertPathIs('/warehouses')
|
||||
->visit('/warehouse-rules')
|
||||
->assertPathIs('/warehouse-rules');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Validation Rules');
|
||||
|
||||
$browser->assertSee('Validation Rules')
|
||||
->select('warehouse_id', (string) $warehouse->id)
|
||||
->type('name', $ruleName)
|
||||
->type('rule_key', $ruleKey)
|
||||
->select('severity', 'error')
|
||||
->type('configuration', '{"field":"expires_at"}')
|
||||
->press('Add rule')
|
||||
->waitForText($ruleName, 10)
|
||||
->assertSee($ruleName)
|
||||
->assertSee($ruleKey);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_warehouse_form_creates_warehouse(): void
|
||||
{
|
||||
$user = $this->tenantUser();
|
||||
|
||||
$user->company->warehouses()->delete();
|
||||
|
||||
$warehouseName = 'East Midlands DC ' . uniqid();
|
||||
$warehouseCode = 'E' . substr(strtoupper(uniqid()), -4);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user, $warehouseName, $warehouseCode): void {
|
||||
$browser->loginAs($user)
|
||||
->visit('/warehouses');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Warehouses');
|
||||
|
||||
$browser->type('code', $warehouseCode)
|
||||
->type('name', $warehouseName)
|
||||
->type('address', 'Main Road, Nottingham')
|
||||
->press('Create')
|
||||
->waitForText($warehouseName, 20)
|
||||
->assertSee($warehouseName);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_validation_rule_form_creates_rule(): void
|
||||
{
|
||||
$user = $this->tenantUser();
|
||||
$warehouse = $this->seedWarehouse($user->company);
|
||||
|
||||
$ruleName = 'Require serial number ' . uniqid();
|
||||
$ruleKey = 'serial-required-' . uniqid();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user, $warehouse, $ruleName, $ruleKey): void {
|
||||
$browser->loginAs($user)
|
||||
->visit('/warehouse-rules');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Validation Rules');
|
||||
$browser->select('warehouse_id', (string) $warehouse->id)
|
||||
->type('name', $ruleName)
|
||||
->type('rule_key', $ruleKey)
|
||||
->select('severity', 'error')
|
||||
->type('configuration', '{"field":"serial_no"}')
|
||||
->press('Add rule')
|
||||
->waitForText($ruleName, 10)
|
||||
->assertSee($ruleKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class WmsPricingCrudBrowserTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_platform_pricing_cards_and_admin_price_crud_are_reachable(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
$this->seedPlatformPrices();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->visit('/platform/pricing');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, 'Pricing');
|
||||
$browser->assertSee('Basic')
|
||||
->assertSee('Advanced')
|
||||
->assertSee('Pro')
|
||||
->visit('/admin/prices');
|
||||
|
||||
$this->assertSingleProfessionalHeader($browser, '');
|
||||
$browser->assertSee('Basic')
|
||||
->assertSee('Advanced')
|
||||
->assertSee('Pro');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsBrowser;
|
||||
use Tests\Browser\Concerns\InteractsWithWmsData;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class WmsResponsiveNavigationTest extends DuskTestCase
|
||||
{
|
||||
use InteractsWithWmsBrowser;
|
||||
use InteractsWithWmsData;
|
||||
|
||||
public function test_desktop_sidebar_navigation_remains_unchanged(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(1440, 1000)
|
||||
->visit('/admin')
|
||||
->waitFor('.wms-sidebar', 10)
|
||||
->assertSee('Platform')
|
||||
->assertSourceHas('Analytics')
|
||||
->assertSourceHas('Inventory');
|
||||
|
||||
$this->clickSidebarHref($browser, '/inventory-management');
|
||||
$browser->assertSourceHas('Inventory');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_mobile_menu_opens_slide_out_navigation(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(390, 844)
|
||||
->visit('/admin')
|
||||
->waitFor('[data-dusk="page-ready"]')
|
||||
->assertSee('Platform')
|
||||
->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1');
|
||||
|
||||
$this->openMobileNavigation($browser);
|
||||
$browser->assertSourceHas('Platform');
|
||||
|
||||
$browser->script('window.location.href = "/shipping";');
|
||||
$browser->waitForLocation('/shipping', 10)
|
||||
->assertSee('Shipping');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_mobile_menu_overlay_closes_drawer(): void
|
||||
{
|
||||
$admin = $this->platformAdminUser();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(390, 844)
|
||||
->visit('/admin')
|
||||
->waitFor('[data-dusk="page-ready"]');
|
||||
|
||||
$this->openMobileNavigation($browser);
|
||||
$this->closeMobileNavigation($browser);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Foundation\Application;
|
||||
|
||||
trait CreatesApplication
|
||||
{
|
||||
/**
|
||||
* Creates the application.
|
||||
*/
|
||||
public function createApplication(): Application
|
||||
{
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||
use Laravel\Dusk\TestCase as BaseTestCase;
|
||||
use PHPUnit\Framework\Attributes\BeforeClass;
|
||||
|
||||
abstract class DuskTestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
/**
|
||||
* Keep the local Laravel server process alive for the Dusk run.
|
||||
*
|
||||
* @var resource|null
|
||||
*/
|
||||
private static $laravelServerProcess = null;
|
||||
|
||||
/**
|
||||
* Prepare for Dusk test execution.
|
||||
*/
|
||||
#[BeforeClass]
|
||||
public static function prepare(): void
|
||||
{
|
||||
static::configureDuskBaseUrl();
|
||||
static::ensureLaravelServerIsReachable();
|
||||
|
||||
if (! static::runningInSail()) {
|
||||
static::ensureChromeDriverBinaryAlias();
|
||||
static::startChromeDriver(['--port=9515']);
|
||||
static::waitForTcpPort('127.0.0.1', 9515, 8.0, 'ChromeDriver');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the RemoteWebDriver instance.
|
||||
*/
|
||||
protected function driver(): RemoteWebDriver
|
||||
{
|
||||
$options = (new ChromeOptions)->addArguments([
|
||||
'--disable-gpu',
|
||||
'--headless=new',
|
||||
'--window-size=1920,1080',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-search-engine-choice-screen',
|
||||
'--ignore-certificate-errors',
|
||||
'--allow-insecure-localhost',
|
||||
'--disable-features=Translate,BackForwardCache,AcceptCHFrame',
|
||||
]);
|
||||
|
||||
return RemoteWebDriver::create(
|
||||
$_ENV['DUSK_DRIVER_URL'] ?? 'http://127.0.0.1:9515',
|
||||
DesiredCapabilities::chrome()->setCapability(
|
||||
ChromeOptions::CAPABILITY,
|
||||
$options,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private static function configureDuskBaseUrl(): void
|
||||
{
|
||||
// Do not trust an already-listening local server. In local Dusk runs it
|
||||
// may be Octane, Valet, or a stale `artisan serve` process using a
|
||||
// different database. Pick a free port for this PHPUnit run and start a
|
||||
// matching Laravel server below, unless explicitly told to use an
|
||||
// external server.
|
||||
$useExternalServer = filter_var(
|
||||
$_ENV['DUSK_USE_EXTERNAL_SERVER'] ?? getenv('DUSK_USE_EXTERNAL_SERVER') ?: false,
|
||||
FILTER_VALIDATE_BOOL,
|
||||
);
|
||||
|
||||
if ($useExternalServer) {
|
||||
$url = rtrim((string) ($_ENV['DUSK_APP_URL'] ?? getenv('DUSK_APP_URL') ?: 'http://127.0.0.1:8000'), '/');
|
||||
} else {
|
||||
$url = 'http://127.0.0.1:' . static::availablePort(8011, 8099);
|
||||
}
|
||||
|
||||
$_ENV['DUSK_APP_URL'] = $url;
|
||||
$_SERVER['DUSK_APP_URL'] = $url;
|
||||
putenv('DUSK_APP_URL=' . $url);
|
||||
|
||||
$_ENV['APP_URL'] = $url;
|
||||
$_SERVER['APP_URL'] = $url;
|
||||
putenv('APP_URL=' . $url);
|
||||
|
||||
if (property_exists(BaseTestCase::class, 'baseUrl')) {
|
||||
static::$baseUrl = $url;
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureLaravelServerIsReachable(): void
|
||||
{
|
||||
$url = rtrim((string) ($_ENV['APP_URL'] ?? 'http://127.0.0.1:8000'), '/');
|
||||
$parts = parse_url($url);
|
||||
|
||||
$scheme = $parts['scheme'] ?? 'http';
|
||||
$host = $parts['host'] ?? '127.0.0.1';
|
||||
$port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
||||
|
||||
$useExternalServer = filter_var(
|
||||
$_ENV['DUSK_USE_EXTERNAL_SERVER'] ?? getenv('DUSK_USE_EXTERNAL_SERVER') ?: false,
|
||||
FILTER_VALIDATE_BOOL,
|
||||
);
|
||||
|
||||
if ($useExternalServer && static::canConnect($host, $port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::startLaravelServer($host, $port);
|
||||
static::waitForTcpPort($host, $port, 15.0, 'Laravel Dusk application server');
|
||||
}
|
||||
|
||||
private static function startLaravelServer(string $host, int $port): void
|
||||
{
|
||||
$root = dirname(__DIR__);
|
||||
$logPath = $root . '/storage/logs/dusk-server.log';
|
||||
|
||||
if (! is_dir(dirname($logPath))) {
|
||||
@mkdir(dirname($logPath), 0775, true);
|
||||
}
|
||||
|
||||
$command = sprintf(
|
||||
'%s %s serve --host=%s --port=%d',
|
||||
escapeshellarg(PHP_BINARY),
|
||||
escapeshellarg($root . '/artisan'),
|
||||
escapeshellarg($host),
|
||||
$port,
|
||||
);
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $logPath, 'a'],
|
||||
2 => ['file', $logPath, 'a'],
|
||||
];
|
||||
|
||||
$env = static::scalarEnvironment([
|
||||
...getenv(),
|
||||
...$_SERVER,
|
||||
...$_ENV,
|
||||
'APP_ENV' => $_ENV['APP_ENV'] ?? 'dusk',
|
||||
'APP_URL' => $_ENV['APP_URL'] ?? "http://{$host}:{$port}",
|
||||
'DUSK_APP_URL' => $_ENV['DUSK_APP_URL'] ?? "http://{$host}:{$port}",
|
||||
'CACHE_STORE' => $_ENV['CACHE_STORE'] ?? 'array',
|
||||
'QUEUE_CONNECTION' => $_ENV['QUEUE_CONNECTION'] ?? 'sync',
|
||||
'MAIL_MAILER' => $_ENV['MAIL_MAILER'] ?? 'array',
|
||||
]);
|
||||
|
||||
self::$laravelServerProcess = proc_open($command, $descriptors, $pipes, $root, $env);
|
||||
|
||||
if (isset($pipes[0]) && is_resource($pipes[0])) {
|
||||
fclose($pipes[0]);
|
||||
}
|
||||
|
||||
if (! is_resource(self::$laravelServerProcess)) {
|
||||
throw new \RuntimeException('Unable to start Laravel Dusk application server. Check storage/logs/dusk-server.log.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $environment
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function scalarEnvironment(array $environment): array
|
||||
{
|
||||
$filtered = [];
|
||||
|
||||
foreach ($environment as $key => $value) {
|
||||
if (! is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
$filtered[$key] = $value ? 'true' : 'false';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_scalar($value) || $value === null) {
|
||||
$filtered[$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private static function availablePort(int $from, int $to): int
|
||||
{
|
||||
for ($port = $from; $port <= $to; $port++) {
|
||||
if (! static::canConnect('127.0.0.1', $port)) {
|
||||
return $port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException("No free TCP port available between {$from} and {$to} for Dusk.");
|
||||
}
|
||||
|
||||
private static function canConnect(string $host, int $port): bool
|
||||
{
|
||||
$connection = @fsockopen($host, $port, $errno, $errstr, 0.25);
|
||||
|
||||
if (! is_resource($connection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose($connection);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function waitForTcpPort(string $host, int $port, float $seconds, string $service): void
|
||||
{
|
||||
$deadline = microtime(true) + $seconds;
|
||||
|
||||
do {
|
||||
if (static::canConnect($host, $port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
usleep(100_000);
|
||||
} while (microtime(true) < $deadline);
|
||||
|
||||
$log = dirname(__DIR__) . '/storage/logs/dusk-server.log';
|
||||
$hint = is_file($log) ? " Check {$log}." : '';
|
||||
|
||||
throw new \RuntimeException("{$service} is not reachable at {$host}:{$port}.{$hint}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dusk may install a differently named macOS Apple Silicon binary.
|
||||
*
|
||||
* This method intentionally does not use base_path(), app(), or Artisan because
|
||||
* PHPUnit calls BeforeClass before Laravel has booted the application container.
|
||||
*/
|
||||
private static function ensureChromeDriverBinaryAlias(): void
|
||||
{
|
||||
$binDirectory = dirname(__DIR__) . '/vendor/laravel/dusk/bin';
|
||||
$expected = $binDirectory . '/chromedriver-mac-arm';
|
||||
|
||||
if (is_file($expected)) {
|
||||
@chmod($expected, 0755);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_dir($binDirectory)) {
|
||||
@mkdir($binDirectory, 0755, true);
|
||||
}
|
||||
|
||||
$candidates = array_values(array_filter(
|
||||
glob($binDirectory . '/chromedriver*') ?: [],
|
||||
static function (string $path) use ($expected): bool {
|
||||
return is_file($path)
|
||||
&& $path !== $expected
|
||||
&& ! str_ends_with($path, '.zip')
|
||||
&& ! str_ends_with($path, '.txt');
|
||||
},
|
||||
));
|
||||
|
||||
if ($candidates === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
copy($candidates[0], $expected);
|
||||
@chmod($expected, 0755);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get(route('login'));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_using_the_login_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->csrfPost(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticated();
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->csrfPost(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrorsIn('email');
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge(): void
|
||||
{
|
||||
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
|
||||
$response = $this->csrfPost(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('two-factor.login'));
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_users_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->csrfPost(route('logout'));
|
||||
|
||||
$response->assertRedirect(route('home'));
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->skipUnlessFortifyHas(Features::emailVerification());
|
||||
}
|
||||
|
||||
public function test_email_verification_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('verification.notice'));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false) . '?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
|
||||
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl)
|
||||
->assertRedirect(route('dashboard', absolute: false) . '?verified=1');
|
||||
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('password.confirm'));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->skipUnlessFortifyHas(Features::resetPasswords());
|
||||
}
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get(route('password.request'));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->csrfPost(route('password.email'), ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->csrfPost(route('password.email'), ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get(route('password.reset', $notification->token));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_can_be_reset_with_valid_token(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->csrfPost(route('password.email'), ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->csrfPost(route('password.update'), [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('login', absolute: false));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->skipUnlessFortifyHas(Features::registration());
|
||||
}
|
||||
|
||||
public function test_registration_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get(route('register'));
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
$response = $this->csrfPost(route('register.store'), [
|
||||
'name' => 'John Doe',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticated();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TwoFactorChallengeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
|
||||
}
|
||||
|
||||
public function test_two_factor_challenge_redirects_to_login_when_not_authenticated(): void
|
||||
{
|
||||
$response = $this->get(route('two-factor.login'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_two_factor_challenge_can_be_rendered(): void
|
||||
{
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
|
||||
$this->csrfPost(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertRedirect(route('two-factor.login'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guests_are_redirected_to_the_login_page(): void
|
||||
{
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_authenticated_users_can_visit_the_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertOk();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
public function test_home_route_redirects_guests_to_login(): void
|
||||
{
|
||||
$this->get(route('home'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_health_endpoint_returns_successful_response(): void
|
||||
{
|
||||
$this->get('/up')->assertOk();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\InternalApi;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Covers CompanySummaryController and VerifyInternalApiSignature.
|
||||
*/
|
||||
class InternalApiCoverageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the internal API rejects empty configuration.
|
||||
*/
|
||||
public function test_internal_api_rejects_when_secret_is_missing(): void
|
||||
{
|
||||
config(['platform.internal_api_key' => '']);
|
||||
|
||||
$this->getJson('/api/internal/companies')->assertUnauthorized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure invalid timestamps are rejected.
|
||||
*/
|
||||
public function test_internal_api_rejects_invalid_timestamp(): void
|
||||
{
|
||||
config(['platform.internal_api_key' => 'testing-secret']);
|
||||
|
||||
$this->withHeaders([
|
||||
'X-WMS-Timestamp' => 'not-a-date',
|
||||
'X-WMS-Signature' => 'bad',
|
||||
])->getJson('/api/internal/companies')->assertUnauthorized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure expired timestamps are rejected.
|
||||
*/
|
||||
public function test_internal_api_rejects_expired_timestamp(): void
|
||||
{
|
||||
config(['platform.internal_api_key' => 'testing-secret']);
|
||||
$timestamp = now()->subMinutes(10)->toIso8601String();
|
||||
$signature = hash_hmac('sha256', $timestamp . '.', 'testing-secret');
|
||||
|
||||
$this->withHeaders([
|
||||
'X-WMS-Timestamp' => $timestamp,
|
||||
'X-WMS-Signature' => $signature,
|
||||
])->getJson('/api/internal/companies')->assertUnauthorized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure incorrect HMAC signatures are rejected.
|
||||
*/
|
||||
public function test_internal_api_rejects_invalid_signature(): void
|
||||
{
|
||||
config(['platform.internal_api_key' => 'testing-secret']);
|
||||
$timestamp = now()->toIso8601String();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-WMS-Timestamp' => $timestamp,
|
||||
'X-WMS-Signature' => hash_hmac('sha256', $timestamp . '.', 'wrong-secret'),
|
||||
])->getJson('/api/internal/companies')->assertUnauthorized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure valid signatures return company summaries and relationship counts.
|
||||
*/
|
||||
public function test_internal_api_returns_company_summary_counts(): void
|
||||
{
|
||||
config(['platform.internal_api_key' => 'testing-secret']);
|
||||
|
||||
$company = Company::create(['name' => 'Summary Tenant', 'slug' => 'summary-tenant']);
|
||||
User::factory()->create(['company_id' => $company->id]);
|
||||
LicenseKey::issueFor($company, 'Summary licence');
|
||||
Warehouse::create(['company_id' => $company->id, 'code' => 'SUM', 'name' => 'Summary Warehouse']);
|
||||
|
||||
$timestamp = now()->toIso8601String();
|
||||
$signature = hash_hmac('sha256', $timestamp . '.', 'testing-secret');
|
||||
|
||||
$this->withHeaders([
|
||||
'X-WMS-Timestamp' => $timestamp,
|
||||
'X-WMS-Signature' => $signature,
|
||||
])->getJson('/api/internal/companies')
|
||||
->assertOk()
|
||||
->assertJsonPath('companies.0.name', 'Summary Tenant')
|
||||
->assertJsonPath('companies.0.users_count', 1)
|
||||
->assertJsonPath('companies.0.warehouses_count', 1)
|
||||
->assertJsonPath('companies.0.license_keys_count', 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Modules;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ModuleSidebarAndStatusTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: string}>
|
||||
*/
|
||||
public static function moduleRoutes(): array
|
||||
{
|
||||
return [
|
||||
['analytics.index', 'Analytics'],
|
||||
['audit.index', 'Audit'],
|
||||
['carrier-management.index', 'Carrier Management'],
|
||||
['configuration.index', 'Configuration'],
|
||||
['document-archive.index', 'Document Archive'],
|
||||
['inventory-management.index', 'Inventory Management'],
|
||||
['m3-integration.index', 'M3 Integration'],
|
||||
['replenishment.index', 'Replenishment'],
|
||||
['reporting.index', 'Reporting'],
|
||||
['security.index', 'Security'],
|
||||
['sentinel-integration.index', 'Sentinel Integration'],
|
||||
['shipping.index', 'Shipping'],
|
||||
['transportation.index', 'Transportation'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('moduleRoutes')]
|
||||
public function test_enabled_module_route_renders(string $routeName, string $label): void
|
||||
{
|
||||
$this->assertTrue(Route::has($routeName), "Missing route [{$routeName}].");
|
||||
|
||||
$user = UserFactory::new()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route($routeName))
|
||||
->assertOk()
|
||||
->assertSee($label, false)
|
||||
->assertSee('Warehouse Management System', false);
|
||||
}
|
||||
|
||||
public function test_sidebar_contains_all_enabled_web_modules(): void
|
||||
{
|
||||
$user = UserFactory::new()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('platform.dashboard'))->assertOk();
|
||||
|
||||
foreach (self::moduleRoutes() as [$routeName, $label]) {
|
||||
$response->assertSee(route($routeName), false);
|
||||
}
|
||||
|
||||
foreach (['Analytics', 'Inventory', 'Replenishment', 'Shipping', 'Transportation', 'Carriers', 'M3 Integration', 'Sentinel', 'Documents', 'Reporting', 'Configuration', 'Security', 'Audit'] as $label) {
|
||||
$response->assertSee($label, false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_modules_statuses_file_enables_all_modules(): void
|
||||
{
|
||||
$statuses = json_decode(file_get_contents(base_path('modules_statuses.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
foreach ([
|
||||
'Analytics', 'Audit', 'CarrierManagement', 'Configuration', 'DocumentArchive', 'InternalApi',
|
||||
'InventoryManagement', 'M3Integration', 'Platform', 'Replenishment', 'Reporting', 'Security',
|
||||
'SentinelIntegration', 'Shipping', 'Transportation', 'Warehouse',
|
||||
] as $module) {
|
||||
$this->assertArrayHasKey($module, $statuses);
|
||||
$this->assertTrue($statuses[$module], "Module [{$module}] is not enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Modules;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NavigatorHeaderCoverageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure every navigator page renders the professional single page hero baseline.
|
||||
*/
|
||||
#[DataProvider('navigatorPages')]
|
||||
public function test_every_navigator_page_has_single_professional_header(string $routeName, string $title, string $description): void
|
||||
{
|
||||
$this->assertTrue(Route::has($routeName), "Missing navigator route [{$routeName}].");
|
||||
|
||||
$user = User::factory()->mpbaPlatformAdmin()->create();
|
||||
$content = $this->actingAs($user)
|
||||
->get(route($routeName))
|
||||
->assertOk()
|
||||
->assertSee('wms-page-hero', false)
|
||||
->getContent();
|
||||
|
||||
if ($title !== '') {
|
||||
$this->assertStringContainsString($title, $content);
|
||||
}
|
||||
|
||||
if ($description !== '') {
|
||||
$this->assertStringContainsString($description, $content);
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
1,
|
||||
preg_match_all('/<section\b[^>]*class="[^"]*\bwms-page-hero\b/', $content),
|
||||
"Navigator route [{$routeName}] must render exactly one professional header section.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the platform dashboard keeps pricing cards visible on Platform.
|
||||
*/
|
||||
public function test_platform_dashboard_does_not_show_pricing_display_cards(): void
|
||||
{
|
||||
$user = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
PlatformPrice::query()->updateOrCreate(
|
||||
['code' => 'basic'],
|
||||
[
|
||||
'name' => 'Basic',
|
||||
'strapline' => 'Starter WMS platform access.',
|
||||
'description' => 'Core warehouse management tools.',
|
||||
'monthly_amount' => 4900,
|
||||
'annual_amount' => 49000,
|
||||
'currency' => 'gbp',
|
||||
'features' => ['2 warehouses', '10 users', 'Standard support'],
|
||||
'sort_order' => 10,
|
||||
'is_active' => true,
|
||||
'is_featured' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('platform.dashboard'))
|
||||
->assertOk()
|
||||
->assertDontSee('Pricing plans', false)
|
||||
->assertSee(route('platform.pricing.index'), false)
|
||||
->assertSee(route('platform.prices.index'), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the public Platform pricing route is separate from Administration price CRUD.
|
||||
*/
|
||||
public function test_pricing_routes_are_in_correct_platform_and_administration_menus(): void
|
||||
{
|
||||
$this->assertSame('/platform/pricing', parse_url(route('platform.pricing.index'), PHP_URL_PATH));
|
||||
$this->assertSame('/admin/prices', parse_url(route('platform.prices.index'), PHP_URL_PATH));
|
||||
$this->assertSame('/admin/prices/create', parse_url(route('platform.prices.create'), PHP_URL_PATH));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: string, 2: string}>
|
||||
*/
|
||||
public static function navigatorPages(): array
|
||||
{
|
||||
return [
|
||||
['platform.dashboard', 'Platform Dashboard', 'System-level tenant'],
|
||||
['platform.pricing.index', 'Pricing', 'Clear platform pricing'],
|
||||
['platform.companies.index', 'Companies', 'Manage tenant companies'],
|
||||
['platform.licenses.index', 'Licence Keys', 'Issue and monitor company licence keys'],
|
||||
['platform.payments.index', 'Payments and Overrides', 'Record platform payments'],
|
||||
['platform.prices.index', 'Pricing Administration', 'Create, edit and publish'],
|
||||
['dashboard', '', ''],
|
||||
['warehouses.index', 'Warehouses', 'Create and manage warehouse records'],
|
||||
['warehouse-rules.index', 'Validation Rules', 'Configure warehouse validation'],
|
||||
['analytics.index', 'Analytics', 'Display KPIs'],
|
||||
['inventory-management.index', 'Inventory Management', 'Track inventory movement'],
|
||||
['replenishment.index', 'Replenishment', 'Configure replenishment thresholds'],
|
||||
['shipping.index', 'Shipping', 'Monitor shipment activity'],
|
||||
['transportation.index', 'Transportation', 'Manage truck closure'],
|
||||
['carrier-management.index', 'Carrier Management', 'Maintain carriers'],
|
||||
['m3-integration.index', 'M3 Integration', 'Observe M3 transactions'],
|
||||
['sentinel-integration.index', 'Sentinel Integration', 'Track Sentinel API activity'],
|
||||
['document-archive.index', 'Document Archive', 'Search cached Sentinel documents'],
|
||||
['reporting.index', 'Reporting', 'Provide operational reports'],
|
||||
['configuration.index', 'Configuration', 'Centralise system settings'],
|
||||
['security.index', 'Security', 'Control roles'],
|
||||
['audit.index', 'Audit', 'Review activity logs'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Platform;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Modules\Platform\app\Models\Payment;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Covers PlatformDashboardController, CompanyController, LicenseKeyController, PaymentController and StripeWebhookController.
|
||||
*/
|
||||
class PlatformAdministrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure guests are redirected away from platform administration.
|
||||
*/
|
||||
public function test_guest_is_redirected_from_platform_dashboard(): void
|
||||
{
|
||||
$this->get(route('platform.dashboard'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure non-platform users cannot access admin administration.
|
||||
*/
|
||||
public function test_regular_user_is_forbidden_from_platform_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('platform.dashboard'))->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure dashboard renders the single platform header and pricing cards.
|
||||
*/
|
||||
public function test_platform_dashboard_renders_counts_without_pricing_cards_and_recent_companies(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
PlatformPrice::query()->delete();
|
||||
$company = Company::create(['name' => 'Customer One', 'slug' => 'customer-one']);
|
||||
LicenseKey::issueFor($company, 'Customer licence', 'enterprise', 25);
|
||||
Warehouse::create(['company_id' => $company->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']);
|
||||
Payment::create(['company_id' => $company->id, 'amount' => 12500, 'currency' => 'gbp', 'status' => 'paid']);
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic']));
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'advanced', 'name' => 'Advanced', 'sort_order' => 20]));
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'pro', 'name' => 'Pro', 'sort_order' => 30]));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('platform.dashboard'))
|
||||
->assertOk()
|
||||
->assertSee('Platform Dashboard', false)
|
||||
->assertDontSee('Pricing plans', false)
|
||||
->assertSee('Customer One', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure companies can be created by platform admins.
|
||||
*/
|
||||
public function test_platform_admin_can_create_company(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.companies.store'), $this->csrfPayload([
|
||||
'name' => 'Abbott Logistics Limited',
|
||||
'billing_email' => 'billing@abbott.example',
|
||||
'status' => 'active',
|
||||
]))->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('companies', [
|
||||
'name' => 'Abbott Logistics Limited',
|
||||
'slug' => 'abbott-logistics-limited',
|
||||
'billing_email' => 'billing@abbott.example',
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure invalid company submissions are rejected.
|
||||
*/
|
||||
public function test_company_create_validates_input(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->from(route('platform.companies.index'))
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->post(route('platform.companies.store'), $this->csrfPayload([
|
||||
'name' => '',
|
||||
'billing_email' => 'not-an-email',
|
||||
'status' => 'unknown',
|
||||
]))
|
||||
->assertRedirect(route('platform.companies.index'))
|
||||
->assertSessionHasErrors(['name', 'billing_email', 'status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure licences are issued hashed and shown once in session.
|
||||
*/
|
||||
public function test_platform_admin_can_issue_license_key(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
$company = Company::create(['name' => 'Tenant A', 'slug' => 'tenant-a']);
|
||||
|
||||
$response = $this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.licenses.store'), $this->csrfPayload([
|
||||
'company_id' => $company->id,
|
||||
'label' => 'Production licence',
|
||||
'plan' => 'enterprise',
|
||||
'max_users' => 500,
|
||||
]));
|
||||
|
||||
$response->assertRedirect()->assertSessionHas('plain_license_key');
|
||||
|
||||
$plainKey = session('plain_license_key');
|
||||
$license = LicenseKey::firstOrFail();
|
||||
|
||||
$this->assertStringStartsWith('WMS-', $plainKey);
|
||||
$this->assertSame(hash('sha256', $plainKey), $license->key_hash);
|
||||
$this->assertSame('enterprise', $license->plan);
|
||||
$this->assertSame(500, $license->max_users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure licence input is validated.
|
||||
*/
|
||||
public function test_license_create_validates_input(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->from(route('platform.licenses.index'))
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->post(route('platform.licenses.store'), $this->csrfPayload([
|
||||
'company_id' => 999,
|
||||
'label' => '',
|
||||
'plan' => 'ultimate',
|
||||
'max_users' => 0,
|
||||
]))
|
||||
->assertRedirect(route('platform.licenses.index'))
|
||||
->assertSessionHasErrors(['company_id', 'label', 'plan', 'max_users']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure manual payments are recorded with paid_at for paid statuses.
|
||||
*/
|
||||
public function test_platform_admin_can_create_manual_payment(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
$company = Company::create(['name' => 'Tenant Payment', 'slug' => 'tenant-payment']);
|
||||
|
||||
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.payments.store'), $this->csrfPayload([
|
||||
'company_id' => $company->id,
|
||||
'amount' => 12345,
|
||||
'currency' => 'gbp',
|
||||
'status' => 'paid',
|
||||
'description' => 'Manual bank transfer',
|
||||
]))->assertRedirect();
|
||||
|
||||
$payment = Payment::firstOrFail();
|
||||
|
||||
$this->assertSame($company->id, $payment->company_id);
|
||||
$this->assertSame(12345, $payment->amount);
|
||||
$this->assertSame('manual_admin', $payment->metadata['source']);
|
||||
$this->assertNotNull($payment->paid_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure unpaid statuses do not set paid_at.
|
||||
*/
|
||||
public function test_pending_payment_does_not_set_paid_at(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
$company = Company::create(['name' => 'Tenant Pending', 'slug' => 'tenant-pending']);
|
||||
|
||||
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.payments.store'), $this->csrfPayload([
|
||||
'company_id' => $company->id,
|
||||
'amount' => 5000,
|
||||
'currency' => 'gbp',
|
||||
'status' => 'pending',
|
||||
'description' => 'Awaiting payment',
|
||||
]))->assertRedirect();
|
||||
|
||||
$this->assertNull(Payment::firstOrFail()->paid_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Stripe webhook creates or updates paid records.
|
||||
*/
|
||||
public function test_stripe_webhook_records_successful_payment_intent(): void
|
||||
{
|
||||
$company = Company::create([
|
||||
'name' => 'Stripe Tenant',
|
||||
'slug' => 'stripe-tenant',
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'type' => 'payment_intent.succeeded',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'pi_123',
|
||||
'customer' => 'cus_test_123',
|
||||
'amount_received' => 7800,
|
||||
'currency' => 'gbp',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->withSession(['_token' => 'test-token'])->withHeader('X-CSRF-TOKEN', 'test-token')->postJson(route('stripe.webhook'), $payload)
|
||||
->assertOk()
|
||||
->assertJson(['received' => true]);
|
||||
|
||||
$this->assertDatabaseHas('payments', [
|
||||
'company_id' => $company->id,
|
||||
'stripe_payment_intent_id' => 'pi_123',
|
||||
'amount' => 7800,
|
||||
'currency' => 'gbp',
|
||||
'status' => 'paid',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure unrelated Stripe events are acknowledged but do not create payments.
|
||||
*/
|
||||
public function test_stripe_webhook_ignores_unhandled_events(): void
|
||||
{
|
||||
$this->withSession(['_token' => 'test-token'])->withHeader('X-CSRF-TOKEN', 'test-token')->postJson(route('stripe.webhook'), ['type' => 'customer.created'])
|
||||
->assertOk()
|
||||
->assertJson(['received' => true]);
|
||||
|
||||
$this->assertDatabaseCount('payments', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function pricePayload(array $overrides = []): array
|
||||
{
|
||||
return [
|
||||
'code' => 'basic',
|
||||
'name' => 'Basic',
|
||||
'strapline' => 'Starter WMS platform access.',
|
||||
'description' => 'Core warehouse management tools.',
|
||||
'monthly_amount' => 4900,
|
||||
'annual_amount' => 49000,
|
||||
'currency' => 'gbp',
|
||||
'badge_label' => 'Starter',
|
||||
'features' => ['2 warehouses', '10 users', 'Standard support'],
|
||||
'sort_order' => 10,
|
||||
'is_featured' => false,
|
||||
'is_active' => true,
|
||||
...$overrides,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return payload with a valid test CSRF token.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function csrfPayload(array $payload = []): array
|
||||
{
|
||||
return ['_token' => 'test-token'] + $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the test session with the matching CSRF token.
|
||||
*/
|
||||
private function withCsrfSession(): self
|
||||
{
|
||||
$this->withSession(['_token' => 'test-token']);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Platform;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PlatformPriceCrudTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the customer-facing Platform pricing page only shows active prices in display order.
|
||||
*/
|
||||
public function test_platform_pricing_page_displays_active_prices_in_card_grid(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
PlatformPrice::query()->delete();
|
||||
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'pro', 'name' => 'Pro', 'sort_order' => 30]));
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic', 'sort_order' => 10]));
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'legacy', 'name' => 'Legacy', 'is_active' => false, 'sort_order' => 1]));
|
||||
|
||||
$response = $this->actingAs($user)->get(route('platform.pricing.index'));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('Pricing', false)
|
||||
->assertSee('Basic', false)
|
||||
->assertSee('Pro', false)
|
||||
->assertDontSee('Legacy', false)
|
||||
->assertSeeInOrder(['Basic', 'Pro']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the admin pricing index remains an Administration CRUD area.
|
||||
*/
|
||||
public function test_platform_admin_can_view_price_crud_index(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
PlatformPrice::query()->delete();
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'advanced', 'name' => 'Advanced']));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('platform.prices.index'))
|
||||
->assertOk()
|
||||
->assertSee('Pricing Administration', false)
|
||||
->assertSee('Advanced', false)
|
||||
->assertSee('Add price', false)
|
||||
->assertSee(route('platform.prices.create'), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure only MPBA platform admins can reach price administration.
|
||||
*/
|
||||
public function test_non_platform_admin_cannot_access_price_crud(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('platform.prices.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure create renders with default form values.
|
||||
*/
|
||||
public function test_platform_admin_can_open_create_price_form(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('platform.prices.create'))
|
||||
->assertOk()
|
||||
->assertSee('Create Price', false)
|
||||
->assertSee('Currency', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure store normalises amounts, code, booleans, currency and feature rows.
|
||||
*/
|
||||
public function test_platform_admin_can_create_price(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.prices.store'), $this->csrfPayload([
|
||||
'code' => '',
|
||||
'name' => 'Ultimate Plan',
|
||||
'strapline' => 'Full control',
|
||||
'description' => 'Unlimited warehouses and users.',
|
||||
'monthly_amount' => '199.99',
|
||||
'annual_amount' => '1999.00',
|
||||
'currency' => 'GBP',
|
||||
'badge_label' => 'Best Value',
|
||||
'features' => "Unlimited warehouses\nUnlimited users\nPriority support\n",
|
||||
'sort_order' => 40,
|
||||
'is_featured' => '1',
|
||||
'is_active' => '1',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('platform.prices.index'));
|
||||
|
||||
$this->assertDatabaseHas('platform_prices', [
|
||||
'code' => 'ultimate-plan',
|
||||
'name' => 'Ultimate Plan',
|
||||
'monthly_amount' => 19999,
|
||||
'annual_amount' => 199900,
|
||||
'currency' => 'gbp',
|
||||
'is_featured' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
['Unlimited warehouses', 'Unlimited users', 'Priority support'],
|
||||
PlatformPrice::where('code', 'ultimate-plan')->firstOrFail()->featureRows(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure validation protects required fields and amount limits.
|
||||
*/
|
||||
public function test_create_price_validates_required_fields(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->from(route('platform.prices.create'))
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->post(route('platform.prices.store'), $this->csrfPayload([
|
||||
'name' => '',
|
||||
'monthly_amount' => '-1',
|
||||
'currency' => 'GB',
|
||||
'sort_order' => -5,
|
||||
]))
|
||||
->assertRedirect(route('platform.prices.create'))
|
||||
->assertSessionHasErrors(['name', 'monthly_amount', 'currency', 'sort_order']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure edit renders the current price.
|
||||
*/
|
||||
public function test_platform_admin_can_open_edit_price_form(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
PlatformPrice::query()->delete();
|
||||
$price = PlatformPrice::create($this->pricePayload(['code' => 'basic-edit', 'name' => 'Basic']));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('platform.prices.edit', $price))
|
||||
->assertOk()
|
||||
->assertSee('Edit Basic', false)
|
||||
->assertSee('Basic', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure update preserves unique-code validation for the current record.
|
||||
*/
|
||||
public function test_platform_admin_can_update_price(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
PlatformPrice::query()->delete();
|
||||
$price = PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic']));
|
||||
|
||||
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->put(route('platform.prices.update', $price), $this->csrfPayload([
|
||||
'code' => 'basic',
|
||||
'name' => 'Basic Updated',
|
||||
'strapline' => 'Entry level stock control',
|
||||
'description' => 'Updated description.',
|
||||
'monthly_amount' => '29.00',
|
||||
'annual_amount' => '',
|
||||
'currency' => 'GBP',
|
||||
'badge_label' => '',
|
||||
'features' => "2 warehouses\n10 users",
|
||||
'sort_order' => 5,
|
||||
'is_active' => '1',
|
||||
]))->assertRedirect(route('platform.prices.index'));
|
||||
|
||||
$this->assertDatabaseHas('platform_prices', [
|
||||
'id' => $price->id,
|
||||
'code' => 'basic',
|
||||
'name' => 'Basic Updated',
|
||||
'monthly_amount' => 2900,
|
||||
'annual_amount' => null,
|
||||
'sort_order' => 5,
|
||||
'is_featured' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure duplicate codes are rejected on update.
|
||||
*/
|
||||
public function test_price_code_must_remain_unique(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
PlatformPrice::query()->delete();
|
||||
PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic']));
|
||||
$price = PlatformPrice::create($this->pricePayload(['code' => 'advanced', 'name' => 'Advanced']));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->from(route('platform.prices.edit', $price))
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->put(route('platform.prices.update', $price), $this->csrfPayload([
|
||||
...$this->requestPayload(),
|
||||
'code' => 'basic',
|
||||
'name' => 'Advanced',
|
||||
]))
|
||||
->assertRedirect(route('platform.prices.edit', $price))
|
||||
->assertSessionHasErrors(['code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure destroy removes the price.
|
||||
*/
|
||||
public function test_platform_admin_can_delete_price(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
PlatformPrice::query()->delete();
|
||||
$price = PlatformPrice::create($this->pricePayload(['code' => 'legacy', 'name' => 'Legacy']));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->delete(route('platform.prices.destroy', $price), ['_token' => 'test-token'])
|
||||
->assertRedirect(route('platform.prices.index'));
|
||||
|
||||
$this->assertDatabaseMissing('platform_prices', ['id' => $price->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure currencies render with correct symbols.
|
||||
*/
|
||||
#[DataProvider('currencySymbolProvider')]
|
||||
public function test_price_currency_symbol_and_formatting(string $currency, string $expected): void
|
||||
{
|
||||
$price = new PlatformPrice(['currency' => $currency]);
|
||||
|
||||
$this->assertSame($expected, $price->currencySymbol());
|
||||
$this->assertStringStartsWith($expected, $price->formatAmount(12345));
|
||||
$this->assertSame('—', $price->formatAmount(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: string}>
|
||||
*/
|
||||
public static function currencySymbolProvider(): array
|
||||
{
|
||||
return [
|
||||
['gbp', '£'],
|
||||
['eur', '€'],
|
||||
['usd', '$'],
|
||||
['aud', 'AUD '],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function pricePayload(array $overrides = []): array
|
||||
{
|
||||
return [
|
||||
'code' => 'basic',
|
||||
'name' => 'Basic',
|
||||
'strapline' => 'Starter WMS platform access.',
|
||||
'description' => 'Core warehouse management tools.',
|
||||
'monthly_amount' => 4900,
|
||||
'annual_amount' => 49000,
|
||||
'currency' => 'gbp',
|
||||
'badge_label' => 'Starter',
|
||||
'features' => ['2 warehouses', '10 users', 'Standard support'],
|
||||
'sort_order' => 10,
|
||||
'is_featured' => false,
|
||||
'is_active' => true,
|
||||
...$overrides,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestPayload(): array
|
||||
{
|
||||
return [
|
||||
'code' => 'advanced',
|
||||
'name' => 'Advanced',
|
||||
'strapline' => 'Expanded controls.',
|
||||
'description' => 'Larger warehouse estate support.',
|
||||
'monthly_amount' => '99.00',
|
||||
'annual_amount' => '999.00',
|
||||
'currency' => 'GBP',
|
||||
'badge_label' => 'Popular',
|
||||
'features' => "5 warehouses\n100 users",
|
||||
'sort_order' => 20,
|
||||
'is_featured' => '1',
|
||||
'is_active' => '1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return payload with a valid test CSRF token.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function csrfPayload(array $payload = []): array
|
||||
{
|
||||
return ['_token' => 'test-token'] + $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the test session with the matching CSRF token.
|
||||
*/
|
||||
private function withCsrfSession(): self
|
||||
{
|
||||
$this->withSession(['_token' => 'test-token']);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Livewire\Settings\Profile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_profile_page_is_displayed(): void
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$this->get('/settings/profile')->assertOk();
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = Livewire::test(Profile::class)
|
||||
->set('name', 'Test User')
|
||||
->set('email', 'test@example.com')
|
||||
->call('updateProfileInformation');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertEquals('Test User', $user->name);
|
||||
$this->assertEquals('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_email_verification_status_is_unchanged_when_email_address_is_unchanged(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = Livewire::test(Profile::class)
|
||||
->set('name', 'Test User')
|
||||
->set('email', $user->email)
|
||||
->call('updateProfileInformation');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_user_can_delete_their_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = Livewire::test('settings.delete-user-form')
|
||||
->set('password', 'password')
|
||||
->call('deleteUser');
|
||||
|
||||
$response
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertNull($user->fresh());
|
||||
$this->assertFalse(auth()->check());
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_delete_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = Livewire::test('settings.delete-user-form')
|
||||
->set('password', 'wrong-password')
|
||||
->call('deleteUser');
|
||||
|
||||
$response->assertHasErrors(['password']);
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Livewire\Settings\Security;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Fortify\Features;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
Features::passkeys([
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_security_settings_page_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession(['auth.password_confirmed_at' => time()])
|
||||
->get(route('security.edit'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$response->assertSee('Passkeys');
|
||||
$response->assertSee('No passkeys yet');
|
||||
$response->assertSee('Two-factor authentication');
|
||||
$response->assertSee('Enable 2FA');
|
||||
}
|
||||
|
||||
public function test_security_settings_page_requires_password_confirmation_when_enabled(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('security.edit'));
|
||||
|
||||
$response->assertRedirect(route('password.confirm'));
|
||||
}
|
||||
|
||||
public function test_security_settings_page_renders_without_two_factor_when_feature_is_disabled(): void
|
||||
{
|
||||
config(['fortify.features' => []]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession(['auth.password_confirmed_at' => time()])
|
||||
->get(route('security.edit'))
|
||||
->assertOk()
|
||||
->assertSee('Update password')
|
||||
->assertDontSee('Manage your passkeys for passwordless sign-in')
|
||||
->assertDontSee('Add a passkey to sign in without a password')
|
||||
->assertDontSee('Two-factor authentication');
|
||||
}
|
||||
|
||||
public function test_two_factor_authentication_disabled_when_confirmation_abandoned_between_requests(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt('test-secret'),
|
||||
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(Security::class);
|
||||
|
||||
$component->assertSet('twoFactorEnabled', false);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_password_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = Livewire::test(Security::class)
|
||||
->set('current_password', 'password')
|
||||
->set('password', 'new-password')
|
||||
->set('password_confirmation', 'new-password')
|
||||
->call('updatePassword');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_update_password(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = Livewire::test(Security::class)
|
||||
->set('current_password', 'wrong-password')
|
||||
->set('password', 'new-password')
|
||||
->set('password_confirmation', 'new-password')
|
||||
->call('updatePassword');
|
||||
|
||||
$response->assertHasErrors(['current_password']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Warehouse;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WarehouseCrudTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure licensed tenant users can see the warehouse dashboard for their company.
|
||||
*/
|
||||
public function test_licensed_user_can_view_warehouse_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Warehouse::create(['company_id' => $user->company_id, 'code' => 'MAIN', 'name' => 'Main Warehouse']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('warehouses.index'))
|
||||
->assertOk()
|
||||
->assertSee('Warehouses', false)
|
||||
->assertSee('Main Warehouse', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure unlicensed tenants are blocked from licensed warehouse screens.
|
||||
*/
|
||||
public function test_unlicensed_tenant_is_blocked_from_warehouse_dashboard(): void
|
||||
{
|
||||
$company = Company::create(['name' => 'Unlicensed Ltd', 'slug' => 'unlicensed-ltd']);
|
||||
$user = User::factory()->create(['company_id' => $company->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('dashboard'))
|
||||
->assertStatus(402);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users without a company are forbidden by tenant assignment middleware.
|
||||
*/
|
||||
public function test_user_without_company_is_forbidden_from_warehouse_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create(['company_id' => null]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('dashboard'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure tenant users only see their own warehouses.
|
||||
*/
|
||||
public function test_warehouse_index_is_tenant_isolated(): void
|
||||
{
|
||||
$companyA = Company::create(['name' => 'Company A', 'slug' => 'company-a']);
|
||||
$companyB = Company::create(['name' => 'Company B', 'slug' => 'company-b']);
|
||||
LicenseKey::issueFor($companyA, 'A licence');
|
||||
$user = User::factory()->create(['company_id' => $companyA->id]);
|
||||
|
||||
Warehouse::create(['company_id' => $companyA->id, 'code' => 'A1', 'name' => 'A Warehouse']);
|
||||
Warehouse::create(['company_id' => $companyB->id, 'code' => 'B1', 'name' => 'B Warehouse']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('warehouses.index'))
|
||||
->assertOk()
|
||||
->assertSee('A Warehouse', false)
|
||||
->assertDontSee('B Warehouse', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure warehouse creation uses the authenticated tenant company and timezone.
|
||||
*/
|
||||
public function test_licensed_user_can_create_warehouse_for_their_company(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('warehouses.store'), $this->csrfPayload([
|
||||
'code' => 'EAST',
|
||||
'name' => 'East Midlands DC',
|
||||
'address' => 'Main Road, Nottingham',
|
||||
]))->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('warehouses', [
|
||||
'company_id' => $user->company_id,
|
||||
'code' => 'EAST',
|
||||
'name' => 'East Midlands DC',
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure warehouse input validation protects required fields.
|
||||
*/
|
||||
public function test_create_warehouse_validates_input(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('warehouses.index'))
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->post(route('warehouses.store'), $this->csrfPayload([
|
||||
'code' => '',
|
||||
'name' => '',
|
||||
]))
|
||||
->assertRedirect(route('warehouses.index'))
|
||||
->assertSessionHasErrors(['code', 'name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure rules index only lists rules attached to the authenticated company.
|
||||
*/
|
||||
public function test_validation_rules_index_is_tenant_isolated(): void
|
||||
{
|
||||
$companyA = Company::create(['name' => 'Rules A', 'slug' => 'rules-a']);
|
||||
$companyB = Company::create(['name' => 'Rules B', 'slug' => 'rules-b']);
|
||||
LicenseKey::issueFor($companyA, 'Rules licence');
|
||||
$user = User::factory()->create(['company_id' => $companyA->id]);
|
||||
|
||||
$warehouseA = Warehouse::create(['company_id' => $companyA->id, 'code' => 'RA', 'name' => 'Rules A Warehouse']);
|
||||
$warehouseB = Warehouse::create(['company_id' => $companyB->id, 'code' => 'RB', 'name' => 'Rules B Warehouse']);
|
||||
WarehouseValidationRule::create(['warehouse_id' => $warehouseA->id, 'name' => 'A Rule', 'rule_key' => 'a-rule', 'severity' => 'warning']);
|
||||
WarehouseValidationRule::create(['warehouse_id' => $warehouseB->id, 'name' => 'B Rule', 'rule_key' => 'b-rule', 'severity' => 'blocker']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('warehouse-rules.index'))
|
||||
->assertOk()
|
||||
->assertSee('A Rule', false)
|
||||
->assertDontSee('B Rule', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users can create validation rules for their own warehouses.
|
||||
*/
|
||||
public function test_user_can_create_validation_rule_for_own_warehouse(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$warehouse = Warehouse::create(['company_id' => $user->company_id, 'code' => 'MAIN', 'name' => 'Main Warehouse']);
|
||||
|
||||
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('warehouse-rules.store'), $this->csrfPayload([
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'name' => 'Require batch number',
|
||||
'rule_key' => 'batch-required',
|
||||
'severity' => 'blocker',
|
||||
'configuration' => json_encode(['field' => 'batch_no']),
|
||||
]))->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('warehouse_validation_rules', [
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'name' => 'Require batch number',
|
||||
'rule_key' => 'batch-required',
|
||||
'severity' => 'blocker',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(['field' => 'batch_no'], WarehouseValidationRule::firstOrFail()->configuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users cannot create rules against another tenant warehouse.
|
||||
*/
|
||||
public function test_user_cannot_create_validation_rule_for_another_company_warehouse(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$otherCompany = Company::create(['name' => 'Other Company', 'slug' => 'other-company']);
|
||||
$otherWarehouse = Warehouse::create(['company_id' => $otherCompany->id, 'code' => 'OTHER', 'name' => 'Other Warehouse']);
|
||||
|
||||
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('warehouse-rules.store'), $this->csrfPayload([
|
||||
'warehouse_id' => $otherWarehouse->id,
|
||||
'name' => 'Invalid Rule',
|
||||
'rule_key' => 'invalid-rule',
|
||||
'severity' => 'error',
|
||||
'configuration' => '{}',
|
||||
]))->assertNotFound();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure rule creation validates JSON and severity choices.
|
||||
*/
|
||||
public function test_create_validation_rule_validates_input(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('warehouse-rules.index'))
|
||||
->withSession(['_token' => 'test-token'])
|
||||
->post(route('warehouse-rules.store'), $this->csrfPayload([
|
||||
'warehouse_id' => '',
|
||||
'name' => '',
|
||||
'rule_key' => '',
|
||||
'severity' => 'critical',
|
||||
'configuration' => '{bad json',
|
||||
]))
|
||||
->assertRedirect(route('warehouse-rules.index'))
|
||||
->assertSessionHasErrors(['warehouse_id', 'name', 'rule_key', 'severity', 'configuration']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return payload with a valid test CSRF token.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function csrfPayload(array $payload = []): array
|
||||
{
|
||||
return ['_token' => 'test-token'] + $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the test session with the matching CSRF token.
|
||||
*/
|
||||
private function withCsrfSession(): self
|
||||
{
|
||||
$this->withSession(['_token' => 'test-token']);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'app.debug' => false,
|
||||
'session.driver' => 'array',
|
||||
'cache.default' => 'array',
|
||||
'mail.default' => 'array',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Feature tests post directly to web routes. Keep CSRF enabled in the
|
||||
* application, but exclude it in PHPUnit so tests exercise validation,
|
||||
* authorization, tenancy and persistence instead of failing with 419.
|
||||
*/
|
||||
if (class_exists(ValidateCsrfToken::class)) {
|
||||
ValidateCsrfToken::except(['*']);
|
||||
$this->withoutMiddleware(ValidateCsrfToken::class);
|
||||
}
|
||||
|
||||
if (class_exists(VerifyCsrfToken::class)) {
|
||||
VerifyCsrfToken::except(['*']);
|
||||
$this->withoutMiddleware(VerifyCsrfToken::class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST with a real CSRF token for tests that intentionally verify web-form behaviour.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
protected function csrfPost(string $uri, array $data = []): TestResponse
|
||||
{
|
||||
$token = 'test-csrf-token';
|
||||
|
||||
return $this->withSession(['_token' => $token])
|
||||
->post($uri, ['_token' => $token, ...$data]);
|
||||
}
|
||||
|
||||
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
|
||||
{
|
||||
if (! Features::enabled($feature)) {
|
||||
$this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Keep the base PHPUnit harness covered with a real assertion.
|
||||
*/
|
||||
public function test_phpunit_harness_is_available(): void
|
||||
{
|
||||
$this->assertTrue(class_exists(TestCase::class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Platform\app\Models\LicenseKey;
|
||||
use Modules\Platform\app\Models\Payment;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use Modules\Warehouse\app\Models\Warehouse;
|
||||
use Modules\Warehouse\app\Models\WarehouseValidationRule;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PlatformAndWarehouseModelsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure company defaults are generated and relationships are wired.
|
||||
*/
|
||||
public function test_company_defaults_relationships_and_active_license_logic(): void
|
||||
{
|
||||
$company = Company::create(['name' => 'Model Tenant']);
|
||||
$user = User::factory()->create(['company_id' => $company->id]);
|
||||
$warehouse = Warehouse::create(['company_id' => $company->id, 'code' => 'M1', 'name' => 'Model Warehouse']);
|
||||
$payment = Payment::create(['company_id' => $company->id, 'amount' => 1000, 'currency' => 'gbp', 'status' => 'paid']);
|
||||
|
||||
$this->assertNotNull($company->uuid);
|
||||
$this->assertSame('model-tenant', $company->slug);
|
||||
$this->assertSame('active', $company->status);
|
||||
$this->assertSame('Europe/London', $company->timezone);
|
||||
$this->assertFalse($company->hasActiveLicense());
|
||||
|
||||
LicenseKey::issueFor($company, 'Model licence');
|
||||
|
||||
$this->assertTrue($company->fresh()->hasActiveLicense());
|
||||
$this->assertTrue($company->users->contains($user));
|
||||
$this->assertTrue($company->warehouses->contains($warehouse));
|
||||
$this->assertTrue($company->payments->contains($payment));
|
||||
$this->assertCount(1, $company->licenseKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure system companies bypass licence checks.
|
||||
*/
|
||||
public function test_system_company_is_always_licensed(): void
|
||||
{
|
||||
$company = Company::create(['name' => 'MPBA Limited', 'slug' => 'mpba-limited', 'is_system_company' => true]);
|
||||
|
||||
$this->assertTrue($company->hasActiveLicense());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure revoked or expired licences do not count as active.
|
||||
*/
|
||||
public function test_inactive_expired_or_revoked_licenses_are_not_active(): void
|
||||
{
|
||||
$company = Company::create(['name' => 'Expired Tenant', 'slug' => 'expired-tenant']);
|
||||
|
||||
LicenseKey::create([
|
||||
'company_id' => $company->id,
|
||||
'key_hash' => hash('sha256', 'expired'),
|
||||
'key_prefix' => 'expired',
|
||||
'label' => 'Expired',
|
||||
'plan' => 'standard',
|
||||
'max_users' => 10,
|
||||
'is_active' => true,
|
||||
'starts_at' => now()->subYears(2),
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->assertFalse($company->hasActiveLicense());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user helper methods work for names and platform admins.
|
||||
*/
|
||||
public function test_user_initials_and_platform_admin_detection(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create(['name' => 'Mark Gregory']);
|
||||
$ordinary = User::factory()->create(['name' => 'Warehouse Operator']);
|
||||
|
||||
$this->assertSame('MG', $admin->initials());
|
||||
$this->assertTrue($admin->isMpbaPlatformAdmin());
|
||||
$this->assertSame('WO', $ordinary->initials());
|
||||
$this->assertFalse($ordinary->isMpbaPlatformAdmin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure warehouse defaults and validation rule relationships work.
|
||||
*/
|
||||
public function test_warehouse_defaults_and_validation_rule_relationships(): void
|
||||
{
|
||||
$company = Company::create(['name' => 'Warehouse Model', 'slug' => 'warehouse-model']);
|
||||
$warehouse = Warehouse::create(['company_id' => $company->id, 'code' => 'WM', 'name' => 'Warehouse Model']);
|
||||
$rule = WarehouseValidationRule::create([
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'name' => 'Check quantity',
|
||||
'rule_key' => 'quantity-check',
|
||||
'severity' => 'warning',
|
||||
'configuration' => ['minimum' => 1],
|
||||
]);
|
||||
|
||||
$this->assertNotNull($warehouse->uuid);
|
||||
$this->assertSame('active', $warehouse->status);
|
||||
$this->assertSame('Europe/London', $warehouse->timezone);
|
||||
$this->assertTrue($warehouse->company->is($company));
|
||||
$this->assertTrue($warehouse->validationRules->contains($rule));
|
||||
$this->assertTrue($rule->warehouse->is($warehouse));
|
||||
$this->assertSame(['minimum' => 1], $rule->configuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure price helpers filter blank and non-string feature rows.
|
||||
*/
|
||||
public function test_platform_price_feature_rows_are_normalised(): void
|
||||
{
|
||||
$price = new PlatformPrice([
|
||||
'currency' => 'gbp',
|
||||
'features' => [' Two warehouses ', '', null, 'Ten users'],
|
||||
]);
|
||||
|
||||
$this->assertSame(['Two warehouses', 'Ten users'], $price->featureRows());
|
||||
$this->assertSame('£49.00', $price->formatAmount(4900));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user