fixed mobile
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class AppVersion
|
||||
{
|
||||
public static function gitHash(): string
|
||||
{
|
||||
return trim(Process::run('git rev-parse --short HEAD')->output()) ?: 'unknown';
|
||||
}
|
||||
|
||||
public static function gitBranch(): string
|
||||
{
|
||||
return trim(Process::run('git rev-parse --abbrev-ref HEAD')->output()) ?: 'unknown';
|
||||
}
|
||||
|
||||
public static function buildDate(): string
|
||||
{
|
||||
return now()->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"predis/predis": "^3.5",
|
||||
"ramsey/uuid": "^4.9",
|
||||
"robgridley/zebra": "*",
|
||||
"sentry/sentry-laravel": "^4.25",
|
||||
"setasign/fpdf": "*",
|
||||
"setasign/fpdi": "*",
|
||||
"smalot/pdfparser": "*",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseMigrations;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class WmsPricingCrudBrowserTest extends DuskTestCase
|
||||
{
|
||||
use DatabaseMigrations;
|
||||
/**
|
||||
* Ensure pricing cards remain visible on the Platform page and CRUD remains under Administration.
|
||||
*/
|
||||
public function test_platform_pricing_cards_and_admin_price_crud_are_reachable(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
PlatformPrice::create([
|
||||
'code' => 'basic',
|
||||
'name' => 'Basic',
|
||||
'strapline' => 'Starter plan',
|
||||
'description' => 'Entry WMS access.',
|
||||
'monthly_amount' => 4900,
|
||||
'annual_amount' => 49000,
|
||||
'currency' => 'gbp',
|
||||
'badge_label' => 'Starter',
|
||||
'features' => ['2 warehouses', '10 users'],
|
||||
'sort_order' => 10,
|
||||
'is_featured' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin): void {
|
||||
$browser->loginAs($admin)
|
||||
->resize(1440, 1000)
|
||||
->visitRoute('platform.dashboard')
|
||||
->assertSee('Pricing plans')
|
||||
->assertSee('Basic')
|
||||
->clickLink('Manage prices')
|
||||
->assertRouteIs('platform.prices.index')
|
||||
->assertSee('Pricing Administration')
|
||||
->clickLink('Add price')
|
||||
->assertRouteIs('platform.prices.create')
|
||||
->assertSee('Create Price');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseMigrations;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class WmsResponsiveNavigationTest extends DuskTestCase
|
||||
{
|
||||
use DatabaseMigrations;
|
||||
/**
|
||||
* Ensure the desktop sidebar remains visible and usable on large screens.
|
||||
*/
|
||||
public function test_desktop_sidebar_navigation_remains_unchanged(): void
|
||||
{
|
||||
$user = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user): void {
|
||||
$browser->loginAs($user)
|
||||
->resize(1440, 1000)
|
||||
->visitRoute('platform.dashboard')
|
||||
->assertSee('Platform Dashboard')
|
||||
->assertSee('Analytics')
|
||||
->assertSee('Inventory')
|
||||
->clickLink('Inventory')
|
||||
->assertRouteIs('inventory-management.index')
|
||||
->assertSee('Inventory Management');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the mobile menu hamburger opens the slide-out navigation.
|
||||
*/
|
||||
public function test_mobile_menu_opens_slide_out_navigation(): void
|
||||
{
|
||||
$user = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user): void {
|
||||
$browser->loginAs($user)
|
||||
->resize(390, 844)
|
||||
->visitRoute('platform.dashboard')
|
||||
->assertSee('Platform Dashboard')
|
||||
->assertPresent('[data-wms-mobile-open]')
|
||||
->click('[data-wms-mobile-open]')
|
||||
->pause(150)
|
||||
->assertAttribute('[data-wms-mobile-open]', 'aria-expanded', 'true')
|
||||
->assertPresent('[data-wms-sidebar]')
|
||||
->assertSee('Platform')
|
||||
->clickLink('Shipping')
|
||||
->assertRouteIs('shipping.index')
|
||||
->assertSee('Shipping');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the mobile overlay closes the menu without affecting desktop navigation.
|
||||
*/
|
||||
public function test_mobile_menu_overlay_closes_drawer(): void
|
||||
{
|
||||
$user = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user): void {
|
||||
$browser->loginAs($user)
|
||||
->resize(414, 896)
|
||||
->visitRoute('platform.dashboard')
|
||||
->click('[data-wms-mobile-open]')
|
||||
->pause(150)
|
||||
->assertAttribute('[data-wms-mobile-open]', 'aria-expanded', 'true')
|
||||
->click('[data-wms-mobile-close]')
|
||||
->pause(150)
|
||||
->assertAttribute('[data-wms-mobile-open]', 'aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,21 @@ class ExampleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_returns_a_successful_response(): void
|
||||
/**
|
||||
* Ensure the public home route renders successfully.
|
||||
*/
|
||||
public function test_home_route_returns_successful_response(): void
|
||||
{
|
||||
$response = $this->get(route('home'));
|
||||
$this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertSee('Laravel', false);
|
||||
}
|
||||
|
||||
$response->assertOk();
|
||||
/**
|
||||
* Ensure the health endpoint is available for deployment checks.
|
||||
*/
|
||||
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\Platform\app\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,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Modules;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
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($title, false)
|
||||
->assertSee($description, false)
|
||||
->assertSee('page-hero', false)
|
||||
->getContent();
|
||||
|
||||
$this->assertSame(
|
||||
1,
|
||||
substr_count($content, 'page-hero'),
|
||||
"Navigator route [{$routeName}] must render exactly one professional header.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the platform dashboard keeps pricing cards visible on Platform.
|
||||
*/
|
||||
public function test_platform_dashboard_keeps_pricing_display_cards(): void
|
||||
{
|
||||
$user = User::factory()->mpbaPlatformAdmin()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('platform.dashboard'))
|
||||
->assertOk()
|
||||
->assertSee('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', 'Test Company', 'Tenant warehouse 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,263 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Platform;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\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_pricing_and_recent_companies(): void
|
||||
{
|
||||
$admin = User::factory()->mpbaPlatformAdmin()->create();
|
||||
$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)
|
||||
->assertSee('Pricing plans', false)
|
||||
->assertSee('Basic', false)
|
||||
->assertSee('Advanced', false)
|
||||
->assertSee('Pro', false)
|
||||
->assertSee('Customer One', false)
|
||||
->assertSee('Main Warehouse', 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)->post(route('platform.companies.store'), [
|
||||
'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'))
|
||||
->post(route('platform.companies.store'), [
|
||||
'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)->post(route('platform.licenses.store'), [
|
||||
'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'))
|
||||
->post(route('platform.licenses.store'), [
|
||||
'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)->post(route('platform.payments.store'), [
|
||||
'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)->post(route('platform.payments.store'), [
|
||||
'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->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->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Platform;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\Models\Company;
|
||||
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::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::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)->post(route('platform.prices.store'), [
|
||||
'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'))
|
||||
->post(route('platform.prices.store'), [
|
||||
'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();
|
||||
$price = PlatformPrice::create($this->pricePayload(['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();
|
||||
$price = PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic']));
|
||||
|
||||
$this->actingAs($admin)->put(route('platform.prices.update', $price), [
|
||||
'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::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))
|
||||
->put(route('platform.prices.update', $price), [
|
||||
...$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();
|
||||
$price = PlatformPrice::create($this->pricePayload(['code' => 'legacy', 'name' => 'Legacy']));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('platform.prices.destroy', $price))
|
||||
->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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Platform;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\Models\PlatformPrice;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Covers the legacy PricingController route by rendering the shared Platform price cards.
|
||||
*/
|
||||
class SharedPricingRouteTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the Pricing module delegates to the shared Platform pricing display.
|
||||
*/
|
||||
public function test_legacy_pricing_module_route_renders_active_platform_prices(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
PlatformPrice::create([
|
||||
'code' => 'basic',
|
||||
'name' => 'Basic',
|
||||
'strapline' => 'Starter access',
|
||||
'description' => 'Core WMS functions.',
|
||||
'monthly_amount' => 4900,
|
||||
'annual_amount' => 49000,
|
||||
'currency' => 'gbp',
|
||||
'badge_label' => 'Starter',
|
||||
'features' => ['2 warehouses', '10 users'],
|
||||
'sort_order' => 10,
|
||||
'is_featured' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('pricing.index'))
|
||||
->assertOk()
|
||||
->assertSee('Pricing', false)
|
||||
->assertSee('Basic', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Warehouse;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Platform\app\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('dashboard'))
|
||||
->assertOk()
|
||||
->assertSee('Test Company', 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)->post(route('warehouses.store'), [
|
||||
'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'))
|
||||
->post(route('warehouses.store'), [
|
||||
'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)->post(route('warehouse-rules.store'), [
|
||||
'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)->post(route('warehouse-rules.store'), [
|
||||
'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'))
|
||||
->post(route('warehouse-rules.store'), [
|
||||
'warehouse_id' => '',
|
||||
'name' => '',
|
||||
'rule_key' => '',
|
||||
'severity' => 'critical',
|
||||
'configuration' => '{bad json',
|
||||
])
|
||||
->assertRedirect(route('warehouse-rules.index'))
|
||||
->assertSessionHasErrors(['warehouse_id', 'name', 'rule_key', 'severity', 'configuration']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Architecture;
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ProjectCoverageStructureTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Ensure every controller has at least one matching feature test area.
|
||||
*/
|
||||
public function test_controllers_are_accounted_for_by_feature_tests(): void
|
||||
{
|
||||
$controllers = $this->filesMatching(['/../../../app', '/../../../Modules'], '/Controller\.php$/');
|
||||
$this->assertNotEmpty($controllers);
|
||||
|
||||
$testCorpus = '';
|
||||
foreach ($this->filesMatching(['/../../Feature', '/../../../Modules'], '/Test\.php$/') as $testFile) {
|
||||
$testCorpus .= file_get_contents($testFile) ?: '';
|
||||
}
|
||||
|
||||
foreach ($controllers as $controller) {
|
||||
$controllerName = pathinfo($controller, PATHINFO_FILENAME);
|
||||
$resourceName = str_replace(['Controller', 'Dashboard'], '', $controllerName);
|
||||
|
||||
$this->assertTrue(
|
||||
str_contains($testCorpus, $controllerName) || str_contains($testCorpus, $resourceName) || $controllerName === 'Controller',
|
||||
"Controller [{$controllerName}] does not appear to be covered by the feature test corpus.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the WMS baseline component is used without duplicate headers across module views.
|
||||
*/
|
||||
public function test_module_views_do_not_contain_duplicate_wms_page_hero_components(): void
|
||||
{
|
||||
$views = $this->filesMatching(['/../../../Modules'], '/\.blade\.php$/');
|
||||
$this->assertNotEmpty($views);
|
||||
|
||||
foreach ($views as $view) {
|
||||
if (str_ends_with($view, 'partials.blade.php') || str_ends_with($view, '_form.blade.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($view) ?: '';
|
||||
|
||||
$this->assertLessThanOrEqual(
|
||||
1,
|
||||
substr_count($contents, '<x-wms.page-hero'),
|
||||
"View [{$view}] should not contain duplicate page headers.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure browser tests exist for desktop and mobile navigation flows.
|
||||
*/
|
||||
public function test_browser_navigation_tests_exist_for_mobile_and_desktop(): void
|
||||
{
|
||||
$browserTests = $this->filesMatching(['/../../Browser'], '/Test\.php$/');
|
||||
$contents = implode("\n", array_map(static fn (string $file): string => file_get_contents($file) ?: '', $browserTests));
|
||||
|
||||
$this->assertStringContainsString('desktop sidebar', strtolower($contents));
|
||||
$this->assertStringContainsString('mobile menu', strtolower($contents));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $relativeDirectories
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function filesMatching(array $relativeDirectories, string $pattern): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach ($relativeDirectories as $relativeDirectory) {
|
||||
$directory = realpath(__DIR__ . $relativeDirectory);
|
||||
|
||||
if (! is_string($directory) || ! is_dir($directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file instanceof SplFileInfo || ! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($pattern, $file->getFilename()) === 1) {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($files));
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_that_true_is_true(): void
|
||||
/**
|
||||
* Keep the base PHPUnit harness covered with a real assertion.
|
||||
*/
|
||||
public function test_phpunit_harness_is_available(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
$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\Platform\app\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