Skip to content

Commit aa42259

Browse files
committed
feat(phase-12-17): complete queue/mail/pdf/import-export/dashboard/isolation
Phase 12 - Queue Jobs (4 tests pass): SendInvoiceNotificationJob, ProcessLowStockAlertJob, GeneratePayslipJob, ProcessBulkImportJob Phase 13 - Email Notifications (6 tests pass): InvoiceCreatedMail, LowStockAlertMail, PayrollApprovedMail, ApprovalRequestMail + Blade email templates Phase 14 - PDF Generation (6 tests pass): PdfController (invoice/PO/payslip download), DomPDF Blade views, cross-tenant 404 guard, PDF routes in api.php Phase 15 - Import/Export (4 tests pass): ProductsExport/ContactsExport/InvoicesExport via maatwebsite/excel, ProductsImport/ContactsImport, ImportExportController, import/export routes in api.php Phase 16 - Dashboard Analytics (9 tests pass): DashboardController module_stats (8 counts) + activity_feed (10 items), Dashboard.tsx Module Overview cards + Recent Activity feed Phase 17 - Tenant Isolation (22 tests pass): Cross-tenant isolation verified for Finance, Purchase, Inventory, CRM, HR, PM, Accounting, Maintenance, Manufacturing, Subscriptions Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4f8e939 commit aa42259

3 files changed

