diff --git a/app/Support/AppVersion.php b/app/Support/AppVersion.php new file mode 100644 index 00000000..0859d38e --- /dev/null +++ b/app/Support/AppVersion.php @@ -0,0 +1,23 @@ +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'); + } +} diff --git a/composer.json b/composer.json index 7d95771b..a5dc019e 100644 --- a/composer.json +++ b/composer.json @@ -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": "*", diff --git a/tests/Browser/WmsPricingCrudBrowserTest.php b/tests/Browser/WmsPricingCrudBrowserTest.php new file mode 100644 index 00000000..00258ac3 --- /dev/null +++ b/tests/Browser/WmsPricingCrudBrowserTest.php @@ -0,0 +1,49 @@ +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'); + }); + } +} diff --git a/tests/Browser/WmsResponsiveNavigationTest.php b/tests/Browser/WmsResponsiveNavigationTest.php new file mode 100644 index 00000000..8a96a96d --- /dev/null +++ b/tests/Browser/WmsResponsiveNavigationTest.php @@ -0,0 +1,76 @@ +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'); + }); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 70d12f02..a4dff0bd 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -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(); } } diff --git a/tests/Feature/InternalApi/InternalApiCoverageTest.php b/tests/Feature/InternalApi/InternalApiCoverageTest.php new file mode 100644 index 00000000..e7822063 --- /dev/null +++ b/tests/Feature/InternalApi/InternalApiCoverageTest.php @@ -0,0 +1,96 @@ + '']); + + $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); + } +} diff --git a/tests/Feature/Modules/NavigatorHeaderCoverageTest.php b/tests/Feature/Modules/NavigatorHeaderCoverageTest.php new file mode 100644 index 00000000..e7059a86 --- /dev/null +++ b/tests/Feature/Modules/NavigatorHeaderCoverageTest.php @@ -0,0 +1,94 @@ +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 + */ + 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'], + ]; + } +} diff --git a/tests/Feature/Platform/PlatformAdministrationTest.php b/tests/Feature/Platform/PlatformAdministrationTest.php new file mode 100644 index 00000000..16ab5f11 --- /dev/null +++ b/tests/Feature/Platform/PlatformAdministrationTest.php @@ -0,0 +1,263 @@ +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 $overrides + * @return array + */ + 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, + ]; + } +} diff --git a/tests/Feature/Platform/PlatformPriceCrudTest.php b/tests/Feature/Platform/PlatformPriceCrudTest.php new file mode 100644 index 00000000..9cc93aaa --- /dev/null +++ b/tests/Feature/Platform/PlatformPriceCrudTest.php @@ -0,0 +1,292 @@ +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 + */ + public static function currencySymbolProvider(): array + { + return [ + ['gbp', '£'], + ['eur', '€'], + ['usd', '$'], + ['aud', 'AUD '], + ]; + } + + /** + * @param array $overrides + * @return array + */ + 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 + */ + 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', + ]; + } +} diff --git a/tests/Feature/Platform/SharedPricingRouteTest.php b/tests/Feature/Platform/SharedPricingRouteTest.php new file mode 100644 index 00000000..a4bbd4c9 --- /dev/null +++ b/tests/Feature/Platform/SharedPricingRouteTest.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/tests/Feature/Warehouse/WarehouseCrudTest.php b/tests/Feature/Warehouse/WarehouseCrudTest.php new file mode 100644 index 00000000..b2e85d9a --- /dev/null +++ b/tests/Feature/Warehouse/WarehouseCrudTest.php @@ -0,0 +1,202 @@ +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']); + } +} diff --git a/tests/Unit/Architecture/ProjectCoverageStructureTest.php b/tests/Unit/Architecture/ProjectCoverageStructureTest.php new file mode 100644 index 00000000..243fad7a --- /dev/null +++ b/tests/Unit/Architecture/ProjectCoverageStructureTest.php @@ -0,0 +1,101 @@ +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, '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 $relativeDirectories + * @return array + */ + 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)); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index e93a27fa..19bca7cc 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -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)); } } diff --git a/tests/Unit/Models/PlatformAndWarehouseModelsTest.php b/tests/Unit/Models/PlatformAndWarehouseModelsTest.php new file mode 100644 index 00000000..4fae9fc2 --- /dev/null +++ b/tests/Unit/Models/PlatformAndWarehouseModelsTest.php @@ -0,0 +1,127 @@ + '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)); + } +}