Repo rebuild

This commit is contained in:
Mark Gregory
2026-09-15 12:33:28 +01:00
commit be28c4feec
5629 changed files with 317074 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Fortify\Features;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get(route('login'));
$response->assertOk();
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
$response = $this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
}
public function test_users_can_not_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();
$response = $this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertSessionHasErrorsIn('email');
$this->assertGuest();
}
public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge(): void
{
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$response = $this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect(route('two-factor.login'));
$this->assertGuest();
}
public function test_users_can_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->csrfPost(route('logout'));
$response->assertRedirect(route('home'));
$this->assertGuest();
}
}
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Features;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::emailVerification());
}
public function test_email_verification_screen_can_be_rendered(): void
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get(route('verification.notice'));
$response->assertOk();
}
public function test_email_can_be_verified(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false) . '?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')],
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
{
$user = User::factory()->create([
'email_verified_at' => now(),
]);
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('dashboard', absolute: false) . '?verified=1');
$this->assertTrue($user->fresh()->hasVerifiedEmail());
Event::assertNotDispatched(Verified::class);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('password.confirm'));
$response->assertOk();
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::resetPasswords());
}
public function test_reset_password_link_screen_can_be_rendered(): void
{
$response = $this->get(route('password.request'));
$response->assertOk();
}
public function test_reset_password_link_can_be_requested(): void
{
Notification::fake();
$user = User::factory()->create();
$this->csrfPost(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered(): void
{
Notification::fake();
$user = User::factory()->create();
$this->csrfPost(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get(route('password.reset', $notification->token));
$response->assertOk();
return true;
});
}
public function test_password_can_be_reset_with_valid_token(): void
{
Notification::fake();
$user = User::factory()->create();
$this->csrfPost(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->csrfPost(route('password.update'), [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login', absolute: false));
return true;
});
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Fortify\Features;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::registration());
}
public function test_registration_screen_can_be_rendered(): void
{
$response = $this->get(route('register'));
$response->assertOk();
}
public function test_new_users_can_register(): void
{
$response = $this->csrfPost(route('register.store'), [
'name' => 'John Doe',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertSessionHasNoErrors()
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Fortify\Features;
use Tests\TestCase;
class TwoFactorChallengeTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
}
public function test_two_factor_challenge_redirects_to_login_when_not_authenticated(): void
{
$response = $this->get(route('two-factor.login'));
$response->assertRedirect(route('login'));
}
public function test_two_factor_challenge_can_be_rendered(): void
{
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$this->csrfPost(route('login.store'), [
'email' => $user->email,
'password' => 'password',
])->assertRedirect(route('two-factor.login'));
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_the_login_page(): void
{
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
}
public function test_authenticated_users_can_visit_the_dashboard(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ExampleTest extends TestCase
{
public function test_home_route_redirects_guests_to_login(): void
{
$this->get(route('home'))->assertRedirect(route('login'));
}
public function test_health_endpoint_returns_successful_response(): void
{
$this->get('/up')->assertOk();
}
}
@@ -0,0 +1,96 @@
<?php
namespace Tests\Feature\InternalApi;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Modules\Warehouse\app\Models\Warehouse;
use Tests\TestCase;
/**
* Covers CompanySummaryController and VerifyInternalApiSignature.
*/
class InternalApiCoverageTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the internal API rejects empty configuration.
*/
public function test_internal_api_rejects_when_secret_is_missing(): void
{
config(['platform.internal_api_key' => '']);
$this->getJson('/api/internal/companies')->assertUnauthorized();
}
/**
* Ensure invalid timestamps are rejected.
*/
public function test_internal_api_rejects_invalid_timestamp(): void
{
config(['platform.internal_api_key' => 'testing-secret']);
$this->withHeaders([
'X-WMS-Timestamp' => 'not-a-date',
'X-WMS-Signature' => 'bad',
])->getJson('/api/internal/companies')->assertUnauthorized();
}
/**
* Ensure expired timestamps are rejected.
*/
public function test_internal_api_rejects_expired_timestamp(): void
{
config(['platform.internal_api_key' => 'testing-secret']);
$timestamp = now()->subMinutes(10)->toIso8601String();
$signature = hash_hmac('sha256', $timestamp . '.', 'testing-secret');
$this->withHeaders([
'X-WMS-Timestamp' => $timestamp,
'X-WMS-Signature' => $signature,
])->getJson('/api/internal/companies')->assertUnauthorized();
}
/**
* Ensure incorrect HMAC signatures are rejected.
*/
public function test_internal_api_rejects_invalid_signature(): void
{
config(['platform.internal_api_key' => 'testing-secret']);
$timestamp = now()->toIso8601String();
$this->withHeaders([
'X-WMS-Timestamp' => $timestamp,
'X-WMS-Signature' => hash_hmac('sha256', $timestamp . '.', 'wrong-secret'),
])->getJson('/api/internal/companies')->assertUnauthorized();
}
/**
* Ensure valid signatures return company summaries and relationship counts.
*/
public function test_internal_api_returns_company_summary_counts(): void
{
config(['platform.internal_api_key' => 'testing-secret']);
$company = Company::create(['name' => 'Summary Tenant', 'slug' => 'summary-tenant']);
User::factory()->create(['company_id' => $company->id]);
LicenseKey::issueFor($company, 'Summary licence');
Warehouse::create(['company_id' => $company->id, 'code' => 'SUM', 'name' => 'Summary Warehouse']);
$timestamp = now()->toIso8601String();
$signature = hash_hmac('sha256', $timestamp . '.', 'testing-secret');
$this->withHeaders([
'X-WMS-Timestamp' => $timestamp,
'X-WMS-Signature' => $signature,
])->getJson('/api/internal/companies')
->assertOk()
->assertJsonPath('companies.0.name', 'Summary Tenant')
->assertJsonPath('companies.0.users_count', 1)
->assertJsonPath('companies.0.warehouses_count', 1)
->assertJsonPath('companies.0.license_keys_count', 1);
}
}
@@ -0,0 +1,79 @@
<?php
namespace Tests\Feature\Modules;
use Database\Factories\UserFactory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class ModuleSidebarAndStatusTest extends TestCase
{
use RefreshDatabase;
/**
* @return array<int, array{0: string, 1: string}>
*/
public static function moduleRoutes(): array
{
return [
['analytics.index', 'Analytics'],
['audit.index', 'Audit'],
['carrier-management.index', 'Carrier Management'],
['configuration.index', 'Configuration'],
['document-archive.index', 'Document Archive'],
['inventory-management.index', 'Inventory Management'],
['m3-integration.index', 'M3 Integration'],
['replenishment.index', 'Replenishment'],
['reporting.index', 'Reporting'],
['security.index', 'Security'],
['sentinel-integration.index', 'Sentinel Integration'],
['shipping.index', 'Shipping'],
['transportation.index', 'Transportation'],
];
}
#[DataProvider('moduleRoutes')]
public function test_enabled_module_route_renders(string $routeName, string $label): void
{
$this->assertTrue(Route::has($routeName), "Missing route [{$routeName}].");
$user = UserFactory::new()->mpbaPlatformAdmin()->create();
$this->actingAs($user)
->get(route($routeName))
->assertOk()
->assertSee($label, false)
->assertSee('Warehouse Management System', false);
}
public function test_sidebar_contains_all_enabled_web_modules(): void
{
$user = UserFactory::new()->mpbaPlatformAdmin()->create();
$response = $this->actingAs($user)->get(route('platform.dashboard'))->assertOk();
foreach (self::moduleRoutes() as [$routeName, $label]) {
$response->assertSee(route($routeName), false);
}
foreach (['Analytics', 'Inventory', 'Replenishment', 'Shipping', 'Transportation', 'Carriers', 'M3 Integration', 'Sentinel', 'Documents', 'Reporting', 'Configuration', 'Security', 'Audit'] as $label) {
$response->assertSee($label, false);
}
}
public function test_modules_statuses_file_enables_all_modules(): void
{
$statuses = json_decode(file_get_contents(base_path('modules_statuses.json')), true, 512, JSON_THROW_ON_ERROR);
foreach ([
'Analytics', 'Audit', 'CarrierManagement', 'Configuration', 'DocumentArchive', 'InternalApi',
'InventoryManagement', 'M3Integration', 'Platform', 'Replenishment', 'Reporting', 'Security',
'SentinelIntegration', 'Shipping', 'Transportation', 'Warehouse',
] as $module) {
$this->assertArrayHasKey($module, $statuses);
$this->assertTrue($statuses[$module], "Module [{$module}] is not enabled.");
}
}
}
@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature\Modules;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Modules\Platform\app\Models\PlatformPrice;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class NavigatorHeaderCoverageTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure every navigator page renders the professional single page hero baseline.
*/
#[DataProvider('navigatorPages')]
public function test_every_navigator_page_has_single_professional_header(string $routeName, string $title, string $description): void
{
$this->assertTrue(Route::has($routeName), "Missing navigator route [{$routeName}].");
$user = User::factory()->mpbaPlatformAdmin()->create();
$content = $this->actingAs($user)
->get(route($routeName))
->assertOk()
->assertSee('wms-page-hero', false)
->getContent();
if ($title !== '') {
$this->assertStringContainsString($title, $content);
}
if ($description !== '') {
$this->assertStringContainsString($description, $content);
}
$this->assertSame(
1,
preg_match_all('/<section\b[^>]*class="[^"]*\bwms-page-hero\b/', $content),
"Navigator route [{$routeName}] must render exactly one professional header section.",
);
}
/**
* Ensure the platform dashboard keeps pricing cards visible on Platform.
*/
public function test_platform_dashboard_does_not_show_pricing_display_cards(): void
{
$user = User::factory()->mpbaPlatformAdmin()->create();
PlatformPrice::query()->updateOrCreate(
['code' => 'basic'],
[
'name' => 'Basic',
'strapline' => 'Starter WMS platform access.',
'description' => 'Core warehouse management tools.',
'monthly_amount' => 4900,
'annual_amount' => 49000,
'currency' => 'gbp',
'features' => ['2 warehouses', '10 users', 'Standard support'],
'sort_order' => 10,
'is_active' => true,
'is_featured' => false,
],
);
$this->actingAs($user)
->get(route('platform.dashboard'))
->assertOk()
->assertDontSee('Pricing plans', false)
->assertSee(route('platform.pricing.index'), false)
->assertSee(route('platform.prices.index'), false);
}
/**
* Ensure the public Platform pricing route is separate from Administration price CRUD.
*/
public function test_pricing_routes_are_in_correct_platform_and_administration_menus(): void
{
$this->assertSame('/platform/pricing', parse_url(route('platform.pricing.index'), PHP_URL_PATH));
$this->assertSame('/admin/prices', parse_url(route('platform.prices.index'), PHP_URL_PATH));
$this->assertSame('/admin/prices/create', parse_url(route('platform.prices.create'), PHP_URL_PATH));
}
/**
* @return array<int, array{0: string, 1: string, 2: string}>
*/
public static function navigatorPages(): array
{
return [
['platform.dashboard', 'Platform Dashboard', 'System-level tenant'],
['platform.pricing.index', 'Pricing', 'Clear platform pricing'],
['platform.companies.index', 'Companies', 'Manage tenant companies'],
['platform.licenses.index', 'Licence Keys', 'Issue and monitor company licence keys'],
['platform.payments.index', 'Payments and Overrides', 'Record platform payments'],
['platform.prices.index', 'Pricing Administration', 'Create, edit and publish'],
['dashboard', '', ''],
['warehouses.index', 'Warehouses', 'Create and manage warehouse records'],
['warehouse-rules.index', 'Validation Rules', 'Configure warehouse validation'],
['analytics.index', 'Analytics', 'Display KPIs'],
['inventory-management.index', 'Inventory Management', 'Track inventory movement'],
['replenishment.index', 'Replenishment', 'Configure replenishment thresholds'],
['shipping.index', 'Shipping', 'Monitor shipment activity'],
['transportation.index', 'Transportation', 'Manage truck closure'],
['carrier-management.index', 'Carrier Management', 'Maintain carriers'],
['m3-integration.index', 'M3 Integration', 'Observe M3 transactions'],
['sentinel-integration.index', 'Sentinel Integration', 'Track Sentinel API activity'],
['document-archive.index', 'Document Archive', 'Search cached Sentinel documents'],
['reporting.index', 'Reporting', 'Provide operational reports'],
['configuration.index', 'Configuration', 'Centralise system settings'],
['security.index', 'Security', 'Control roles'],
['audit.index', 'Audit', 'Review activity logs'],
];
}
}
@@ -0,0 +1,285 @@
<?php
namespace Tests\Feature\Platform;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Modules\Platform\app\Models\Payment;
use Modules\Platform\app\Models\PlatformPrice;
use Modules\Warehouse\app\Models\Warehouse;
use Tests\TestCase;
/**
* Covers PlatformDashboardController, CompanyController, LicenseKeyController, PaymentController and StripeWebhookController.
*/
class PlatformAdministrationTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure guests are redirected away from platform administration.
*/
public function test_guest_is_redirected_from_platform_dashboard(): void
{
$this->get(route('platform.dashboard'))->assertRedirect(route('login'));
}
/**
* Ensure non-platform users cannot access admin administration.
*/
public function test_regular_user_is_forbidden_from_platform_dashboard(): void
{
$user = User::factory()->create();
$this->actingAs($user)->get(route('platform.dashboard'))->assertForbidden();
}
/**
* Ensure dashboard renders the single platform header and pricing cards.
*/
public function test_platform_dashboard_renders_counts_without_pricing_cards_and_recent_companies(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
PlatformPrice::query()->delete();
$company = Company::create(['name' => 'Customer One', 'slug' => 'customer-one']);
LicenseKey::issueFor($company, 'Customer licence', 'enterprise', 25);
Warehouse::create(['company_id' => $company->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']);
Payment::create(['company_id' => $company->id, 'amount' => 12500, 'currency' => 'gbp', 'status' => 'paid']);
PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic']));
PlatformPrice::create($this->pricePayload(['code' => 'advanced', 'name' => 'Advanced', 'sort_order' => 20]));
PlatformPrice::create($this->pricePayload(['code' => 'pro', 'name' => 'Pro', 'sort_order' => 30]));
$this->actingAs($admin)
->get(route('platform.dashboard'))
->assertOk()
->assertSee('Platform Dashboard', false)
->assertDontSee('Pricing plans', false)
->assertSee('Customer One', false);
}
/**
* Ensure companies can be created by platform admins.
*/
public function test_platform_admin_can_create_company(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.companies.store'), $this->csrfPayload([
'name' => 'Abbott Logistics Limited',
'billing_email' => 'billing@abbott.example',
'status' => 'active',
]))->assertRedirect();
$this->assertDatabaseHas('companies', [
'name' => 'Abbott Logistics Limited',
'slug' => 'abbott-logistics-limited',
'billing_email' => 'billing@abbott.example',
'status' => 'active',
'timezone' => 'Europe/London',
]);
}
/**
* Ensure invalid company submissions are rejected.
*/
public function test_company_create_validates_input(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$this->actingAs($admin)
->from(route('platform.companies.index'))
->withSession(['_token' => 'test-token'])
->post(route('platform.companies.store'), $this->csrfPayload([
'name' => '',
'billing_email' => 'not-an-email',
'status' => 'unknown',
]))
->assertRedirect(route('platform.companies.index'))
->assertSessionHasErrors(['name', 'billing_email', 'status']);
}
/**
* Ensure licences are issued hashed and shown once in session.
*/
public function test_platform_admin_can_issue_license_key(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$company = Company::create(['name' => 'Tenant A', 'slug' => 'tenant-a']);
$response = $this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.licenses.store'), $this->csrfPayload([
'company_id' => $company->id,
'label' => 'Production licence',
'plan' => 'enterprise',
'max_users' => 500,
]));
$response->assertRedirect()->assertSessionHas('plain_license_key');
$plainKey = session('plain_license_key');
$license = LicenseKey::firstOrFail();
$this->assertStringStartsWith('WMS-', $plainKey);
$this->assertSame(hash('sha256', $plainKey), $license->key_hash);
$this->assertSame('enterprise', $license->plan);
$this->assertSame(500, $license->max_users);
}
/**
* Ensure licence input is validated.
*/
public function test_license_create_validates_input(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$this->actingAs($admin)
->from(route('platform.licenses.index'))
->withSession(['_token' => 'test-token'])
->post(route('platform.licenses.store'), $this->csrfPayload([
'company_id' => 999,
'label' => '',
'plan' => 'ultimate',
'max_users' => 0,
]))
->assertRedirect(route('platform.licenses.index'))
->assertSessionHasErrors(['company_id', 'label', 'plan', 'max_users']);
}
/**
* Ensure manual payments are recorded with paid_at for paid statuses.
*/
public function test_platform_admin_can_create_manual_payment(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$company = Company::create(['name' => 'Tenant Payment', 'slug' => 'tenant-payment']);
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.payments.store'), $this->csrfPayload([
'company_id' => $company->id,
'amount' => 12345,
'currency' => 'gbp',
'status' => 'paid',
'description' => 'Manual bank transfer',
]))->assertRedirect();
$payment = Payment::firstOrFail();
$this->assertSame($company->id, $payment->company_id);
$this->assertSame(12345, $payment->amount);
$this->assertSame('manual_admin', $payment->metadata['source']);
$this->assertNotNull($payment->paid_at);
}
/**
* Ensure unpaid statuses do not set paid_at.
*/
public function test_pending_payment_does_not_set_paid_at(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$company = Company::create(['name' => 'Tenant Pending', 'slug' => 'tenant-pending']);
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.payments.store'), $this->csrfPayload([
'company_id' => $company->id,
'amount' => 5000,
'currency' => 'gbp',
'status' => 'pending',
'description' => 'Awaiting payment',
]))->assertRedirect();
$this->assertNull(Payment::firstOrFail()->paid_at);
}
/**
* Ensure the Stripe webhook creates or updates paid records.
*/
public function test_stripe_webhook_records_successful_payment_intent(): void
{
$company = Company::create([
'name' => 'Stripe Tenant',
'slug' => 'stripe-tenant',
'stripe_customer_id' => 'cus_test_123',
]);
$payload = [
'type' => 'payment_intent.succeeded',
'data' => [
'object' => [
'id' => 'pi_123',
'customer' => 'cus_test_123',
'amount_received' => 7800,
'currency' => 'gbp',
],
],
];
$this->withSession(['_token' => 'test-token'])->withHeader('X-CSRF-TOKEN', 'test-token')->postJson(route('stripe.webhook'), $payload)
->assertOk()
->assertJson(['received' => true]);
$this->assertDatabaseHas('payments', [
'company_id' => $company->id,
'stripe_payment_intent_id' => 'pi_123',
'amount' => 7800,
'currency' => 'gbp',
'status' => 'paid',
]);
}
/**
* Ensure unrelated Stripe events are acknowledged but do not create payments.
*/
public function test_stripe_webhook_ignores_unhandled_events(): void
{
$this->withSession(['_token' => 'test-token'])->withHeader('X-CSRF-TOKEN', 'test-token')->postJson(route('stripe.webhook'), ['type' => 'customer.created'])
->assertOk()
->assertJson(['received' => true]);
$this->assertDatabaseCount('payments', 0);
}
/**
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
private function pricePayload(array $overrides = []): array
{
return [
'code' => 'basic',
'name' => 'Basic',
'strapline' => 'Starter WMS platform access.',
'description' => 'Core warehouse management tools.',
'monthly_amount' => 4900,
'annual_amount' => 49000,
'currency' => 'gbp',
'badge_label' => 'Starter',
'features' => ['2 warehouses', '10 users', 'Standard support'],
'sort_order' => 10,
'is_featured' => false,
'is_active' => true,
...$overrides,
];
}
/**
* Return payload with a valid test CSRF token.
*
* @param array<string, mixed> $payload
*
* @return array<string, mixed>
*/
private function csrfPayload(array $payload = []): array
{
return ['_token' => 'test-token'] + $payload;
}
/**
* Seed the test session with the matching CSRF token.
*/
private function withCsrfSession(): self
{
$this->withSession(['_token' => 'test-token']);
return $this;
}
}
@@ -0,0 +1,323 @@
<?php
namespace Tests\Feature\Platform;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Platform\app\Models\PlatformPrice;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class PlatformPriceCrudTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the customer-facing Platform pricing page only shows active prices in display order.
*/
public function test_platform_pricing_page_displays_active_prices_in_card_grid(): void
{
$user = User::factory()->create();
PlatformPrice::query()->delete();
PlatformPrice::create($this->pricePayload(['code' => 'pro', 'name' => 'Pro', 'sort_order' => 30]));
PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic', 'sort_order' => 10]));
PlatformPrice::create($this->pricePayload(['code' => 'legacy', 'name' => 'Legacy', 'is_active' => false, 'sort_order' => 1]));
$response = $this->actingAs($user)->get(route('platform.pricing.index'));
$response->assertOk()
->assertSee('Pricing', false)
->assertSee('Basic', false)
->assertSee('Pro', false)
->assertDontSee('Legacy', false)
->assertSeeInOrder(['Basic', 'Pro']);
}
/**
* Ensure the admin pricing index remains an Administration CRUD area.
*/
public function test_platform_admin_can_view_price_crud_index(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
PlatformPrice::query()->delete();
PlatformPrice::create($this->pricePayload(['code' => 'advanced', 'name' => 'Advanced']));
$this->actingAs($admin)
->get(route('platform.prices.index'))
->assertOk()
->assertSee('Pricing Administration', false)
->assertSee('Advanced', false)
->assertSee('Add price', false)
->assertSee(route('platform.prices.create'), false);
}
/**
* Ensure only MPBA platform admins can reach price administration.
*/
public function test_non_platform_admin_cannot_access_price_crud(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('platform.prices.index'))
->assertForbidden();
}
/**
* Ensure create renders with default form values.
*/
public function test_platform_admin_can_open_create_price_form(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$this->actingAs($admin)
->get(route('platform.prices.create'))
->assertOk()
->assertSee('Create Price', false)
->assertSee('Currency', false);
}
/**
* Ensure store normalises amounts, code, booleans, currency and feature rows.
*/
public function test_platform_admin_can_create_price(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$response = $this->actingAs($admin)->withSession(['_token' => 'test-token'])->post(route('platform.prices.store'), $this->csrfPayload([
'code' => '',
'name' => 'Ultimate Plan',
'strapline' => 'Full control',
'description' => 'Unlimited warehouses and users.',
'monthly_amount' => '199.99',
'annual_amount' => '1999.00',
'currency' => 'GBP',
'badge_label' => 'Best Value',
'features' => "Unlimited warehouses\nUnlimited users\nPriority support\n",
'sort_order' => 40,
'is_featured' => '1',
'is_active' => '1',
]));
$response->assertRedirect(route('platform.prices.index'));
$this->assertDatabaseHas('platform_prices', [
'code' => 'ultimate-plan',
'name' => 'Ultimate Plan',
'monthly_amount' => 19999,
'annual_amount' => 199900,
'currency' => 'gbp',
'is_featured' => true,
'is_active' => true,
]);
$this->assertSame(
['Unlimited warehouses', 'Unlimited users', 'Priority support'],
PlatformPrice::where('code', 'ultimate-plan')->firstOrFail()->featureRows(),
);
}
/**
* Ensure validation protects required fields and amount limits.
*/
public function test_create_price_validates_required_fields(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
$this->actingAs($admin)
->from(route('platform.prices.create'))
->withSession(['_token' => 'test-token'])
->post(route('platform.prices.store'), $this->csrfPayload([
'name' => '',
'monthly_amount' => '-1',
'currency' => 'GB',
'sort_order' => -5,
]))
->assertRedirect(route('platform.prices.create'))
->assertSessionHasErrors(['name', 'monthly_amount', 'currency', 'sort_order']);
}
/**
* Ensure edit renders the current price.
*/
public function test_platform_admin_can_open_edit_price_form(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
PlatformPrice::query()->delete();
$price = PlatformPrice::create($this->pricePayload(['code' => 'basic-edit', 'name' => 'Basic']));
$this->actingAs($admin)
->get(route('platform.prices.edit', $price))
->assertOk()
->assertSee('Edit Basic', false)
->assertSee('Basic', false);
}
/**
* Ensure update preserves unique-code validation for the current record.
*/
public function test_platform_admin_can_update_price(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
PlatformPrice::query()->delete();
$price = PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic']));
$this->actingAs($admin)->withSession(['_token' => 'test-token'])->put(route('platform.prices.update', $price), $this->csrfPayload([
'code' => 'basic',
'name' => 'Basic Updated',
'strapline' => 'Entry level stock control',
'description' => 'Updated description.',
'monthly_amount' => '29.00',
'annual_amount' => '',
'currency' => 'GBP',
'badge_label' => '',
'features' => "2 warehouses\n10 users",
'sort_order' => 5,
'is_active' => '1',
]))->assertRedirect(route('platform.prices.index'));
$this->assertDatabaseHas('platform_prices', [
'id' => $price->id,
'code' => 'basic',
'name' => 'Basic Updated',
'monthly_amount' => 2900,
'annual_amount' => null,
'sort_order' => 5,
'is_featured' => false,
'is_active' => true,
]);
}
/**
* Ensure duplicate codes are rejected on update.
*/
public function test_price_code_must_remain_unique(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
PlatformPrice::query()->delete();
PlatformPrice::create($this->pricePayload(['code' => 'basic', 'name' => 'Basic']));
$price = PlatformPrice::create($this->pricePayload(['code' => 'advanced', 'name' => 'Advanced']));
$this->actingAs($admin)
->from(route('platform.prices.edit', $price))
->withSession(['_token' => 'test-token'])
->put(route('platform.prices.update', $price), $this->csrfPayload([
...$this->requestPayload(),
'code' => 'basic',
'name' => 'Advanced',
]))
->assertRedirect(route('platform.prices.edit', $price))
->assertSessionHasErrors(['code']);
}
/**
* Ensure destroy removes the price.
*/
public function test_platform_admin_can_delete_price(): void
{
$admin = User::factory()->mpbaPlatformAdmin()->create();
PlatformPrice::query()->delete();
$price = PlatformPrice::create($this->pricePayload(['code' => 'legacy', 'name' => 'Legacy']));
$this->actingAs($admin)
->withSession(['_token' => 'test-token'])
->delete(route('platform.prices.destroy', $price), ['_token' => 'test-token'])
->assertRedirect(route('platform.prices.index'));
$this->assertDatabaseMissing('platform_prices', ['id' => $price->id]);
}
/**
* Ensure currencies render with correct symbols.
*/
#[DataProvider('currencySymbolProvider')]
public function test_price_currency_symbol_and_formatting(string $currency, string $expected): void
{
$price = new PlatformPrice(['currency' => $currency]);
$this->assertSame($expected, $price->currencySymbol());
$this->assertStringStartsWith($expected, $price->formatAmount(12345));
$this->assertSame('—', $price->formatAmount(null));
}
/**
* @return array<int, array{0: string, 1: string}>
*/
public static function currencySymbolProvider(): array
{
return [
['gbp', '£'],
['eur', '€'],
['usd', '$'],
['aud', 'AUD '],
];
}
/**
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
private function pricePayload(array $overrides = []): array
{
return [
'code' => 'basic',
'name' => 'Basic',
'strapline' => 'Starter WMS platform access.',
'description' => 'Core warehouse management tools.',
'monthly_amount' => 4900,
'annual_amount' => 49000,
'currency' => 'gbp',
'badge_label' => 'Starter',
'features' => ['2 warehouses', '10 users', 'Standard support'],
'sort_order' => 10,
'is_featured' => false,
'is_active' => true,
...$overrides,
];
}
/**
* @return array<string, mixed>
*/
private function requestPayload(): array
{
return [
'code' => 'advanced',
'name' => 'Advanced',
'strapline' => 'Expanded controls.',
'description' => 'Larger warehouse estate support.',
'monthly_amount' => '99.00',
'annual_amount' => '999.00',
'currency' => 'GBP',
'badge_label' => 'Popular',
'features' => "5 warehouses\n100 users",
'sort_order' => 20,
'is_featured' => '1',
'is_active' => '1',
];
}
/**
* Return payload with a valid test CSRF token.
*
* @param array<string, mixed> $payload
*
* @return array<string, mixed>
*/
private function csrfPayload(array $payload = []): array
{
return ['_token' => 'test-token'] + $payload;
}
/**
* Seed the test session with the matching CSRF token.
*/
private function withCsrfSession(): self
{
$this->withSession(['_token' => 'test-token']);
return $this;
}
}
@@ -0,0 +1,90 @@
<?php
namespace Tests\Feature\Settings;
use App\Livewire\Settings\Profile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ProfileUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed(): void
{
$this->actingAs($user = User::factory()->create());
$this->get('/settings/profile')->assertOk();
}
public function test_profile_information_can_be_updated(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test(Profile::class)
->set('name', 'Test User')
->set('email', 'test@example.com')
->call('updateProfileInformation');
$response->assertHasNoErrors();
$user->refresh();
$this->assertEquals('Test User', $user->name);
$this->assertEquals('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_email_address_is_unchanged(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test(Profile::class)
->set('name', 'Test User')
->set('email', $user->email)
->call('updateProfileInformation');
$response->assertHasNoErrors();
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test('settings.delete-user-form')
->set('password', 'password')
->call('deleteUser');
$response
->assertHasNoErrors()
->assertRedirect('/');
$this->assertNull($user->fresh());
$this->assertFalse(auth()->check());
}
public function test_correct_password_must_be_provided_to_delete_account(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test('settings.delete-user-form')
->set('password', 'wrong-password')
->call('deleteUser');
$response->assertHasErrors(['password']);
$this->assertNotNull($user->fresh());
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature\Settings;
use App\Livewire\Settings\Security;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Laravel\Fortify\Features;
use Livewire\Livewire;
use Tests\TestCase;
class SecurityTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
Features::passkeys([
'confirmPassword' => true,
]);
}
public function test_security_settings_page_can_be_rendered(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'));
$response->assertOk();
$response->assertSee('Passkeys');
$response->assertSee('No passkeys yet');
$response->assertSee('Two-factor authentication');
$response->assertSee('Enable 2FA');
}
public function test_security_settings_page_requires_password_confirmation_when_enabled(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->get(route('security.edit'));
$response->assertRedirect(route('password.confirm'));
}
public function test_security_settings_page_renders_without_two_factor_when_feature_is_disabled(): void
{
config(['fortify.features' => []]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertOk()
->assertSee('Update password')
->assertDontSee('Manage your passkeys for passwordless sign-in')
->assertDontSee('Add a passkey to sign in without a password')
->assertDontSee('Two-factor authentication');
}
public function test_two_factor_authentication_disabled_when_confirmation_abandoned_between_requests(): void
{
$user = User::factory()->create();
$user->forceFill([
'two_factor_secret' => encrypt('test-secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
'two_factor_confirmed_at' => null,
])->save();
$this->actingAs($user);
$component = Livewire::test(Security::class);
$component->assertSet('twoFactorEnabled', false);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
]);
}
public function test_password_can_be_updated(): void
{
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
$this->actingAs($user);
$response = Livewire::test(Security::class)
->set('current_password', 'password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$response->assertHasNoErrors();
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password(): void
{
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
$this->actingAs($user);
$response = Livewire::test(Security::class)
->set('current_password', 'wrong-password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$response->assertHasErrors(['current_password']);
}
}
@@ -0,0 +1,226 @@
<?php
namespace Tests\Feature\Warehouse;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Modules\Warehouse\app\Models\Warehouse;
use Modules\Warehouse\app\Models\WarehouseValidationRule;
use Tests\TestCase;
class WarehouseCrudTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure licensed tenant users can see the warehouse dashboard for their company.
*/
public function test_licensed_user_can_view_warehouse_dashboard(): void
{
$user = User::factory()->create();
Warehouse::create(['company_id' => $user->company_id, 'code' => 'MAIN', 'name' => 'Main Warehouse']);
$this->actingAs($user)
->get(route('warehouses.index'))
->assertOk()
->assertSee('Warehouses', false)
->assertSee('Main Warehouse', false);
}
/**
* Ensure unlicensed tenants are blocked from licensed warehouse screens.
*/
public function test_unlicensed_tenant_is_blocked_from_warehouse_dashboard(): void
{
$company = Company::create(['name' => 'Unlicensed Ltd', 'slug' => 'unlicensed-ltd']);
$user = User::factory()->create(['company_id' => $company->id]);
$this->actingAs($user)
->get(route('dashboard'))
->assertStatus(402);
}
/**
* Ensure users without a company are forbidden by tenant assignment middleware.
*/
public function test_user_without_company_is_forbidden_from_warehouse_dashboard(): void
{
$user = User::factory()->create(['company_id' => null]);
$this->actingAs($user)
->get(route('dashboard'))
->assertForbidden();
}
/**
* Ensure tenant users only see their own warehouses.
*/
public function test_warehouse_index_is_tenant_isolated(): void
{
$companyA = Company::create(['name' => 'Company A', 'slug' => 'company-a']);
$companyB = Company::create(['name' => 'Company B', 'slug' => 'company-b']);
LicenseKey::issueFor($companyA, 'A licence');
$user = User::factory()->create(['company_id' => $companyA->id]);
Warehouse::create(['company_id' => $companyA->id, 'code' => 'A1', 'name' => 'A Warehouse']);
Warehouse::create(['company_id' => $companyB->id, 'code' => 'B1', 'name' => 'B Warehouse']);
$this->actingAs($user)
->get(route('warehouses.index'))
->assertOk()
->assertSee('A Warehouse', false)
->assertDontSee('B Warehouse', false);
}
/**
* Ensure warehouse creation uses the authenticated tenant company and timezone.
*/
public function test_licensed_user_can_create_warehouse_for_their_company(): void
{
$user = User::factory()->create();
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('warehouses.store'), $this->csrfPayload([
'code' => 'EAST',
'name' => 'East Midlands DC',
'address' => 'Main Road, Nottingham',
]))->assertRedirect();
$this->assertDatabaseHas('warehouses', [
'company_id' => $user->company_id,
'code' => 'EAST',
'name' => 'East Midlands DC',
'status' => 'active',
'timezone' => 'Europe/London',
]);
}
/**
* Ensure warehouse input validation protects required fields.
*/
public function test_create_warehouse_validates_input(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->from(route('warehouses.index'))
->withSession(['_token' => 'test-token'])
->post(route('warehouses.store'), $this->csrfPayload([
'code' => '',
'name' => '',
]))
->assertRedirect(route('warehouses.index'))
->assertSessionHasErrors(['code', 'name']);
}
/**
* Ensure rules index only lists rules attached to the authenticated company.
*/
public function test_validation_rules_index_is_tenant_isolated(): void
{
$companyA = Company::create(['name' => 'Rules A', 'slug' => 'rules-a']);
$companyB = Company::create(['name' => 'Rules B', 'slug' => 'rules-b']);
LicenseKey::issueFor($companyA, 'Rules licence');
$user = User::factory()->create(['company_id' => $companyA->id]);
$warehouseA = Warehouse::create(['company_id' => $companyA->id, 'code' => 'RA', 'name' => 'Rules A Warehouse']);
$warehouseB = Warehouse::create(['company_id' => $companyB->id, 'code' => 'RB', 'name' => 'Rules B Warehouse']);
WarehouseValidationRule::create(['warehouse_id' => $warehouseA->id, 'name' => 'A Rule', 'rule_key' => 'a-rule', 'severity' => 'warning']);
WarehouseValidationRule::create(['warehouse_id' => $warehouseB->id, 'name' => 'B Rule', 'rule_key' => 'b-rule', 'severity' => 'blocker']);
$this->actingAs($user)
->get(route('warehouse-rules.index'))
->assertOk()
->assertSee('A Rule', false)
->assertDontSee('B Rule', false);
}
/**
* Ensure users can create validation rules for their own warehouses.
*/
public function test_user_can_create_validation_rule_for_own_warehouse(): void
{
$user = User::factory()->create();
$warehouse = Warehouse::create(['company_id' => $user->company_id, 'code' => 'MAIN', 'name' => 'Main Warehouse']);
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('warehouse-rules.store'), $this->csrfPayload([
'warehouse_id' => $warehouse->id,
'name' => 'Require batch number',
'rule_key' => 'batch-required',
'severity' => 'blocker',
'configuration' => json_encode(['field' => 'batch_no']),
]))->assertRedirect();
$this->assertDatabaseHas('warehouse_validation_rules', [
'warehouse_id' => $warehouse->id,
'name' => 'Require batch number',
'rule_key' => 'batch-required',
'severity' => 'blocker',
'is_active' => true,
]);
$this->assertSame(['field' => 'batch_no'], WarehouseValidationRule::firstOrFail()->configuration);
}
/**
* Ensure users cannot create rules against another tenant warehouse.
*/
public function test_user_cannot_create_validation_rule_for_another_company_warehouse(): void
{
$user = User::factory()->create();
$otherCompany = Company::create(['name' => 'Other Company', 'slug' => 'other-company']);
$otherWarehouse = Warehouse::create(['company_id' => $otherCompany->id, 'code' => 'OTHER', 'name' => 'Other Warehouse']);
$this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('warehouse-rules.store'), $this->csrfPayload([
'warehouse_id' => $otherWarehouse->id,
'name' => 'Invalid Rule',
'rule_key' => 'invalid-rule',
'severity' => 'error',
'configuration' => '{}',
]))->assertNotFound();
}
/**
* Ensure rule creation validates JSON and severity choices.
*/
public function test_create_validation_rule_validates_input(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->from(route('warehouse-rules.index'))
->withSession(['_token' => 'test-token'])
->post(route('warehouse-rules.store'), $this->csrfPayload([
'warehouse_id' => '',
'name' => '',
'rule_key' => '',
'severity' => 'critical',
'configuration' => '{bad json',
]))
->assertRedirect(route('warehouse-rules.index'))
->assertSessionHasErrors(['warehouse_id', 'name', 'rule_key', 'severity', 'configuration']);
}
/**
* Return payload with a valid test CSRF token.
*
* @param array<string, mixed> $payload
*
* @return array<string, mixed>
*/
private function csrfPayload(array $payload = []): array
{
return ['_token' => 'test-token'] + $payload;
}
/**
* Seed the test session with the matching CSRF token.
*/
private function withCsrfSession(): self
{
$this->withSession(['_token' => 'test-token']);
return $this;
}
}