diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5374caa71..51630b3e1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,6 +22,7 @@ This project is **InvoicePlane v2**, a **multi-tenant Laravel application** with - **Module System:** nwidart/laravel-modules - **Permissions:** spatie/laravel-permission - **Multi-tenancy:** Filament Companies with `BelongsToCompany` trait +- **Queue System:** Required for export functionality (Redis, database, or sync for local development) ## Development Commands @@ -57,8 +58,16 @@ composer install cp .env.example .env php artisan key:generate php artisan migrate --seed + +# Start queue worker for export functionality +php artisan queue:work ``` +**Queue Configuration:** +- Export functionality requires a queue worker to be running +- For local development, you can use `QUEUE_CONNECTION=sync` in `.env` +- For production, use Redis or database queue driver with Supervisor + ## Related Documentation - **Installation:** `.github/INSTALLATION.md` @@ -124,6 +133,15 @@ php artisan migrate --seed - Reusable logic (e.g., fixtures, setup) must live in abstract test cases, not inline. - Tests have inline comment blocks above sections (Arrange, Act, Assert). +### Export System Rules + +- **Exports use Filament's asynchronous export system** which requires queue workers. +- **Export tests must use fakes:** `Queue::fake()`, `Storage::fake()`, and verify job dispatching with `Bus::assertChained()`. +- **The `exports` table is temporary** and managed by Filament for job coordination only. +- **No export history feature** - export records are ephemeral and auto-prunable. +- **Queue configuration is required** for export functionality to work in production. +- See `Modules/Core/Filament/Exporters/README.md` for export architecture details. + ### Database & Models - **No `$fillable` array in Models.** diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 000000000..eb31b0a19 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,472 @@ +# Junie AI Agent Guidelines for InvoicePlane v2 + +This document provides comprehensive guidelines for AI agents (like Junie) working on the InvoicePlane v2 codebase to ensure maximum information accuracy and performance. + +--- + +## 🎯 Project Overview + +**InvoicePlane v2** is a multi-tenant invoicing and billing application built with modern PHP/Laravel technologies. + +### Core Architecture +- **Framework:** Laravel 12+ (PHP 8.2+) +- **UI:** Filament 4.0 (Admin/Company/Invoice panels) +- **Frontend:** Livewire + Tailwind CSS +- **Module System:** nwidart/laravel-modules (modular monolith) +- **Multi-tenancy:** Filament Companies with `BelongsToCompany` trait +- **Permissions:** spatie/laravel-permission +- **Queue System:** Required for export functionality + +### Module Structure +``` +Modules/ +├── ModuleName/ +│ ├── Models/ # Eloquent models +│ ├── Services/ # Business logic layer +│ ├── Repositories/ # Data access layer +│ ├── DTOs/ # Data Transfer Objects +│ ├── Transformers/ # DTO ↔ Model transformations +│ ├── Filament/ # Filament resources (Admin/Company panels) +│ ├── Tests/ # PHPUnit tests +│ └── Database/ # Migrations, seeders, factories +``` + +--- + +## 📋 Critical Principles (MUST FOLLOW) + +### 1. SOLID Principles +- **Single Responsibility:** Each class has one clear purpose +- **Open/Closed:** Extend behavior without modifying existing code +- **Liskov Substitution:** Subtypes must be substitutable for base types +- **Interface Segregation:** No fat interfaces; clients shouldn't depend on unused methods +- **Dependency Inversion:** Depend on abstractions, not concretions + +### 2. Code Quality Standards +- **Early Returns:** Prefer early returns over nested conditions +- **No Inline Logic:** Business logic must be in services, not controllers/resources +- **Dynamic Programming:** Apply where relevant (memoization, tabulation) +- **Centralize Shared Logic:** Use traits to avoid duplication +- **Type Safety:** Use native PHP type hints throughout + +### 3. Error Handling +```php +// Catch specific exceptions separately +try { + // code +} catch (Error $e) { + // Handle Error +} catch (ErrorException $e) { + // Handle ErrorException +} catch (Throwable $e) { + // Handle other throwables +} +``` + +--- + +## 🏗️ Architecture Patterns + +### DTO & Transformer Rules + +**DTOs (Data Transfer Objects):** +- ❌ NO constructors in DTOs +- ✅ Use static named constructors when necessary +- ✅ Rely on getters and setters for data access +- ✅ DTOs are transformed using Transformers + +**Transformers:** +- Must implement `toDto()` and `toModel()` methods +- Services must use Transformers directly (not build DTOs manually) +- EntityExtractionService must use Transformers for entire transformation process + +**Example:** +```php +// DTO +class InvoiceDTO +{ + private string $number; + private float $total; + + // No constructor! + + public static function fromArray(array $data): self + { + $dto = new self(); + $dto->setNumber($data['number']); + $dto->setTotal($data['total']); + return $dto; + } + + public function getNumber(): string { return $this->number; } + public function setNumber(string $number): void { $this->number = $number; } +} + +// Transformer +class InvoiceTransformer +{ + public function toDto(Invoice $model): InvoiceDTO + { + return InvoiceDTO::fromArray([ + 'number' => $model->number, + 'total' => $model->total, + ]); + } + + public function toModel(InvoiceDTO $dto): Invoice + { + $model = new Invoice(); + $model->number = $dto->getNumber(); + $model->total = $dto->getTotal(); + return $model; + } +} +``` + +### Service Layer +- All business logic must be in services +- Services coordinate between repositories, transformers, and external systems +- Services must not build DTOs manually—use Transformers +- Services return DTOs or collections of DTOs + +### Repository Layer +- Repositories handle data access only +- Use repository methods for upserts (not `updateOrCreate`) +- Repositories return models or collections of models + +### API Integration +- All API requests must go through the Advanced API Client +- No direct API calls in controllers, services, or jobs +- Use Laravel's HTTP client (not curl or Guzzle) +- All transformations must go through Transformers +- API responses and errors must be logged separately + +--- + +## 🧪 Testing Standards + +### Test Structure +```php +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\Group; + +class InvoiceServiceTest extends AbstractCompanyPanelTestCase +{ + use RefreshDatabase; + + #[Test] + #[Group('invoices')] + public function it_creates_invoice_with_valid_data(): void + { + /* Arrange */ + $data = ['number' => 'INV-001', 'total' => 100.00]; + + /* Act */ + $result = $this->service->createInvoice($data); + + /* Assert */ + $this->assertInstanceOf(InvoiceDTO::class, $result); + $this->assertEquals('INV-001', $result->getNumber()); + } +} +``` + +### Testing Rules (MANDATORY) +1. **Test Naming:** Functions prefixed with `it_` (e.g., `it_creates_invoice`) +2. **No `@test` Annotations:** Use `#[Test]` attribute instead +3. **Prefer Fakes over Mocks:** + ```php + Queue::fake(); + Storage::fake('local'); + Notification::fake(); + ``` +4. **Happy Paths Last:** Place success scenarios at the end +5. **Reusable Setup:** Abstract test cases for fixtures, not inline +6. **Comment Blocks:** Use `/* Arrange */`, `/* Act */`, `/* Assert */` + +### Export Testing +```php +#[Test] +#[Group('export')] +public function it_dispatches_csv_export_job(): void +{ + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $records = Model::factory()->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListPage::class) + ->callAction('exportCsv', data: [ + 'columnMap' => [ + 'field' => ['isEnabled' => true, 'label' => 'Label'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + fn($batch) => $batch instanceof \Illuminate\Bus\PendingBatch + ]); +} +``` + +--- + +## 🗄️ Database & Models + +### Migration Rules +- ❌ NO JSON columns in migrations +- ❌ NO ENUM columns in migrations +- ❌ NO `timestamps()` unless explicitly specified +- ❌ NO `softDeletes()` unless explicitly specified + +### Model Rules +- ❌ NO `$fillable` array in models +- ❌ NO `timestamps` or `softDeletes` properties unless needed +- ✅ Use native PHP type hints +- ✅ Use `$casts` for Enum fields + +```php +class Invoice extends Model +{ + // No $fillable! + + protected $casts = [ + 'status' => InvoiceStatus::class, // Enum + 'total' => 'decimal:2', + 'issued_at' => 'datetime', + ]; +} +``` + +--- + +## 🎨 Filament Resources + +### Resource Generation +- Must use Filament internal traits (`CanReadModelSchemas`, etc.) +- No reflection for relationship detection +- Separate form and table generators by field type +- Keep configurable `$excludedFields` array +- Detect Enums via `$casts` and `enum_exists()` +- Add docblocks above `form()`, `table()`, `getRelations()` +- Use `copyStubToApp()` instead of inline string replacements + +### Panel Separation +- Respect proper panel namespaces (Admin/Company/Invoice) +- Resources in correct panel directories +- Preserve exact method signatures + +### Best Practices +- Use correct `Action::make()` syntax with fluent methods +- Don't display raw `created_at` or `updated_at` in tables/infolists +- Use dedicated timestamp columns instead + +--- + +## 📤 Export System + +### Architecture +- Exports use Filament's asynchronous export system +- **Requires queue workers** to be running +- The `exports` table is temporary (job coordination only) +- NO export history feature +- Auto-prunable via Laravel's model pruning + +### Queue Configuration + +**Local Development:** +```bash +# Option 1: Sync driver (blocks request) +QUEUE_CONNECTION=sync + +# Option 2: Queue worker +php artisan queue:work +``` + +**Production:** +```bash +# Redis (recommended) +QUEUE_CONNECTION=redis + +# With Supervisor +[program:invoiceplane-worker] +command=php /path/to/artisan queue:work --sleep=3 --tries=3 +``` + +### Export Test Requirements +- Must use `Queue::fake()` and `Storage::fake()` +- Verify job dispatching with `Bus::assertChained()` +- Don't test file content (test job dispatch only) +- See: `Modules/Core/Filament/Exporters/README.md` + +--- + +## 🔐 Security & Permissions + +### Seeding Rules +- Seed 5 default roles: `superadmin`, `admin`, `assistance`, `useradmin`, `user` +- Users can belong to accounts (multi-tenancy) +- Admin Panel access restricted to `admin` and `superadmin` + +### Multi-tenancy +- Use `BelongsToCompany` trait on models +- Company context required for all user operations +- Filament panels enforce tenant isolation + +--- + +## 🛠️ Development Workflow + +### Commands + +**Testing:** +```bash +php artisan test # All tests +php artisan test --coverage # With coverage +php artisan test --testsuite=Unit # Unit tests only +php artisan test --group=export # Export tests only +``` + +**Code Quality:** +```bash +vendor/bin/pint # Format code (PSR-12) +vendor/bin/phpstan analyse # Static analysis +vendor/bin/rector process --dry-run # Refactoring suggestions +``` + +**Setup:** +```bash +composer install +cp .env.example .env +php artisan key:generate +php artisan migrate --seed +php artisan queue:work # For exports +``` + +### Git Commit Conventions +- Follow conventions in `.github/git-commit-instructions.md` +- Use semantic commit messages +- Reference issues when applicable + +--- + +## 📚 Documentation References + +### Key Documentation Files +- **Installation:** `.github/INSTALLATION.md` +- **Contributing:** `.github/CONTRIBUTING.md` +- **Testing:** Module tests in `Modules/*/Tests/` +- **Seeding:** `.github/SEEDING.md` +- **Commits:** `.github/git-commit-instructions.md` +- **Export Architecture:** `Modules/Core/Filament/Exporters/README.md` +- **Module Checklist:** `CHECKLIST.md` + +### Related Documentation +- Laravel 12: https://laravel.com/docs/12.x +- Filament 4: https://filamentphp.com/docs/4.x +- Livewire 3: https://livewire.laravel.com/docs +- PHPUnit 11: https://docs.phpunit.de/en/11.0/ + +--- + +## ⚡ Performance Optimization + +### Query Optimization +- Use eager loading to prevent N+1 queries +- Index foreign keys and frequently queried columns +- Use `select()` to limit columns when possible +- Chunk large datasets for processing + +### Caching Strategy +- Cache expensive computations +- Use Redis for session and cache storage +- Implement query result caching where appropriate + +### Queue Workers +- Use multiple workers for high-volume operations +- Configure max execution time appropriately +- Monitor failed jobs and retry logic + +--- + +## 🚫 Common Pitfalls to Avoid + +1. ❌ Don't use `$fillable` in models +2. ❌ Don't create DTOs with constructors +3. ❌ Don't build DTOs manually in services—use Transformers +4. ❌ Don't use JSON or ENUM columns in migrations +5. ❌ Don't add timestamps/softDeletes unless specified +6. ❌ Don't test export file content—test job dispatching +7. ❌ Don't make direct API calls—use Advanced API Client +8. ❌ Don't use `updateOrCreate`—use repository upsert methods +9. ❌ Don't nest conditions deeply—use early returns +10. ❌ Don't duplicate logic—centralize in traits + +--- + +## ✅ Code Review Checklist + +Before submitting code, verify: + +- [ ] Follows SOLID principles +- [ ] No inline business logic (in services) +- [ ] DTOs use static constructors, not `__construct()` +- [ ] Transformers used for DTO ↔ Model conversions +- [ ] Tests use `it_` prefix and `#[Test]` attribute +- [ ] Tests have Arrange/Act/Assert comments +- [ ] No `$fillable` in models +- [ ] No JSON/ENUM in migrations +- [ ] Type hints used throughout +- [ ] Early returns instead of nested conditions +- [ ] Fakes used instead of mocks in tests +- [ ] Export tests use Queue/Storage fakes +- [ ] Code formatted with `vendor/bin/pint` +- [ ] Static analysis passes (`vendor/bin/phpstan`) +- [ ] All tests pass (`php artisan test`) +- [ ] Documentation updated if needed + +--- + +## 🎓 Learning Resources + +### InvoicePlane-Specific +- Review existing modules for patterns +- Check test files for examples +- Read module-specific README files +- Follow CHECKLIST.md for feature status + +### Laravel/PHP +- [Laravel Best Practices](https://github.com/alexeymezenin/laravel-best-practices) +- [PHP: The Right Way](https://phptherightway.com/) +- [SOLID Principles in PHP](https://solidprinciples.dev/) + +### Filament +- [Filament Tricks](https://filamentphp.com/tricks) +- [Filament Community](https://github.com/filamentphp) + +--- + +## 🔄 Continuous Improvement + +This document should be updated as: +- New patterns emerge +- Architecture decisions change +- Best practices evolve +- Performance optimizations discovered + +**Last Updated:** 2025-11-13 + +--- + +## 📞 Support + +- **Discord:** https://discord.gg/PPzD2hTrXt +- **Forums:** https://community.invoiceplane.com +- **Issues:** https://github.com/InvoicePlane/InvoicePlane/issues +- **Wiki:** https://wiki.invoiceplane.com + +--- + +**Remember:** These guidelines ensure consistency, maintainability, and performance across the InvoicePlane v2 codebase. When in doubt, refer to existing code that follows these patterns, and always prioritize code quality over speed of delivery. diff --git a/Modules/Clients/Feature/Modules/ClientsExportImportTest.php b/Modules/Clients/Feature/Modules/ClientsExportImportTest.php index 9db20f1f3..89f605f0f 100644 --- a/Modules/Clients/Feature/Modules/ClientsExportImportTest.php +++ b/Modules/Clients/Feature/Modules/ClientsExportImportTest.php @@ -3,9 +3,12 @@ namespace Modules\Clients\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; -use Modules\Clients\Filament\Company\Resources\Contacts\Pages\ListContacts; -use Modules\Clients\Models\Contact; +use Modules\Clients\Filament\Company\Resources\Clients\Pages\ListClients; +use Modules\Clients\Models\Client; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; @@ -16,81 +19,159 @@ class ClientsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_contacts_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ - $contacts = Contact::factory()->for($this->company)->count(3)->create(); + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('exportCsv') - ->callMountedAction(); - $response = $component->lastResponse; + Livewire::actingAs($this->user) + ->test(ListClients::class) + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($contacts->count() + 1, $lines); - foreach ($contacts as $contact) { - $this->assertStringContainsString($contact->name, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_contacts_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ - $contacts = Contact::factory()->for($this->company)->count(3)->create(); + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + Livewire::actingAs($this->user) + ->test(ListClients::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - // Check for XLSX file signature (PK\x03\x04) - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_contacts_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); /* Arrange */ - // No contacts created + Queue::fake(); + Storage::fake('local'); + // No clients created /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListContacts::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + Livewire::actingAs($this->user) + ->test(ListClients::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); // Only header row + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_special_characters(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $client = Client::factory()->for($this->company)->create([ + 'company_name' => 'ÜClient, "Test"', + ]); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListClients::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListClients::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $clients = Client::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListClients::class) + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Clients/Feature/Modules/RelationsExportImportTest.php b/Modules/Clients/Feature/Modules/RelationsExportImportTest.php index cdcb70030..36392a529 100644 --- a/Modules/Clients/Feature/Modules/RelationsExportImportTest.php +++ b/Modules/Clients/Feature/Modules/RelationsExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Clients\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Clients\Filament\Company\Resources\Relations\Pages\ListRelations; use Modules\Clients\Models\Relation; @@ -16,103 +19,159 @@ class RelationsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_relations_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $relations = Relation::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListRelations::class) - ->mountAction('exportCsv') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($relations->count() + 1, $lines); - foreach ($relations as $relation) { - $this->assertStringContainsString($relation->name, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_relations_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $relations = Relation::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListRelations::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_relations_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No relations created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListRelations::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_relations_with_special_characters(): void + public function it_exports_with_special_characters(): void { - $this->markTestIncomplete(); /* Arrange */ - $relations = Relation::factory()->for($this->company)->create(['name' => 'Rëlâtïon, "Test"', 'email' => 'special@example.com']); + Queue::fake(); + Storage::fake('local'); + $relation = Relation::factory()->for($this->company)->create([ + 'name' => 'ÜRelation, "Test"', + ]); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListRelations::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('Rëlâtïon', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('special@example.com', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_csv_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $relations = Relation::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $relations = Relation::factory()->for($this->company)->count(3)->create(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListRelations::class) + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Relation Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Core/Database/Migrations/2025_11_13_061624_create_exports_table.php b/Modules/Core/Database/Migrations/2025_11_13_061624_create_exports_table.php deleted file mode 100644 index 7c0d82c53..000000000 --- a/Modules/Core/Database/Migrations/2025_11_13_061624_create_exports_table.php +++ /dev/null @@ -1,29 +0,0 @@ -id(); - $table->timestamp('completed_at')->nullable(); - $table->string('file_disk'); - $table->string('file_name')->nullable(); - $table->string('exporter'); - $table->unsignedInteger('processed_rows')->default(0); - $table->unsignedInteger('total_rows'); - $table->unsignedInteger('successful_rows')->default(0); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('exports'); - } -}; diff --git a/Modules/Core/Filament/Exporters/README.md b/Modules/Core/Filament/Exporters/README.md new file mode 100644 index 000000000..16356bd24 --- /dev/null +++ b/Modules/Core/Filament/Exporters/README.md @@ -0,0 +1,151 @@ +# Export Architecture + +## Overview + +This application uses Filament's export system, which handles exports **asynchronously via queued jobs**. + +**⚠️ Queue Worker Required**: Export functionality requires a running queue worker to process export jobs. + +## Queue Configuration + +### Local Development + +For local development, you can use the `sync` queue driver: + +```bash +# In .env +QUEUE_CONNECTION=sync +``` + +Or run a queue worker in a separate terminal: + +```bash +php artisan queue:work +``` + +### Production + +For production environments, configure a proper queue driver: + +**Redis (Recommended):** +```bash +# In .env +QUEUE_CONNECTION=redis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 +``` + +**Database:** +```bash +# In .env +QUEUE_CONNECTION=database + +# Run migration +php artisan queue:table +php artisan migrate +``` + +**Supervisor Configuration:** + +Use Supervisor to keep queue workers running: + +```ini +[program:invoiceplane-worker] +process_name=%(program_name)s_%(process_num)02d +command=php /path/to/artisan queue:work --sleep=3 --tries=3 --max-time=3600 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=2 +redirect_stderr=true +stdout_logfile=/path/to/storage/logs/worker.log +stopwaitsecs=3600 +``` + +## Database Storage + +**Important**: The `exports` table is managed by Filament and is used **only for internal job coordination**. Export records are temporary and serve these purposes: + +1. **Job Coordination**: Track export progress across multiple queue jobs +2. **File Management**: Store temporary file paths until download +3. **Notification**: Send completion notifications to users + +**The exports table is NOT meant for long-term storage or export history.** + +## Export Lifecycle + +1. User initiates export → Export record created +2. Jobs dispatched to queue → Export record tracks progress +3. File generated → Export record stores file path +4. User downloads file → Export record remains temporarily +5. **Automatic Cleanup**: Filament's Export model uses the `Prunable` trait and will be automatically deleted by Laravel's model pruning system + +## Testing + +Tests use `Queue::fake()` and `Storage::fake()` to avoid actual database/file operations: + +```php +Queue::fake(); +Storage::fake('local'); + +// Act +Livewire::actingAs($this->user) + ->test(ListExpenses::class) + ->callAction('exportCsvV2', data: [...]); + +// Assert - verify job dispatching, not database records +Bus::assertChained([...]); +``` + +## Configuration + +### Queue Worker + +Exports will not process without a queue worker running. Choose one of these options: + +**Option 1: Sync Driver (Local Development Only)** +```bash +# In .env +QUEUE_CONNECTION=sync +``` +This processes jobs immediately but blocks the request. + +**Option 2: Queue Worker (Recommended)** +```bash +# Run in separate terminal +php artisan queue:work + +# Or with specific options +php artisan queue:work --queue=default --sleep=3 --tries=3 +``` + +**Option 3: Supervisor (Production)** + +See configuration example above. + +### Model Pruning + +To automatically clean up old export records, run Laravel's model pruning command: + +```bash +php artisan model:prune +``` + +This should be scheduled to run daily in production (add to your task scheduler): + +```php +// In routes/console.php or bootstrap/app.php +Schedule::command('model:prune')->daily(); +``` + +## No Export History + +By design, there is **no export history feature**. Users can export data when needed, download it immediately, and the system automatically cleans up the temporary records. This approach: + +- ✅ Reduces database bloat +- ✅ Improves privacy (no lingering export data) +- ✅ Simplifies the system +- ✅ Follows the principle: "I don't need to see what I exported in the past" diff --git a/Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php b/Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php index 9f1663623..9e450a325 100644 --- a/Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php +++ b/Modules/Expenses/Feature/Modules/ExpensesExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Expenses\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Expenses\Filament\Company\Resources\Expenses\Pages\ListExpenses; @@ -16,195 +19,221 @@ class ExpensesExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_expenses_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $expenses = Expense::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportCsv') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'expense_status' => ['isEnabled' => true, 'label' => 'Status'], + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($expenses->count() + 1, $lines); - foreach ($expenses as $expense) { - $this->assertStringContainsString((string) $expense->amount, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_expenses_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); - /* Arrange */ + Queue::fake(); + Storage::fake('local'); $expenses = Expense::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_status' => ['isEnabled' => true, 'label' => 'Status'], + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_expenses_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No expenses created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_expenses_with_special_characters(): void + public function it_exports_with_special_characters(): void { - $this->markTestIncomplete(); /* Arrange */ - $expenses = Expense::factory()->for($this->company)->create(['description' => 'Üxpense, "Test"', 'amount' => 123.45]); + Queue::fake(); + Storage::fake('local'); + $expense = Expense::factory()->for($this->company)->create([ + 'description' => 'Üxpense, "Test"', + 'amount' => 123.45, + ]); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('Üxpense', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('123.45', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_expenses_downloads_csv_with_correct_data_v2(): void + public function it_dispatches_csv_export_job_v2_with_column_selection(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $expenses = Expense::factory()->for($this->company)->count(3)->create(); + /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'expense_status' => ['isEnabled' => true, 'label' => 'Status'], + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => false, 'label' => 'Amount'], + ], + ]); + /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($expenses->count() + 1, $lines); - foreach ($expenses as $expense) { - $this->assertStringContainsString((string) $expense->amount, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_expenses_downloads_csv_with_correct_data_v1(): void + public function it_dispatches_csv_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $expenses = Expense::factory()->for($this->company)->count(3)->create(); + /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportCsvV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); + /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($expenses->count() + 1, $lines); - foreach ($expenses as $expense) { - $this->assertStringContainsString((string) $expense->amount, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_expenses_downloads_excel_with_correct_data_v2(): void + public function it_dispatches_excel_export_job_v2_with_data(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $expenses = Expense::factory()->for($this->company)->count(3)->create(); + /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'], + ], + ]); + /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_expenses_downloads_excel_with_correct_data_v1(): void + public function it_dispatches_excel_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $expenses = Expense::factory()->for($this->company)->count(3)->create(); + /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListExpenses::class) - ->mountAction('exportExcelV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'expense_number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php b/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php index b07837485..8d7710e52 100644 --- a/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php +++ b/Modules/Invoices/Feature/Modules/InvoicesExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Invoices\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices; @@ -16,201 +19,214 @@ class InvoicesExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_invoices_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportCsv') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsv', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Invoice Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($invoices->count() + 1, $lines); - foreach ($invoices as $invoice) { - $this->assertStringContainsString($invoice->number, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_invoices_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Invoice Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_invoices_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No invoices created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_invoices_with_special_characters(): void + public function it_exports_with_special_characters(): void { - $this->markTestIncomplete(); /* Arrange */ - $invoices = Invoice::factory()->for($this->company)->create(['number' => 'INV-Ü, "Test"', 'total' => 123.45]); + Queue::fake(); + Storage::fake('local'); + $invoice = Invoice::factory()->for($this->company)->create([ + 'number' => 'INV-Ü, "Test"', + 'total' => 123.45, + ]); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('INV-Ü', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('123.45', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_invoices_downloads_csv_with_correct_data_v2(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($invoices->count() + 1, $lines); - foreach ($invoices as $invoice) { - $this->assertStringContainsString($invoice->number, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_invoices_downloads_csv_with_correct_data_v1(): void + public function it_dispatches_csv_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportCsvV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($invoices->count() + 1, $lines); - foreach ($invoices as $invoice) { - $this->assertStringContainsString($invoice->number, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_invoices_downloads_excel_with_correct_data_v2(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_invoices_downloads_excel_with_correct_data_v1(): void + public function it_dispatches_excel_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $invoices = Invoice::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListInvoices::class) - ->mountAction('exportExcelV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php b/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php index 01778cf8c..f19e61fe6 100644 --- a/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php +++ b/Modules/Payments/Feature/Modules/PaymentsExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Payments\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Payments\Filament\Company\Resources\Payments\Pages\ListPayments; @@ -16,200 +19,160 @@ class PaymentsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_payments_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('exportCsv') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertContains( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($payments->count() + 1, $lines); - foreach ($payments as $payment) { - $this->assertStringContainsString((string) $payment->amount, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_payments_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_payments_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No payments created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_payments_with_special_characters(): void + public function it_exports_with_special_characters(): void { - $this->markTestIncomplete(); /* Arrange */ - $payments = Payment::factory()->for($this->company)->create(['amount' => 123.45, 'reference' => 'REF-Ü, "Test"']); + Queue::fake(); + Storage::fake('local'); + $payment = Payment::factory()->for($this->company)->create([ + 'amount' => 123.45, + 'note' => 'Ü Payment, "Test"', + ]); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('REF-Ü', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('123.45', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_payments_downloads_csv_with_correct_data_v2(): void + public function it_dispatches_csv_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($payments->count() + 1, $lines); - foreach ($payments as $payment) { - $this->assertStringContainsString((string) $payment->amount, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_payments_downloads_csv_with_correct_data_v1(): void + public function it_dispatches_excel_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $payments = Payment::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListPayments::class) - ->mountAction('exportCsvV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'amount' => ['isEnabled' => true, 'label' => 'Payment Amount'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($payments->count() + 1, $lines); - foreach ($payments as $payment) { - $this->assertStringContainsString((string) $payment->amount, $content); - } - } - - #[Test] - #[Group('export')] - public function it_exports_payments_downloads_excel_with_correct_data_v2(): void - { - $this->markTestIncomplete(); - /* Arrange */ - $payments = Payment::factory()->for($this->company)->count(3)->create(); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); - } - - #[Test] - #[Group('export')] - public function it_exports_payments_downloads_excel_with_correct_data_v1(): void - { - $this->markTestIncomplete(); - - /* Arrange */ - $payments = Payment::factory()->for($this->company)->count(3)->create(); - - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListPayments::class) - ->mountAction('exportExcelV1') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Products/Feature/Modules/ProductsExportImportTest.php b/Modules/Products/Feature/Modules/ProductsExportImportTest.php index b0f9366d7..af400edc1 100644 --- a/Modules/Products/Feature/Modules/ProductsExportImportTest.php +++ b/Modules/Products/Feature/Modules/ProductsExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Products\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Products\Filament\Company\Resources\Products\Pages\ListProducts; @@ -16,105 +19,160 @@ class ProductsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_products_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); - /* Arrange */ + Queue::fake(); + Storage::fake('local'); $products = Product::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('exportCsv') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertContains( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($products->count() + 1, $lines); - foreach ($products as $product) { - $this->assertStringContainsString($product->name, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_products_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); - /* Arrange */ + Queue::fake(); + Storage::fake('local'); $products = Product::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_products_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); - /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No products created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) + ->test(ListProducts::class) + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_exports_with_special_characters(): void + { + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $product = Product::factory()->for($this->company)->create([ + 'name' => 'ÜProduct, "Test"', + 'price' => 123.45, + ]); + + /* Act */ + Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_products_with_special_characters(): void + public function it_dispatches_csv_export_job_v1(): void { - $this->markTestIncomplete(); + /* Arrange */ + Queue::fake(); + Storage::fake('local'); + $products = Product::factory()->for($this->company)->count(3)->create(); + /* Act */ + Livewire::actingAs($this->user) + ->test(ListProducts::class) + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); + + /* Assert */ + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); + } + + #[Test] + #[Group('export')] + public function it_dispatches_excel_export_job_v1(): void + { /* Arrange */ - $products = Product::factory()->for($this->company)->create(['name' => 'Prødüct, "Test"', 'sku' => 'special-sku']); + Queue::fake(); + Storage::fake('local'); + $products = Product::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProducts::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'name' => ['isEnabled' => true, 'label' => 'Product Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('Prødüct', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('special-sku', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Projects/Feature/Modules/ProjectsExportImportTest.php b/Modules/Projects/Feature/Modules/ProjectsExportImportTest.php index 774a55237..2893f7871 100644 --- a/Modules/Projects/Feature/Modules/ProjectsExportImportTest.php +++ b/Modules/Projects/Feature/Modules/ProjectsExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Projects\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Projects\Filament\Company\Resources\Projects\Pages\ListProjects; @@ -16,183 +19,160 @@ class ProjectsExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_projects_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $projects = Project::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProjects::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($projects->count() + 1, $lines); - foreach ($projects as $project) { - $this->assertStringContainsString($project->project_name, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_projects_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $projects = Project::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProjects::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_projects_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No projects created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProjects::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_projects_with_special_characters(): void + public function it_exports_with_special_characters(): void { - $this->markTestIncomplete(); /* Arrange */ - $projects = Project::factory()->for($this->company)->create(['project_name' => 'ÜProject, "Test"', 'description' => 'Special chars']); + Queue::fake(); + Storage::fake('local'); + $project = Project::factory()->for($this->company)->create([ + 'project_name' => 'ÜProject, "Test"', + 'description' => 'Special chars', + ]); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProjects::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('ÜProject', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('Special chars', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_projects_downloads_csv_with_correct_data_v2(): void + public function it_dispatches_csv_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $projects = Project::factory()->for($this->company)->count(3)->create(); - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListProjects::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($projects->count() + 1, $lines); - foreach ($projects as $project) { - $this->assertStringContainsString($project->project_name, $content); - } - } - #[Test] - #[Group('export')] - public function it_exports_projects_downloads_csv_with_correct_data_v1(): void - { - $this->markTestIncomplete(); - /* Arrange */ - $projects = Project::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProjects::class) - ->mountAction('exportCsvV1') - ->callMountedAction(); - $response = $component->lastResponse; - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($projects->count() + 1, $lines); - foreach ($projects as $project) { - $this->assertStringContainsString($project->project_name, $content); - } - } + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); - #[Test] - #[Group('export')] - public function it_exports_projects_downloads_excel_with_correct_data_v2(): void - { - $this->markTestIncomplete(); - /* Arrange */ - $projects = Project::factory()->for($this->company)->count(3)->create(); - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListProjects::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_projects_downloads_excel_with_correct_data_v1(): void + public function it_dispatches_excel_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $projects = Project::factory()->for($this->company)->count(3)->create(); + /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListProjects::class) - ->mountAction('exportExcelV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'], + ], + ]); + /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Projects/Feature/Modules/TasksExportImportTest.php b/Modules/Projects/Feature/Modules/TasksExportImportTest.php index 1f6df6cb4..660bd8f81 100644 --- a/Modules/Projects/Feature/Modules/TasksExportImportTest.php +++ b/Modules/Projects/Feature/Modules/TasksExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Projects\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Projects\Filament\Company\Resources\Tasks\Pages\ListTasks; @@ -16,189 +19,160 @@ class TasksExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_tasks_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $tasks = Task::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListTasks::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($tasks->count() + 1, $lines); - foreach ($tasks as $task) { - $this->assertStringContainsString($task->task_name, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_tasks_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job_v2(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $tasks = Task::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListTasks::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_tasks_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No tasks created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListTasks::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_tasks_with_special_characters(): void + public function it_exports_with_special_characters(): void { - $this->markTestIncomplete(); /* Arrange */ - $tasks = Task::factory()->for($this->company)->create(['task_name' => 'ÜTask, "Test"', 'description' => 'Special chars']); + Queue::fake(); + Storage::fake('local'); + $task = Task::factory()->for($this->company)->create([ + 'task_name' => 'ÜTask, "Test"', + 'description' => 'Special chars', + ]); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListTasks::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV2', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('ÜTask', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('Special chars', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_tasks_downloads_csv_with_correct_data_v2(): void + public function it_dispatches_csv_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $tasks = Task::factory()->for($this->company)->count(3)->create(); - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListTasks::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($tasks->count() + 1, $lines); - foreach ($tasks as $task) { - $this->assertStringContainsString($task->task_name, $content); - } - } - #[Test] - #[Group('export')] - public function it_exports_tasks_downloads_csv_with_correct_data_v1(): void - { - $this->markTestIncomplete(); - /* Arrange */ - $tasks = Task::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListTasks::class) - ->mountAction('exportCsvV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV1', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue(in_array($response->headers->get('content-type'), ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($tasks->count() + 1, $lines); - foreach ($tasks as $task) { - $this->assertStringContainsString($task->task_name, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_tasks_downloads_excel_with_correct_data_v2(): void + public function it_dispatches_excel_export_job_v1(): void { - $this->markTestIncomplete(); /* Arrange */ + Queue::fake(); + Storage::fake('local'); $tasks = Task::factory()->for($this->company)->count(3)->create(); - /* Act */ - $component = Livewire::actingAs($this->user) - ->test(ListTasks::class) - ->mountAction('exportExcelV2') - ->callMountedAction(); - $response = $component->lastResponse; - - /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); - } - #[Test] - #[Group('export')] - public function it_exports_tasks_downloads_excel_with_correct_data_v1(): void - { - $this->markTestIncomplete(); - /* Arrange */ - $tasks = Task::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListTasks::class) - ->mountAction('exportExcelV1') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcelV1', data: [ + 'columnMap' => [ + 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php b/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php index 983dccfdb..f4e61f63d 100644 --- a/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php +++ b/Modules/Quotes/Feature/Modules/QuotesExportImportTest.php @@ -3,6 +3,9 @@ namespace Modules\Quotes\Feature\Modules; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Quotes\Filament\Company\Resources\Quotes\Pages\ListQuotes; @@ -16,107 +19,110 @@ class QuotesExportImportTest extends AbstractCompanyPanelTestCase #[Test] #[Group('export')] - public function it_exports_quotes_downloads_csv_with_correct_data(): void + public function it_dispatches_csv_export_job_v2(): void { - $this->markTestIncomplete(); - /* Arrange */ + Queue::fake(); + Storage::fake('local'); $quotes = Quote::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('exportCsvV2') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportCsvV2', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Quote Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertTrue( - in_array( - $response->headers->get('content-type'), - [ - 'text/csv', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ] - ) - ); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(2, count($lines)); - $this->assertCount($quotes->count() + 1, $lines); - foreach ($quotes as $quote) { - $this->assertStringContainsString($quote->number, $content); - } + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_quotes_downloads_excel_with_correct_data(): void + public function it_dispatches_excel_export_job(): void { - $this->markTestIncomplete(); - /* Arrange */ + Queue::fake(); + Storage::fake('local'); $quotes = Quote::factory()->for($this->company)->count(3)->create(); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('content-type')); - $content = $response->getContent(); - $this->assertStringStartsWith('PK', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_quotes_with_no_records(): void + public function it_exports_with_no_records(): void { - $this->markTestIncomplete(); - /* Arrange */ + Queue::fake(); + Storage::fake('local'); // No quotes created /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $lines = preg_split('/\r?\n/', mb_trim($content)); - $this->assertGreaterThanOrEqual(1, count($lines)); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } #[Test] #[Group('export')] - public function it_exports_quotes_with_special_characters(): void + public function it_exports_with_special_characters(): void { - $this->markTestIncomplete(); - /* Arrange */ - $quotes = Quote::factory()->for($this->company)->create(['number' => 'QÜØTË, "Test"', 'total' => 123.45]); + Queue::fake(); + Storage::fake('local'); + $quote = Quote::factory()->for($this->company)->create([ + 'number' => 'QÜØTË, "Test"', + 'total' => 123.45, + ]); /* Act */ - $component = Livewire::actingAs($this->user) + Livewire::actingAs($this->user) ->test(ListQuotes::class) - ->mountAction('exportExcel') - ->callMountedAction(); - $response = $component->lastResponse; + ->callAction('exportExcel', data: [ + 'columnMap' => [ + 'number' => ['isEnabled' => true, 'label' => 'Number'], + 'total' => ['isEnabled' => true, 'label' => 'Total'], + ], + ]); /* Assert */ - $this->assertEquals(200, $response->status()); - $content = $response->getContent(); - $this->assertStringContainsString('QÜØTË', $content); - $this->assertStringContainsString('"Test"', $content); - $this->assertStringContainsString('123.45', $content); + Bus::assertChained([ + function ($batch) { + return $batch instanceof \Illuminate\Bus\PendingBatch; + }, + ]); } } diff --git a/README.md b/README.md index 37fdaa0df..a217d622d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - Modular Architecture (Laravel + Filament) - Multi-Tenant Support via Filament Companies - Realtime UI with Livewire +- Asynchronous Export System (requires queue workers) --- @@ -22,8 +23,13 @@ composer install cp .env.example .env php artisan key:generate php artisan migrate --seed + +# Start queue worker for export functionality +php artisan queue:work ``` +**Note:** Export functionality requires a queue worker to be running. For production, configure a queue driver (Redis, database, etc.) and use a process manager like Supervisor. + For detailed steps, see: INSTALLATION.md