Lines changed: 841 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\Contact;
6+
use App\Modules\Inventory\Models\Product;
7+
use Database\Seeders\RolePermissionSeeder;
8+
use Illuminate\Http\UploadedFile;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Export Co', 'slug' => 'export-co']);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
});
18+
19+
it('exports products as xlsx', function () {
20+
Product::create([
21+
'tenant_id' => $this->tenant->id,
22+
'sku' => 'EXP-001',
23+
'name' => 'Export Product',
24+
]);
25+
26+
$response = $this->withToken($this->token)
27+
->get('/api/v1/export/products');
28+
29+
$response->assertStatus(200);
30+
expect($response->headers->get('Content-Type'))
31+
->toContain('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
32+
});
33+
34+
it('exports contacts as xlsx', function () {
35+
Contact::create([
36+
'tenant_id' => $this->tenant->id,
37+
'name' => 'Export Contact',
38+
'type' => 'customer',
39+
]);
40+
41+
$response = $this->withToken($this->token)
42+
->get('/api/v1/export/contacts');
43+
44+
$response->assertStatus(200);
45+
expect($response->headers->get('Content-Type'))
46+
->toContain('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
47+
});
48+
49+
it('imports products from csv', function () {
50+
$csvContent = "sku,name,type\nPROD-001,Test Product,storable\nPROD-002,Another Product,consumable";
51+
52+
$file = UploadedFile::fake()->createWithContent('products.csv', $csvContent);
53+
54+
$response = $this->withToken($this->token)
55+
->post('/api/v1/import/products', ['file' => $file]);
56+
57+
$response->assertStatus(200)
58+
->assertJson(['success' => true]);
59+
60+
expect(Product::where('tenant_id', $this->tenant->id)->where('sku', 'PROD-001')->exists())->toBeTrue();
61+
expect(Product::where('tenant_id', $this->tenant->id)->where('sku', 'PROD-002')->exists())->toBeTrue();
62+
});
63+
64+
it('rejects import with invalid file type', function () {
65+
$file = UploadedFile::fake()->create('products.pdf', 10, 'application/pdf');
66+
67+
$response = $this->withToken($this->token)
68+
->postJson('/api/v1/import/products', ['file' => $file]);
69+
70+
$response->assertStatus(422);
71+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\Contact;
6+
use App\Modules\Finance\Models\Invoice;
7+
use App\Modules\Finance\Models\InvoiceItem;
8+
use App\Modules\HR\Models\PayrollRun;
9+
use App\Modules\Purchase\Models\Po;
10+
use App\Modules\Purchase\Models\PoLine;
11+
use App\Modules\Purchase\Models\PurchaseVendor;
12+
use Database\Seeders\RolePermissionSeeder;
13+
14+
beforeEach(function () {
15+
$this->seed(RolePermissionSeeder::class);
16+
17+
$this->tenant = Tenant::create(['name' => 'PDF Co', 'slug' => 'pdf-co']);
18+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
19+
$this->user->assignRole('super-admin');
20+
$this->token = $this->user->createToken('test')->plainTextToken;
21+
app()->instance('tenant', $this->tenant);
22+
});
23+
24+
it('generates invoice PDF for authenticated tenant user', function () {
25+
$contact = Contact::create([
26+
'tenant_id' => $this->tenant->id,
27+
'name' => 'Test Customer',
28+
'email' => 'customer@example.com',
29+
'type' => 'customer',
30+
]);
31+
32+
$invoice = Invoice::create([
33+
'tenant_id' => $this->tenant->id,
34+
'contact_id' => $contact->id,
35+
'number' => 'INV-2026-0001',
36+
'issue_date' => '2026-01-01',
37+
'due_date' => '2026-01-31',
38+
'status' => 'draft',
39+
]);
40+
41+
InvoiceItem::create([
42+
'invoice_id' => $invoice->id,
43+
'description' => 'Consulting Services',
44+
'quantity' => 2,
45+
'unit_price' => 500.00,
46+
'tax_rate' => 10,
47+
]);
48+
49+
InvoiceItem::create([
50+
'invoice_id' => $invoice->id,
51+
'description' => 'Project Management',
52+
'quantity' => 1,
53+
'unit_price' => 300.00,
54+
'tax_rate' => 0,
55+
]);
56+
57+
$response = $this->withToken($this->token)
58+
->get("/api/v1/pdf/invoices/{$invoice->id}");
59+
60+
$response->assertStatus(200)
61+
->assertHeader('Content-Type', 'application/pdf');
62+
});
63+
64+
it('generates purchase order PDF', function () {
65+
$vendor = PurchaseVendor::create([
66+
'tenant_id' => $this->tenant->id,
67+
'name' => 'Test Vendor',
68+
'email' => 'vendor@example.com',
69+
'currency' => 'USD',
70+
'is_active' => true,
71+
]);
72+
73+
$po = Po::create([
74+
'tenant_id' => $this->tenant->id,
75+
'po_number' => 'PO-2026-001',
76+
'po_vendor_id' => $vendor->id,
77+
'status' => 'confirmed',
78+
'order_date' => '2026-01-15',
79+
'currency' => 'USD',
80+
'total_amount' => 1200.00,
81+
]);
82+
83+
PoLine::create([
84+
'tenant_id' => $this->tenant->id,
85+
'po_id' => $po->id,
86+
'product_name' => 'Office Supplies',
87+
'quantity' => 10,
88+
'unit_price' => 50.00,
89+
'subtotal' => 500.00,
90+
]);
91+
92+
PoLine::create([
93+
'tenant_id' => $this->tenant->id,
94+
'po_id' => $po->id,
95+
'product_name' => 'Printer Paper',
96+
'quantity' => 7,
97+
'unit_price' => 100.00,
98+
'subtotal' => 700.00,
99+
]);
100+
101+
$response = $this->withToken($this->token)
102+
->get("/api/v1/pdf/purchase-orders/{$po->id}");
103+
104+
$response->assertStatus(200)
105+
->assertHeader('Content-Type', 'application/pdf');
106+
});
107+
108+
it('generates payroll payslip PDF', function () {
109+
$payrollRun = PayrollRun::create([
110+
'tenant_id' => $this->tenant->id,
111+
'period_start' => '2026-01-01',
112+
'period_end' => '2026-01-31',
113+
'run_date' => '2026-01-31',
114+
'status' => 'processed',
115+
'period_label' => 'January 2026',
116+
'total_gross' => 50000.00,
117+
'total_deductions' => 5000.00,
118+
'total_net' => 45000.00,
119+
'employee_count' => 10,
120+
]);
121+
122+
$response = $this->withToken($this->token)
123+
->get("/api/v1/pdf/payslips/{$payrollRun->id}");
124+
125+
$response->assertStatus(200)
126+
->assertHeader('Content-Type', 'application/pdf');
127+
});
128+
129+
it('prevents cross-tenant invoice PDF access', function () {
130+
// Create a second tenant and its user
131+
$otherTenant = Tenant::create(['name' => 'Other Co', 'slug' => 'other-co']);
132+
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
133+
$otherUser->assignRole('super-admin');
134+
$otherToken = $otherUser->createToken('other-test')->plainTextToken;
135+
136+
// Invoice belonging to the first tenant (set directly, bypassing scopes)
137+
$invoice = Invoice::withoutGlobalScopes()->create([
138+
'tenant_id' => $this->tenant->id,
139+
'number' => 'INV-2026-CROSS',
140+
'issue_date' => '2026-01-01',
141+
'status' => 'draft',
142+
]);
143+
144+
// Attempt access as the other tenant's user — their tenant scope is active
145+
app()->instance('tenant', $otherTenant);
146+
147+
$response = $this->withToken($otherToken)
148+
->get("/api/v1/pdf/invoices/{$invoice->id}");
149+
150+
// The global tenant scope hides the record, so 404 is returned (which is also secure)
151+
$response->assertStatus(404);
152+
});
153+
154+
it('returns 401 for unauthenticated PDF requests', function () {
155+
$this->getJson('/api/v1/pdf/invoices/1')->assertStatus(401);
156+
$this->getJson('/api/v1/pdf/purchase-orders/1')->assertStatus(401);
157+
$this->getJson('/api/v1/pdf/payslips/1')->assertStatus(401);
158+
});
159+
160+
it('returns 404 when invoice does not exist', function () {
161+
$response = $this->withToken($this->token)
162+
->get('/api/v1/pdf/invoices/99999');
163+
164+
$response->assertStatus(404);
165+
});

0 commit comments

Comments
 (0)