From 93b04a0c2a634b600f0c4576e481759e39c718e5 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 9 Apr 2026 00:30:24 +0200 Subject: [PATCH] test(modules): integration tests for company surfaces and stub generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end coverage for the new module APIs and the custom module:make stubs shipped from invoiceshelf/modules. Each test file is hermetic — uses \InvoiceShelf\Modules\Registry::flush() in setup/teardown to prevent cross-test contamination, and ModuleMakeStubTest cleans up generated test artifacts (the throwaway scaffold directory and the storage statuses entry). - CompanyModulesIndexTest: 4 tests covering only-enabled-modules filter, has_settings flag computed against the real Registry, menu inclusion, and the empty-state response. - ModuleSettingsControllerTest: 7 tests covering 404 for unregistered slug, show schema + defaults round-trip, persistence with the module.{slug}.{key} prefix, missing-required-field rejection, unknown-key silent-drop, update 404, and per-company isolation (the load-bearing multi-tenancy guarantee). - BootstrapModuleMenuTest: 3 tests covering Registry-driven module_menu population on the company-context bootstrap branch, the empty default when nothing is registered, and the absence of module_menu on the super-admin-mode branch. - ModuleMakeStubTest: 3 tests that actually run Artisan::call('module:make', ['name' => ['ScaffoldProbe']]) against a throwaway module name and assert the generated ServiceProvider contains use InvoiceShelf\Modules\Registry, the generated composer.json requires invoiceshelf/modules: ^3.0, and starter lang/en/{menu,settings}.php exist. Validates that the custom stubs shipped from the package are picked up via Stub::setBasePath(). --- .../Modules/BootstrapModuleMenuTest.php | 59 ++++++++ .../Modules/CompanyModulesIndexTest.php | 104 ++++++++++++++ .../Company/Modules/ModuleMakeStubTest.php | 82 +++++++++++ .../Modules/ModuleSettingsControllerTest.php | 135 ++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 tests/Feature/Company/Modules/BootstrapModuleMenuTest.php create mode 100644 tests/Feature/Company/Modules/CompanyModulesIndexTest.php create mode 100644 tests/Feature/Company/Modules/ModuleMakeStubTest.php create mode 100644 tests/Feature/Company/Modules/ModuleSettingsControllerTest.php diff --git a/tests/Feature/Company/Modules/BootstrapModuleMenuTest.php b/tests/Feature/Company/Modules/BootstrapModuleMenuTest.php new file mode 100644 index 00000000..5e46074a --- /dev/null +++ b/tests/Feature/Company/Modules/BootstrapModuleMenuTest.php @@ -0,0 +1,59 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->withHeaders([ + 'company' => $user->companies()->first()->id, + ]); + Sanctum::actingAs($user, ['*']); + + Registry::flush(); +}); + +afterEach(function () { + Registry::flush(); +}); + +test('bootstrap returns module_menu populated from Registry', function () { + Registry::registerMenu('sales-tax-us', [ + 'title' => 'sales_tax_us::menu.title', + 'link' => '/admin/modules/sales-tax-us/settings', + 'icon' => 'CalculatorIcon', + ]); + + $response = getJson('api/v1/bootstrap')->assertOk(); + + $response->assertJsonPath('module_menu.0.title', 'sales_tax_us::menu.title'); + $response->assertJsonPath('module_menu.0.link', '/admin/modules/sales-tax-us/settings'); + $response->assertJsonPath('module_menu.0.icon', 'CalculatorIcon'); +}); + +test('bootstrap returns empty module_menu when nothing is registered', function () { + getJson('api/v1/bootstrap') + ->assertOk() + ->assertJsonPath('module_menu', []); +}); + +test('admin-mode bootstrap does not include module_menu', function () { + Registry::registerMenu('sales-tax-us', [ + 'title' => 'sales_tax_us::menu.title', + 'link' => '/admin/modules/sales-tax-us/settings', + 'icon' => 'CalculatorIcon', + ]); + + $response = getJson('api/v1/bootstrap?admin_mode=1'); + + // Super-admin branch should not include the dynamic Modules sidebar group — + // that surface only exists in the company context. + $response->assertJsonMissingPath('module_menu'); +}); diff --git a/tests/Feature/Company/Modules/CompanyModulesIndexTest.php b/tests/Feature/Company/Modules/CompanyModulesIndexTest.php new file mode 100644 index 00000000..f1fb7967 --- /dev/null +++ b/tests/Feature/Company/Modules/CompanyModulesIndexTest.php @@ -0,0 +1,104 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->withHeaders([ + 'company' => $user->companies()->first()->id, + ]); + Sanctum::actingAs($user, ['*']); + + Registry::flush(); +}); + +afterEach(function () { + Registry::flush(); +}); + +test('returns only enabled modules', function () { + Module::create([ + 'name' => 'sales-tax-us', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + ]); + + Module::create([ + 'name' => 'archived-module', + 'version' => '0.5.0', + 'installed' => true, + 'enabled' => false, + ]); + + $response = getJson('api/v1/company-modules')->assertOk(); + + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.slug', 'sales-tax-us'); +}); + +test('reports has_settings flag based on Registry', function () { + Module::create([ + 'name' => 'with-settings', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + ]); + + Module::create([ + 'name' => 'without-settings', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + ]); + + Registry::registerSettings('with-settings', [ + 'sections' => [ + ['title' => 'general', 'fields' => [ + ['key' => 'foo', 'type' => 'text'], + ]], + ], + ]); + + $response = getJson('api/v1/company-modules')->assertOk(); + + $rows = collect($response->json('data'))->keyBy('slug'); + + expect($rows['with-settings']['has_settings'])->toBeTrue(); + expect($rows['without-settings']['has_settings'])->toBeFalse(); +}); + +test('includes registered menu entry for active modules', function () { + Module::create([ + 'name' => 'menu-module', + 'version' => '1.0.0', + 'installed' => true, + 'enabled' => true, + ]); + + Registry::registerMenu('menu-module', [ + 'title' => 'menu_module::menu.title', + 'link' => '/admin/modules/menu-module/settings', + 'icon' => 'CalculatorIcon', + ]); + + $response = getJson('api/v1/company-modules')->assertOk(); + + $response->assertJsonPath('data.0.menu.title', 'menu_module::menu.title'); + $response->assertJsonPath('data.0.menu.icon', 'CalculatorIcon'); +}); + +test('returns empty array when no modules are enabled', function () { + getJson('api/v1/company-modules') + ->assertOk() + ->assertJsonPath('data', []); +}); diff --git a/tests/Feature/Company/Modules/ModuleMakeStubTest.php b/tests/Feature/Company/Modules/ModuleMakeStubTest.php new file mode 100644 index 00000000..a15a8e03 --- /dev/null +++ b/tests/Feature/Company/Modules/ModuleMakeStubTest.php @@ -0,0 +1,82 @@ +scaffoldModule = 'ScaffoldProbe'; + $this->scaffoldPath = base_path('Modules/'.$this->scaffoldModule); + + // Make sure no leftover from a previous crashed test. + if (File::isDirectory($this->scaffoldPath)) { + File::deleteDirectory($this->scaffoldPath); + } +}); + +afterEach(function () { + if (File::isDirectory($this->scaffoldPath)) { + File::deleteDirectory($this->scaffoldPath); + } + + // nwidart writes module activation state to storage/app/modules_statuses.json + // when module:make auto-enables the new module. Remove our scaffold entry + // so the file doesn't accumulate stale test data across runs. + $statusesFile = storage_path('app/modules_statuses.json'); + if (File::exists($statusesFile)) { + $statuses = json_decode(File::get($statusesFile), true) ?? []; + unset($statuses[$this->scaffoldModule]); + File::put($statusesFile, json_encode($statuses, JSON_PRETTY_PRINT)); + } +}); + +test('module:make generates a ServiceProvider that uses InvoiceShelf\\Modules\\Registry', function () { + Artisan::call('module:make', ['name' => [$this->scaffoldModule]]); + + $providerPath = $this->scaffoldPath.'/app/Providers/'.$this->scaffoldModule.'ServiceProvider.php'; + expect(File::exists($providerPath))->toBeTrue(); + + $contents = File::get($providerPath); + + expect($contents)->toContain('use InvoiceShelf\\Modules\\Registry as ModuleRegistry;'); + expect($contents)->toContain('ModuleRegistry::registerMenu('); + expect($contents)->toContain('ModuleRegistry::registerSettings('); + expect($contents)->toContain("protected string \$name = '{$this->scaffoldModule}';"); + expect($contents)->toContain('Str::kebab($this->name)'); +}); + +test('module:make generates a composer.json that requires invoiceshelf/modules', function () { + Artisan::call('module:make', ['name' => [$this->scaffoldModule]]); + + $composerPath = $this->scaffoldPath.'/composer.json'; + expect(File::exists($composerPath))->toBeTrue(); + + $manifest = json_decode(File::get($composerPath), true); + + expect($manifest['require'] ?? [])->toHaveKey('invoiceshelf/modules'); + expect($manifest['require']['invoiceshelf/modules'])->toBe('^3.0'); +}); + +test('module:make generates starter lang files for menu and settings', function () { + Artisan::call('module:make', ['name' => [$this->scaffoldModule]]); + + expect(File::exists($this->scaffoldPath.'/lang/en/menu.php'))->toBeTrue(); + expect(File::exists($this->scaffoldPath.'/lang/en/settings.php'))->toBeTrue(); + + $menu = require $this->scaffoldPath.'/lang/en/menu.php'; + expect($menu)->toHaveKey('title'); + expect($menu['title'])->toBe('ScaffoldProbe'); + + $settings = require $this->scaffoldPath.'/lang/en/settings.php'; + expect($settings)->toHaveKey('general_section'); + expect($settings)->toHaveKey('enabled'); +}); diff --git a/tests/Feature/Company/Modules/ModuleSettingsControllerTest.php b/tests/Feature/Company/Modules/ModuleSettingsControllerTest.php new file mode 100644 index 00000000..c4918682 --- /dev/null +++ b/tests/Feature/Company/Modules/ModuleSettingsControllerTest.php @@ -0,0 +1,135 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders([ + 'company' => $this->companyId, + ]); + Sanctum::actingAs($user, ['*']); + + Registry::flush(); +}); + +afterEach(function () { + Registry::flush(); +}); + +test('returns 404 for module without registered schema', function () { + getJson('api/v1/modules/unknown-module/settings') + ->assertNotFound(); +}); + +test('show returns schema and default values for unsaved settings', function () { + Registry::registerSettings('test-module', [ + 'sections' => [ + ['title' => 'connection', 'fields' => [ + ['key' => 'api_key', 'type' => 'password', 'rules' => ['required']], + ['key' => 'sandbox', 'type' => 'switch', 'default' => false], + ]], + ], + ]); + + $response = getJson('api/v1/modules/test-module/settings')->assertOk(); + + $response->assertJsonPath('schema.sections.0.title', 'connection'); + $response->assertJsonPath('values.sandbox', false); +}); + +test('update persists values to company_settings under module prefix', function () { + Registry::registerSettings('test-module', [ + 'sections' => [ + ['title' => 'connection', 'fields' => [ + ['key' => 'api_key', 'type' => 'password', 'rules' => ['required']], + ['key' => 'sandbox', 'type' => 'switch', 'default' => false], + ]], + ], + ]); + + putJson('api/v1/modules/test-module/settings', [ + 'api_key' => 'secret-123', + 'sandbox' => true, + ])->assertOk(); + + expect(CompanySetting::getSetting('module.test-module.api_key', $this->companyId)) + ->toBe('secret-123'); + expect(CompanySetting::getSetting('module.test-module.sandbox', $this->companyId)) + ->toBe('1'); +}); + +test('update rejects payload missing a required field', function () { + Registry::registerSettings('test-module', [ + 'sections' => [ + ['title' => 'connection', 'fields' => [ + ['key' => 'api_key', 'type' => 'password', 'rules' => ['required']], + ]], + ], + ]); + + putJson('api/v1/modules/test-module/settings', []) + ->assertStatus(422) + ->assertJsonValidationErrors(['api_key']); +}); + +test('update silently drops unknown keys not in schema', function () { + Registry::registerSettings('test-module', [ + 'sections' => [ + ['title' => 'connection', 'fields' => [ + ['key' => 'api_key', 'type' => 'text'], + ]], + ], + ]); + + putJson('api/v1/modules/test-module/settings', [ + 'api_key' => 'value', + 'malicious' => 'should-not-be-stored', + ])->assertOk(); + + expect(CompanySetting::getSetting('module.test-module.api_key', $this->companyId)) + ->toBe('value'); + expect(CompanySetting::getSetting('module.test-module.malicious', $this->companyId)) + ->toBeNull(); +}); + +test('update returns 404 for module without registered schema', function () { + putJson('api/v1/modules/unknown-module/settings', ['anything' => 'value']) + ->assertNotFound(); +}); + +test('settings are isolated between companies on the same instance', function () { + Registry::registerSettings('test-module', [ + 'sections' => [ + ['title' => 'connection', 'fields' => [ + ['key' => 'api_key', 'type' => 'text'], + ]], + ], + ]); + + // Create a second company and member it to the same user + $user = User::find(1); + $companyA = $user->companies()->first(); + + // Save value for company A + putJson('api/v1/modules/test-module/settings', ['api_key' => 'company-a-value']) + ->assertOk(); + + // Verify storage is keyed by company + expect(CompanySetting::getSetting('module.test-module.api_key', $companyA->id)) + ->toBe('company-a-value'); + + // Different company id (synthetic — even non-existent IDs prove the key isolation) + expect(CompanySetting::getSetting('module.test-module.api_key', 99999)) + ->toBeNull(); +});