{{ $company->name }}
@endif diff --git a/resources/views/app/pdf/reports/partials/layout.blade.php b/resources/views/app/pdf/reports/partials/layout.blade.php index 05a25c1b..2b9906d2 100644 --- a/resources/views/app/pdf/reports/partials/layout.blade.php +++ b/resources/views/app/pdf/reports/partials/layout.blade.php @@ -28,7 +28,7 @@{{ $company->name }}
@endif diff --git a/routes/api.php b/routes/api.php index d3ca5cc9..3f739d90 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,89 +1,5 @@ group(function () { // App version // ---------------------------------- - Route::get('/app/version', AppVersionController::class); + require app_path('Platform/Operations/routes/version.php'); // Authentication & Password Reset // ---------------------------------- - Route::prefix('auth')->group(function () { - Route::post('login', [AuthController::class, 'login']); - - Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); - - // Send reset password mail - Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:10,2'); - - // handle reset password form process - Route::post('reset/password', [ResetPasswordController::class, 'reset']); - }); - - // Invitation Registration (public) - // ---------------------------------- - - Route::get('/invitations/{token}/details', [InvitationRegistrationController::class, 'details']); - Route::post('/auth/register-with-invitation', [InvitationRegistrationController::class, 'register']); + require app_path('Domains/Accounts/routes/public.php'); // Countries // ---------------------------------- - Route::get('/countries', CountriesController::class); + require app_path('Domains/Contacts/routes/public.php'); // Onboarding // ---------------------------------- Route::middleware(['redirect-if-installed'])->prefix('installation')->group(function () { - Route::get('/wizard-step', [OnboardingWizardController::class, 'getStep']); - - Route::post('/wizard-step', [OnboardingWizardController::class, 'updateStep']); - - Route::post('/wizard-language', [OnboardingWizardController::class, 'saveLanguage']); - - Route::get('/languages', [LanguagesController::class, 'languages']); - - Route::get('/requirements', [RequirementsController::class, 'requirements']); - - Route::get('/permissions', [FilePermissionsController::class, 'permissions']); - - Route::post('/database/config', [DatabaseConfigurationController::class, 'saveDatabaseEnvironment']); - - Route::get('/database/config', [DatabaseConfigurationController::class, 'getDatabaseEnvironment']); - - Route::put('/set-domain', AppDomainController::class); - - Route::get('/ai/config', [InstallerAiConfigurationController::class, 'show']); - Route::post('/ai/config', [InstallerAiConfigurationController::class, 'save']); - - Route::post('/login', LoginController::class); - - Route::post('/finish', FinishController::class); + require app_path('Platform/Operations/Installation/routes/api.php'); + require app_path('Platform/Ai/routes/installer.php'); }); // Super Admin // ---------------------------------- Route::middleware(['auth:sanctum', 'super-admin'])->prefix('super-admin')->group(function () { - Route::get('dashboard', [AdminDashboardController::class, 'index']); - Route::get('companies', [CompaniesController::class, 'index']); - Route::get('companies/{company}', [CompaniesController::class, 'show']); - Route::put('companies/{company}', [CompaniesController::class, 'update']); - - Route::get('users', [UsersController::class, 'index']); - Route::get('users/{user}', [UsersController::class, 'show']); - Route::put('users/{user}', [UsersController::class, 'update']); - Route::post('users/{user}/impersonate', [UsersController::class, 'impersonate']); + require app_path('Platform/Operations/routes/admin.php'); + require app_path('Domains/Accounts/routes/admin.php'); }); // Stop impersonation - uses auth:sanctum only (the impersonated user's token, not super-admin) Route::middleware(['auth:sanctum'])->prefix('super-admin')->group(function () { - Route::post('stop-impersonating', [UsersController::class, 'stopImpersonating']); + require app_path('Domains/Accounts/routes/impersonation.php'); }); Route::middleware(['auth:sanctum', 'company'])->group(function () { Route::middleware(['bouncer'])->group(function () { - - // Bootstrap - // ---------------------------------- - - Route::get('/bootstrap', BootstrapController::class); - - // Invitations (user-scoped — respond to invitations) - // ---------------------------------- - - Route::get('/invitations/pending', [InvitationResponseController::class, 'pending']); - Route::post('/invitations/{invitation:token}/accept', [InvitationResponseController::class, 'accept']); - Route::post('/invitations/{invitation:token}/decline', [InvitationResponseController::class, 'decline']); + require app_path('Domains/Accounts/routes/company.php'); + require app_path('Platform/Operations/routes/company.php'); // Currencies // ---------------------------------- - Route::prefix('/currencies')->group(function () { - Route::get('/used', [ExchangeRateProviderController::class, 'usedCurrenciesWithoutRate']); + require app_path('Domains/Money/routes/company.php'); - Route::post('/bulk-update-exchange-rate', [ExchangeRateProviderController::class, 'bulkUpdate']); - }); - - // Dashboard + // Reporting and customers // ---------------------------------- - Route::get('/dashboard', DashboardController::class); - - // Auth check - // ---------------------------------- - - Route::get('/auth/check', [AuthController::class, 'check']); - - // Search users - // ---------------------------------- - - Route::get('/search', SearchController::class); - - Route::get('/search/user', [SearchController::class, 'users']); - - // MISC - // ---------------------------------- - - Route::get('/config', ConfigController::class); - - Route::get('/currencies', CurrenciesController::class); - - Route::get('/timezones', [FormatsController::class, 'timezones']); - - Route::get('/date/formats', [FormatsController::class, 'dateFormats']); - - Route::get('/time/formats', [FormatsController::class, 'timeFormats']); - - Route::get('/next-number', [SerialNumberController::class, 'nextNumber']); - - Route::get('/number-placeholders', [SerialNumberController::class, 'placeholders']); - - Route::get('/current-company', [BootstrapController::class, 'currentCompany']); - - // Company Invitations (company-scoped — send invitations) - // ---------------------------------- - - Route::apiResource('company-invitations', InvitationController::class)->only(['index', 'store', 'destroy']); - - // Customers - // ---------------------------------- - - Route::post('/customers/delete', [CustomersController::class, 'delete']); - - Route::get('customers/{customer}/stats', CustomerStatsController::class); - - Route::get('customers/{customer}/statement', CustomerStatementController::class); - Route::post('customers/{customer}/statement/send', SendCustomerStatementController::class); - Route::post('customers/{customer}/credit-allocations', [CreditAllocationsController::class, 'store']); - - Route::resource('customers', CustomersController::class); + require app_path('Domains/Reporting/routes/company.php'); + require app_path('Domains/Contacts/routes/company.php'); // Items // ---------------------------------- - Route::post('/items/delete', [ItemsController::class, 'delete']); + require app_path('Domains/Catalog/routes/company.php'); - Route::resource('items', ItemsController::class); - - Route::resource('units', UnitsController::class); - - // Invoices + // Sales documents // ------------------------------------------------- - Route::get('/invoices/{invoice}/send/preview', [InvoicesController::class, 'sendPreview']); - - Route::post('/invoices/{invoice}/send', [InvoicesController::class, 'send']); - - Route::post('/invoices/{invoice}/clone', [InvoicesController::class, 'clone']); - - Route::post('/invoices/{invoice}/convert-to-estimate', [InvoicesController::class, 'convertToEstimate']); - - Route::post('/invoices/{invoice}/credit-note', [InvoicesController::class, 'createCreditNote']); - - Route::post('/invoices/{invoice}/status', [InvoicesController::class, 'changeStatus']); - - Route::post('/invoices/delete', [InvoicesController::class, 'delete']); - - Route::get('/invoices/templates', InvoiceTemplatesController::class); - - Route::apiResource('invoices', InvoicesController::class); - - // Recurring Invoice - // ------------------------------------------------- - - Route::get('/recurring-invoice-frequency', RecurringInvoiceFrequencyController::class); - - Route::post('/recurring-invoices/delete', [RecurringInvoiceController::class, 'delete']); - - Route::apiResource('recurring-invoices', RecurringInvoiceController::class); - - // Estimates - // ------------------------------------------------- - - Route::get('/estimates/{estimate}/send/preview', [EstimatesController::class, 'sendPreview']); - - Route::post('/estimates/{estimate}/send', [EstimatesController::class, 'send']); - - Route::post('/estimates/{estimate}/clone', [EstimatesController::class, 'clone']); - - Route::post('/estimates/{estimate}/status', [EstimatesController::class, 'changeStatus']); - - Route::post('/estimates/{estimate}/convert-to-invoice', [EstimatesController::class, 'convertToInvoice']); - - Route::get('/estimates/templates', EstimateTemplatesController::class); - - Route::post('/estimates/delete', [EstimatesController::class, 'delete']); - - Route::apiResource('estimates', EstimatesController::class); + require app_path('Domains/Sales/routes/company.php'); // Expenses // ---------------------------------- - Route::get('/expenses/{expense}/show/receipt', [ExpensesController::class, 'showReceipt']); - - Route::post('/expenses/{expense}/upload/receipts', [ExpensesController::class, 'uploadReceipt']); - - Route::post('/expenses/delete', [ExpensesController::class, 'delete']); - - Route::apiResource('expenses', ExpensesController::class); - - Route::apiResource('categories', ExpenseCategoriesController::class); + require app_path('Domains/Purchases/routes/company.php'); // Payments // ---------------------------------- - Route::get('/payments/{payment}/send/preview', [PaymentsController::class, 'sendPreview']); - - Route::post('/payments/{payment}/send', [PaymentsController::class, 'send']); - - Route::put('/payments/{payment}/allocations', [PaymentsController::class, 'replaceAllocations']); - - Route::post('/payments/delete', [PaymentsController::class, 'delete']); - - Route::apiResource('payments', PaymentsController::class); - - Route::apiResource('payment-methods', PaymentMethodsController::class); + require app_path('Domains/Receivables/routes/company.php'); // Custom fields // ---------------------------------- - Route::resource('custom-fields', CustomFieldsController::class); + require app_path('Domains/Metadata/routes/company.php'); // Backup & Disk // ---------------------------------- - Route::apiResource('backups', BackupsController::class); + require app_path('Platform/Storage/routes/company.php'); - Route::apiResource('/disks', DiskController::class); - - Route::get('download-backup', [BackupsController::class, 'download']); - - Route::get('/disk/drivers', [DiskController::class, 'getDiskDrivers']); - Route::get('/disk/purposes', [DiskController::class, 'getDiskPurposes']); - Route::put('/disk/purposes', [DiskController::class, 'updateDiskPurposes']); - - // Fonts + // PDF rendering and fonts // ---------------------------------- - Route::get('/fonts/status', [FontController::class, 'status']); - Route::post('/fonts/{package}/install', [FontController::class, 'install']); + require app_path('Platform/Pdf/routes/admin.php'); - // Exchange Rate - // ---------------------------------- - - Route::get('/currencies/{currency}/exchange-rate', [ExchangeRateProviderController::class, 'getRate']); - - Route::get('/currencies/{currency}/active-provider', [ExchangeRateProviderController::class, 'activeProvider']); - - Route::get('/used-currencies', [ExchangeRateProviderController::class, 'usedCurrencies']); - - Route::get('/supported-currencies', [ExchangeRateProviderController::class, 'supportedCurrencies']); - - Route::apiResource('exchange-rate-providers', ExchangeRateProviderController::class); - - // Settings - // ---------------------------------- - - Route::get('/me', [UserProfileController::class, 'show']); - - Route::put('/me', [UserProfileController::class, 'update']); - - Route::get('/me/settings', [UserProfileController::class, 'showSettings']); - - Route::put('/me/settings', [UserProfileController::class, 'updateSettings']); - - Route::post('/me/upload-avatar', [UserProfileController::class, 'uploadAvatar']); - - Route::put('/company', [CompanyController::class, 'updateCompany']); - - Route::post('/company/upload-logo', [CompanyController::class, 'uploadCompanyLogo']); - - Route::get('/company/settings', [CompanySettingsController::class, 'show']); - - Route::post('/company/settings', [CompanySettingsController::class, 'update']); - - Route::get('/settings', [SettingsController::class, 'show']); - - Route::post('/settings', [SettingsController::class, 'update']); - - Route::get('/company/has-transactions', [CompanySettingsController::class, 'checkTransactions']); + require app_path('Platform/Operations/routes/settings.php'); // Mails // ---------------------------------- - Route::get('/mail/drivers', [MailConfigurationController::class, 'getMailDrivers']); + require app_path('Platform/Mail/routes/company.php'); - Route::get('/mail/config', [MailConfigurationController::class, 'getMailEnvironment']); - - Route::post('/mail/config', [MailConfigurationController::class, 'saveMailEnvironment']); - - Route::post('/mail/test', [MailConfigurationController::class, 'testEmailConfig']); - - Route::get('/company/mail/config', [CompanyMailConfigurationController::class, 'getDefaultConfig']); - - Route::get('/company/mail/company-config', [CompanyMailConfigurationController::class, 'getMailConfig']); - Route::post('/company/mail/company-config', [CompanyMailConfigurationController::class, 'saveMailConfig']); - Route::post('/company/mail/company-test', [CompanyMailConfigurationController::class, 'testMailConfig']); - - // AI Configuration - // ---------------------------------- - - Route::get('/ai/drivers', [AiConfigurationController::class, 'getDrivers']); - Route::get('/ai/config', [AiConfigurationController::class, 'getConfig']); - Route::post('/ai/config', [AiConfigurationController::class, 'saveConfig']); - Route::post('/ai/test', [AiConfigurationController::class, 'testConnection']); - - Route::get('/company/ai/config', [CompanyAiConfigurationController::class, 'getConfig']); - Route::post('/company/ai/config', [CompanyAiConfigurationController::class, 'saveConfig']); - Route::post('/company/ai/test', [CompanyAiConfigurationController::class, 'testConnection']); - - // AI Chat + text generation — rate-limited via the 'ai' limiter defined in RouteServiceProvider - Route::middleware('throttle:ai')->group(function () { - Route::post('/ai/chat', AiChatController::class); - Route::get('/ai/conversations', [AiConversationController::class, 'index']); - Route::get('/ai/conversations/{id}', [AiConversationController::class, 'show']); - Route::patch('/ai/conversations/{id}', [AiConversationController::class, 'update']); - Route::delete('/ai/conversations/{id}', [AiConversationController::class, 'destroy']); - - Route::post('/ai/generate', AiGenerationController::class); - }); - - // PDF Generation - // ---------------------------------- - - Route::get('/pdf/drivers', [PDFConfigurationController::class, 'getDrivers']); - - Route::get('/pdf/config', [PDFConfigurationController::class, 'getEnvironment']); - - Route::post('/pdf/config', [PDFConfigurationController::class, 'saveEnvironment']); - - Route::apiResource('notes', NotesController::class); + require app_path('Platform/Ai/routes/company.php'); // Tax Types // ---------------------------------- - Route::apiResource('tax-types', TaxTypesController::class); + require app_path('Domains/Taxation/routes/company.php'); - // Roles - // ---------------------------------- - - Route::get('abilities', AbilitiesController::class); - - Route::apiResource('roles', RolesController::class); }); // Self Update @@ -488,59 +134,10 @@ Route::prefix('/v1')->group(function () { // Disabled inside the official Docker image — containers upgrade via // `docker compose pull`, not the in-app updater (see EnsureNotContainerized). - Route::middleware('not-containerized')->group(function () { - Route::get('/check/update', [UpdateController::class, 'checkVersion']); - Route::post('/update/download', [UpdateController::class, 'download']); - Route::post('/update/unzip', [UpdateController::class, 'unzip']); - Route::post('/update/copy', [UpdateController::class, 'copy']); - Route::post('/update/delete', [UpdateController::class, 'delete']); - Route::post('/update/clean', [UpdateController::class, 'clean']); - Route::post('/update/migrate', [UpdateController::class, 'migrate']); - Route::post('/update/finish', [UpdateController::class, 'finish']); - }); + require app_path('Platform/Operations/routes/updater.php'); - // Companies - // ------------------------------------------------- + require app_path('Domains/Accounts/routes/management.php'); - Route::post('companies', [CompaniesController::class, 'store']); - - Route::post('/transfer/ownership/{user}', [CompanySettingsController::class, 'transferOwnership']); - - Route::post('companies/delete', [CompaniesController::class, 'destroy']); - - Route::get('companies', [CompaniesController::class, 'userCompanies']); - - // Users - // ---------------------------------- - - Route::post('/members/delete', [MembersController::class, 'delete']); - - Route::apiResource('/members', MembersController::class); - - // Modules - // ---------------------------------- - - Route::prefix('/modules')->group(function () { - Route::get('/', [ModulesController::class, 'index']); - Route::get('/pairing', [MarketplacePairingController::class, 'status']); - Route::post('/pairing/start', [MarketplacePairingController::class, 'start']); - Route::post('/pairing/poll', [MarketplacePairingController::class, 'poll']); - Route::delete('/pairing', [MarketplacePairingController::class, 'disconnect']); - Route::get('/{module}', [ModulesController::class, 'show']); - Route::post('/{module}/enable', [ModulesController::class, 'enable']); - Route::post('/{module}/disable', [ModulesController::class, 'disable']); - Route::post('/{module}/uninstall', [ModuleInstallationController::class, 'uninstall']); - - Route::post('/install', [ModuleInstallationController::class, 'install']); - - // Per-slug settings (schema-driven, per-company storage) - Route::get('/{slug}/settings', [ModuleSettingsController::class, 'show']); - Route::put('/{slug}/settings', [ModuleSettingsController::class, 'update']); - }); - - // Company-context Active Modules index (read-only, lists every - // instance-activated module with a has_settings flag) - Route::get('/company-modules', [CompanyModulesController::class, 'index']); }); Route::prefix('/{company:slug}/customer')->group(function () { @@ -548,50 +145,22 @@ Route::prefix('/v1')->group(function () { // Authentication & Password Reset // ---------------------------------- - Route::prefix('auth')->group(function () { - - // Send reset password mail - Route::post('password/email', [AuthForgotPasswordController::class, 'sendResetLinkEmail']); - - // handle reset password form process - Route::post('reset/password', [AuthResetPasswordController::class, 'reset'])->name('customer.password.reset'); - }); + require app_path('Domains/Contacts/routes/customer-public.php'); // Invoices, Estimates, Payments and Expenses endpoints // ------------------------------------------------------- Route::middleware(['auth:customer', 'customer-portal'])->group(function () { - Route::get('/bootstrap', CustomerBootstrapController::class); + require app_path('Domains/Contacts/routes/customer.php'); - Route::get('/dashboard', CustomerDashboardController::class); + require app_path('Domains/Sales/routes/customer.php'); - Route::get('invoices', [CustomerInvoicesController::class, 'index']); + require app_path('Domains/Receivables/routes/customer.php'); - Route::get('invoices/{id}', [CustomerInvoicesController::class, 'show']); + require app_path('Domains/Purchases/routes/customer.php'); - Route::post('/estimate/{estimate}/status', CustomerAcceptEstimateController::class); - - Route::get('estimates', [CustomerEstimatesController::class, 'index']); - - Route::get('estimates/{id}', [CustomerEstimatesController::class, 'show']); - - Route::get('payments', [CustomerPaymentsController::class, 'index']); - - Route::get('payments/{id}', [CustomerPaymentsController::class, 'show']); - - Route::get('/payment-method', PaymentMethodController::class); - - Route::get('expenses', [CustomerExpensesController::class, 'index']); - - Route::get('expenses/{id}', [CustomerExpensesController::class, 'show']); - - Route::post('/profile', [CustomerProfileController::class, 'updateProfile']); - - Route::get('/me', [CustomerProfileController::class, 'getUser']); - - Route::get('/countries', CountriesController::class); }); }); }); -Route::get('/cron', CronJobController::class)->middleware('cron-job'); +require app_path('Platform/Operations/routes/webhooks.php'); diff --git a/routes/console.php b/routes/console.php index f7571995..ab83c4bc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,9 +1,9 @@ withoutOverlapping(); } -if (InstallUtils::isDbCreated()) { +if (InstallationState::isDbCreated()) { Schedule::command('check:invoices:status') ->daily(); diff --git a/routes/web.php b/routes/web.php index 79b02648..730c3f4b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,147 +1,40 @@ logout(); - - request()->session()->invalidate(); - request()->session()->regenerateToken(); -}); - -// Customer auth -// ---------------------------------------------- - -Route::post('/{company:slug}/customer/login', CustomerLoginController::class); - -Route::post('/{company:slug}/customer/logout', function () { - Auth::guard('customer')->logout(); -}); +require app_path('Domains/Accounts/routes/web.php'); +require app_path('Domains/Contacts/routes/web.php'); // Report PDF & Expense Endpoints // ---------------------------------------------- Route::middleware('auth:sanctum')->prefix('reports')->group(function () { + require app_path('Domains/Reporting/routes/web.php'); - Route::get('/customers/{customer}/statement', CustomerStatementReportController::class); - - // sales report by customer - // ---------------------------------- - Route::get('/sales/customers/{hash}', CustomerSalesReportController::class); - - // sales report by items - // ---------------------------------- - Route::get('/sales/items/{hash}', ItemSalesReportController::class); - - // report for expenses - // ---------------------------------- - Route::get('/expenses/{hash}', ExpensesReportController::class); - - // report for tax summary - // ---------------------------------- - Route::get('/tax-summary/{hash}', TaxSummaryReportController::class); - - // report for profit and loss - // ---------------------------------- - Route::get('/profit-loss/{hash}', ProfitLossReportController::class); - - // download expense receipt - // ------------------------------------------------- - Route::get('/expenses/{expense}/download-receipt', [ExpensesController::class, 'downloadReceipt']); - Route::get('/expenses/{expense}/receipt', [ExpensesController::class, 'showReceipt']); + require app_path('Domains/Purchases/routes/web.php'); }); // PDF Endpoints // ---------------------------------------------- -// Invitation email link handlers -// ------------------------------------------------- - -Route::get('/invitations/{token}/decline', function (string $token) { - $invitation = CompanyInvitation::where('token', $token)->pending()->first(); - - if (! $invitation) { - return view('app')->with(['message' => 'Invitation not found or already expired.']); - } - - $invitation->update(['status' => CompanyInvitation::STATUS_DECLINED]); - - return view('app')->with(['message' => 'Invitation declined.']); -}); - Route::middleware('pdf-auth')->group(function () { - - // invoice pdf - // ------------------------------------------------- - Route::get('/invoices/pdf/{invoice:unique_hash}', [DocumentPdfController::class, 'invoice']); - Route::get('/estimates/pdf/{estimate:unique_hash}', [DocumentPdfController::class, 'estimate']); - Route::get('/payments/pdf/{payment:unique_hash}', [DocumentPdfController::class, 'payment']); + require app_path('Domains/Sales/routes/pdf.php'); + require app_path('Domains/Receivables/routes/pdf.php'); }); // customer pdf endpoints for invoice, estimate and Payment // ------------------------------------------------- Route::prefix('/customer')->group(function () { - Route::get('/invoices/{email_log:token}', [CustomerInvoicePdfController::class, 'getInvoice']); - Route::get('/invoices/view/{email_log:token}', [CustomerInvoicePdfController::class, 'getPdf'])->name('invoice'); - - Route::get('/estimates/{email_log:token}', [CustomerEstimatePdfController::class, 'getEstimate']); - Route::get('/estimates/view/{email_log:token}', [CustomerEstimatePdfController::class, 'getPdf'])->name('estimate'); - - Route::get('/payments/{email_log:token}', [CustomerPaymentPdfController::class, 'getPayment']); - Route::get('/payments/view/{email_log:token}', [CustomerPaymentPdfController::class, 'getPdf'])->name('payment'); + require app_path('Domains/Sales/routes/public.php'); + require app_path('Domains/Receivables/routes/public.php'); }); // Setup for installation of app // ---------------------------------------------- -Route::get('/installation', function () { - return view('app'); -})->name('install') - ->middleware(['redirect-if-installed']); - -// Catch-all for installation wizard sub-routes (language, requirements, -// permissions, database, domain, mail, account, company, preferences). -// The Vue Router handles the actual step rendering on the SPA side; this -// just makes sure deep links and hard refreshes inside the wizard hit the -// SPA shell instead of 404ing. -Route::get('/installation/{vue?}', function () { - return view('app'); -})->where('vue', '.*') - ->middleware(['redirect-if-installed']); - -Route::post('/installation/session-login', SessionLoginController::class) - ->middleware(['redirect-if-installed', 'auth:sanctum']); +require app_path('Platform/Operations/Installation/routes/web.php'); // Registration via invitation (serves SPA) // ------------------------------------------------- diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php index 9f997616..7880a184 100644 --- a/tests/Feature/Admin/AdminSettingsTest.php +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -1,6 +1,6 @@ getProviders(AccountsServiceProvider::class))->toHaveCount(1) + ->and(app(CompanyAddressWriter::class))->toBeInstanceOf(EloquentCompanyAddressWriter::class) + ->and(app(CompanyDataPurger::class))->toBeInstanceOf(EloquentCompanyDataPurger::class) + ->and(app(CompanyDefaultsProvisioner::class))->toBeInstanceOf(EloquentBusinessDefaultsProvisioner::class) + ->and(app(CompanyInvitationSender::class))->toBeInstanceOf(LaravelCompanyInvitationSender::class) + ->and(app(CompanyLogoManager::class))->toBeInstanceOf(MediaLibraryCompanyLogoManager::class) + ->and(app(MemberReferencesCleaner::class))->toBeInstanceOf(EloquentMemberReferencesCleaner::class) + ->and(app(UserAvatarManager::class))->toBeInstanceOf(MediaLibraryUserAvatarManager::class) + ->and(Gate::getPolicyFor(Company::class))->toBeInstanceOf(CompanyPolicy::class) + ->and(Gate::getPolicyFor(User::class))->toBeInstanceOf(UserPolicy::class) + ->and(Gate::getPolicyFor(Role::class))->toBeInstanceOf(RolePolicy::class) + ->and(Gate::has('create company'))->toBeTrue() + ->and(Gate::has('transfer company ownership'))->toBeTrue() + ->and(Gate::has('delete company'))->toBeTrue() + ->and(Gate::has('manage company'))->toBeTrue() + ->and(Gate::has('delete multiple users'))->toBeTrue() + ->and(Gate::has('owner only'))->toBeTrue(); + + expect(class_exists('App\\Services\\Company\\CompanyService'))->toBeFalse() + ->and(class_exists('App\\Services\\Company\\InvitationService'))->toBeFalse() + ->and(class_exists('App\\Services\\Company\\MemberService'))->toBeFalse() + ->and(class_exists('App\\Policies\\CompanyPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\UserPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\RolePolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\SettingsPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\OwnerPolicy'))->toBeFalse() + ->and(class_exists('App\\Mail\\CompanyInvitationMail'))->toBeFalse() + ->and(class_exists('App\\Notifications\\MailResetPasswordNotification'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\CompaniesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\UsersController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Members\\MembersController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Role\\RolesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Settings\\CompanyController'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CompanyCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CompanyResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\UserResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\RoleCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\UserCollection'))->toBeFalse(); +}); + +test('account middleware aliases resolve to the accounts domain', function () { + $middleware = app('router')->getMiddleware(); + + expect($middleware['auth'])->toBe(Authenticate::class) + ->and($middleware['company'])->toBe(CompanyMiddleware::class) + ->and($middleware['guest'])->toBe(RedirectIfAuthenticated::class) + ->and($middleware['redirect-if-unauthenticated'])->toBe(RedirectIfUnauthorized::class) + ->and($middleware['bouncer'])->toBe(ScopeBouncer::class) + ->and($middleware['super-admin'])->toBe(SuperAdminMiddleware::class); +}); + +test('the accounts domain preserves public and super-admin routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()); + $publicUris = [ + 'api/v1/auth/login', + 'api/v1/auth/logout', + 'api/v1/auth/password/email', + 'api/v1/auth/register-with-invitation', + 'api/v1/auth/reset/password', + 'api/v1/invitations/{token}/details', + 'login', + 'auth/logout', + 'invitations/{token}/decline', + ]; + $publicRoutes = $routes + ->filter(fn ($route): bool => in_array($route->uri(), $publicUris, true)) + ->filter(fn ($route): bool => str_starts_with($route->getActionName(), 'App\\Domains\\Accounts\\Http\\Controllers\\')); + + expect($publicRoutes)->toHaveCount(count($publicUris)); + + foreach ($publicRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Accounts\\Http\\Controllers\\'); + } + + $adminRoutes = $routes + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/super-admin/')) + ->reject(fn ($route): bool => $route->uri() === 'api/v1/super-admin/dashboard'); + + expect($adminRoutes)->toHaveCount(8); + + foreach ($adminRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Accounts\\Http\\Controllers\\Admin\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum'); + + if ($route->uri() !== 'api/v1/super-admin/stop-impersonating') { + expect($route->gatherMiddleware())->toContain('super-admin'); + } + } +}); + +test('the accounts domain preserves company account routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->getActionName(), 'App\\Domains\\Accounts\\Http\\Controllers\\')) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/')) + ->reject(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/auth/')) + ->reject(fn ($route): bool => $route->uri() === 'api/v1/invitations/{token}/details') + ->reject(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/super-admin/')); + + expect($routes)->toHaveCount(32); + + foreach ($routes as $route) { + expect($route->gatherMiddleware())->toContain('auth:sanctum', 'company'); + + if (! preg_match('#^api/v1/(?:companies|members|transfer/)#', $route->uri())) { + expect($route->gatherMiddleware())->toContain('bouncer'); + } + } +}); diff --git a/tests/Feature/Architecture/AiPlatformBoundaryTest.php b/tests/Feature/Architecture/AiPlatformBoundaryTest.php new file mode 100644 index 00000000..309a6b24 --- /dev/null +++ b/tests/Feature/Architecture/AiPlatformBoundaryTest.php @@ -0,0 +1,56 @@ +getProviders(AiServiceProvider::class))->toHaveCount(1) + ->and(app(AiToolRegistry::class))->toBe(app(AiToolRegistry::class)) + ->and(Gate::getPolicyFor(AiConversation::class))->toBeInstanceOf(AiConversationPolicy::class) + ->and(Gate::has('manage ai config'))->toBeTrue() + ->and(Gate::has('use ai'))->toBeTrue() + ->and(RateLimiter::limiter('ai'))->not->toBeNull() + ->and(Registry::driverMeta('ai', 'openrouter')['class'])->toBe(OpenRouterDriver::class); + + expect(class_exists('App\\Providers\\AiServiceProvider'))->toBeFalse() + ->and(class_exists('App\\Services\\Ai\\AiAssistantService'))->toBeFalse() + ->and(class_exists('App\\Support\\Ai\\AiDriver'))->toBeFalse() + ->and(class_exists('App\\Policies\\AiConversationPolicy'))->toBeFalse(); +}); + +test('the ai platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_contains($route->uri(), '/ai/')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/ai/conversations/{id}', + 'GET|HEAD api/v1/ai/config', + 'GET|HEAD api/v1/ai/conversations', + 'GET|HEAD api/v1/ai/conversations/{id}', + 'GET|HEAD api/v1/ai/drivers', + 'GET|HEAD api/v1/company/ai/config', + 'GET|HEAD api/v1/installation/ai/config', + 'PATCH api/v1/ai/conversations/{id}', + 'POST api/v1/ai/chat', + 'POST api/v1/ai/config', + 'POST api/v1/ai/generate', + 'POST api/v1/ai/test', + 'POST api/v1/company/ai/config', + 'POST api/v1/company/ai/test', + 'POST api/v1/installation/ai/config', + ])->sort()->values()->all()); + + $chat = $routes->get('POST api/v1/ai/chat'); + + expect($chat->getActionName())->toBe(ChatController::class) + ->and($chat->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer', 'throttle:ai'); +}); diff --git a/tests/Feature/Architecture/CatalogDomainBoundaryTest.php b/tests/Feature/Architecture/CatalogDomainBoundaryTest.php new file mode 100644 index 00000000..9448aeec --- /dev/null +++ b/tests/Feature/Architecture/CatalogDomainBoundaryTest.php @@ -0,0 +1,62 @@ +getProviders(CatalogServiceProvider::class))->toHaveCount(1) + ->and(app(ItemTaxManager::class))->toBeInstanceOf(TaxationItemTaxManager::class) + ->and(Gate::getPolicyFor(Item::class))->toBeInstanceOf(ItemPolicy::class) + ->and(Gate::getPolicyFor(Unit::class))->toBeInstanceOf(UnitPolicy::class) + ->and(Gate::has('delete multiple items'))->toBeTrue(); + + expect(class_exists('App\\Services\\ItemService'))->toBeFalse() + ->and(class_exists('App\\Policies\\ItemPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\UnitPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Item\\ItemsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Item\\UnitsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ItemsRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\DeleteItemsRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\UnitRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ItemResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\UnitResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ItemCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\UnitCollection'))->toBeFalse(); +}); + +test('the catalog domain preserves item and unit routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:items|units)(?:$|/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/items/{item}', + 'DELETE api/v1/units/{unit}', + 'GET|HEAD api/v1/items', + 'GET|HEAD api/v1/items/create', + 'GET|HEAD api/v1/items/{item}', + 'GET|HEAD api/v1/items/{item}/edit', + 'GET|HEAD api/v1/units', + 'GET|HEAD api/v1/units/create', + 'GET|HEAD api/v1/units/{unit}', + 'GET|HEAD api/v1/units/{unit}/edit', + 'POST api/v1/items', + 'POST api/v1/items/delete', + 'POST api/v1/units', + 'PUT|PATCH api/v1/items/{item}', + 'PUT|PATCH api/v1/units/{unit}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Catalog\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Architecture/ContactsDomainBoundaryTest.php b/tests/Feature/Architecture/ContactsDomainBoundaryTest.php new file mode 100644 index 00000000..882eb972 --- /dev/null +++ b/tests/Feature/Architecture/ContactsDomainBoundaryTest.php @@ -0,0 +1,111 @@ +getProviders(ContactsServiceProvider::class))->toHaveCount(1) + ->and(app(CustomerAvatarManager::class))->toBeInstanceOf(MediaLibraryCustomerAvatarManager::class) + ->and(app(CustomerDataPurger::class))->toBeInstanceOf(EloquentCustomerDataPurger::class) + ->and(app(CustomerPortalDashboardProvider::class))->toBeInstanceOf(EloquentCustomerPortalDashboardProvider::class) + ->and(app(CustomerStatsProvider::class))->toBeInstanceOf(EloquentCustomerStatsProvider::class) + ->and(Gate::getPolicyFor(Customer::class))->toBeInstanceOf(CustomerPolicy::class) + ->and(Gate::has('delete multiple customers'))->toBeTrue(); + + foreach ([ + 'App\\Services\\CustomerService', + 'App\\Policies\\CustomerPolicy', + 'App\\Notifications\\CustomerMailResetPasswordNotification', + 'App\\Http\\Controllers\\Admin\\CountriesController', + 'App\\Http\\Controllers\\Company\\Customer\\CustomersController', + 'App\\Http\\Controllers\\Company\\Customer\\CustomerStatsController', + 'App\\Http\\Controllers\\CustomerPortal\\Auth\\ForgotPasswordController', + 'App\\Http\\Controllers\\CustomerPortal\\Auth\\LoginController', + 'App\\Http\\Controllers\\CustomerPortal\\Auth\\ResetPasswordController', + 'App\\Http\\Controllers\\CustomerPortal\\General\\BootstrapController', + 'App\\Http\\Controllers\\CustomerPortal\\General\\DashboardController', + 'App\\Http\\Controllers\\CustomerPortal\\General\\ProfileController', + 'App\\Http\\Requests\\CustomerRequest', + 'App\\Http\\Requests\\DeleteCustomersRequest', + 'App\\Http\\Requests\\Customer\\CustomerLoginRequest', + 'App\\Http\\Requests\\Customer\\CustomerProfileRequest', + 'App\\Http\\Resources\\AddressResource', + 'App\\Http\\Resources\\CountryResource', + 'App\\Http\\Resources\\CustomerResource', + 'App\\Http\\Resources\\Customer\\AddressResource', + 'App\\Http\\Resources\\Customer\\CountryResource', + 'App\\Http\\Resources\\Customer\\CustomerResource', + 'App\\Http\\Resources\\AddressCollection', + 'App\\Http\\Resources\\CountryCollection', + 'App\\Http\\Resources\\CustomerCollection', + ] as $legacyClass) { + expect(class_exists($legacyClass))->toBeFalse(); + } +}); + +test('customer middleware aliases resolve to the contacts domain', function () { + $middleware = app('router')->getMiddleware(); + + expect($middleware['customer'])->toBe(CustomerRedirectIfAuthenticated::class) + ->and($middleware['customer-guest'])->toBe(CustomerGuest::class) + ->and($middleware['customer-portal'])->toBe(CustomerPortalMiddleware::class); +}); + +test('the contacts domain preserves public and company customer routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()); + + $countryRoutes = $routes->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/countries', + 'api/v1/{company}/customer/countries', + ], true)); + + expect($countryRoutes)->toHaveCount(2); + + foreach ($countryRoutes as $route) { + expect($route->getActionName())->toBe('App\\Domains\\Contacts\\Http\\Controllers\\CountriesController'); + } + + $companyRoutes = $routes + ->filter(fn ($route): bool => preg_match('#^api/v1/customers(?:$|/)#', $route->uri()) === 1) + ->reject(fn ($route): bool => str_contains($route->uri(), '/statement') || str_contains($route->uri(), '/credit-allocations')); + + expect($companyRoutes)->toHaveCount(9); + + foreach ($companyRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Contacts\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the contacts domain preserves customer portal identity routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()); + $portalRoutes = $routes + ->filter(fn ($route): bool => str_starts_with($route->getActionName(), 'App\\Domains\\Contacts\\Http\\Controllers\\CustomerPortal\\')); + + expect($portalRoutes)->toHaveCount(8); + + $authenticatedRoutes = $portalRoutes + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/')) + ->reject(fn ($route): bool => str_contains($route->uri(), '/auth/')); + + expect($authenticatedRoutes)->toHaveCount(4); + + foreach ($authenticatedRoutes as $route) { + expect($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } +}); diff --git a/tests/Feature/Architecture/MailPlatformBoundaryTest.php b/tests/Feature/Architecture/MailPlatformBoundaryTest.php new file mode 100644 index 00000000..72fcf1cc --- /dev/null +++ b/tests/Feature/Architecture/MailPlatformBoundaryTest.php @@ -0,0 +1,51 @@ +getProviders(MailServiceProvider::class))->toHaveCount(1) + ->and(app(MailConfigurator::class))->toBeInstanceOf(MailConfigurationService::class) + ->and(app(EmailLogWriter::class))->toBeInstanceOf(EloquentEmailLogWriter::class) + ->and(Gate::has('manage email config'))->toBeTrue(); + + expect(class_exists('App\\Services\\Mail\\MailConfigurationService'))->toBeFalse() + ->and(class_exists('App\\Services\\Mail\\CompanyMailConfigService'))->toBeFalse() + ->and(class_exists('App\\Mail\\TestMail'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\MailConfigurationController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Settings\\CompanyMailConfigurationController'))->toBeFalse(); +}); + +test('the mail platform preserves its public configuration routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:company/)?mail/#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/company/mail/company-config', + 'GET|HEAD api/v1/company/mail/config', + 'GET|HEAD api/v1/mail/config', + 'GET|HEAD api/v1/mail/drivers', + 'POST api/v1/company/mail/company-config', + 'POST api/v1/company/mail/company-test', + 'POST api/v1/mail/config', + 'POST api/v1/mail/test', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Mail\\Http\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + expect($routes->get('GET|HEAD api/v1/mail/config')->getActionName()) + ->toBe(MailConfigurationController::class.'@getMailEnvironment') + ->and($routes->get('GET|HEAD api/v1/company/mail/config')->getActionName()) + ->toBe(CompanyMailConfigurationController::class.'@getDefaultConfig'); +}); diff --git a/tests/Feature/Architecture/MetadataDomainBoundaryTest.php b/tests/Feature/Architecture/MetadataDomainBoundaryTest.php new file mode 100644 index 00000000..4471cd97 --- /dev/null +++ b/tests/Feature/Architecture/MetadataDomainBoundaryTest.php @@ -0,0 +1,64 @@ +getProviders(MetadataServiceProvider::class))->toHaveCount(1) + ->and(app(CustomFieldValueWriter::class))->toBeInstanceOf(EloquentCustomFieldValueWriter::class) + ->and(Gate::getPolicyFor(CustomField::class))->toBeInstanceOf(CustomFieldPolicy::class) + ->and(Gate::getPolicyFor(Note::class))->toBeInstanceOf(NotePolicy::class) + ->and(Gate::has('manage notes'))->toBeTrue() + ->and(Gate::has('view notes'))->toBeTrue(); + + expect(class_exists('App\\Services\\CustomFieldService'))->toBeFalse() + ->and(trait_exists('App\\Traits\\HasCustomFieldsTrait'))->toBeFalse() + ->and(class_exists('App\\Policies\\CustomFieldPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\NotePolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\CustomField\\CustomFieldsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\NotesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\CustomFieldRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\NotesRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldValueResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CustomFieldResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CustomFieldValueResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\NoteResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldValueCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\NoteCollection'))->toBeFalse(); +}); + +test('the metadata domain preserves custom field and note routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:custom-fields|notes)(?:$|/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/custom-fields/{custom_field}', + 'DELETE api/v1/notes/{note}', + 'GET|HEAD api/v1/custom-fields', + 'GET|HEAD api/v1/custom-fields/create', + 'GET|HEAD api/v1/custom-fields/{custom_field}', + 'GET|HEAD api/v1/custom-fields/{custom_field}/edit', + 'GET|HEAD api/v1/notes', + 'GET|HEAD api/v1/notes/{note}', + 'POST api/v1/custom-fields', + 'POST api/v1/notes', + 'PUT|PATCH api/v1/custom-fields/{custom_field}', + 'PUT|PATCH api/v1/notes/{note}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Metadata\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Architecture/ModelTypeAliasMigrationTest.php b/tests/Feature/Architecture/ModelTypeAliasMigrationTest.php new file mode 100644 index 00000000..d957a79d --- /dev/null +++ b/tests/Feature/Architecture/ModelTypeAliasMigrationTest.php @@ -0,0 +1,84 @@ +toBe([ + 'media' => ['model_type'], + 'email_logs' => ['mailable_type'], + 'notifications' => ['notifiable_type'], + 'personal_access_tokens' => ['tokenable_type'], + 'custom_field_values' => ['custom_field_valuable_type'], + 'abilities' => ['entity_type'], + 'assigned_roles' => ['entity_type', 'restricted_to_type'], + 'permissions' => ['entity_type'], + ]); +}); + +test('legacy model types migrate to stable aliases and can be rolled back', function () { + $abilityId = DB::table('abilities')->insertGetId([ + 'name' => 'architecture-migration-test', + 'entity_type' => 'InvoiceShelf\\Models\\Invoice', + 'only_owned' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_08_05_120000_stabilize_model_type_aliases.php'); + $migration->up(); + + expect(DB::table('abilities')->where('id', $abilityId)->value('entity_type'))->toBe('invoice'); + + $migration->down(); + + expect(DB::table('abilities')->where('id', $abilityId)->value('entity_type'))->toBe('App\\Models\\Invoice'); +}); + +test('legacy bouncer role identities migrate and can be rolled back', function () { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'architecture-migration-role', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $abilityId = DB::table('abilities')->insertGetId([ + 'name' => 'architecture-migration-ability', + 'only_owned' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $permissionId = DB::table('permissions')->insertGetId([ + 'ability_id' => $abilityId, + 'entity_id' => $roleId, + 'entity_type' => 'roles', + 'forbidden' => false, + ]); + + $migration = require database_path('migrations/2026_08_05_120000_stabilize_model_type_aliases.php'); + $migration->up(); + + expect(DB::table('permissions')->where('id', $permissionId)->value('entity_type')) + ->toBe('bouncer_role'); + + $migration->down(); + + expect(DB::table('permissions')->where('id', $permissionId)->value('entity_type')) + ->toBe('roles'); +}); + +test('unknown model identities are left untouched', function () { + $abilityId = DB::table('abilities')->insertGetId([ + 'name' => 'architecture-unknown-test', + 'entity_type' => 'Modules\\Example\\Models\\Record', + 'only_owned' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_08_05_120000_stabilize_model_type_aliases.php'); + $migration->up(); + + expect(DB::table('abilities')->where('id', $abilityId)->value('entity_type')) + ->toBe('Modules\\Example\\Models\\Record'); +}); diff --git a/tests/Feature/Architecture/ModulePlatformBoundaryTest.php b/tests/Feature/Architecture/ModulePlatformBoundaryTest.php new file mode 100644 index 00000000..b9746965 --- /dev/null +++ b/tests/Feature/Architecture/ModulePlatformBoundaryTest.php @@ -0,0 +1,62 @@ +getProviders(ModuleServiceProvider::class))->not->toBeEmpty() + ->and((new Module)->getTable())->toBe('modules') + ->and((new MarketplaceCredential)->getTable())->toBe('marketplace_credentials') + ->and((new MarketplaceOperation)->getTable())->toBe('marketplace_operations') + ->and(class_exists('App\\Models\\Module'))->toBeFalse() + ->and(class_exists('App\\Services\\Marketplace\\MarketplaceInstaller'))->toBeFalse(); +}); + +test('the module platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes())->filter( + fn (IlluminateRoute $route): bool => $route->uri() === 'api/v1/company-modules' + || str_starts_with($route->uri(), 'api/v1/modules') + || str_starts_with($route->uri(), 'modules/scripts') + || str_starts_with($route->uri(), 'modules/styles') + ); + + $signatures = $routes + ->flatMap(fn (IlluminateRoute $route): array => collect($route->methods()) + ->reject(fn (string $method): bool => $method === 'HEAD') + ->map(fn (string $method): string => "{$method} {$route->uri()}") + ->all()) + ->sort() + ->values() + ->all(); + + expect($signatures)->toBe(collect([ + 'GET api/v1/company-modules', + 'GET api/v1/modules', + 'GET api/v1/modules/pairing', + 'POST api/v1/modules/pairing/start', + 'POST api/v1/modules/pairing/poll', + 'DELETE api/v1/modules/pairing', + 'GET api/v1/modules/{module}', + 'POST api/v1/modules/{module}/enable', + 'POST api/v1/modules/{module}/disable', + 'POST api/v1/modules/{module}/uninstall', + 'POST api/v1/modules/install', + 'GET api/v1/modules/{slug}/settings', + 'PUT api/v1/modules/{slug}/settings', + 'GET modules/scripts/{script}', + 'GET modules/styles/{style}', + ])->sort()->values()->all()); + + $routes->each(function (IlluminateRoute $route): void { + $expected = str_starts_with($route->uri(), 'api/') + ? ['api', 'auth:sanctum', 'company'] + : ['web']; + + expect($route->middleware())->toBe($expected) + ->and($route->getActionName())->toStartWith('App\\Platform\\Modules\\'); + }); +}); diff --git a/tests/Feature/Architecture/MoneyDomainBoundaryTest.php b/tests/Feature/Architecture/MoneyDomainBoundaryTest.php new file mode 100644 index 00000000..22f4a9b1 --- /dev/null +++ b/tests/Feature/Architecture/MoneyDomainBoundaryTest.php @@ -0,0 +1,62 @@ +getProviders(MoneyServiceProvider::class))->toHaveCount(1) + ->and(app(ExchangeRateBackfill::class))->toBeInstanceOf(EloquentExchangeRateBackfill::class) + ->and(Gate::getPolicyFor(ExchangeRateProvider::class))->toBeInstanceOf(ExchangeRateProviderPolicy::class) + ->and(Registry::driverMeta('exchange_rate', 'currency_converter')['class'] ?? null) + ->toBe(CurrencyConverterDriver::class) + ->and(Registry::driverMeta('exchange_rate', 'currency_freak')['class'] ?? null) + ->toBe(CurrencyFreakDriver::class); + + expect(class_exists('App\\Providers\\DriverRegistryProvider'))->toBeFalse() + ->and(class_exists('App\\Support\\ExchangeRate\\ExchangeRateDriverFactory'))->toBeFalse() + ->and(class_exists('App\\Services\\Document\\CurrencyService'))->toBeFalse() + ->and(class_exists('App\\Services\\ExchangeRateProviderService'))->toBeFalse() + ->and(class_exists('App\\Policies\\ExchangeRateProviderPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\CurrenciesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\ExchangeRate\\ExchangeRateProviderController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ExchangeRateProviderRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CurrencyResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CurrencyResource'))->toBeFalse(); +}); + +test('the money domain preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/v1/(?:currencies(?:$|/)|exchange-rate-providers(?:$|/)|supported-currencies$|used-currencies$)#', + $route->uri(), + ) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/exchange-rate-providers/{exchange_rate_provider}', + 'GET|HEAD api/v1/currencies', + 'GET|HEAD api/v1/currencies/used', + 'GET|HEAD api/v1/currencies/{currency}/active-provider', + 'GET|HEAD api/v1/currencies/{currency}/exchange-rate', + 'GET|HEAD api/v1/exchange-rate-providers', + 'GET|HEAD api/v1/exchange-rate-providers/{exchange_rate_provider}', + 'GET|HEAD api/v1/supported-currencies', + 'GET|HEAD api/v1/used-currencies', + 'POST api/v1/currencies/bulk-update-exchange-rate', + 'POST api/v1/exchange-rate-providers', + 'PUT|PATCH api/v1/exchange-rate-providers/{exchange_rate_provider}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Money\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Architecture/OperationsPlatformBoundaryTest.php b/tests/Feature/Architecture/OperationsPlatformBoundaryTest.php new file mode 100644 index 00000000..39c2a462 --- /dev/null +++ b/tests/Feature/Architecture/OperationsPlatformBoundaryTest.php @@ -0,0 +1,152 @@ +getProviders(OperationsServiceProvider::class))->toHaveCount(1) + ->and(app(StorageConfigurator::class))->toBeInstanceOf(FileDiskService::class) + ->and(Gate::has('manage settings'))->toBeTrue() + ->and(Gate::has('manage update app'))->toBeTrue() + ->and(Artisan::all())->toHaveKeys(['core:update', 'reset:app']); + + expect(class_exists('App\\Providers\\AppConfigProvider'))->toBeFalse() + ->and(class_exists('App\\Support\\Update\\Updater'))->toBeFalse() + ->and(class_exists('App\\Console\\Commands\\UpdateCommand'))->toBeFalse() + ->and(class_exists('App\\Console\\Commands\\ResetApp'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\AppVersionController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\UpdateController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\SettingsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\AdminDashboardController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\BootstrapController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\ConfigController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\FormatsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Webhook\\CronJobController'))->toBeFalse() + ->and(class_exists('App\\Support\\Setup\\InstallUtils'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Setup\\LoginController'))->toBeFalse() + ->and(class_exists('App\\Http\\Middleware\\InstallationMiddleware'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\DatabaseEnvironmentRequest'))->toBeFalse(); +}); + +test('the operations platform owns bootstrap configuration and admin diagnostics routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/bootstrap', + 'api/v1/config', + 'api/v1/current-company', + 'api/v1/date/formats', + 'api/v1/super-admin/dashboard', + 'api/v1/time/formats', + 'api/v1/timezones', + ], true)) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/bootstrap', + 'GET|HEAD api/v1/config', + 'GET|HEAD api/v1/current-company', + 'GET|HEAD api/v1/date/formats', + 'GET|HEAD api/v1/super-admin/dashboard', + 'GET|HEAD api/v1/time/formats', + 'GET|HEAD api/v1/timezones', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Operations\\Http\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum'); + } + + expect($routes->get('GET|HEAD api/v1/super-admin/dashboard')->gatherMiddleware()) + ->toContain('super-admin') + ->and($routes->except('GET|HEAD api/v1/super-admin/dashboard')->every( + fn ($route): bool => in_array('company', $route->gatherMiddleware(), true) + && in_array('bouncer', $route->gatherMiddleware(), true), + ))->toBeTrue(); +}); + +test('the operations platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/(?:v1/(?:app/version|settings$|check/update$|update/)|cron$)#', + $route->uri(), + ) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/cron', + 'GET|HEAD api/v1/app/version', + 'GET|HEAD api/v1/check/update', + 'GET|HEAD api/v1/settings', + 'POST api/v1/settings', + 'POST api/v1/update/clean', + 'POST api/v1/update/copy', + 'POST api/v1/update/delete', + 'POST api/v1/update/download', + 'POST api/v1/update/finish', + 'POST api/v1/update/migrate', + 'POST api/v1/update/unzip', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Operations\\Http\\'); + } + + foreach (['GET|HEAD api/v1/settings', 'POST api/v1/settings'] as $key) { + expect($routes->get($key)->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + foreach ($routes->filter(fn ($route) => str_contains($route->uri(), 'update')) as $route) { + expect($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'not-containerized'); + } + + expect($routes->get('GET|HEAD api/cron')->gatherMiddleware())->toContain('cron-job') + ->and(app('router')->getMiddleware()['cron-job'] ?? null)->toBe(CronJobMiddleware::class) + ->and(app('router')->getMiddleware()['not-containerized'] ?? null)->toBe(EnsureNotContainerized::class); +}); + +test('the operations platform owns installation routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => ( + str_starts_with($route->uri(), 'api/v1/installation/') + && ! str_starts_with($route->uri(), 'api/v1/installation/ai/') + ) || str_starts_with($route->uri(), 'installation')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/installation/database/config', + 'GET|HEAD api/v1/installation/languages', + 'GET|HEAD api/v1/installation/permissions', + 'GET|HEAD api/v1/installation/requirements', + 'GET|HEAD api/v1/installation/wizard-step', + 'GET|HEAD installation', + 'GET|HEAD installation/{vue?}', + 'POST api/v1/installation/database/config', + 'POST api/v1/installation/finish', + 'POST api/v1/installation/login', + 'POST api/v1/installation/wizard-language', + 'POST api/v1/installation/wizard-step', + 'POST installation/session-login', + 'PUT api/v1/installation/set-domain', + ])->sort()->values()->all()); + + foreach ($routes->filter(fn ($route) => str_starts_with($route->uri(), 'api/')) as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Platform\\Operations\\Installation\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('redirect-if-installed'); + } + + expect($routes->get('POST installation/session-login')->getActionName()) + ->toBe('App\\Platform\\Operations\\Installation\\Http\\Controllers\\SessionLoginController') + ->and(app('router')->getMiddleware()['install'] ?? null)->toBe(EnsureInstalled::class) + ->and(app('router')->getMiddleware()['redirect-if-installed'] ?? null)->toBe(RedirectIfInstalled::class) + ->and(app()->make(UseInstallWizardTokenAuth::class))->toBeInstanceOf(UseInstallWizardTokenAuth::class); +}); diff --git a/tests/Feature/Architecture/PdfPlatformBoundaryTest.php b/tests/Feature/Architecture/PdfPlatformBoundaryTest.php new file mode 100644 index 00000000..54ff9e04 --- /dev/null +++ b/tests/Feature/Architecture/PdfPlatformBoundaryTest.php @@ -0,0 +1,56 @@ +getProviders(PdfServiceProvider::class))->toHaveCount(1) + ->and(app('pdf.driver'))->toBeInstanceOf(PdfService::class) + ->and(app(PdfConfigurator::class))->toBeInstanceOf(PdfConfigurationService::class) + ->and(Gate::has('manage pdf config'))->toBeTrue() + ->and(Artisan::all())->toHaveKeys(['make:template', 'pdf:compare']); + + expect(class_exists('App\\Providers\\PdfServiceProvider'))->toBeFalse() + ->and(class_exists('App\\Services\\FontService'))->toBeFalse() + ->and(class_exists('App\\Facades\\Pdf'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\FontController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\PDFConfigurationController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\PDFConfigurationRequest'))->toBeFalse() + ->and(class_exists('App\\Console\\Commands\\ComparePdfDriversCommand'))->toBeFalse() + ->and(trait_exists('App\\Traits\\GeneratesPdfTrait'))->toBeFalse() + ->and(is_dir(app_path('Support/Pdf')))->toBeFalse(); +}); + +test('the pdf platform preserves its public configuration routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:fonts/|pdf/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/fonts/status', + 'GET|HEAD api/v1/pdf/config', + 'GET|HEAD api/v1/pdf/drivers', + 'POST api/v1/fonts/{package}/install', + 'POST api/v1/pdf/config', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Pdf\\Http\\Admin\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + expect($routes->get('GET|HEAD api/v1/fonts/status')->getActionName()) + ->toBe(FontController::class.'@status') + ->and($routes->get('GET|HEAD api/v1/pdf/config')->getActionName()) + ->toBe(PdfConfigurationController::class.'@getEnvironment') + ->and(app('router')->getMiddleware()['pdf-auth'] ?? null) + ->toBe(PdfMiddleware::class); +}); diff --git a/tests/Feature/Architecture/PurchasesDomainBoundaryTest.php b/tests/Feature/Architecture/PurchasesDomainBoundaryTest.php new file mode 100644 index 00000000..46369997 --- /dev/null +++ b/tests/Feature/Architecture/PurchasesDomainBoundaryTest.php @@ -0,0 +1,99 @@ +getProviders(PurchasesServiceProvider::class))->toHaveCount(1) + ->and(app(ExpenseTaxManager::class))->toBeInstanceOf(TaxationExpenseTaxManager::class) + ->and(app(ExpenseExchangeRateRecorder::class))->toBeInstanceOf(MoneyExpenseExchangeRateRecorder::class) + ->and(app(ExpenseReceiptManager::class))->toBeInstanceOf(MediaLibraryExpenseReceiptManager::class) + ->and(Gate::getPolicyFor(Expense::class))->toBeInstanceOf(ExpensePolicy::class) + ->and(Gate::getPolicyFor(ExpenseCategory::class))->toBeInstanceOf(ExpenseCategoryPolicy::class) + ->and(Gate::has('delete multiple expenses'))->toBeTrue(); + + expect(class_exists('App\\Services\\Document\\ExpenseService'))->toBeFalse() + ->and(class_exists('App\\Policies\\ExpensePolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\ExpenseCategoryPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Expense\\ExpensesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Expense\\ExpenseCategoriesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\CustomerPortal\\Expense\\ExpensesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ExpenseRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ExpenseCategoryRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\DeleteExpensesRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\UploadExpenseReceiptRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseCategoryResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseCategoryCollection'))->toBeFalse(); + + expect(class_exists('App\\Http\\Resources\\Customer\\ExpenseResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\ExpenseCategoryResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\ExpenseCollection'))->toBeFalse(); +}); + +test('the purchases domain preserves company expense routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:expenses|categories)(?:$|/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/categories/{category}', + 'DELETE api/v1/expenses/{expense}', + 'GET|HEAD api/v1/categories', + 'GET|HEAD api/v1/categories/{category}', + 'GET|HEAD api/v1/expenses', + 'GET|HEAD api/v1/expenses/{expense}', + 'GET|HEAD api/v1/expenses/{expense}/show/receipt', + 'POST api/v1/categories', + 'POST api/v1/expenses', + 'POST api/v1/expenses/delete', + 'POST api/v1/expenses/{expense}/upload/receipts', + 'PUT|PATCH api/v1/categories/{category}', + 'PUT|PATCH api/v1/expenses/{expense}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Purchases\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the purchases domain preserves customer and receipt web routes', function () { + $customerRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/expenses')); + + expect($customerRoutes)->toHaveCount(2); + + foreach ($customerRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Purchases\\Http\\Controllers\\CustomerPortal\\') + ->and($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } + + $receiptRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'reports/expenses/{expense}/download-receipt', + 'reports/expenses/{expense}/receipt', + ], true)); + + expect($receiptRoutes)->toHaveCount(2); + + foreach ($receiptRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Purchases\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum'); + } +}); diff --git a/tests/Feature/Architecture/ReceivablesDomainBoundaryTest.php b/tests/Feature/Architecture/ReceivablesDomainBoundaryTest.php new file mode 100644 index 00000000..4f45a3f7 --- /dev/null +++ b/tests/Feature/Architecture/ReceivablesDomainBoundaryTest.php @@ -0,0 +1,123 @@ +getProviders(ReceivablesServiceProvider::class))->toHaveCount(1) + ->and(app(PaymentPdfDataProvider::class))->toBeInstanceOf(PaymentService::class) + ->and(app(InvoiceBalanceUpdater::class))->toBeInstanceOf(SalesInvoiceBalanceUpdater::class) + ->and(app(PaymentNumberAssigner::class))->toBeInstanceOf(SalesPaymentNumberAssigner::class) + ->and(app(PaymentExchangeRateRecorder::class))->toBeInstanceOf(MoneyPaymentExchangeRateRecorder::class) + ->and(app(PaymentEmailSender::class))->toBeInstanceOf(LaravelPaymentEmailSender::class) + ->and(Gate::getPolicyFor(Payment::class))->toBeInstanceOf(PaymentPolicy::class) + ->and(Gate::getPolicyFor(PaymentMethod::class))->toBeInstanceOf(PaymentMethodPolicy::class) + ->and(Gate::has('send payment'))->toBeTrue() + ->and(Gate::has('delete multiple payments'))->toBeTrue(); + + foreach ([ + 'App\\Services\\Document\\PaymentService', + 'App\\Services\\Document\\PaymentAllocationService', + 'App\\Policies\\PaymentPolicy', + 'App\\Policies\\PaymentMethodPolicy', + 'App\\Jobs\\GeneratePaymentPdfJob', + 'App\\Mail\\SendPaymentMail', + 'App\\Http\\Controllers\\Company\\Payment\\PaymentsController', + 'App\\Http\\Controllers\\Company\\Payment\\PaymentMethodsController', + 'App\\Http\\Controllers\\Company\\Payment\\CreditAllocationsController', + 'App\\Http\\Controllers\\CustomerPortal\\Payment\\PaymentsController', + 'App\\Http\\Controllers\\CustomerPortal\\Payment\\PaymentMethodController', + 'App\\Http\\Controllers\\CustomerPortal\\PaymentPdfController', + 'App\\Http\\Requests\\PaymentRequest', + 'App\\Http\\Requests\\PaymentMethodRequest', + 'App\\Http\\Requests\\DeletePaymentsRequest', + 'App\\Http\\Requests\\ReplacePaymentAllocationsRequest', + 'App\\Http\\Requests\\CreditAllocationRequest', + 'App\\Http\\Requests\\SendPaymentRequest', + 'App\\Http\\Resources\\PaymentResource', + 'App\\Http\\Resources\\PaymentMethodResource', + 'App\\Http\\Resources\\PaymentCollection', + 'App\\Http\\Resources\\PaymentMethodCollection', + 'App\\Http\\Resources\\TransactionResource', + 'App\\Http\\Resources\\Customer\\PaymentResource', + 'App\\Http\\Resources\\Customer\\PaymentMethodResource', + 'App\\Http\\Resources\\Customer\\PaymentCollection', + 'App\\Http\\Resources\\Customer\\PaymentMethodCollection', + 'App\\Http\\Resources\\Customer\\TransactionResource', + ] as $legacyClass) { + expect(class_exists($legacyClass))->toBeFalse(); + } +}); + +test('the receivables domain preserves company payment routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:payments|payment-methods)(?:$|/)#', $route->uri()) === 1 + || $route->uri() === 'api/v1/customers/{customer}/credit-allocations') + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/payment-methods/{payment_method}', + 'DELETE api/v1/payments/{payment}', + 'GET|HEAD api/v1/payment-methods', + 'GET|HEAD api/v1/payment-methods/{payment_method}', + 'GET|HEAD api/v1/payments', + 'GET|HEAD api/v1/payments/{payment}', + 'GET|HEAD api/v1/payments/{payment}/send/preview', + 'POST api/v1/customers/{customer}/credit-allocations', + 'POST api/v1/payment-methods', + 'POST api/v1/payments', + 'POST api/v1/payments/delete', + 'POST api/v1/payments/{payment}/send', + 'PUT api/v1/payments/{payment}/allocations', + 'PUT|PATCH api/v1/payment-methods/{payment_method}', + 'PUT|PATCH api/v1/payments/{payment}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Receivables\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the receivables domain preserves customer and pdf routes', function () { + $customerRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/payments') + || $route->uri() === 'api/v1/{company}/customer/payment-method'); + + expect($customerRoutes)->toHaveCount(3); + + foreach ($customerRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Receivables\\Http\\Controllers\\CustomerPortal\\') + ->and($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } + + $pdfRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'payments/pdf/{payment}', + 'customer/payments/{email_log}', + 'customer/payments/view/{email_log}', + ], true)); + + expect($pdfRoutes)->toHaveCount(3); + + foreach ($pdfRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Receivables\\Http\\Controllers\\'); + } +}); diff --git a/tests/Feature/Architecture/ReportingDomainBoundaryTest.php b/tests/Feature/Architecture/ReportingDomainBoundaryTest.php new file mode 100644 index 00000000..1757f1f4 --- /dev/null +++ b/tests/Feature/Architecture/ReportingDomainBoundaryTest.php @@ -0,0 +1,92 @@ +getProviders(ReportingServiceProvider::class))->toHaveCount(1) + ->and(app(CustomerStatementQuery::class))->toBeInstanceOf(CustomerStatementQuery::class) + ->and(app(CustomerStatementPdfRenderer::class))->toBeInstanceOf(CustomerStatementPdfRenderer::class) + ->and(Gate::has('view dashboard'))->toBeTrue() + ->and(Gate::has('view report'))->toBeTrue(); + + expect(class_exists('App\\Policies\\ReportPolicy'))->toBeFalse() + ->and(class_exists('App\\Services\\CustomerStatementService'))->toBeFalse() + ->and(class_exists('App\\Services\\CustomerStatementPdfService'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\CustomerStatementRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\SendCustomerStatementRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomerStatementResource'))->toBeFalse() + ->and(class_exists('App\\Mail\\SendCustomerStatementMail'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Customer\\CustomerStatementController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Customer\\SendCustomerStatementController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Dashboard\\DashboardController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\SearchController'))->toBeFalse() + ->and(class_exists('App\\Policies\\DashboardPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\CustomerSalesReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\CustomerStatementReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\ExpensesReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\ItemSalesReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\ProfitLossReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\TaxSummaryReportController'))->toBeFalse(); +}); + +test('the reporting domain owns dashboard and search projections', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/dashboard', + 'api/v1/search', + 'api/v1/search/user', + ], true)); + + expect($routes)->toHaveCount(3); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Reporting\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the reporting domain preserves customer statement api routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/customers/{customer}/statement', + 'api/v1/customers/{customer}/statement/send', + ], true)); + + expect($routes)->toHaveCount(2); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Reporting\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the reporting domain preserves authenticated report routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^reports/(?:customers/[^/]+/statement|sales/(?:customers|items)/[^/]+|expenses/[^/]+|tax-summary/[^/]+|profit-loss/[^/]+)$#', + $route->uri(), + ) === 1) + ->reject(fn ($route): bool => str_contains($route->uri(), 'download-receipt') || str_contains($route->uri(), 'receipt')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD reports/customers/{customer}/statement', + 'GET|HEAD reports/expenses/{hash}', + 'GET|HEAD reports/profit-loss/{hash}', + 'GET|HEAD reports/sales/customers/{hash}', + 'GET|HEAD reports/sales/items/{hash}', + 'GET|HEAD reports/tax-summary/{hash}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Reporting\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('web', 'auth:sanctum'); + } +}); diff --git a/tests/Feature/Architecture/SalesDomainBoundaryTest.php b/tests/Feature/Architecture/SalesDomainBoundaryTest.php new file mode 100644 index 00000000..0f50ff4f --- /dev/null +++ b/tests/Feature/Architecture/SalesDomainBoundaryTest.php @@ -0,0 +1,124 @@ +getProviders(SalesServiceProvider::class))->toHaveCount(1) + ->and(app(EstimatePdfDataProvider::class))->toBeInstanceOf(EstimateService::class) + ->and(app(InvoicePdfDataProvider::class))->toBeInstanceOf(InvoiceService::class) + ->and(app(DocumentExchangeRateRecorder::class))->toBeInstanceOf(MoneyDocumentExchangeRateRecorder::class) + ->and(app(EstimateEmailSender::class))->toBeInstanceOf(LaravelEstimateEmailSender::class) + ->and(app(InvoiceEmailSender::class))->toBeInstanceOf(LaravelInvoiceEmailSender::class) + ->and(Gate::getPolicyFor(Estimate::class))->toBeInstanceOf(EstimatePolicy::class) + ->and(Gate::getPolicyFor(Invoice::class))->toBeInstanceOf(InvoicePolicy::class) + ->and(Gate::getPolicyFor(RecurringInvoice::class))->toBeInstanceOf(RecurringInvoicePolicy::class) + ->and(Gate::has('send invoice'))->toBeTrue() + ->and(Gate::has('create credit note'))->toBeTrue() + ->and(Gate::has('send estimate'))->toBeTrue() + ->and(Gate::has('delete multiple invoices'))->toBeTrue() + ->and(Gate::has('delete multiple estimates'))->toBeTrue() + ->and(Gate::has('delete multiple recurring invoices'))->toBeTrue() + ->and(Artisan::all())->toHaveKeys(['check:estimates:status', 'check:invoices:status']); + + foreach ([ + 'App\\Services\\Document\\CreditNoteService', + 'App\\Services\\Document\\DocumentItemService', + 'App\\Services\\Document\\EstimateService', + 'App\\Services\\Document\\InvoiceBalanceService', + 'App\\Services\\Document\\InvoiceService', + 'App\\Services\\Document\\RecurringInvoiceService', + 'App\\Services\\Document\\SerialNumberService', + 'App\\Policies\\CreditNotePolicy', + 'App\\Policies\\EstimatePolicy', + 'App\\Policies\\InvoicePolicy', + 'App\\Policies\\RecurringInvoicePolicy', + 'App\\Jobs\\GenerateEstimatePdfJob', + 'App\\Jobs\\GenerateInvoicePdfJob', + 'App\\Mail\\EstimateViewedMail', + 'App\\Mail\\InvoiceViewedMail', + 'App\\Mail\\SendCreditNoteMail', + 'App\\Mail\\SendEstimateMail', + 'App\\Mail\\SendInvoiceMail', + 'App\\Http\\Controllers\\Company\\Estimate\\EstimatesController', + 'App\\Http\\Controllers\\Company\\Estimate\\EstimateTemplatesController', + 'App\\Http\\Controllers\\Company\\Invoice\\InvoicesController', + 'App\\Http\\Controllers\\Company\\Invoice\\InvoiceTemplatesController', + 'App\\Http\\Controllers\\Company\\RecurringInvoice\\RecurringInvoiceController', + 'App\\Http\\Controllers\\Company\\RecurringInvoice\\RecurringInvoiceFrequencyController', + 'App\\Http\\Controllers\\Company\\General\\SerialNumberController', + 'App\\Http\\Controllers\\CustomerPortal\\Estimate\\AcceptEstimateController', + 'App\\Http\\Controllers\\CustomerPortal\\Estimate\\EstimatesController', + 'App\\Http\\Controllers\\CustomerPortal\\Invoice\\InvoicesController', + 'App\\Http\\Controllers\\CustomerPortal\\EstimatePdfController', + 'App\\Http\\Controllers\\CustomerPortal\\InvoicePdfController', + 'App\\Http\\Controllers\\Pdf\\DocumentPdfController', + ] as $legacyClass) { + expect(class_exists($legacyClass))->toBeFalse(); + } +}); + +test('the sales domain preserves company document routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/v1/(?:invoices|estimates|recurring-invoices|recurring-invoice-frequency|next-number|number-placeholders)(?:$|/)#', + $route->uri(), + ) === 1); + + expect($routes)->toHaveCount(34); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Sales\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the sales domain preserves customer and pdf routes', function () { + $customerRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/invoices') + || str_starts_with($route->uri(), 'api/v1/{company}/customer/estimates') + || $route->uri() === 'api/v1/{company}/customer/estimate/{estimate}/status'); + + expect($customerRoutes)->toHaveCount(5); + + foreach ($customerRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Sales\\Http\\Controllers\\CustomerPortal\\') + ->and($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } + + $pdfRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'invoices/pdf/{invoice}', + 'estimates/pdf/{estimate}', + 'customer/invoices/{email_log}', + 'customer/invoices/view/{email_log}', + 'customer/estimates/{email_log}', + 'customer/estimates/view/{email_log}', + ], true)); + + expect($pdfRoutes)->toHaveCount(6); + + foreach ($pdfRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Sales\\Http\\Controllers\\'); + } +}); diff --git a/tests/Feature/Architecture/StoragePlatformBoundaryTest.php b/tests/Feature/Architecture/StoragePlatformBoundaryTest.php new file mode 100644 index 00000000..870c8756 --- /dev/null +++ b/tests/Feature/Architecture/StoragePlatformBoundaryTest.php @@ -0,0 +1,60 @@ +getProviders(StorageServiceProvider::class))->toHaveCount(1) + ->and(app(StorageConfigurator::class))->toBeInstanceOf(FileDiskService::class) + ->and(Gate::has('manage backups'))->toBeTrue() + ->and(Gate::has('manage file disk'))->toBeTrue() + ->and(Artisan::all())->toHaveKey('media:secure'); + + expect(class_exists('App\\Providers\\DropboxServiceProvider'))->toBeFalse() + ->and(class_exists('App\\Services\\Storage\\FileDiskService'))->toBeFalse() + ->and(class_exists('App\\Jobs\\CreateBackupJob'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\BackupsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\DiskController'))->toBeFalse(); +}); + +test('the storage platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/v1/(backups(?:/|$)|disks(?:/|$)|download-backup$|disk/(?:drivers|purposes)$)#', + $route->uri(), + ) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/backups/{backup}', + 'DELETE api/v1/disks/{disk}', + 'GET|HEAD api/v1/backups', + 'GET|HEAD api/v1/backups/{backup}', + 'GET|HEAD api/v1/disk/drivers', + 'GET|HEAD api/v1/disk/purposes', + 'GET|HEAD api/v1/disks', + 'GET|HEAD api/v1/disks/{disk}', + 'GET|HEAD api/v1/download-backup', + 'POST api/v1/backups', + 'POST api/v1/disks', + 'PUT api/v1/disk/purposes', + 'PUT|PATCH api/v1/backups/{backup}', + 'PUT|PATCH api/v1/disks/{disk}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Storage\\Http\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + expect($routes->get('GET|HEAD api/v1/backups')->getActionName()) + ->toBe(BackupsController::class.'@index') + ->and($routes->get('GET|HEAD api/v1/disks')->getActionName()) + ->toBe(DiskController::class.'@index'); +}); diff --git a/tests/Feature/Architecture/TaxationDomainBoundaryTest.php b/tests/Feature/Architecture/TaxationDomainBoundaryTest.php new file mode 100644 index 00000000..1dd2a29b --- /dev/null +++ b/tests/Feature/Architecture/TaxationDomainBoundaryTest.php @@ -0,0 +1,40 @@ +getProviders(TaxationServiceProvider::class))->toHaveCount(1) + ->and(Gate::getPolicyFor(TaxType::class))->toBeInstanceOf(TaxTypePolicy::class); + + expect(class_exists('App\\Policies\\TaxTypePolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Settings\\TaxTypesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\TaxTypeRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\TaxTypeResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\TaxResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\TaxTypeResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\TaxResource'))->toBeFalse(); +}); + +test('the taxation domain preserves tax-type routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/tax-types')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/tax-types/{tax_type}', + 'GET|HEAD api/v1/tax-types', + 'GET|HEAD api/v1/tax-types/{tax_type}', + 'POST api/v1/tax-types', + 'PUT|PATCH api/v1/tax-types/{tax_type}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Taxation\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Company/BootstrapSecurityTest.php b/tests/Feature/Company/BootstrapSecurityTest.php index 9ed247a2..56aab0e6 100644 --- a/tests/Feature/Company/BootstrapSecurityTest.php +++ b/tests/Feature/Company/BootstrapSecurityTest.php @@ -1,7 +1,7 @@ 'runtime@example.com', ], $this->companyId); - CompanyMailConfigService::apply($this->companyId); + app(MailConfigurationService::class)->applyCompanyConfig($this->companyId); expect(config('mail.default'))->toBe('postmark'); expect(config('services.postmark.token'))->toBe('runtime-postmark-token'); diff --git a/tests/Feature/Company/ExchangeRate/BulkExchangeRateTest.php b/tests/Feature/Company/ExchangeRate/BulkExchangeRateTest.php new file mode 100644 index 00000000..df4c67ff --- /dev/null +++ b/tests/Feature/Company/ExchangeRate/BulkExchangeRateTest.php @@ -0,0 +1,53 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $this->user = User::findOrFail(1); + $this->companyId = $this->user->companies()->firstOrFail()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->user, ['*']); +}); + +test('bulk exchange-rate setup still updates legacy documents through the domain contract', function () { + CompanySetting::setSettings(['bulk_exchange_rate_configured' => 'NO'], $this->companyId); + + $currency = Currency::findOrFail(1); + $invoice = Invoice::factory()->create([ + 'company_id' => $this->companyId, + 'currency_id' => $currency->id, + 'sub_total' => 100, + 'total' => 140, + 'tax' => 20, + 'due_amount' => 80, + 'exchange_rate' => null, + ]); + + postJson('/api/v1/currencies/bulk-update-exchange-rate', [ + 'currencies' => [[ + 'id' => $currency->id, + 'exchange_rate' => 2, + ]], + ])->assertOk()->assertJson(['success' => true]); + + $invoice->refresh(); + + expect($invoice->exchange_rate)->toBe(2.0) + ->and($invoice->base_discount_val)->toBe(200) + ->and($invoice->base_sub_total)->toBe(200) + ->and($invoice->base_total)->toBe(280) + ->and($invoice->base_tax)->toBe(40) + ->and($invoice->base_due_amount)->toBe(160) + ->and(CompanySetting::getSetting('bulk_exchange_rate_configured', $this->companyId)) + ->toBe('YES'); +}); diff --git a/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php b/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php index 332dbc0a..ab3d3981 100644 --- a/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php +++ b/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php @@ -1,6 +1,6 @@ company->slug}/customer/me")->assertOk(); }); + +test('updating one address does not replace the other address', function () { + $customer = Auth::guard('customer')->user(); + $billing = Address::factory()->create([ + 'customer_id' => $customer->id, + 'type' => Address::BILLING_TYPE, + 'name' => 'Old Billing', + ]); + $shipping = Address::factory()->create([ + 'customer_id' => $customer->id, + 'type' => Address::SHIPPING_TYPE, + 'name' => 'Existing Shipping', + ]); + + postJson("api/v1/{$customer->company->slug}/customer/profile", [ + 'billing' => [ + 'name' => 'New Billing', + 'address_street_1' => 'Billing Street', + ], + ])->assertOk(); + + expect($customer->fresh()->billingAddress->name)->toBe('New Billing') + ->and($customer->fresh()->shippingAddress->is($shipping))->toBeTrue(); + + $this->assertDatabaseMissing('addresses', ['id' => $billing->id]); +}); diff --git a/tests/Feature/CustomerStatementTest.php b/tests/Feature/CustomerStatementTest.php index df67fc35..ad693782 100644 --- a/tests/Feature/CustomerStatementTest.php +++ b/tests/Feature/CustomerStatementTest.php @@ -1,13 +1,13 @@ build(); expect(EmailLog::query() - ->where('mailable_type', Customer::class) + ->where('mailable_type', $customer->getMorphClass()) ->where('mailable_id', $customer->id) ->where('from', 'configured@example.test') ->exists())->toBeTrue(); diff --git a/tests/Feature/Marketplace/CanonicalJsonTest.php b/tests/Feature/Marketplace/CanonicalJsonTest.php index ea829b5d..7ce5d354 100644 --- a/tests/Feature/Marketplace/CanonicalJsonTest.php +++ b/tests/Feature/Marketplace/CanonicalJsonTest.php @@ -1,6 +1,6 @@ stream() — already a Response + * payload, because GeneratesPdf wrapped $pdf->stream() — already a Response * — in another response()->make(). Readers scan for the header so nobody * noticed, but the bytes were malformed. The trait now passes ->output(), so the * position can be asserted, and a regression would be caught rather than diff --git a/tests/Feature/Pdf/PdfTemplateValidationTest.php b/tests/Feature/Pdf/PdfTemplateValidationTest.php index 95dcdd74..be650ae9 100644 --- a/tests/Feature/Pdf/PdfTemplateValidationTest.php +++ b/tests/Feature/Pdf/PdfTemplateValidationTest.php @@ -1,7 +1,7 @@ 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); +}); + +test('it stores replaces reads and clears expense receipts through the purchases contract', function () { + Storage::fake('local'); + + $expense = Expense::factory()->create(); + $manager = app(ExpenseReceiptManager::class); + + $uploadedReceipt = UploadedFile::fake()->create('first-receipt.pdf', 10, 'application/pdf'); + + $manager->attach($expense, new PendingExpenseReceipt( + $uploadedReceipt->getPathname(), + $uploadedReceipt->getClientOriginalName(), + )); + + $firstReceipt = $manager->first($expense); + + expect($firstReceipt)->not->toBeNull() + ->and($firstReceipt->fileName)->toBe('first-receipt.pdf') + ->and($expense->fresh()->getMedia('receipts'))->toHaveCount(1); + + $manager->attachBase64( + $expense, + 'data:image/png;base64,'.base64_encode('replacement receipt'), + 'replacement.png', + replaceExisting: true, + ); + + $replacement = $manager->first($expense); + + expect($replacement)->not->toBeNull() + ->and($replacement->fileName)->toBe('replacement.png') + ->and($expense->fresh()->getMedia('receipts'))->toHaveCount(1); + + $manager->clear($expense); + + expect($manager->first($expense))->toBeNull() + ->and($expense->fresh()->getMedia('receipts'))->toBeEmpty(); +}); diff --git a/tests/Feature/RealisticDemoSeederTest.php b/tests/Feature/RealisticDemoSeederTest.php index 102344e1..108bc20b 100644 --- a/tests/Feature/RealisticDemoSeederTest.php +++ b/tests/Feature/RealisticDemoSeederTest.php @@ -1,11 +1,11 @@ firstOrFail()->companies()->firstOrFail(); $from = Carbon::now()->startOfMonth(); $to = Carbon::now(); - $statementService = app(CustomerStatementService::class); + $statementQuery = app(CustomerStatementQuery::class); Customer::query() ->where('company_id', $company->id) - ->each(function (Customer $customer) use ($statementService, $from, $to): void { - $statement = $statementService->statement( + ->each(function (Customer $customer) use ($statementQuery, $from, $to): void { + $statement = $statementQuery->statement( $customer, - CustomerStatementService::TYPE_ACTIVITY, + CustomerStatementQuery::TYPE_ACTIVITY, $from, $to, ); diff --git a/tests/Feature/Receivables/PaymentEmailLogTest.php b/tests/Feature/Receivables/PaymentEmailLogTest.php new file mode 100644 index 00000000..264ddc59 --- /dev/null +++ b/tests/Feature/Receivables/PaymentEmailLogTest.php @@ -0,0 +1,39 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); +}); + +test('payment mail records a stable public link through the mail platform', function () { + $payment = Payment::factory()->create(); + $mail = new SendPaymentMail([ + 'from' => 'billing@example.com', + 'to' => 'customer@example.com', + 'subject' => 'Payment receipt', + 'body' => 'Thanks for your payment.', + 'payment' => $payment->toArray(), + 'attach' => ['data' => null], + ]); + + $mail->build(); + + $log = EmailLog::query()->sole(); + + expect($log->mailable_type)->toBe(ModelIdentityMap::aliasFor(Payment::class)) + ->and((int) $log->mailable_id)->toBe($payment->id) + ->and($log->token)->not->toBeEmpty() + ->and($mail->data['url'])->toBe(route('payment', ['email_log' => $log->token])); + + getJson('/customer/payments/'.$log->token) + ->assertOk() + ->assertJsonPath('data.id', $payment->id); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 2deca95e..bb47d2e7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,7 +6,17 @@ use Tests\TestCase; uses(TestCase::class, RefreshDatabase::class)->in('Feature'); uses(TestCase::class, RefreshDatabase::class)->in('Unit'); -// The module-system tests scaffold real modules on disk (Modules/ScaffoldProbe). -// Paratest isolates the database but not that shared filesystem path, so run this -// group serially after the parallel pass to avoid cross-worker collisions. -uses()->group('modules')->in('Feature/Company/Modules'); +// Module-system tests scaffold, install, and remove real directories under +// Modules. Paratest isolates the database but not that shared filesystem path, +// so every filesystem-mutating module suite runs serially after the parallel +// pass to avoid one worker scanning another worker's staging directory. +uses()->group('modules')->in( + 'Feature/Admin/Modules', + 'Feature/Company/Modules', + 'Feature/Marketplace', +); + +// Architecture assertions parse broad namespace graphs and retain that graph +// for the life of a worker. Run them in the serial phase so an ordinary feature +// test is not handed the parser's memory footprint in the same 128 MB process. +uses()->group('architecture')->in('Unit/Architecture', 'Feature/Architecture'); diff --git a/tests/Support/ScriptedAiDriver.php b/tests/Support/ScriptedAiDriver.php index cb862936..2d1ea15e 100644 --- a/tests/Support/ScriptedAiDriver.php +++ b/tests/Support/ScriptedAiDriver.php @@ -2,8 +2,8 @@ namespace Tests\Support; -use App\Support\Ai\AiChatResponse; -use App\Support\Ai\AiDriver; +use App\Platform\Ai\Contracts\AiDriver; +use App\Platform\Ai\Data\AiChatResponse; /** * Test double for AiDriver that returns pre-queued responses from an array, so diff --git a/tests/TestCase.php b/tests/TestCase.php index 0faafe0d..bc77f724 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,7 @@ namespace Tests; -use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -use Illuminate\Support\Str; use JMac\Testing\Traits\AdditionalAssertions; abstract class TestCase extends BaseTestCase @@ -19,17 +17,5 @@ abstract class TestCase extends BaseTestCase // (resources/views/app.blade.php → @vite) would throw ViteManifestNotFoundException. // Stub Vite so those views render without a built manifest. $this->withoutVite(); - - Factory::guessFactoryNamesUsing(function (string $modelName) { - // We can also customise where our factories live too if we want: - $namespace = 'Database\\Factories\\'; - - // Here we are getting the model name from the class namespace - $modelName = Str::afterLast($modelName, '\\'); - - // Finally we'll build up the full class path where - // Laravel will find our model factory - return $namespace.$modelName.'Factory'; - }); } } diff --git a/tests/Unit/AddressCountryLocalizationTest.php b/tests/Unit/AddressCountryLocalizationTest.php index eebcdb05..e2b6fb69 100644 --- a/tests/Unit/AddressCountryLocalizationTest.php +++ b/tests/Unit/AddressCountryLocalizationTest.php @@ -1,7 +1,7 @@ + */ +function phpFilesUnder(string $directory): array +{ + if (! is_dir($directory)) { + return []; + } + + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + + return $files; +} + +test('domain code does not depend on legacy service or controller layers', function () { + $files = phpFilesUnder(app_path('Domains')); + + expect($files)->toBeArray(); + + foreach ($files as $file) { + $source = file_get_contents($file); + + expect($source)->not->toContain('App\\Http\\Controllers') + ->not->toContain('App\\Services'); + } +}); + +test('platform code does not depend on legacy controllers', function () { + foreach (phpFilesUnder(app_path('Platform')) as $file) { + expect(file_get_contents($file))->not->toContain('App\\Http\\Controllers'); + } +}); + +test('legacy application layer directories contain no php classes', function () { + $legacyDirectories = [ + 'Console/Commands', + 'Http/Controllers', + 'Http/Requests', + 'Http/Resources', + 'Jobs', + 'Mail', + 'Models', + 'Policies', + 'Services', + 'Traits', + ]; + + foreach ($legacyDirectories as $directory) { + expect(phpFilesUnder(app_path($directory)))->toBe([]); + } +}); + +test('support remains independent of domains', function () { + foreach (phpFilesUnder(app_path('Support')) as $file) { + expect(file_get_contents($file))->not->toContain('App\\Domains'); + } +}); diff --git a/tests/Unit/Architecture/HashidIdentityTest.php b/tests/Unit/Architecture/HashidIdentityTest.php new file mode 100644 index 00000000..93131ac3 --- /dev/null +++ b/tests/Unit/Architecture/HashidIdentityTest.php @@ -0,0 +1,23 @@ +value}"); + + expect($config)->toBeArray() + ->and($config['salt'])->toBe($legacyClass.config('app.key')); + + $legacy = new Hashids($legacyClass.config('app.key'), $config['length'], $config['alphabet']); + + expect(HashidsFacade::connection($connection->value)->encode(1))->toBe($legacy->encode(1)); +})->with([ + [HashidConnection::Invoice, 'App\\Models\\Invoice'], + [HashidConnection::Estimate, 'App\\Models\\Estimate'], + [HashidConnection::Payment, 'App\\Models\\Payment'], + [HashidConnection::Company, 'App\\Models\\Company'], + [HashidConnection::EmailLog, 'App\\Models\\EmailLog'], + [HashidConnection::Transaction, 'App\\Models\\Transaction'], +]); diff --git a/tests/Unit/Architecture/ModelIdentityMapTest.php b/tests/Unit/Architecture/ModelIdentityMapTest.php new file mode 100644 index 00000000..dede281a --- /dev/null +++ b/tests/Unit/Architecture/ModelIdentityMapTest.php @@ -0,0 +1,80 @@ +toHaveKey('company', Company::class) + ->toHaveKey('customer', Customer::class) + ->toHaveKey('invoice', Invoice::class) + ->toHaveKey('marketplace_credential', MarketplaceCredential::class) + ->toHaveKey('marketplace_operation', MarketplaceOperation::class) + ->toHaveKey('module', Module::class) + ->toHaveKey('user', User::class) + ->toHaveKey('bouncer_ability', Ability::class) + ->toHaveKey('bouncer_role', Role::class); + + expect((new Invoice)->getMorphClass())->toBe('invoice') + ->and((new User)->getMorphClass())->toBe('user') + ->and((new Role)->getMorphClass())->toBe('bouncer_role') + ->and(Relation::getMorphedModel('customer'))->toBe(Customer::class); +}); + +test('every first-party model has a stable identity', function () { + $models = collect([app_path('Models'), app_path('Domains'), app_path('Platform')]) + ->filter(fn (string $directory): bool => is_dir($directory)) + ->flatMap(function (string $directory): array { + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)); + + foreach ($iterator as $file) { + $path = str_replace('\\', '/', $file->getPathname()); + + if ($file->isFile() && $file->getExtension() === 'php' && str_contains($path, '/Models/')) { + $relative = substr($path, strlen(str_replace('\\', '/', app_path())) + 1); + $files[] = 'App\\'.str_replace('/', '\\', substr($relative, 0, -4)); + } + } + + return $files; + }) + ->values(); + + $mappedModels = collect(ModelIdentityMap::aliases()) + ->values() + ->filter(fn (string $model): bool => str_starts_with($model, 'App\\')) + ->values(); + + expect($models->diff($mappedModels)->values()->all())->toBe([]) + ->and($mappedModels->diff($models)->values()->all())->toBe([]); +}); + +test('first-party models have canonical owners and explicit table contracts', function () { + expect(is_dir(app_path('Models')))->toBeFalse(); + + collect(ModelIdentityMap::aliases()) + ->values() + ->filter(fn (string $model): bool => str_starts_with($model, 'App\\')) + ->each(function (string $model): void { + $table = new ReflectionProperty($model, 'table'); + + expect($table->getDeclaringClass()->getName())->toBe($model) + ->and((new $model)->getTable())->not->toBeEmpty(); + }); +}); + +test('database aliases do not leak through the existing v1 discriminator', function () { + expect(ModelIdentityMap::publicType('invoice'))->toBe('App\\Models\\Invoice') + ->and(ModelIdentityMap::publicType('Modules\\Example\\Models\\Record')) + ->toBe('Modules\\Example\\Models\\Record'); +}); diff --git a/tests/Unit/CompanySettingTest.php b/tests/Unit/CompanySettingTest.php index 7a768b94..4b36b21f 100644 --- a/tests/Unit/CompanySettingTest.php +++ b/tests/Unit/CompanySettingTest.php @@ -1,7 +1,7 @@ assertTrue($fieldValue->customField()->exists()); }); + +test('custom field values are attached and updated through the metadata contract', function () { + $customField = CustomField::factory()->create([ + 'model_type' => 'Customer', + 'type' => 'Input', + ]); + $customer = Customer::factory()->create([ + 'company_id' => $customField->company_id, + ]); + $writer = app(CustomFieldValueWriter::class); + + $writer->attach($customer, [[ + 'id' => $customField->id, + 'value' => 'First value', + ]]); + + expect($customer->fields()->sole()->string_answer)->toBe('First value'); + + $writer->update($customer, [[ + 'id' => $customField->id, + 'value' => 'Updated value', + ]]); + + expect($customer->fields()->sole()->string_answer)->toBe('Updated value'); +}); diff --git a/tests/Unit/CustomerTest.php b/tests/Unit/CustomerTest.php index 91402664..278e17b6 100644 --- a/tests/Unit/CustomerTest.php +++ b/tests/Unit/CustomerTest.php @@ -1,7 +1,7 @@ replace($estimate); - $response = app(EstimateService::class)->create($request); + $response = app(EstimateService::class)->create( + attributes: $request->getEstimatePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('estimate_items', [ 'estimate_id' => $response->id, @@ -97,7 +101,12 @@ test('update estimate', function () { $number_attributes['estimate_number'] = $estimate_number[0].'-'.sprintf('%06d', intval($estimate_number[1])); - app(EstimateService::class)->update($estimate, $request); + app(EstimateService::class)->update( + estimate: $estimate, + attributes: $request->getEstimatePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('estimate_items', [ 'estimate_id' => $estimate->id, diff --git a/tests/Unit/ExchangeRateDriverFactoryTest.php b/tests/Unit/ExchangeRateDriverFactoryTest.php index 127481c0..49ca26c0 100644 --- a/tests/Unit/ExchangeRateDriverFactoryTest.php +++ b/tests/Unit/ExchangeRateDriverFactoryTest.php @@ -1,8 +1,8 @@ 'DatabaseSeeder', '--force' => true]); diff --git a/tests/Unit/ExpenseCategoryTest.php b/tests/Unit/ExpenseCategoryTest.php index 2232c36c..1ad47880 100644 --- a/tests/Unit/ExpenseCategoryTest.php +++ b/tests/Unit/ExpenseCategoryTest.php @@ -1,6 +1,6 @@ $taxType->id, ]); - app(CompanyService::class)->delete($company, $user); + app(CompanyService::class)->delete($company); $this->assertDatabaseMissing('taxes', ['id' => $tax->id]); }); @@ -116,7 +116,7 @@ test('company deletion removes payment allocations before bulk payment deletion' 'invoice_id' => $invoice->id, ]); - app(CompanyService::class)->delete($company, $user); + app(CompanyService::class)->delete($company); $this->assertDatabaseMissing('payment_allocations', ['id' => $allocation->id]); }); diff --git a/tests/Unit/FormatMoneyPdfTest.php b/tests/Unit/FormatMoneyPdfTest.php index 18ffae2f..4aa014ad 100644 --- a/tests/Unit/FormatMoneyPdfTest.php +++ b/tests/Unit/FormatMoneyPdfTest.php @@ -1,6 +1,6 @@ 'gotenberg', 'gotenberg_host' => $url, ])->rules(); diff --git a/tests/Unit/GotenbergPdfDriverTest.php b/tests/Unit/GotenbergPdfDriverTest.php index d9a95213..217c3dd9 100644 --- a/tests/Unit/GotenbergPdfDriverTest.php +++ b/tests/Unit/GotenbergPdfDriverTest.php @@ -1,6 +1,6 @@ create($request); + $response = app(InvoiceService::class)->create( + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('invoice_items', [ 'invoice_id' => $response->id, @@ -130,7 +134,12 @@ test('update invoice', function () { $number_attributes['invoice_number'] = $invoice_number[0].'-'.sprintf('%06d', intval($invoice_number[1])); - $response = app(InvoiceService::class)->update($invoice, $request); + $response = app(InvoiceService::class)->update( + invoice: $invoice, + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('invoice_items', [ 'invoice_id' => $response->id, diff --git a/tests/Unit/ItemTest.php b/tests/Unit/ItemTest.php index 47dbaf6b..c7693514 100644 --- a/tests/Unit/ItemTest.php +++ b/tests/Unit/ItemTest.php @@ -1,10 +1,10 @@ Hi