diff --git a/app-modules/ImportWizard/config/import-wizard.php b/app-modules/ImportWizard/config/import-wizard.php index f08140db..7318fbd3 100644 --- a/app-modules/ImportWizard/config/import-wizard.php +++ b/app-modules/ImportWizard/config/import-wizard.php @@ -16,37 +16,13 @@ /* |-------------------------------------------------------------------------- - | Preview Settings + | Session TTL |-------------------------------------------------------------------------- | - | Configure how import previews are generated. + | How long import session data (cache keys and temp files) should be + | retained before automatic cleanup. | */ - 'preview' => [ - // Maximum rows to process for preview generation - 'sample_size' => 1000, + 'session_ttl_hours' => 24, - // Maximum rows to display in the UI - 'display_limit' => 50, - ], - - /* - |-------------------------------------------------------------------------- - | Row Counting Settings - |-------------------------------------------------------------------------- - | - | Configure how row counts are calculated for CSV files. - | - */ - 'row_count' => [ - // Files smaller than this (in bytes) use exact counting - // Larger files use estimation for performance - 'exact_threshold_bytes' => 1_048_576, // 1MB - - // Number of rows to sample for estimation - 'sample_size' => 100, - - // Bytes to read for row size estimation - 'sample_bytes' => 8192, - ], ]; diff --git a/app-modules/ImportWizard/resources/views/livewire/import-preview-table.blade.php b/app-modules/ImportWizard/resources/views/livewire/import-preview-table.blade.php new file mode 100644 index 00000000..5bd2c055 --- /dev/null +++ b/app-modules/ImportWizard/resources/views/livewire/import-preview-table.blade.php @@ -0,0 +1,88 @@ +
+ {{-- Summary Stats --}} +
+
+
+ + + + + + + + will be created +
+
+ + + + + + + + will be updated +
+
+ +
+
+ + Ready to import +
+
+
+ + {{-- Sample Rows Table --}} + +
diff --git a/app-modules/ImportWizard/resources/views/livewire/partials/step-preview.blade.php b/app-modules/ImportWizard/resources/views/livewire/partials/step-preview.blade.php index 1d925ee0..e25f7e55 100644 --- a/app-modules/ImportWizard/resources/views/livewire/partials/step-preview.blade.php +++ b/app-modules/ImportWizard/resources/views/livewire/partials/step-preview.blade.php @@ -1,160 +1,20 @@ @php $totalRows = $this->previewResultData['totalRows'] ?? 0; - $sampleRows = $previewRows; - $hasMore = $totalRows > count($sampleRows); - $showCompanyMatch = in_array($entityType, ['people', 'opportunities']); - - // Calculate company match statistics - $companyMatchStats = []; - if ($showCompanyMatch) { - $companyMatchStats = [ - 'id' => collect($sampleRows)->where('_company_match_type', 'id')->count(), - 'domain' => collect($sampleRows)->where('_company_match_type', 'domain')->count(), - 'new' => collect($sampleRows)->where('_company_match_type', 'new')->count(), - // 'none' is not counted - records with no company data - ]; - } @endphp
- {{-- Summary Stats --}} -
-
-
- - {{ number_format($this->getCreateCount()) }} - will be created -
-
- - {{ number_format($this->getUpdateCount()) }} - will be updated -
-
-
- - {{-- Company Match Statistics (for People/Opportunities) --}} - @if ($showCompanyMatch && array_sum($companyMatchStats) > 0) -
- Company Matching: - @if ($companyMatchStats['id'] > 0) -
- {{ $companyMatchStats['id'] }} - by ID -
- @endif - @if ($companyMatchStats['domain'] > 0) -
- {{ $companyMatchStats['domain'] }} - by domain -
- @endif - @if ($companyMatchStats['new'] > 0) -
- {{ $companyMatchStats['new'] }} - new companies -
- @endif -
- @endif - - {{-- Sample Rows Table --}} - @if (count($sampleRows) > 0) -
- {{-- Table Header --}} -
- Sample Preview - @if ($hasMore) - Showing {{ count($sampleRows) }} of {{ number_format($totalRows) }} rows - @endif -
-
- - - - - @foreach (array_keys($columnMap) as $fieldName) - @if ($columnMap[$fieldName] !== '') - - @endif - @endforeach - @if ($showCompanyMatch) - - @endif - - - - @foreach ($sampleRows as $index => $row) - - - @foreach (array_keys($columnMap) as $fieldName) - @if ($columnMap[$fieldName] !== '') - - @endif - @endforeach - @if ($showCompanyMatch) - - @endif - - @endforeach - -
- {{ str($fieldName)->headline() }} - Company Match
- @php - $isNew = $row['_is_new'] ?? true; - $updateMethod = $row['_update_method'] ?? null; - @endphp - @if ($isNew) - - @else - - @endif - - {{ $row[$fieldName] ?? '-' }} - - @php - $matchType = $row['_company_match_type'] ?? 'none'; - $matchCount = $row['_company_match_count'] ?? 0; - $companyName = $row['_company_name'] ?? $row['company_name'] ?? '-'; - @endphp -
- - {{ $companyName ?: '-' }} - - @switch($matchType) - @case('id') - - ID - - @break - @case('domain') - - Domain - - @break - @case('new') - - New - - @break - @default {{-- none --}} - - - @endswitch -
-
-
-
- @endif + {{-- Nested Livewire component isolates Alpine from parent morphing --}} + @livewire('import-preview-table', [ + 'sessionId' => $sessionId, + 'entityType' => $entityType, + 'columnMap' => $columnMap, + 'previewRows' => $previewRows, + 'totalRows' => $totalRows, + ], key('preview-table-' . $sessionId)) +
- {{-- Navigation --}} -
- Back - - Start Import - -
+{{-- Navigation buttons outside nested component --}} +
+ Back + {{ $this->startImportAction }}
diff --git a/app-modules/ImportWizard/resources/views/livewire/partials/step-review.blade.php b/app-modules/ImportWizard/resources/views/livewire/partials/step-review.blade.php index 826d6c82..937cb752 100644 --- a/app-modules/ImportWizard/resources/views/livewire/partials/step-review.blade.php +++ b/app-modules/ImportWizard/resources/views/livewire/partials/step-review.blade.php @@ -40,8 +40,17 @@ ? $this->columnAnalyses->firstWhere('mappedToField', $expandedColumn) : $this->columnAnalyses->first(); $perPage = 100; - $values = $selectedAnalysis?->paginatedValues($reviewPage, $perPage) ?? []; - $totalUnique = $selectedAnalysis?->uniqueCount ?? 0; + $hasColumnErrors = $selectedAnalysis?->hasErrors() ?? false; + $errorValueCount = $selectedAnalysis?->getErrorCount() ?? 0; + + if ($showOnlyErrors && $hasColumnErrors) { + $values = $selectedAnalysis?->paginatedErrorValues($reviewPage, $perPage) ?? []; + $totalUnique = $errorValueCount; + } else { + $values = $selectedAnalysis?->paginatedValues($reviewPage, $perPage) ?? []; + $totalUnique = $selectedAnalysis?->uniqueCount ?? 0; + } + $showing = min($reviewPage * $perPage, $totalUnique); $hasMore = $showing < $totalUnique; @endphp @@ -49,8 +58,24 @@ @if ($selectedAnalysis) {{-- Column Header with Stats --}}
-
- {{ number_format($totalUnique) }} unique values +
+
+ {{ number_format($selectedAnalysis->uniqueCount) }} unique values +
+ @if ($hasColumnErrors) + + @endif
Showing {{ number_format($showing) }} of {{ number_format($totalUnique) }} diff --git a/app-modules/ImportWizard/routes/web.php b/app-modules/ImportWizard/routes/web.php new file mode 100644 index 00000000..53720624 --- /dev/null +++ b/app-modules/ImportWizard/routes/web.php @@ -0,0 +1,16 @@ +prefix('app/import') + ->name('import.') + ->group(function (): void { + Route::get('/{sessionId}/status', [PreviewController::class, 'status']) + ->name('preview-status'); + Route::get('/{sessionId}/rows', [PreviewController::class, 'rows']) + ->name('preview-rows'); + }); diff --git a/app-modules/ImportWizard/src/Console/CleanupOrphanedImportsCommand.php b/app-modules/ImportWizard/src/Console/CleanupOrphanedImportsCommand.php new file mode 100644 index 00000000..2d1a98b6 --- /dev/null +++ b/app-modules/ImportWizard/src/Console/CleanupOrphanedImportsCommand.php @@ -0,0 +1,57 @@ +option('hours'); + $cutoff = now()->subHours($hours); + $deleted = 0; + + $directories = Storage::disk('local')->directories('temp-imports'); + + foreach ($directories as $dir) { + $originalFile = "{$dir}/original.csv"; + + if (! Storage::disk('local')->exists($originalFile)) { + continue; + } + + $lastModified = Storage::disk('local')->lastModified($originalFile); + + if (Carbon::createFromTimestamp($lastModified)->lt($cutoff)) { + $sessionId = basename($dir); + + Storage::disk('local')->deleteDirectory($dir); + Cache::forget("import:{$sessionId}:status"); + Cache::forget("import:{$sessionId}:progress"); + Cache::forget("import:{$sessionId}:team"); + + $deleted++; + } + } + + $this->info("Deleted {$deleted} orphaned import sessions."); + + return Command::SUCCESS; + } +} diff --git a/app-modules/ImportWizard/src/Data/ColumnAnalysis.php b/app-modules/ImportWizard/src/Data/ColumnAnalysis.php index 488f9cd7..e35f645c 100644 --- a/app-modules/ImportWizard/src/Data/ColumnAnalysis.php +++ b/app-modules/ImportWizard/src/Data/ColumnAnalysis.php @@ -4,6 +4,7 @@ namespace Relaticle\ImportWizard\Data; +use Illuminate\Support\Collection; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; @@ -32,7 +33,6 @@ public function __construct( /** * Get unique values for display with "load more" pattern. - * Returns first (page * perPage) items cumulatively. * * @return array */ @@ -43,18 +43,12 @@ public function paginatedValues(int $page = 1, int $perPage = 100, ?string $sear if ($search !== null && $search !== '') { $values = array_filter( $values, - fn (int $count, string $value): bool => str_contains( - strtolower($value), - strtolower($search) - ), + fn (int $count, string $value): bool => str_contains(strtolower($value), strtolower($search)), ARRAY_FILTER_USE_BOTH ); } - // Cumulative: show all items up to page * perPage - $limit = $page * $perPage; - - return array_slice($values, 0, $limit, preserve_keys: true); + return array_slice($values, 0, $page * $perPage, preserve_keys: true); } /** @@ -62,8 +56,7 @@ public function paginatedValues(int $page = 1, int $perPage = 100, ?string $sear */ public function hasErrors(): bool { - return $this->issues->toCollection() - ->contains('severity', 'error'); + return $this->errorIssues()->isNotEmpty(); } /** @@ -71,9 +64,7 @@ public function hasErrors(): bool */ public function getErrorCount(): int { - return $this->issues->toCollection() - ->where('severity', 'error') - ->count(); + return $this->errorIssues()->count(); } /** @@ -83,4 +74,31 @@ public function getIssueForValue(string $value): ?ValueIssue { return $this->issues->toCollection()->firstWhere('value', $value); } + + /** + * Get unique values that have errors, with "load more" pattern. + * + * @return array + */ + public function paginatedErrorValues(int $page = 1, int $perPage = 100): array + { + $errorValues = $this->errorIssues()->pluck('value')->all(); + + $filteredValues = array_filter( + $this->uniqueValues, + fn (int $count, string $value): bool => in_array($value, $errorValues, true), + ARRAY_FILTER_USE_BOTH + ); + + return array_slice($filteredValues, 0, $page * $perPage, preserve_keys: true); + } + + /** + * @return Collection + */ + private function errorIssues(): Collection + { + /** @var Collection */ + return once(fn () => $this->issues->toCollection()->where('severity', 'error')->values()); + } } diff --git a/app-modules/ImportWizard/src/Data/CompanyMatchResult.php b/app-modules/ImportWizard/src/Data/CompanyMatchResult.php index c744dee5..a536eccd 100644 --- a/app-modules/ImportWizard/src/Data/CompanyMatchResult.php +++ b/app-modules/ImportWizard/src/Data/CompanyMatchResult.php @@ -24,39 +24,8 @@ public function __construct( public ?string $companyId = null, ) {} - public function isIdMatch(): bool - { - return $this->matchType === 'id'; - } - public function isDomainMatch(): bool { return $this->matchType === 'domain'; } - - public function isNew(): bool - { - return $this->matchType === 'new'; - } - - public function isNone(): bool - { - return $this->matchType === 'none'; - } - - /** - * @deprecated Name matching removed - use ID or domain matching only - */ - public function isNameMatch(): bool - { - return $this->matchType === 'name'; - } - - /** - * @deprecated Ambiguous handling simplified - returns 'new' instead - */ - public function isAmbiguous(): bool - { - return $this->matchType === 'ambiguous'; - } } diff --git a/app-modules/ImportWizard/src/Events/ImportChunkProcessed.php b/app-modules/ImportWizard/src/Events/ImportChunkProcessed.php deleted file mode 100644 index fc69f92c..00000000 --- a/app-modules/ImportWizard/src/Events/ImportChunkProcessed.php +++ /dev/null @@ -1,29 +0,0 @@ -, dependencies: array}> - */ - public function getEntities(): array - { - return [ - 'companies' => [ - 'label' => 'Companies', - 'icon' => 'heroicon-o-building-office-2', - 'description' => 'Import company records with addresses, phone numbers, and custom fields', - 'importer' => CompanyImporter::class, - 'dependencies' => [], - ], - 'people' => [ - 'label' => 'People', - 'icon' => 'heroicon-o-users', - 'description' => 'Import contacts with their company associations and custom fields', - 'importer' => PeopleImporter::class, - 'dependencies' => ['companies'], - ], - 'opportunities' => [ - 'label' => 'Opportunities', - 'icon' => 'heroicon-o-currency-dollar', - 'description' => 'Import deals and opportunities with values, stages, and dates', - 'importer' => OpportunityImporter::class, - 'dependencies' => ['companies'], - ], - 'tasks' => [ - 'label' => 'Tasks', - 'icon' => 'heroicon-o-clipboard-document-check', - 'description' => 'Import tasks with priorities, statuses, and entity associations', - 'importer' => TaskImporter::class, - 'dependencies' => [], - ], - 'notes' => [ - 'label' => 'Notes', - 'icon' => 'heroicon-o-document-text', - 'description' => 'Import notes linked to companies, people, or opportunities', - 'importer' => NoteImporter::class, - 'dependencies' => [], - ], - ]; - } -} diff --git a/app-modules/ImportWizard/src/Filament/Imports/BaseImporter.php b/app-modules/ImportWizard/src/Filament/Imports/BaseImporter.php index 6920f34b..65ab7312 100644 --- a/app-modules/ImportWizard/src/Filament/Imports/BaseImporter.php +++ b/app-modules/ImportWizard/src/Filament/Imports/BaseImporter.php @@ -64,7 +64,7 @@ protected function getRecordResolver(): ?ImportRecordResolver } /** - * Set row data for preview mode (used by ImportPreviewService). + * Set row data for preview mode (used by PreviewChunkService). * * This allows the preview service to call public Filament methods * (remapData, castData, resolveRecord) without using reflection. diff --git a/app-modules/ImportWizard/src/Filament/Imports/Concerns/HasPolymorphicEntityAttachment.php b/app-modules/ImportWizard/src/Filament/Imports/Concerns/HasPolymorphicEntityAttachment.php index 54d90e11..39e645d1 100644 --- a/app-modules/ImportWizard/src/Filament/Imports/Concerns/HasPolymorphicEntityAttachment.php +++ b/app-modules/ImportWizard/src/Filament/Imports/Concerns/HasPolymorphicEntityAttachment.php @@ -15,17 +15,11 @@ * * Use this trait in importers that need to attach companies, people, * and opportunities to records via morphToMany relationships. - * - * The record must implement companies(), people(), and opportunities() - * relationship methods that return MorphToMany instances. */ trait HasPolymorphicEntityAttachment { /** * Attach related entities (company, person, opportunity) to the record. - * - * Creates entities if they don't exist, using firstOrCreate. - * Uses syncWithoutDetaching to avoid removing existing relationships. */ protected function attachRelatedEntities(): void { @@ -34,68 +28,25 @@ protected function attachRelatedEntities(): void $teamId = $this->import->team_id; $creatorId = $this->import->user_id; - $this->attachCompanyIfProvided($record, $teamId, $creatorId); - $this->attachPersonIfProvided($record, $teamId, $creatorId); - $this->attachOpportunityIfProvided($record, $teamId, $creatorId); - } - - /** - * Attach a company to the record if company_name is provided. - */ - private function attachCompanyIfProvided(Model $record, string $teamId, string $creatorId): void - { - $companyName = $this->data['company_name'] ?? null; - - if (blank($companyName)) { - return; + $entities = [ + ['field' => 'company_name', 'model' => Company::class, 'relation' => 'companies'], + ['field' => 'person_name', 'model' => People::class, 'relation' => 'people'], + ['field' => 'opportunity_name', 'model' => Opportunity::class, 'relation' => 'opportunities'], + ]; + + foreach ($entities as $entity) { + $name = $this->data[$entity['field']] ?? null; + if (blank($name)) { + continue; + } + + $model = $entity['model']::firstOrCreate( + ['name' => trim((string) $name), 'team_id' => $teamId], + ['creator_id' => $creatorId, 'creation_source' => CreationSource::IMPORT] + ); + + /** @phpstan-ignore-next-line */ + $record->{$entity['relation']}()->syncWithoutDetaching([$model->id]); } - - $company = Company::firstOrCreate( - ['name' => trim((string) $companyName), 'team_id' => $teamId], - ['creator_id' => $creatorId, 'creation_source' => CreationSource::IMPORT] - ); - - /** @phpstan-ignore-next-line */ - $record->companies()->syncWithoutDetaching([$company->id]); - } - - /** - * Attach a person to the record if person_name is provided. - */ - private function attachPersonIfProvided(Model $record, string $teamId, string $creatorId): void - { - $personName = $this->data['person_name'] ?? null; - - if (blank($personName)) { - return; - } - - $person = People::firstOrCreate( - ['name' => trim((string) $personName), 'team_id' => $teamId], - ['creator_id' => $creatorId, 'creation_source' => CreationSource::IMPORT] - ); - - /** @phpstan-ignore-next-line */ - $record->people()->syncWithoutDetaching([$person->id]); - } - - /** - * Attach an opportunity to the record if opportunity_name is provided. - */ - private function attachOpportunityIfProvided(Model $record, string $teamId, string $creatorId): void - { - $opportunityName = $this->data['opportunity_name'] ?? null; - - if (blank($opportunityName)) { - return; - } - - $opportunity = Opportunity::firstOrCreate( - ['name' => trim((string) $opportunityName), 'team_id' => $teamId], - ['creator_id' => $creatorId, 'creation_source' => CreationSource::IMPORT] - ); - - /** @phpstan-ignore-next-line */ - $record->opportunities()->syncWithoutDetaching([$opportunity->id]); } } diff --git a/app-modules/ImportWizard/src/Filament/Imports/OpportunityImporter.php b/app-modules/ImportWizard/src/Filament/Imports/OpportunityImporter.php index 0a10c8b7..85b6a09c 100644 --- a/app-modules/ImportWizard/src/Filament/Imports/OpportunityImporter.php +++ b/app-modules/ImportWizard/src/Filament/Imports/OpportunityImporter.php @@ -80,23 +80,18 @@ public static function getColumns(): array throw new \RuntimeException('Team ID is required for import'); } - try { - $company = Company::firstOrCreate( - [ - 'name' => trim($state), - 'team_id' => $importer->import->team_id, - ], - [ - 'creator_id' => $importer->import->user_id, - 'creation_source' => CreationSource::IMPORT, - ] - ); - - $record->company_id = $company->getKey(); - } catch (\Exception $e) { - report($e); - throw $e; // Re-throw to fail the import for this row - } + $company = Company::firstOrCreate( + [ + 'name' => trim($state), + 'team_id' => $importer->import->team_id, + ], + [ + 'creator_id' => $importer->import->user_id, + 'creation_source' => CreationSource::IMPORT, + ] + ); + + $record->company_id = $company->getKey(); }), ImportColumn::make('contact_name') @@ -115,23 +110,18 @@ public static function getColumns(): array throw new \RuntimeException('Team ID is required for import'); } - try { - $contact = People::firstOrCreate( - [ - 'name' => trim($state), - 'team_id' => $importer->import->team_id, - ], - [ - 'creator_id' => $importer->import->user_id, - 'creation_source' => CreationSource::IMPORT, - ] - ); - - $record->contact_id = $contact->getKey(); - } catch (\Exception $e) { - report($e); - throw $e; - } + $contact = People::firstOrCreate( + [ + 'name' => trim($state), + 'team_id' => $importer->import->team_id, + ], + [ + 'creator_id' => $importer->import->user_id, + 'creation_source' => CreationSource::IMPORT, + ] + ); + + $record->contact_id = $contact->getKey(); }), ...CustomFields::importer()->forModel(self::getModel())->columns(), diff --git a/app-modules/ImportWizard/src/Filament/Imports/PeopleImporter.php b/app-modules/ImportWizard/src/Filament/Imports/PeopleImporter.php index 05d8a2a6..2c3fb72b 100644 --- a/app-modules/ImportWizard/src/Filament/Imports/PeopleImporter.php +++ b/app-modules/ImportWizard/src/Filament/Imports/PeopleImporter.php @@ -97,23 +97,18 @@ public static function getColumns(): array } // Find or create company by name (prevents duplicates within import) - try { - $company = Company::firstOrCreate( - [ - 'name' => $companyName, - 'team_id' => $importer->import->team_id, - ], - [ - 'creator_id' => $importer->import->user_id, - 'creation_source' => CreationSource::IMPORT, - ] - ); - - $record->company_id = $company->getKey(); - } catch (\Exception $e) { - report($e); - throw $e; - } + $company = Company::firstOrCreate( + [ + 'name' => $companyName, + 'team_id' => $importer->import->team_id, + ], + [ + 'creator_id' => $importer->import->user_id, + 'creation_source' => CreationSource::IMPORT, + ] + ); + + $record->company_id = $company->getKey(); }), ...CustomFields::importer()->forModel(self::getModel())->columns(), diff --git a/app-modules/ImportWizard/src/Filament/Pages/ImportPeople.php b/app-modules/ImportWizard/src/Filament/Pages/ImportPeople.php index f5e732e3..b56c262e 100644 --- a/app-modules/ImportWizard/src/Filament/Pages/ImportPeople.php +++ b/app-modules/ImportWizard/src/Filament/Pages/ImportPeople.php @@ -19,9 +19,4 @@ public static function getResourceClass(): string { return PeopleResource::class; } - - public static function getEntityLabel(): string - { - return 'People'; - } } diff --git a/app-modules/ImportWizard/src/Http/Controllers/PreviewController.php b/app-modules/ImportWizard/src/Http/Controllers/PreviewController.php new file mode 100644 index 00000000..249e910a --- /dev/null +++ b/app-modules/ImportWizard/src/Http/Controllers/PreviewController.php @@ -0,0 +1,91 @@ +validateSession($sessionId); + + $enrichedPath = Storage::disk('local')->path("temp-imports/{$sessionId}/enriched.csv"); + + return response()->json([ + 'status' => Cache::get("import:{$sessionId}:status", 'pending'), + 'progress' => Cache::get("import:{$sessionId}:progress", [ + 'processed' => 0, + 'creates' => 0, + 'updates' => 0, + 'total' => 0, + ]), + 'hasEnrichedFile' => file_exists($enrichedPath), + ]); + } + + /** + * Fetch a range of rows from the enriched CSV for virtual scroll. + */ + public function rows(Request $request, string $sessionId): JsonResponse + { + $this->validateSession($sessionId); + + $enrichedPath = Storage::disk('local')->path("temp-imports/{$sessionId}/enriched.csv"); + + if (! file_exists($enrichedPath)) { + return response()->json(['error' => 'Session not found'], 404); + } + + $start = $request->integer('start', 0); + $limit = min($request->integer('limit', 100), 500); + + try { + $csv = Reader::createFromPath($enrichedPath, 'r'); + $csv->setHeaderOffset(0); + + $rows = iterator_to_array( + Statement::create()->offset($start)->limit($limit)->process($csv) + ); + + return response()->json([ + 'rows' => array_values($rows), + 'start' => $start, + 'count' => count($rows), + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json(['error' => 'Failed to read preview data'], 500); + } + } + + /** + * Validate that the session belongs to the current team. + */ + private function validateSession(string $sessionId): void + { + /** @var User|null $user */ + $user = auth()->user(); + $teamId = $user?->currentTeam?->getKey(); + + if ($teamId === null || Cache::get("import:{$sessionId}:team") !== $teamId) { + abort(404, 'Session not found'); + } + } +} diff --git a/app-modules/ImportWizard/src/ImportWizardServiceProvider.php b/app-modules/ImportWizard/src/ImportWizardServiceProvider.php index cae92baa..9dec4202 100644 --- a/app-modules/ImportWizard/src/ImportWizardServiceProvider.php +++ b/app-modules/ImportWizard/src/ImportWizardServiceProvider.php @@ -8,6 +8,8 @@ use Filament\Actions\Imports\Models\Import as BaseImport; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; +use Relaticle\ImportWizard\Console\CleanupOrphanedImportsCommand; +use Relaticle\ImportWizard\Livewire\ImportPreviewTable; use Relaticle\ImportWizard\Livewire\ImportWizard; use Relaticle\ImportWizard\Models\FailedImportRow; use Relaticle\ImportWizard\Models\Import; @@ -25,8 +27,17 @@ public function register(): void public function boot(): void { $this->loadViewsFrom(__DIR__.'/../resources/views', 'import-wizard'); + $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); // Register Livewire components Livewire::component('import-wizard', ImportWizard::class); + Livewire::component('import-preview-table', ImportPreviewTable::class); + + // Register commands + if ($this->app->runningInConsole()) { + $this->commands([ + CleanupOrphanedImportsCommand::class, + ]); + } } } diff --git a/app-modules/ImportWizard/src/Jobs/ProcessImportPreview.php b/app-modules/ImportWizard/src/Jobs/ProcessImportPreview.php new file mode 100644 index 00000000..5fe26908 --- /dev/null +++ b/app-modules/ImportWizard/src/Jobs/ProcessImportPreview.php @@ -0,0 +1,154 @@ + $importerClass + * @param array $columnMap + * @param array $options + * @param array> $valueCorrections + */ + public function __construct( + public string $sessionId, + public string $csvPath, + public string $enrichedPath, + public string $importerClass, + public array $columnMap, + public array $options, + public string $teamId, + public string $userId, + public int $startRow, + public int $totalRows, + public int $initialCreates, + public int $initialUpdates, + public array $valueCorrections = [], + ) { + $this->onQueue('imports'); + } + + public function handle(PreviewChunkService $service): void + { + $processed = $this->startRow; + $creates = $this->initialCreates; + $updates = $this->initialUpdates; + + // Pre-load records for fast lookups + $recordResolver = app(ImportRecordResolver::class); + $recordResolver->loadForTeam($this->teamId, $this->importerClass); + + // Open enriched CSV for appending + $writer = Writer::createFromPath($this->enrichedPath, 'a'); + + try { + while ($processed < $this->totalRows) { + $limit = min(self::CHUNK_SIZE, $this->totalRows - $processed); + + $result = $service->processChunk( + importerClass: $this->importerClass, + csvPath: $this->csvPath, + startRow: $processed, + limit: $limit, + columnMap: $this->columnMap, + options: $this->options, + teamId: $this->teamId, + userId: $this->userId, + valueCorrections: $this->valueCorrections, + recordResolver: $recordResolver, + ); + + // Write rows to CSV + foreach ($result['rows'] as $row) { + $writer->insertOne($service->rowToArray($row, $this->columnMap)); + } + + $creates += $result['creates']; + $updates += $result['updates']; + $processed += $limit; + + // Update progress in cache + $this->updateProgress($processed, $creates, $updates); + } + + // Mark as ready + Cache::put( + "import:{$this->sessionId}:status", + 'ready', + now()->addHours($this->ttlHours()) + ); + } catch (\Throwable $e) { + report($e); + + Cache::put( + "import:{$this->sessionId}:status", + 'failed', + now()->addHours($this->ttlHours()) + ); + + throw $e; + } + } + + /** + * Update progress in cache. + */ + private function updateProgress(int $processed, int $creates, int $updates): void + { + Cache::put( + "import:{$this->sessionId}:progress", + [ + 'processed' => $processed, + 'creates' => $creates, + 'updates' => $updates, + 'total' => $this->totalRows, + ], + now()->addHours($this->ttlHours()) + ); + } + + private function ttlHours(): int + { + return (int) config('import-wizard.session_ttl_hours', 24); + } + + /** + * Get the tags for the job. + * + * @return array + */ + public function tags(): array + { + return [ + 'import-preview', + "session:{$this->sessionId}", + "team:{$this->teamId}", + ]; + } +} diff --git a/app-modules/ImportWizard/src/Jobs/StreamingImportCsv.php b/app-modules/ImportWizard/src/Jobs/StreamingImportCsv.php index 3a5ed9f8..e8be45d5 100644 --- a/app-modules/ImportWizard/src/Jobs/StreamingImportCsv.php +++ b/app-modules/ImportWizard/src/Jobs/StreamingImportCsv.php @@ -14,7 +14,6 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Storage; use League\Csv\Statement; -use Relaticle\ImportWizard\Events\ImportChunkProcessed; use Relaticle\ImportWizard\Models\Import; use Relaticle\ImportWizard\Services\CsvReaderFactory; @@ -86,14 +85,12 @@ public function handle(): void $processedCount = 0; $successCount = 0; - $failureCount = 0; foreach ($records as $record) { try { ($importer)($record); $successCount++; } catch (\Throwable $e) { - $failureCount++; report($e); } @@ -102,13 +99,6 @@ public function handle(): void $this->import->increment('processed_rows', $processedCount); $this->import->increment('successful_rows', $successCount); - - event(new ImportChunkProcessed( - import: $this->import, - processedRows: $processedCount, - successfulRows: $successCount, - failedRows: $failureCount, - )); } /** diff --git a/app-modules/ImportWizard/src/Livewire/Concerns/HasColumnMapping.php b/app-modules/ImportWizard/src/Livewire/Concerns/HasColumnMapping.php index 7bc4e122..e57b38eb 100644 --- a/app-modules/ImportWizard/src/Livewire/Concerns/HasColumnMapping.php +++ b/app-modules/ImportWizard/src/Livewire/Concerns/HasColumnMapping.php @@ -9,19 +9,10 @@ use Livewire\Attributes\Computed; use Relaticle\ImportWizard\Filament\Imports\BaseImporter; -/** - * Provides column mapping functionality for the Import Wizard. - * - * @property array<\Filament\Actions\Imports\ImportColumn> $importerColumns - */ +/** @property array<\Filament\Actions\Imports\ImportColumn> $importerColumns */ trait HasColumnMapping { - /** - * Get importer columns for the selected entity type. - * This is a computed property to avoid storing complex objects in Livewire state. - * - * @return array - */ + /** @return array */ #[Computed] public function importerColumns(): array { @@ -33,9 +24,6 @@ public function importerColumns(): array return $importerClass::getColumns(); } - /** - * Auto-map CSV columns to importer columns based on guesses. - */ protected function autoMapColumns(): void { if ($this->csvHeaders === [] || $this->importerColumns === []) { @@ -56,11 +44,7 @@ protected function autoMapColumns(): void ->toArray(); } - /** - * Get the importer class for the selected entity type. - * - * @return class-string|null - */ + /** @return class-string|null */ protected function getImporterClass(): ?string { $entities = $this->getEntities(); @@ -68,9 +52,6 @@ protected function getImporterClass(): ?string return $entities[$this->entityType]['importer'] ?? null; } - /** - * Check if all required columns are mapped. - */ public function hasAllRequiredMappings(): bool { return collect($this->importerColumns) @@ -78,9 +59,6 @@ public function hasAllRequiredMappings(): bool ->every(fn (ImportColumn $column): bool => ($this->columnMap[$column->getName()] ?? '') !== ''); } - /** - * Map a CSV column to a field, or unmap if fieldName is empty. - */ public function mapCsvColumnToField(string $csvColumn, string $fieldName): void { // First, find and clear any existing mapping for this CSV column @@ -96,9 +74,6 @@ public function mapCsvColumnToField(string $csvColumn, string $fieldName): void } } - /** - * Unmap a column. - */ public function unmapColumn(string $fieldName): void { if (isset($this->columnMap[$fieldName])) { @@ -106,9 +81,6 @@ public function unmapColumn(string $fieldName): void } } - /** - * Get the label for a field name. - */ public function getFieldLabel(string $fieldName): string { $column = collect($this->importerColumns) @@ -117,9 +89,6 @@ public function getFieldLabel(string $fieldName): string return $column?->getLabel() ?? Str::title(str_replace('_', ' ', $fieldName)); } - /** - * Check if any unique identifier column is mapped. - */ protected function hasUniqueIdentifierMapped(): bool { $importerClass = $this->getImporterClass(); @@ -144,9 +113,6 @@ protected function hasUniqueIdentifierMapped(): bool return false; } - /** - * Get the user-friendly message for missing unique identifiers. - */ protected function getMissingUniqueIdentifiersMessage(): string { $importerClass = $this->getImporterClass(); diff --git a/app-modules/ImportWizard/src/Livewire/Concerns/HasCsvParsing.php b/app-modules/ImportWizard/src/Livewire/Concerns/HasCsvParsing.php index ec573f61..0d9902ca 100644 --- a/app-modules/ImportWizard/src/Livewire/Concerns/HasCsvParsing.php +++ b/app-modules/ImportWizard/src/Livewire/Concerns/HasCsvParsing.php @@ -4,21 +4,15 @@ namespace Relaticle\ImportWizard\Livewire\Concerns; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use League\Csv\SyntaxError; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Relaticle\ImportWizard\Services\CsvReaderFactory; -use Relaticle\ImportWizard\Services\CsvRowCounter; -/** - * Provides CSV file parsing functionality for the Import Wizard. - */ trait HasCsvParsing { - /** - * Parse the uploaded file and extract headers and row count. - */ protected function parseUploadedFile(): void { if ($this->uploadedFile === null) { @@ -37,7 +31,7 @@ protected function parseUploadedFile(): void // Parse the CSV $csvReader = app(CsvReaderFactory::class)->createFromPath($csvPath); $this->csvHeaders = $csvReader->getHeader(); - $this->rowCount = app(CsvRowCounter::class)->count($csvPath, $csvReader); + $this->rowCount = iterator_count($csvReader->getRecords()); // Validate row count $maxRows = (int) config('import-wizard.max_rows_per_file', 10000); @@ -68,20 +62,20 @@ protected function parseUploadedFile(): void } } - /** - * Persist the uploaded CSV file to storage. - */ protected function persistFile(TemporaryUploadedFile $file): ?string { try { - $sourcePath = $file->getRealPath(); - $storagePath = 'temp-imports/'.Str::uuid()->toString().'.csv'; + $this->sessionId = Str::uuid()->toString(); + $folder = "temp-imports/{$this->sessionId}"; + $storagePath = "{$folder}/original.csv"; + $sourcePath = $file->getRealPath(); $content = file_get_contents($sourcePath); if ($content === false) { throw new \RuntimeException('Failed to read file content'); } + Storage::disk('local')->makeDirectory($folder); Storage::disk('local')->put($storagePath, $content); return Storage::disk('local')->path($storagePath); @@ -93,26 +87,23 @@ protected function persistFile(TemporaryUploadedFile $file): ?string } } - /** - * Clean up temporary files. - */ protected function cleanupTempFile(): void { - if ($this->persistedFilePath === null) { + if ($this->sessionId === null) { return; } - $storagePath = str_replace(Storage::disk('local')->path(''), '', $this->persistedFilePath); - if (Storage::disk('local')->exists($storagePath)) { - Storage::disk('local')->delete($storagePath); + $folder = "temp-imports/{$this->sessionId}"; + if (Storage::disk('local')->exists($folder)) { + Storage::disk('local')->deleteDirectory($folder); } + + Cache::forget("import:{$this->sessionId}:status"); + Cache::forget("import:{$this->sessionId}:progress"); + Cache::forget("import:{$this->sessionId}:team"); } - /** - * Get preview values for a specific CSV column. - * - * @return array - */ + /** @return array */ public function getColumnPreviewValues(string $csvColumn, int $limit = 5): array { if ($this->persistedFilePath === null) { diff --git a/app-modules/ImportWizard/src/Livewire/Concerns/HasImportPreview.php b/app-modules/ImportWizard/src/Livewire/Concerns/HasImportPreview.php index 121f2baf..e0aa0dee 100644 --- a/app-modules/ImportWizard/src/Livewire/Concerns/HasImportPreview.php +++ b/app-modules/ImportWizard/src/Livewire/Concerns/HasImportPreview.php @@ -5,26 +5,25 @@ namespace Relaticle\ImportWizard\Livewire\Concerns; use Filament\Facades\Filament; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Storage; +use League\Csv\Writer; use Livewire\Attributes\Computed; use Relaticle\ImportWizard\Data\ImportPreviewResult; -use Relaticle\ImportWizard\Services\ImportPreviewService; +use Relaticle\ImportWizard\Enums\DuplicateHandlingStrategy; +use Relaticle\ImportWizard\Jobs\ProcessImportPreview; +use Relaticle\ImportWizard\Services\ImportRecordResolver; +use Relaticle\ImportWizard\Services\PreviewChunkService; -/** - * Provides import preview functionality for the Import Wizard's Preview step. - * - * @property ImportPreviewResult|null $previewResult - */ +/** @property ImportPreviewResult|null $previewResult */ trait HasImportPreview { - private const int PREVIEW_SAMPLE_SIZE = 50; + private const int INITIAL_BATCH_SIZE = 50; - /** - * Generate a preview of what the import will do. - */ protected function generateImportPreview(): void { $importerClass = $this->getImporterClass(); - if ($importerClass === null || $this->persistedFilePath === null) { + if ($importerClass === null || $this->persistedFilePath === null || $this->sessionId === null) { $this->previewResultData = null; $this->previewRows = []; @@ -41,41 +40,157 @@ protected function generateImportPreview(): void return; } - $previewService = app(ImportPreviewService::class); + $teamId = $team->getKey(); + $userId = $user->getAuthIdentifier(); + $options = ['duplicate_handling' => DuplicateHandlingStrategy::SKIP]; - // Calculate optimal sample size (min of rowCount and 1,000) - $sampleSize = min($this->rowCount, 1000); + // Set up the enriched CSV path + $enrichedPath = Storage::disk('local')->path("temp-imports/{$this->sessionId}/enriched.csv"); - $result = $previewService->preview( + // Pre-load records for fast lookups + $recordResolver = app(ImportRecordResolver::class); + $recordResolver->loadForTeam($teamId, $importerClass); + + // SYNC: Process first batch immediately + $service = app(PreviewChunkService::class); + $initialBatchSize = min(self::INITIAL_BATCH_SIZE, $this->rowCount); + + $firstBatch = $service->processChunk( importerClass: $importerClass, csvPath: $this->persistedFilePath, + startRow: 0, + limit: $initialBatchSize, columnMap: $this->columnMap, - options: [ - 'duplicate_handling' => \Relaticle\ImportWizard\Enums\DuplicateHandlingStrategy::SKIP, - ], - teamId: $team->getKey(), - userId: $user->getAuthIdentifier(), + options: $options, + teamId: $teamId, + userId: $userId, valueCorrections: $this->valueCorrections, - sampleSize: $sampleSize, + recordResolver: $recordResolver, + ); + + // Write header + first batch to enriched CSV + $this->writeInitialEnrichedCsv($enrichedPath, $firstBatch['rows'], $service); + + // Store rows for immediate display + $this->previewRows = $firstBatch['rows']; + + // Store team ownership for API validation + Cache::put( + "import:{$this->sessionId}:team", + $teamId, + now()->addHours($this->sessionTtlHours()) ); - // Store counts and sampling metadata (without rows to keep state small) + // Set initial progress + $this->setPreviewProgress( + processed: $initialBatchSize, + creates: $firstBatch['creates'], + updates: $firstBatch['updates'], + ); + + // Store metadata $this->previewResultData = [ - 'totalRows' => $result->totalRows, - 'createCount' => $result->createCount, - 'updateCount' => $result->updateCount, + 'totalRows' => $this->rowCount, + 'createCount' => $firstBatch['creates'], + 'updateCount' => $firstBatch['updates'], 'rows' => [], - 'isSampled' => $result->isSampled, - 'sampleSize' => $result->sampleSize, + 'isSampled' => false, + 'sampleSize' => $this->rowCount, ]; - // Store only sample rows for preview display (not all 5000+) - $this->previewRows = array_slice($result->rows, 0, self::PREVIEW_SAMPLE_SIZE); + // ASYNC: Dispatch job for remaining rows + if ($this->rowCount > $initialBatchSize) { + Cache::put( + "import:{$this->sessionId}:status", + 'processing', + now()->addHours($this->sessionTtlHours()) + ); + + ProcessImportPreview::dispatch( + sessionId: $this->sessionId, + csvPath: $this->persistedFilePath, + enrichedPath: $enrichedPath, + importerClass: $importerClass, + columnMap: $this->columnMap, + options: $options, + teamId: $teamId, + userId: $userId, + startRow: $initialBatchSize, + totalRows: $this->rowCount, + initialCreates: $firstBatch['creates'], + initialUpdates: $firstBatch['updates'], + valueCorrections: $this->valueCorrections, + ); + } else { + // Small file - already done + Cache::put( + "import:{$this->sessionId}:status", + 'ready', + now()->addHours($this->sessionTtlHours()) + ); + } + } + + /** @param array> $rows */ + private function writeInitialEnrichedCsv(string $path, array $rows, PreviewChunkService $service): void + { + $writer = Writer::createFromPath($path, 'w'); + + $writer->insertOne($service->getEnrichedHeaders($this->columnMap)); + + foreach ($rows as $row) { + $writer->insertOne($service->rowToArray($row, $this->columnMap)); + } + } + + private function setPreviewProgress(int $processed, int $creates, int $updates): void + { + Cache::put( + "import:{$this->sessionId}:progress", + [ + 'processed' => $processed, + 'creates' => $creates, + 'updates' => $updates, + 'total' => $this->rowCount, + ], + now()->addHours($this->sessionTtlHours()) + ); + } + + public function getPreviewStatus(): string + { + if ($this->sessionId === null) { + return 'pending'; + } + + return Cache::get("import:{$this->sessionId}:status", 'pending'); + } + + /** @return array{processed: int, creates: int, updates: int, total: int} */ + public function getPreviewProgress(): array + { + if ($this->sessionId === null) { + return [ + 'processed' => 0, + 'creates' => 0, + 'updates' => 0, + 'total' => 0, + ]; + } + + return Cache::get("import:{$this->sessionId}:progress", [ + 'processed' => 0, + 'creates' => 0, + 'updates' => 0, + 'total' => $this->rowCount, + ]); + } + + public function isPreviewReady(): bool + { + return $this->getPreviewStatus() === 'ready'; } - /** - * Get preview result as DTO (computed from stored array data). - */ #[Computed] public function previewResult(): ?ImportPreviewResult { @@ -83,30 +198,35 @@ public function previewResult(): ?ImportPreviewResult return null; } + // Update counts from cache if processing + $progress = $this->getPreviewProgress(); + $this->previewResultData['createCount'] = $progress['creates']; + $this->previewResultData['updateCount'] = $progress['updates']; + return ImportPreviewResult::from($this->previewResultData); } - /** - * Check if the preview indicates any records will be imported. - */ public function hasRecordsToImport(): bool { return ($this->previewResultData['totalRows'] ?? 0) > 0; } - /** - * Get the count of new records to be created. - */ public function getCreateCount(): int { - return $this->previewResultData['createCount'] ?? 0; + $progress = $this->getPreviewProgress(); + + return $progress['creates']; } - /** - * Get the count of existing records to be updated. - */ public function getUpdateCount(): int { - return $this->previewResultData['updateCount'] ?? 0; + $progress = $this->getPreviewProgress(); + + return $progress['updates']; + } + + private function sessionTtlHours(): int + { + return (int) config('import-wizard.session_ttl_hours', 24); } } diff --git a/app-modules/ImportWizard/src/Livewire/Concerns/HasValueAnalysis.php b/app-modules/ImportWizard/src/Livewire/Concerns/HasValueAnalysis.php index 480f9af6..215cd524 100644 --- a/app-modules/ImportWizard/src/Livewire/Concerns/HasValueAnalysis.php +++ b/app-modules/ImportWizard/src/Livewire/Concerns/HasValueAnalysis.php @@ -9,16 +9,9 @@ use Relaticle\ImportWizard\Data\ColumnAnalysis; use Relaticle\ImportWizard\Services\CsvAnalyzer; -/** - * Provides value analysis functionality for the Import Wizard's Review step. - * - * @property Collection $columnAnalyses - */ +/** @property Collection $columnAnalyses */ trait HasValueAnalysis { - /** - * Analyze all mapped columns for unique values and issues. - */ protected function analyzeColumns(): void { if ($this->persistedFilePath === null) { @@ -43,9 +36,6 @@ protected function analyzeColumns(): void $this->columnAnalysesData = $analyses->map(fn (ColumnAnalysis $analysis): array => $analysis->toArray())->toArray(); } - /** - * Get the entity type (model class) for custom field validation lookup. - */ protected function getEntityTypeForAnalysis(): ?string { $importerClass = $this->getImporterClass(); @@ -58,9 +48,6 @@ protected function getEntityTypeForAnalysis(): ?string return $importerClass::getModel(); } - /** - * Check if there are any validation errors in the analyzed columns. - */ public function hasValidationErrors(): bool { return $this->columnAnalyses->contains( @@ -68,9 +55,6 @@ public function hasValidationErrors(): bool ); } - /** - * Get the total count of validation errors across all columns. - */ public function getTotalErrorCount(): int { return $this->columnAnalyses->sum( @@ -78,20 +62,13 @@ public function getTotalErrorCount(): int ); } - /** - * Get column analyses as DTOs (computed from stored array data). - * - * @return Collection - */ + /** @return Collection */ #[Computed] public function columnAnalyses(): Collection { return collect($this->columnAnalysesData)->map(fn (array $data): ColumnAnalysis => ColumnAnalysis::from($data)); } - /** - * Apply a value correction and revalidate. - */ public function correctValue(string $fieldName, string $oldValue, string $newValue): void { if (! isset($this->valueCorrections[$fieldName])) { @@ -104,9 +81,6 @@ public function correctValue(string $fieldName, string $oldValue, string $newVal $this->revalidateCorrectedValue($fieldName, $oldValue, $newValue); } - /** - * Revalidate a corrected value and update the issues list. - */ private function revalidateCorrectedValue(string $fieldName, string $oldValue, string $newValue): void { // Find the column analysis for this field @@ -151,9 +125,6 @@ private function revalidateCorrectedValue(string $fieldName, string $oldValue, s $this->columnAnalysesData[$analysisIndex]['issues'] = $issues; } - /** - * Skip a value (mark it to be excluded from import). - */ public function skipValue(string $fieldName, string $oldValue): void { // If already skipped, unskip it @@ -167,18 +138,12 @@ public function skipValue(string $fieldName, string $oldValue): void $this->correctValue($fieldName, $oldValue, ''); } - /** - * Check if a value is skipped. - */ public function isValueSkipped(string $fieldName, string $value): bool { return $this->hasCorrectionForValue($fieldName, $value) && $this->getCorrectedValue($fieldName, $value) === ''; } - /** - * Remove a value correction. - */ public function removeCorrectionForValue(string $fieldName, string $oldValue): void { if (isset($this->valueCorrections[$fieldName][$oldValue])) { @@ -190,27 +155,24 @@ public function removeCorrectionForValue(string $fieldName, string $oldValue): v } } - /** - * Get the corrected value for a field, or null if no correction exists. - */ public function getCorrectedValue(string $fieldName, string $originalValue): ?string { return $this->valueCorrections[$fieldName][$originalValue] ?? null; } - /** - * Check if a value has a correction. - */ public function hasCorrectionForValue(string $fieldName, string $value): bool { return isset($this->valueCorrections[$fieldName][$value]); } - /** - * Load more values for the current column (infinite scroll). - */ public function loadMoreValues(): void { $this->reviewPage++; } + + public function toggleShowOnlyErrors(): void + { + $this->showOnlyErrors = ! $this->showOnlyErrors; + $this->reviewPage = 1; + } } diff --git a/app-modules/ImportWizard/src/Livewire/ImportPreviewTable.php b/app-modules/ImportWizard/src/Livewire/ImportPreviewTable.php new file mode 100644 index 00000000..e6322bd9 --- /dev/null +++ b/app-modules/ImportWizard/src/Livewire/ImportPreviewTable.php @@ -0,0 +1,68 @@ + */ + #[Reactive] + public array $columnMap; + + /** @var array> */ + #[Reactive] + public array $previewRows; + + #[Reactive] + public int $totalRows; + + public function render(): View + { + $progress = Cache::get("import:{$this->sessionId}:progress", [ + 'processed' => 0, + 'creates' => 0, + 'updates' => 0, + ]); + + $status = Cache::get("import:{$this->sessionId}:status", 'pending'); + + $columns = collect($this->columnMap) + ->filter() + ->map(fn ($_, $field): array => ['key' => $field, 'label' => str($field)->headline()->toString()]) + ->values() + ->all(); + + return view('import-wizard::livewire.import-preview-table', [ + 'previewConfig' => [ + 'sessionId' => $this->sessionId, + 'totalRows' => $this->totalRows, + 'columns' => $columns, + 'showCompanyMatch' => in_array($this->entityType, ['people', 'opportunities']), + 'isProcessing' => $status === 'processing', + 'isReady' => $status === 'ready', + 'creates' => $progress['creates'], + 'updates' => $progress['updates'], + 'processed' => $progress['processed'], + 'rows' => $this->previewRows, + ], + ]); + } +} diff --git a/app-modules/ImportWizard/src/Livewire/ImportWizard.php b/app-modules/ImportWizard/src/Livewire/ImportWizard.php index c61c373f..7e103182 100644 --- a/app-modules/ImportWizard/src/Livewire/ImportWizard.php +++ b/app-modules/ImportWizard/src/Livewire/ImportWizard.php @@ -23,7 +23,12 @@ use Livewire\WithFileUploads; use Relaticle\ImportWizard\Data\ColumnAnalysis; use Relaticle\ImportWizard\Enums\DuplicateHandlingStrategy; -use Relaticle\ImportWizard\Filament\Concerns\HasImportEntities; +use Relaticle\ImportWizard\Filament\Imports\BaseImporter; +use Relaticle\ImportWizard\Filament\Imports\CompanyImporter; +use Relaticle\ImportWizard\Filament\Imports\NoteImporter; +use Relaticle\ImportWizard\Filament\Imports\OpportunityImporter; +use Relaticle\ImportWizard\Filament\Imports\PeopleImporter; +use Relaticle\ImportWizard\Filament\Imports\TaskImporter; use Relaticle\ImportWizard\Jobs\StreamingImportCsv; use Relaticle\ImportWizard\Livewire\Concerns\HasColumnMapping; use Relaticle\ImportWizard\Livewire\Concerns\HasCsvParsing; @@ -45,7 +50,6 @@ final class ImportWizard extends Component implements HasActions, HasForms { use HasColumnMapping; use HasCsvParsing; - use HasImportEntities; use HasImportPreview; use HasValueAnalysis; use InteractsWithActions; @@ -75,6 +79,8 @@ final class ImportWizard extends Component implements HasActions, HasForms #[Validate('required|file|max:51200|mimes:csv,txt')] public mixed $uploadedFile = null; + public ?string $sessionId = null; + public ?string $persistedFilePath = null; public int $rowCount = 0; @@ -98,6 +104,8 @@ final class ImportWizard extends Component implements HasActions, HasForms public ?string $expandedColumn = null; + public bool $showOnlyErrors = false; + // Step 4: Preview /** @var array|null */ public ?array $previewResultData = null; @@ -220,6 +228,21 @@ private function prepareForPreview(): void $this->generateImportPreview(); } + /** + * Start import action with confirmation modal. + */ + public function startImportAction(): Action + { + return Action::make('startImport') + ->label('Start Import') + ->icon(Heroicon::OutlinedArrowUpTray) + ->requiresConfirmation() + ->modalHeading('Confirm Import') + ->modalDescription('Are you sure you want to start this import? This action cannot be undone.') + ->modalSubmitActionLabel('Start Import') + ->action(fn () => $this->executeImport()); + } + /** * Execute the import. */ @@ -444,6 +467,7 @@ public function resetWizard(): void $this->currentStep = self::STEP_UPLOAD; // Note: entityType and returnUrl are locked, don't reset them $this->uploadedFile = null; + $this->sessionId = null; $this->persistedFilePath = null; $this->rowCount = 0; $this->csvHeaders = []; @@ -493,6 +517,7 @@ public function toggleColumn(string $columnName): void { $this->expandedColumn = $this->expandedColumn === $columnName ? null : $columnName; $this->reviewPage = 1; + $this->showOnlyErrors = false; } /** @@ -585,6 +610,47 @@ private function getAffectedRowCount(): int return $count; } + /** + * Get entity configuration for imports. + * + * @return array}> + */ + public function getEntities(): array + { + return [ + 'companies' => [ + 'label' => 'Companies', + 'icon' => 'heroicon-o-building-office-2', + 'description' => 'Import company records with addresses, phone numbers, and custom fields', + 'importer' => CompanyImporter::class, + ], + 'people' => [ + 'label' => 'People', + 'icon' => 'heroicon-o-users', + 'description' => 'Import contacts with their company associations and custom fields', + 'importer' => PeopleImporter::class, + ], + 'opportunities' => [ + 'label' => 'Opportunities', + 'icon' => 'heroicon-o-currency-dollar', + 'description' => 'Import deals and opportunities with values, stages, and dates', + 'importer' => OpportunityImporter::class, + ], + 'tasks' => [ + 'label' => 'Tasks', + 'icon' => 'heroicon-o-clipboard-document-check', + 'description' => 'Import tasks with priorities, statuses, and entity associations', + 'importer' => TaskImporter::class, + ], + 'notes' => [ + 'label' => 'Notes', + 'icon' => 'heroicon-o-document-text', + 'description' => 'Import notes linked to companies, people, or opportunities', + 'importer' => NoteImporter::class, + ], + ]; + } + public function render(): View { return view('import-wizard::livewire.import-wizard'); diff --git a/app-modules/ImportWizard/src/Models/Export.php b/app-modules/ImportWizard/src/Models/Export.php index 55065a8e..e6e32388 100644 --- a/app-modules/ImportWizard/src/Models/Export.php +++ b/app-modules/ImportWizard/src/Models/Export.php @@ -5,7 +5,6 @@ namespace Relaticle\ImportWizard\Models; use App\Models\Concerns\HasTeam; -use App\Models\User; use Filament\Actions\Exports\Models\Export as BaseExport; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -13,18 +12,4 @@ final class Export extends BaseExport { use HasTeam; use HasUlids; - - /** - * Bootstrap the model and its traits. - */ - protected static function booted(): void - { - self::creating(function (Export $export): void { - if (auth()->check()) { - /** @var User $user */ - $user = auth()->user(); - $export->team_id = $user->currentTeam?->getKey(); - } - }); - } } diff --git a/app-modules/ImportWizard/src/Models/Import.php b/app-modules/ImportWizard/src/Models/Import.php index 3c189ada..0b5fd28b 100644 --- a/app-modules/ImportWizard/src/Models/Import.php +++ b/app-modules/ImportWizard/src/Models/Import.php @@ -5,7 +5,6 @@ namespace Relaticle\ImportWizard\Models; use App\Models\Concerns\HasTeam; -use App\Models\User; use Filament\Actions\Imports\Models\Import as BaseImport; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -13,18 +12,4 @@ final class Import extends BaseImport { use HasTeam; use HasUlids; - - /** - * Bootstrap the model and its traits. - */ - protected static function booted(): void - { - self::creating(function (Import $import): void { - if (auth()->check()) { - /** @var User $user */ - $user = auth()->user(); - $import->team_id = $user->currentTeam?->getKey(); - } - }); - } } diff --git a/app-modules/ImportWizard/src/Services/CompanyMatcher.php b/app-modules/ImportWizard/src/Services/CompanyMatcher.php index 52b01a7b..a3b6f2fa 100644 --- a/app-modules/ImportWizard/src/Services/CompanyMatcher.php +++ b/app-modules/ImportWizard/src/Services/CompanyMatcher.php @@ -7,45 +7,17 @@ use App\Enums\CustomFields\CompanyField; use App\Models\Company; use App\Models\CustomFieldValue; +use Illuminate\Support\Str; use Relaticle\ImportWizard\Data\CompanyMatchResult; -/** - * Smart company matching service for import previews. - * - * Matching priority: - * 1. ID match: exact company ID (ULID) - * 2. Domain match: email domain → company domains custom field - * 3. Create: company_name provided → will create new company - * 4. None: no company data → no association - * - * Returns match type for transparent preview display. - * - * Performance: Pre-loads all companies for a team into memory to avoid N+1 queries. - */ final class CompanyMatcher { - /** - * Cached companies indexed by ID and domain for fast lookup. - * - * @var array{byId: array, byDomain: array>}|null - */ + /** @var array{byId: array, byDomain: array>}|null */ private ?array $companyCache = null; private ?string $cachedTeamId = null; - /** - * Match a company by ID (highest priority) or domain (from emails). - * - * Priority: - * 1. ID match (if provided) - 100% accurate matching - * 2. Domain match (from email) - auto-linking - * 3. Create new (if company_name provided) - * 4. None (if no company data) - * - * @param string $companyId Company ULID (from 'id' column if mapped) - * @param string $companyName Company name (from 'company_name' column if mapped) - * @param array $emails Person's email addresses - */ + /** @param array $emails */ public function match(string $companyId, string $companyName, array $emails, string $teamId): CompanyMatchResult { $companyId = trim($companyId); @@ -55,9 +27,9 @@ public function match(string $companyId, string $companyName, array $emails, str $this->ensureCompaniesLoaded($teamId); // Priority 1: ID matching (highest confidence) - if ($companyId !== '' && \Illuminate\Support\Str::isUlid($companyId)) { + if ($companyId !== '' && Str::isUlid($companyId)) { $company = $this->findInCacheById($companyId); - if ($company !== null) { + if ($company instanceof Company) { return new CompanyMatchResult( companyName: $company->name, matchType: 'id', @@ -73,7 +45,7 @@ public function match(string $companyId, string $companyName, array $emails, str $domainMatches = $this->findInCacheByDomain($domains); if (count($domainMatches) >= 1) { - // Take first match + // Domain field is unique per company, so take first match $company = reset($domainMatches); return new CompanyMatchResult( @@ -102,9 +74,6 @@ public function match(string $companyId, string $companyName, array $emails, str ); } - /** - * Load all companies for team into memory cache. - */ private function ensureCompaniesLoaded(string $teamId): void { // Cache already loaded for this team @@ -128,7 +97,9 @@ private function ensureCompaniesLoaded(string $teamId): void // Index by ID foreach ($companies as $company) { - $this->companyCache['byId'][(string) $company->id] = $company; + /** @var string $id */ + $id = (string) $company->id; + $this->companyCache['byId'][$id] = $company; } // Index by domains custom field (stored as json_value collection) @@ -155,17 +126,12 @@ private function ensureCompaniesLoaded(string $teamId): void } } - /** - * Find company in cache by ID. - */ private function findInCacheById(string $id): ?Company { return $this->companyCache['byId'][$id] ?? null; } /** - * Find companies in cache by domain. - * * @param array $domains * @return array */ @@ -185,8 +151,6 @@ private function findInCacheByDomain(array $domains): array } /** - * Extract unique domains from email addresses. - * * @param array $emails * @return array */ diff --git a/app-modules/ImportWizard/src/Services/CsvAnalyzer.php b/app-modules/ImportWizard/src/Services/CsvAnalyzer.php index 2ed2d2c4..55402c1c 100644 --- a/app-modules/ImportWizard/src/Services/CsvAnalyzer.php +++ b/app-modules/ImportWizard/src/Services/CsvAnalyzer.php @@ -15,12 +15,6 @@ use Relaticle\ImportWizard\Data\ValueIssue; use Spatie\LaravelData\DataCollection; -/** - * Analyzes CSV files to extract column statistics, unique values, and detect validation issues. - * - * Used in the Review Values step of the import wizard to help users identify - * and fix data problems before importing. - */ final readonly class CsvAnalyzer { public function __construct( @@ -29,13 +23,8 @@ public function __construct( ) {} /** - * Analyze all mapped columns in a CSV file. - * - * Uses single-pass analysis to read CSV once and collect stats for all columns simultaneously. - * - * @param array $columnMap Maps importer field name to CSV column name - * @param array $importerColumns Column definitions from the importer - * @param string|null $entityType Entity model class for custom field lookup + * @param array $columnMap + * @param array $importerColumns * @return Collection */ public function analyze( @@ -133,12 +122,7 @@ public function analyze( })->values(); } - /** - * Load all custom fields for an entity type. - * - * @param string|null $entityType Model class (e.g., App\Models\Company) - * @return Collection - */ + /** @return Collection */ private function loadCustomFieldsForEntity(?string $entityType): Collection { if ($entityType === null) { @@ -155,9 +139,6 @@ private function loadCustomFieldsForEntity(?string $entityType): Collection ->get(); } - /** - * Get the morph alias for a model class. - */ private function getMorphAlias(string $modelClass): string { if (! class_exists($modelClass)) { @@ -170,11 +151,7 @@ private function getMorphAlias(string $modelClass): string return $model->getMorphClass(); } - /** - * Get the custom field for a column if it's a custom field column. - * - * @param Collection $customFields - */ + /** @param Collection $customFields */ private function getCustomFieldForColumn(string $fieldName, Collection $customFields): ?CustomField { if (! str_starts_with($fieldName, 'custom_fields_')) { @@ -186,11 +163,7 @@ private function getCustomFieldForColumn(string $fieldName, Collection $customFi return $customFields->firstWhere('code', $code); } - /** - * Get validation rules for a column from ImportColumn and/or CustomField. - * - * @return array{rules: array, itemRules: array, isMultiValue: bool} - */ + /** @return array{rules: array, itemRules: array, isMultiValue: bool} */ private function getValidationRulesForColumn( ?ImportColumn $importerColumn, ?CustomField $customField, @@ -200,19 +173,16 @@ private function getValidationRulesForColumn( $isMultiValue = false; // Get rules from ImportColumn - if ($importerColumn instanceof \Filament\Actions\Imports\ImportColumn) { + if ($importerColumn instanceof ImportColumn) { $rules = $this->filterValidatableRules($importerColumn->getDataValidationRules()); } // For custom fields, use ValidationService to get complete rules - if ($customField instanceof \Relaticle\CustomFields\Models\CustomField) { - $customFieldRules = $this->validationService->getValidationRules($customField); - $customFieldRules = $this->filterValidatableRules($customFieldRules); - - // Merge rules, preferring custom field rules for overlapping rule types + if ($customField instanceof CustomField) { + $customFieldRules = $this->filterValidatableRules( + $this->validationService->getValidationRules($customField) + ); $rules = $this->mergeValidationRules($rules, $customFieldRules); - - // Get item-level validation rules for multi-value fields (e.g., email format) $itemRules = $this->validationService->getItemValidationRules($customField); $isMultiValue = $customField->isMultiChoiceField(); } @@ -225,11 +195,6 @@ private function getValidationRulesForColumn( } /** - * Filter out validation rules that shouldn't be applied to individual values. - * - * Some rules like 'array', closure-based rules, or object rules - * don't make sense for validating individual CSV values. - * * @param array $rules * @return array */ @@ -255,9 +220,6 @@ private function filterValidatableRules(array $rules): array } /** - * Merge two sets of validation rules, preferring rules from the second set - * when rule types overlap. - * * @param array $baseRules * @param array $overrideRules * @return array @@ -287,8 +249,6 @@ private function mergeValidationRules(array $baseRules, array $overrideRules): a } /** - * Detect validation issues using Laravel Validator. - * * @param array $values * @param array{rules: array, itemRules: array, isMultiValue: bool} $rulesData * @return array @@ -333,7 +293,7 @@ private function detectIssuesWithValidator( // For multi-value fields with item rules, split and validate each item if ($rulesData['isMultiValue'] && $rulesData['itemRules'] !== []) { $issue = $this->validateMultiValueField($value, $rulesData['itemRules'], $count); - if ($issue instanceof \Relaticle\ImportWizard\Data\ValueIssue) { + if ($issue instanceof ValueIssue) { $issues[] = $issue; } @@ -365,11 +325,7 @@ private function detectIssuesWithValidator( return $issues; } - /** - * Validate a multi-value field by splitting and validating each item. - * - * @param array $itemRules - */ + /** @param array $itemRules */ private function validateMultiValueField(string $value, array $itemRules, int $count): ?ValueIssue { // Split by comma and trim each item @@ -407,9 +363,6 @@ private function validateMultiValueField(string $value, array $itemRules, int $c return null; } - /** - * Determine the field type based on ImportColumn configuration. - */ private function determineFieldType(?ImportColumn $column): string { if (! $column instanceof ImportColumn) { @@ -452,12 +405,7 @@ private function isBlank(mixed $value): bool return $value === null || $value === ''; } - /** - * Validate a single value against the rules for a field. - * Returns error message if validation fails, null if valid. - * - * @param array $importerColumns - */ + /** @param array $importerColumns */ public function validateSingleValue( string $value, string $fieldName, diff --git a/app-modules/ImportWizard/src/Services/CsvReaderFactory.php b/app-modules/ImportWizard/src/Services/CsvReaderFactory.php index 5b4df8b4..e90df97b 100644 --- a/app-modules/ImportWizard/src/Services/CsvReaderFactory.php +++ b/app-modules/ImportWizard/src/Services/CsvReaderFactory.php @@ -8,29 +8,16 @@ /** * Factory for creating CSV readers with auto-detected delimiters. - * - * Centralizes CSV parsing configuration to eliminate code duplication - * across CsvAnalyzer, ImportPreviewService, and ImportWizard. */ final class CsvReaderFactory { - /** @var array>> */ - private static array $readerCache = []; - /** * Create a CSV reader with auto-detected delimiter. * - * @param bool $useCache Whether to cache and reuse readers for the same path * @return CsvReader> */ - public function createFromPath(string $csvPath, int $headerOffset = 0, bool $useCache = false): CsvReader + public function createFromPath(string $csvPath, int $headerOffset = 0): CsvReader { - $cacheKey = $csvPath.':'.$headerOffset; - - if ($useCache && isset(self::$readerCache[$cacheKey])) { - return self::$readerCache[$cacheKey]; - } - $csvReader = CsvReader::createFromPath($csvPath); $csvReader->setHeaderOffset($headerOffset); @@ -39,21 +26,9 @@ public function createFromPath(string $csvPath, int $headerOffset = 0, bool $use $csvReader->setDelimiter($delimiter); } - if ($useCache) { - self::$readerCache[$cacheKey] = $csvReader; - } - return $csvReader; } - /** - * Clear the reader cache. - */ - public static function clearCache(): void - { - self::$readerCache = []; - } - /** * Auto-detect CSV delimiter by sampling first 1KB of file. */ diff --git a/app-modules/ImportWizard/src/Services/CsvRowCounter.php b/app-modules/ImportWizard/src/Services/CsvRowCounter.php deleted file mode 100644 index 4f5a209c..00000000 --- a/app-modules/ImportWizard/src/Services/CsvRowCounter.php +++ /dev/null @@ -1,111 +0,0 @@ -> $csvReader - */ - public function count(string $csvPath, Reader $csvReader): int - { - $fileSize = filesize($csvPath); - if ($fileSize === false) { - return $this->exactCount($csvReader); - } - - $threshold = (int) config('import-wizard.row_count.exact_threshold_bytes', 1_048_576); - - if ($fileSize < $threshold) { - return $this->exactCount($csvReader); - } - - return $this->estimatedCount($csvPath, $csvReader); - } - - /** - * Get exact row count by iterating through all records. - * - * @param Reader> $csvReader - */ - private function exactCount(Reader $csvReader): int - { - return iterator_count($csvReader->getRecords()); - } - - /** - * Estimate row count by sampling rows and calculating average row size. - * - * @param Reader> $csvReader - */ - private function estimatedCount(string $csvPath, Reader $csvReader): int - { - $sampleSize = (int) config('import-wizard.row_count.sample_size', 100); - $sampleBytes = (int) config('import-wizard.row_count.sample_bytes', 8192); - - // Sample rows to verify the file has content - $iterator = $csvReader->getRecords(); - $count = 0; - - foreach ($iterator as $record) { - $count++; - if ($count >= $sampleSize) { - break; - } - } - - if ($count === 0) { - return 0; - } - - // Get header size (first line) - $headerBytes = $this->getHeaderSize($csvPath); - - // Calculate average row size from sample content - $sampleContent = file_get_contents($csvPath, offset: $headerBytes, length: max(0, $sampleBytes)); - if ($sampleContent === false) { - return $this->exactCount($csvReader); - } - - $sampleLines = explode("\n", trim($sampleContent)); - $avgRowSize = strlen($sampleContent) / max(1, count($sampleLines)); - - // Estimate total rows - $fileSize = filesize($csvPath); - if ($fileSize === false) { - return $this->exactCount($csvReader); - } - - $dataSize = $fileSize - $headerBytes; - - return (int) ceil($dataSize / max(1, $avgRowSize)); - } - - /** - * Get the size of the header line in bytes. - */ - private function getHeaderSize(string $csvPath): int - { - $file = fopen($csvPath, 'r'); - if ($file === false) { - return 0; - } - - $headerContent = fgets($file) ?: ''; - fclose($file); - - return strlen($headerContent); - } -} diff --git a/app-modules/ImportWizard/src/Services/ImportRecordResolver.php b/app-modules/ImportWizard/src/Services/ImportRecordResolver.php index 52103380..95dcfd2a 100644 --- a/app-modules/ImportWizard/src/Services/ImportRecordResolver.php +++ b/app-modules/ImportWizard/src/Services/ImportRecordResolver.php @@ -14,19 +14,9 @@ use Relaticle\ImportWizard\Filament\Imports\OpportunityImporter; use Relaticle\ImportWizard\Filament\Imports\PeopleImporter; -/** - * Fast record resolution for import previews using in-memory caching. - * - * Follows the CompanyMatcher pattern: pre-load all records in bulk queries, - * then use O(1) hash table lookups instead of per-row database queries. - * - * Performance: Reduces 10,000 queries to 3-5 queries for 10,000 row previews. - */ final class ImportRecordResolver { /** - * In-memory cache of records indexed for O(1) lookups. - * * @var array{ * people: array{byId: array, byEmail: array}, * companies: array{byId: array, byDomain: array}, @@ -41,11 +31,7 @@ final class ImportRecordResolver private ?string $cachedTeamId = null; - /** - * Preload all records for a team based on importer class. - * - * @param class-string $importerClass - */ + /** @param class-string $importerClass */ public function loadForTeam(string $teamId, string $importerClass): void { // Skip if already loaded for this team @@ -69,11 +55,7 @@ public function loadForTeam(string $teamId, string $importerClass): void }; } - /** - * Resolve a People record by email addresses. - * - * @param array $emails - */ + /** @param array $emails */ public function resolvePeopleByEmail(array $emails, string $teamId): ?People { $this->ensureCacheLoaded($teamId); @@ -88,9 +70,6 @@ public function resolvePeopleByEmail(array $emails, string $teamId): ?People return null; } - /** - * Resolve a Company record by domains custom field. - */ public function resolveCompanyByDomain(string $domain, string $teamId): ?Company { $this->ensureCacheLoaded($teamId); @@ -100,9 +79,6 @@ public function resolveCompanyByDomain(string $domain, string $teamId): ?Company return $this->cache['companies']['byDomain'][$domain] ?? null; } - /** - * Load all people for a team with email custom field values. - */ private function loadPeople(string $teamId): void { // Query 1: Get emails custom field ID @@ -146,9 +122,6 @@ private function loadPeople(string $teamId): void } } - /** - * Load all companies for a team with domains custom field values. - */ private function loadCompanies(string $teamId): void { // Query 1: Get domains custom field ID @@ -195,9 +168,6 @@ private function loadCompanies(string $teamId): void } } - /** - * Load all opportunities for a team. - */ private function loadOpportunities(string $teamId): void { // Query: Load ALL opportunities @@ -212,9 +182,6 @@ private function loadOpportunities(string $teamId): void } } - /** - * Ensure cache is loaded for the team. - */ private function ensureCacheLoaded(string $teamId): void { if ($this->cachedTeamId !== $teamId) { diff --git a/app-modules/ImportWizard/src/Services/ImportPreviewService.php b/app-modules/ImportWizard/src/Services/PreviewChunkService.php similarity index 60% rename from app-modules/ImportWizard/src/Services/ImportPreviewService.php rename to app-modules/ImportWizard/src/Services/PreviewChunkService.php index 8d7613ef..2e6de94d 100644 --- a/app-modules/ImportWizard/src/Services/ImportPreviewService.php +++ b/app-modules/ImportWizard/src/Services/PreviewChunkService.php @@ -7,65 +7,57 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; use League\Csv\Statement; -use Relaticle\ImportWizard\Data\ImportPreviewResult; use Relaticle\ImportWizard\Filament\Imports\BaseImporter; use Relaticle\ImportWizard\Filament\Imports\OpportunityImporter; use Relaticle\ImportWizard\Filament\Imports\PeopleImporter; use Relaticle\ImportWizard\Models\Import; -/** - * Service for previewing import results without actually saving data. - * - * Performs a dry-run of the import by calling each importer's resolveRecord() - * method to determine whether records would be created or updated. - */ -final readonly class ImportPreviewService +final readonly class PreviewChunkService { public function __construct( private CsvReaderFactory $csvReaderFactory, private CompanyMatcher $companyMatcher, - private CsvRowCounter $csvRowCounter, ) {} /** - * Generate a preview of what an import will do. - * * @param class-string $importerClass - * @param array $columnMap Maps importer field name to CSV column name - * @param array $options Import options - * @param array> $valueCorrections User-defined value corrections - * @param int $sampleSize Maximum number of rows to process for preview (default 1,000) + * @param array $columnMap + * @param array $options + * @param array> $valueCorrections + * @return array{rows: array>, creates: int, updates: int} */ - public function preview( + public function processChunk( string $importerClass, string $csvPath, + int $startRow, + int $limit, array $columnMap, array $options, string $teamId, string $userId, array $valueCorrections = [], - int $sampleSize = 1000, - ): ImportPreviewResult { + ?ImportRecordResolver $recordResolver = null, + ): array { // Create a non-persisted Import model for the importer $import = new Import; $import->setAttribute('team_id', $teamId); $import->setAttribute('user_id', $userId); $csvReader = $this->csvReaderFactory->createFromPath($csvPath); - $totalRows = $this->csvRowCounter->count($csvPath, $csvReader); - // Process only sampled rows for preview - $records = (new Statement)->limit($sampleSize)->process($csvReader); + // Get the specific range of rows + $records = Statement::create() + ->offset($startRow) + ->limit($limit) + ->process($csvReader); - // Pre-load all records for fast O(1) lookups (avoids N+1 queries) - $recordResolver = app(ImportRecordResolver::class); - $recordResolver->loadForTeam($teamId, $importerClass); + $recordResolver ??= tap(app(ImportRecordResolver::class), fn (ImportRecordResolver $r) => $r->loadForTeam($teamId, $importerClass)); - $willCreate = 0; - $willUpdate = 0; + $creates = 0; + $updates = 0; $rows = []; - $rowNumber = 0; + $rowNumber = $startRow; foreach ($records as $record) { $rowNumber++; @@ -85,12 +77,12 @@ public function preview( $isNew = $result['action'] === 'create'; if ($isNew) { - $willCreate++; + $creates++; } else { - $willUpdate++; + $updates++; } - // Store row data with metadata + // Format row data $formattedRow = $this->formatRowRecord($record, $columnMap); // Enrich with company match data for People/Opportunity imports @@ -98,7 +90,7 @@ public function preview( $formattedRow = $this->enrichRowWithCompanyMatch($formattedRow, $teamId); } - // Detect update method (ID-based or attribute-based) + // Detect update method $hasId = ! blank($formattedRow['id'] ?? null); $updateMethod = null; $recordId = null; @@ -112,7 +104,7 @@ public function preview( $formattedRow, [ '_row_index' => $rowNumber, - '_is_new' => $isNew, + '_action' => $isNew ? 'create' : 'update', '_update_method' => $updateMethod, '_record_id' => $recordId, ] @@ -120,37 +112,63 @@ public function preview( } catch (\Throwable $e) { report($e); - // Skip errored rows in preview - they'll be handled during actual import - continue; + // Include errored rows with error flag + $rows[] = [ + '_row_index' => $rowNumber, + '_action' => 'error', + '_error' => $e->getMessage(), + ]; } } - $actualSampleSize = min($totalRows, $sampleSize); - $isSampled = $totalRows > $sampleSize; + return [ + 'rows' => $rows, + 'creates' => $creates, + 'updates' => $updates, + ]; + } - // Scale counts to full dataset if sampled - $scaledCreateCount = $willCreate; - $scaledUpdateCount = $willUpdate; + /** + * @param array $columnMap + * @return array + */ + public function getEnrichedHeaders(array $columnMap): array + { + $headers = ['_row_index', '_action', '_update_method', '_record_id']; + + foreach ($columnMap as $fieldName => $csvColumn) { + if ($csvColumn !== '') { + $headers[] = $fieldName; + } + } + + return $headers; + } + + /** + * @param array $row + * @param array $columnMap + * @return array + */ + public function rowToArray(array $row, array $columnMap): array + { + $values = [ + $row['_row_index'] ?? '', + $row['_action'] ?? '', + $row['_update_method'] ?? '', + $row['_record_id'] ?? '', + ]; - if ($isSampled && $actualSampleSize > 0) { - $scaleFactor = $totalRows / $actualSampleSize; - $scaledCreateCount = (int) round($willCreate * $scaleFactor); - $scaledUpdateCount = (int) round($willUpdate * $scaleFactor); + foreach ($columnMap as $fieldName => $csvColumn) { + if ($csvColumn !== '') { + $values[] = $row[$fieldName] ?? ''; + } } - return new ImportPreviewResult( - totalRows: $totalRows, - createCount: $scaledCreateCount, - updateCount: $scaledUpdateCount, - rows: $rows, - isSampled: $isSampled, - sampleSize: $actualSampleSize, - ); + return $values; } /** - * Preview a single row to determine what action would be taken. - * * @param class-string $importerClass * @param array $columnMap * @param array $options @@ -172,17 +190,19 @@ private function previewRow( 'options' => $options, ]); - // Set resolver for fast preview lookups (avoids per-row database queries) $importer->setRecordResolver($recordResolver); - // Invoke importer's resolution logic using public Filament APIs - $record = $this->invokeImporterResolution($importer, $rowData); + // Set row data and invoke resolution + $importer->setRowDataForPreview($rowData); + $importer->remapData(); + $importer->castData(); + + $record = $importer->resolveRecord(); - if (! $record instanceof \Illuminate\Database\Eloquent\Model) { + if (! $record instanceof Model) { return ['action' => 'create', 'record' => null]; } - // If record exists in DB, it's an update; otherwise it's a create if ($record->exists) { return ['action' => 'update', 'record' => $record]; } @@ -191,32 +211,9 @@ private function previewRow( } /** - * Invoke the importer's record resolution logic using public Filament APIs. - * - * Uses BaseImporter::setRowDataForPreview() to set the row data, then calls - * Filament's public remapData(), castData(), and resolveRecord() methods. - * - * @param array $rowData - */ - private function invokeImporterResolution(BaseImporter $importer, array $rowData): ?Model - { - // Set row data via our public method - $importer->setRowDataForPreview($rowData); - - // Process through Filament's public pipeline methods - $importer->remapData(); - $importer->castData(); - - // Resolve record (queries DB but doesn't save) - return $importer->resolveRecord(); - } - - /** - * Apply value corrections to a row. - * * @param array $record * @param array $columnMap - * @param array> $corrections Map of field name => [old_value => new_value] + * @param array> $corrections * @return array */ private function applyCorrections(array $record, array $columnMap, array $corrections): array @@ -240,8 +237,6 @@ private function applyCorrections(array $record, array $columnMap, array $correc } /** - * Format a row record with mapped field names. - * * @param array $record * @param array $columnMap * @return array @@ -259,11 +254,7 @@ private function formatRowRecord(array $record, array $columnMap): array return $formatted; } - /** - * Check if the importer should have company match enrichment. - * - * @param class-string $importerClass - */ + /** @param class-string $importerClass */ private function shouldEnrichWithCompanyMatch(string $importerClass): bool { return in_array($importerClass, [ @@ -273,8 +264,6 @@ private function shouldEnrichWithCompanyMatch(string $importerClass): bool } /** - * Enrich a row with company match data for transparent preview. - * * @param array $row * @return array */ @@ -295,26 +284,22 @@ private function enrichRowWithCompanyMatch(array $row, string $teamId): array } /** - * Extract emails from a row for company matching. - * * @param array $row * @return array */ private function extractEmailsFromRow(array $row): array { - $emailsRaw = $row['custom_fields_emails'] ?? null; - - if ($emailsRaw === null || $emailsRaw === '') { + $raw = $row['custom_fields_emails'] ?? ''; + if (blank($raw)) { return []; } - $emails = is_string($emailsRaw) - ? array_map(trim(...), explode(',', $emailsRaw)) - : (array) $emailsRaw; + /** @var array $emails */ + $emails = is_string($raw) ? explode(',', $raw) : (array) $raw; return array_values(array_filter( - $emails, - static fn (mixed $email): bool => filter_var($email, FILTER_VALIDATE_EMAIL) !== false + array_map(trim(...), $emails), + static fn (string $e): bool => filter_var($e, FILTER_VALIDATE_EMAIL) !== false )); } } diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index ce081927..c9547fa3 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -173,6 +173,10 @@ public function panel(Panel $panel): Panel ->renderHook( PanelsRenderHook::HEAD_END, fn (): View|Factory => view('filament.app.analytics') + ) + ->renderHook( + PanelsRenderHook::HEAD_END, + fn (): View|Factory => view('filament.app.import-preview-alpine') ); if (Features::hasApiFeatures()) { diff --git a/bootstrap/app.php b/bootstrap/app.php index 8ff9893a..dded3dd0 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -23,6 +23,7 @@ }) ->withSchedule(function (Schedule $schedule): void { $schedule->command('app:generate-sitemap')->daily(); + $schedule->command('import:cleanup')->hourly(); }) ->booting(function (): void { // Model::automaticallyEagerLoadRelationships(); TODO: Before enabling this, check the test suite for any issues with eager loading. diff --git a/composer.json b/composer.json index b42d544b..8d8f4ae5 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "fakerphp/faker": "^1.23", "itsgoingd/clockwork": "^5.3", "larastan/larastan": "^3.0", - "laravel/boost": "^1.0", + "laravel/boost": "^1.8", "laravel/pail": "^1.2.2", "laravel/pint": "^1.21", "laravel/sail": "^1.26", diff --git a/composer.lock b/composer.lock index 24923e13..bfe4ec1b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e0b0a333a58e4289e7a5236f73887998", + "content-hash": "208d6ac776e41856b9949b1a2bd99022", "packages": [ { "name": "anourvalar/eloquent-serialize", diff --git a/package-lock.json b/package-lock.json index ab279c17..9fbdf62d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@tailwindcss/vite": "^4.1.17", + "@tanstack/virtual-core": "^3.13.14", "shiki": "^3.19.0" }, "devDependencies": { @@ -1117,6 +1118,16 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.14", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.14.tgz", + "integrity": "sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index 50a7170e..75f05a68 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.1.17", + "@tanstack/virtual-core": "^3.13.14", "shiki": "^3.19.0" } } diff --git a/resources/views/filament/app/import-preview-alpine.blade.php b/resources/views/filament/app/import-preview-alpine.blade.php new file mode 100644 index 00000000..5d6328ef --- /dev/null +++ b/resources/views/filament/app/import-preview-alpine.blade.php @@ -0,0 +1,126 @@ + diff --git a/tests/Feature/Filament/App/Imports/CompanyImporterTest.php b/tests/Feature/Filament/App/Imports/CompanyImporterTest.php index 454d6b66..135ebd8d 100644 --- a/tests/Feature/Filament/App/Imports/CompanyImporterTest.php +++ b/tests/Feature/Filament/App/Imports/CompanyImporterTest.php @@ -2,331 +2,176 @@ declare(strict_types=1); -namespace Tests\Feature\Filament\App\Imports; - use App\Enums\CustomFields\CompanyField; -use App\Filament\Resources\CompanyResource\Pages\ListCompanies; use App\Models\Company; -use App\Models\CustomField; -use App\Models\CustomFieldValue; use App\Models\Team; -use App\Models\User; -use Filament\Facades\Filament; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; -use Livewire\Livewire; -use Relaticle\CustomFields\Services\TenantContextService; +use Relaticle\ImportWizard\Data\CompanyMatchResult; use Relaticle\ImportWizard\Enums\DuplicateHandlingStrategy; use Relaticle\ImportWizard\Filament\Imports\CompanyImporter; -use Relaticle\ImportWizard\Models\Import; - -uses(RefreshDatabase::class); - -function createCompanyTestImportRecord(User $user, Team $team): Import -{ - return Import::create([ - 'user_id' => $user->id, - 'team_id' => $team->id, - 'importer' => CompanyImporter::class, - 'file_name' => 'test.csv', - 'file_path' => '/tmp/test.csv', - 'total_rows' => 1, - ]); -} - -function setCompanyImporterData(object $importer, array $data): void -{ - $reflection = new \ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, $data); -} +use Relaticle\ImportWizard\Services\CompanyMatcher; -beforeEach(function () { +beforeEach(function (): void { Storage::fake('local'); - - $this->team = Team::factory()->create(); - $this->user = User::factory()->create(['current_team_id' => $this->team->id]); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); - TenantContextService::setTenantId($this->team->id); -}); - -test('company importer has correct columns defined', function () { - $columns = CompanyImporter::getColumns(); - - $columnNames = collect($columns)->map(fn ($column) => $column->getName())->all(); - - // Core database columns are defined explicitly - // Custom fields like address, country, phone are handled by CustomFields::importer() - expect($columnNames) - ->toContain('name') - ->toContain('account_owner_email'); -}); - -test('company importer has required name column', function () { - $columns = CompanyImporter::getColumns(); - - $nameColumn = collect($columns)->first(fn ($column) => $column->getName() === 'name'); - - expect($nameColumn)->not->toBeNull() - ->and($nameColumn->isMappingRequired())->toBeTrue(); -}); - -test('company importer has options form with duplicate handling', function () { - $components = CompanyImporter::getOptionsFormComponents(); - - $duplicateHandlingComponent = collect($components)->first( - fn ($component) => $component->getName() === 'duplicate_handling' - ); - - expect($duplicateHandlingComponent)->not->toBeNull() - ->and($duplicateHandlingComponent->isRequired())->toBeTrue(); -}); - -test('import action exists on list companies page', function () { - Livewire::test(ListCompanies::class) - ->assertSuccessful() - ->assertActionExists('import'); -}); - -test('company importer guesses column names correctly', function () { - $columns = CompanyImporter::getColumns(); - - $nameColumn = collect($columns)->first(fn ($column) => $column->getName() === 'name'); - - expect($nameColumn->getGuesses()) - ->toContain('name') - ->toContain('company_name') - ->toContain('company'); -}); - -test('company importer provides example values', function () { - $columns = CompanyImporter::getColumns(); - - $nameColumn = collect($columns)->first(fn ($column) => $column->getName() === 'name'); - - expect($nameColumn->getExample())->not->toBeNull() - ->and($nameColumn->getExample())->toBe('Acme Corporation'); -}); - -test('duplicate handling strategy enum has correct values', function () { - expect(DuplicateHandlingStrategy::SKIP->value)->toBe('skip') - ->and(DuplicateHandlingStrategy::UPDATE->value)->toBe('update') - ->and(DuplicateHandlingStrategy::CREATE_NEW->value)->toBe('create_new'); -}); - -test('duplicate handling strategy has labels', function () { - expect(DuplicateHandlingStrategy::SKIP->getLabel())->toBe('Skip duplicates') - ->and(DuplicateHandlingStrategy::UPDATE->getLabel())->toBe('Update existing records') - ->and(DuplicateHandlingStrategy::CREATE_NEW->getLabel())->toBe('Create new records anyway'); -}); - -test('company importer returns completed notification body', function () { - $import = new Import; - $import->successful_rows = 10; - - $body = CompanyImporter::getCompletedNotificationBody($import); - - expect($body)->toContain('10') - ->and($body)->toContain('imported'); -}); - -test('company importer includes failed rows in notification', function () { - $import = Import::create([ - 'team_id' => $this->team->id, - 'user_id' => $this->user->id, - 'successful_rows' => 8, - 'total_rows' => 10, - 'processed_rows' => 10, - 'importer' => CompanyImporter::class, - 'file_name' => 'test.csv', - 'file_path' => 'imports/test.csv', - ]); - - // Create some failed rows - $import->failedRows()->createMany([ - ['data' => ['name' => 'Failed 1'], 'validation_error' => 'Invalid data'], - ['data' => ['name' => 'Failed 2'], 'validation_error' => 'Invalid data'], - ]); - - $body = CompanyImporter::getCompletedNotificationBody($import); - - expect($body)->toContain('8') - ->and($body)->toContain('2') - ->and($body)->toContain('failed'); + ['user' => $this->user, 'team' => $this->team] = setupImportTestContext(); + $this->domainField = createDomainsField($this->team); + $this->domainsKey = 'custom_fields_'.CompanyField::DOMAINS->value; + $this->matcher = app(CompanyMatcher::class); }); describe('Domain-Based Duplicate Detection', function (): void { - function createDomainsFieldForCompany(Team $team): CustomField - { - return CustomField::withoutGlobalScopes()->create([ - 'code' => CompanyField::DOMAINS->value, - 'name' => CompanyField::DOMAINS->getDisplayName(), - 'type' => 'link', - 'entity_type' => 'company', - 'tenant_id' => $team->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, - ]); - } - - function setCompanyDomainValue(Company $company, string $domain, CustomField $field): void - { - CustomFieldValue::withoutGlobalScopes()->create([ - 'entity_type' => 'company', - 'entity_id' => $company->id, - 'custom_field_id' => $field->id, - 'tenant_id' => $company->team_id, - 'json_value' => [$domain], - ]); - } - - it('matches company by domains with UPDATE strategy', function (): void { - $domainField = createDomainsFieldForCompany($this->team); - $existingCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - setCompanyDomainValue($existingCompany, 'acme.com', $domainField); - - $domainsKey = 'custom_fields_'.CompanyField::DOMAINS->value; - $import = createCompanyTestImportRecord($this->user, $this->team); - $importer = new CompanyImporter( - $import, - ['name' => 'name', $domainsKey => $domainsKey], + it(':dataset with UPDATE strategy', function (string $scenario, array $setup, array $importData, string $expectation): void { + $company = Company::factory()->for($this->team)->create(['name' => 'Acme Inc']); + setCompanyDomain($company, 'acme.com', $this->domainField); + + if ($scenario === 'domain priority') { + Company::factory()->for($this->team)->create(['name' => 'Acme Inc']); + } + + $importer = createImporter( + CompanyImporter::class, + $this->user, + $this->team, + ['name' => 'name', $this->domainsKey => $this->domainsKey], + $importData, ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] ); - setCompanyImporterData($importer, [ - 'name' => 'Different Name', - $domainsKey => 'acme.com', - ]); - - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($existingCompany->id) - ->and($record->exists)->toBeTrue(); - }); - - it('prioritizes domain match over name match', function (): void { - $domainField = createDomainsFieldForCompany($this->team); - - // Company that matches by name only - $nameMatchCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - - // Company that matches by domain but has different name - $domainMatchCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Corporation']); - setCompanyDomainValue($domainMatchCompany, 'acme.com', $domainField); + $result = $importer->resolveRecord(); + + match ($expectation) { + 'matches' => expect($result)->id->toBe($company->id)->exists->toBeTrue(), + 'creates_new' => expect($result->exists)->toBeFalse(), + }; + })->with([ + 'matches by domain' => ['match', [], ['name' => 'Different Name', 'custom_fields_domains' => 'acme.com'], 'matches'], + 'prioritizes domain over name' => ['domain priority', [], ['name' => 'Acme Inc', 'custom_fields_domains' => 'acme.com'], 'matches'], + 'normalizes domain to lowercase' => ['match', [], ['name' => 'Acme Inc', 'custom_fields_domains' => 'ACME.COM'], 'matches'], + 'creates new when no domain match' => ['no match', [], ['name' => 'New Company', 'custom_fields_domains' => 'newcompany.com'], 'creates_new'], + ]); - $domainsKey = 'custom_fields_'.CompanyField::DOMAINS->value; - $import = createCompanyTestImportRecord($this->user, $this->team); - $importer = new CompanyImporter( - $import, - ['name' => 'name', $domainsKey => $domainsKey], - ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] + it('respects CREATE_NEW strategy even with domain match', function (): void { + $company = Company::factory()->for($this->team)->create(['name' => 'Acme Inc']); + setCompanyDomain($company, 'acme.com', $this->domainField); + + $importer = createImporter( + CompanyImporter::class, + $this->user, + $this->team, + ['name' => 'name', $this->domainsKey => $this->domainsKey], + ['name' => 'Acme Inc', $this->domainsKey => 'acme.com'], + ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] ); - setCompanyImporterData($importer, [ - 'name' => 'Acme Inc', // Matches first company by name - $domainsKey => 'acme.com', // Matches second company by domain - ]); - - $record = $importer->resolveRecord(); - - // Should match by domain (higher priority), not by name - expect($record->id)->toBe($domainMatchCompany->id); + expect($importer->resolveRecord()->exists)->toBeFalse(); }); +}); - it('falls back to name match when no domain provided', function (): void { - $domainField = createDomainsFieldForCompany($this->team); - $existingCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - setCompanyDomainValue($existingCompany, 'acme.com', $domainField); +describe('CompanyMatcher Service', function (): void { + it('matches by :dataset', function (string $matchType, ?string $companyId, string $companyName, array $emails, string $expectedType): void { + $company = Company::factory()->for($this->team)->create(['name' => 'Acme Inc']); + setCompanyDomain($company, 'acme.com', $this->domainField); + + $id = match ($companyId) { + 'valid' => $company->id, + 'invalid' => 'not-a-valid-ulid', + 'notfound' => '01KCCNZ9T2X1R00369K6WM6WK2', + default => '', + }; + + $result = $this->matcher->match($id, $companyName, $emails, $this->team->id); + + expect($result)->toBeInstanceOf(CompanyMatchResult::class) + ->matchType->toBe($expectedType); + + if ($expectedType !== 'new' && $expectedType !== 'none') { + expect($result->companyId)->toBe($company->id); + } + })->with([ + 'ID with highest priority' => ['id', 'valid', 'Different Name', [], 'id'], + 'domain from email' => ['domain', '', 'Acme Inc', ['john@acme.com'], 'domain'], + 'domain over company name' => ['domain', '', 'Acme Inc', ['john@acme.com'], 'domain'], + 'multiple emails' => ['domain', '', 'Some Company', ['john@gmail.com', 'jane@acme.com'], 'domain'], + 'uppercase domain normalization' => ['domain', '', 'Acme Inc', ['JOHN@ACME.COM'], 'domain'], + 'invalid ID falls back to domain' => ['domain', 'invalid', 'Some Name', ['john@acme.com'], 'domain'], + ]); - $import = createCompanyTestImportRecord($this->user, $this->team); - $importer = new CompanyImporter( - $import, - ['name' => 'name'], - ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] - ); + it('returns :dataset match result', function (string $scenario, string $companyName, array $emails, string $expectedType): void { + if ($scenario === 'with company') { + Company::factory()->for($this->team)->create(['name' => 'Acme Inc']); + } + + $result = $this->matcher->match('', $companyName, $emails, $this->team->id); + + expect($result) + ->matchType->toBe($expectedType) + ->companyId->toBeNull(); + })->with([ + 'new when company name without domain match' => ['with company', 'Acme Inc', [], 'new'], + 'new for personal emails' => ['with company', 'Acme Inc', ['john@gmail.com'], 'new'], + 'new when no match exists' => ['empty', 'Unknown Company', ['contact@unknown.com'], 'new'], + 'new when ID not found' => ['empty', 'New Company', [], 'new'], + 'none for empty company name' => ['empty', '', ['john@acme.com'], 'none'], + ]); - setCompanyImporterData($importer, [ - 'name' => 'Acme Inc', - // No domain provided - ]); + it('handles invalid emails gracefully', function (): void { + Company::factory()->for($this->team)->create(['name' => 'Acme Inc']); - $record = $importer->resolveRecord(); + $result = $this->matcher->match('', 'Acme Inc', ['not-an-email', 'also.not.valid', ''], $this->team->id); - expect($record->id)->toBe($existingCompany->id) - ->and($record->exists)->toBeTrue(); + expect($result)->matchType->toBe('new')->matchCount->toBe(0); }); - it('creates new company when domain does not match', function (): void { - $domainField = createDomainsFieldForCompany($this->team); - $existingCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Existing Inc']); - setCompanyDomainValue($existingCompany, 'existing.com', $domainField); - - $domainsKey = 'custom_fields_'.CompanyField::DOMAINS->value; - $import = createCompanyTestImportRecord($this->user, $this->team); - $importer = new CompanyImporter( - $import, - ['name' => 'name', $domainsKey => $domainsKey], - ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] - ); - - setCompanyImporterData($importer, [ - 'name' => 'New Company', - $domainsKey => 'newcompany.com', // Different domain - ]); + it('takes first match when multiple companies share domain', function (): void { + $company1 = Company::factory()->for($this->team)->create(['name' => 'Acme Inc']); + setCompanyDomain($company1, 'acme.com', $this->domainField); + $company2 = Company::factory()->for($this->team)->create(['name' => 'Acme Corp']); + setCompanyDomain($company2, 'acme.com', $this->domainField); - $record = $importer->resolveRecord(); + $result = $this->matcher->match('', 'Something Else', ['john@acme.com'], $this->team->id); - expect($record->exists)->toBeFalse(); + expect($result) + ->matchType->toBe('domain') + ->matchCount->toBe(2) + ->companyId->toBeIn([$company1->id, $company2->id]); }); +}); - it('normalizes domain to lowercase for matching', function (): void { - $domainField = createDomainsFieldForCompany($this->team); - $existingCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - setCompanyDomainValue($existingCompany, 'acme.com', $domainField); - - $domainsKey = 'custom_fields_'.CompanyField::DOMAINS->value; - $import = createCompanyTestImportRecord($this->user, $this->team); - $importer = new CompanyImporter( - $import, - ['name' => 'name', $domainsKey => $domainsKey], - ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] +describe('Team Isolation', function (): void { + it('does not match :dataset from other teams', function (string $matchType): void { + $otherTeam = Team::factory()->create(); + $otherCompany = Company::factory()->for($otherTeam)->create(['name' => 'Acme Inc']); + + if ($matchType === 'domain') { + createDomainsField($otherTeam); + setCompanyDomain($otherCompany, 'acme.com', createDomainsField($otherTeam)); + } + + $result = $this->matcher->match( + $matchType === 'id' ? $otherCompany->id : '', + 'Acme Inc', + $matchType === 'domain' ? ['john@acme.com'] : [], + $this->team->id ); - setCompanyImporterData($importer, [ - 'name' => 'Acme Inc', - $domainsKey => 'ACME.COM', // Uppercase - ]); - - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($existingCompany->id); - }); - - it('respects CREATE_NEW strategy even with domain match', function (): void { - $domainField = createDomainsFieldForCompany($this->team); - $existingCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - setCompanyDomainValue($existingCompany, 'acme.com', $domainField); + expect($result)->matchType->toBe('new')->companyId->toBeNull(); + })->with(['id', 'domain']); +}); - $domainsKey = 'custom_fields_'.CompanyField::DOMAINS->value; - $import = createCompanyTestImportRecord($this->user, $this->team); - $importer = new CompanyImporter( - $import, - ['name' => 'name', $domainsKey => $domainsKey], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] +describe('CompanyMatchResult', function (): void { + it('isDomainMatch returns correct value for :dataset', function (string $type, bool $expected): void { + $result = new CompanyMatchResult( + companyName: 'Test', + matchType: $type, + matchCount: $type === 'domain' ? 1 : 0, + companyId: $type === 'domain' ? '01kccnz9t2x1r00369k6wm6wk2' : null ); - setCompanyImporterData($importer, [ - 'name' => 'Acme Inc', - $domainsKey => 'acme.com', - ]); - - $record = $importer->resolveRecord(); - - // CREATE_NEW should always create new record - expect($record->exists)->toBeFalse(); - }); + expect($result->isDomainMatch())->toBe($expected); + })->with([ + 'domain match' => ['domain', true], + 'new' => ['new', false], + 'id' => ['id', false], + 'none' => ['none', false], + ]); }); diff --git a/tests/Feature/Filament/App/Imports/CsvReaderFactoryTest.php b/tests/Feature/Filament/App/Imports/CsvReaderFactoryTest.php index 7d857455..4fa393d9 100644 --- a/tests/Feature/Filament/App/Imports/CsvReaderFactoryTest.php +++ b/tests/Feature/Filament/App/Imports/CsvReaderFactoryTest.php @@ -2,123 +2,81 @@ declare(strict_types=1); -namespace Tests\Feature\Filament\App\Imports; - use Illuminate\Support\Facades\Storage; use Relaticle\ImportWizard\Services\CsvReaderFactory; -beforeEach(function () { - Storage::fake('local'); - CsvReaderFactory::clearCache(); -}); - -describe('Delimiter Auto-Detection', function () { - test('it detects comma delimiter', function () { - $csvContent = "name,email,phone\nJohn,john@example.com,555-1234\nJane,jane@example.com,555-5678"; - $csvPath = Storage::disk('local')->path('comma.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); - - expect($reader->getDelimiter())->toBe(',') - ->and($reader->getHeader())->toBe(['name', 'email', 'phone']); - }); - - test('it detects semicolon delimiter', function () { - $csvContent = "name;email;phone\nJohn;john@example.com;555-1234\nJane;jane@example.com;555-5678"; - $csvPath = Storage::disk('local')->path('semicolon.csv'); - file_put_contents($csvPath, $csvContent); +beforeEach(fn () => Storage::fake('local')); - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); +describe('Delimiter Auto-Detection', function (): void { + it('detects :dataset delimiter', function (string $delimiter, string $name): void { + $csvPath = Storage::disk('local')->path("{$name}.csv"); + file_put_contents($csvPath, "name{$delimiter}email{$delimiter}phone\nJohn{$delimiter}john@example.com{$delimiter}555-1234"); - expect($reader->getDelimiter())->toBe(';') - ->and($reader->getHeader())->toBe(['name', 'email', 'phone']); - }); - - test('it detects tab delimiter', function () { - $csvContent = "name\temail\tphone\nJohn\tjohn@example.com\t555-1234"; - $csvPath = Storage::disk('local')->path('tab.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); + $reader = (new CsvReaderFactory)->createFromPath($csvPath); - expect($reader->getDelimiter())->toBe("\t") + expect($reader->getDelimiter())->toBe($delimiter) ->and($reader->getHeader())->toBe(['name', 'email', 'phone']); - }); - - test('it detects pipe delimiter', function () { - $csvContent = "name|email|phone\nJohn|john@example.com|555-1234"; - $csvPath = Storage::disk('local')->path('pipe.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); - - expect($reader->getDelimiter())->toBe('|') - ->and($reader->getHeader())->toBe(['name', 'email', 'phone']); - }); - - test('it falls back to comma when no delimiter detected', function () { - $csvContent = "single_column\nvalue1\nvalue2"; + })->with([ + 'comma' => [',', 'comma'], + 'semicolon' => [';', 'semicolon'], + 'tab' => ["\t", 'tab'], + 'pipe' => ['|', 'pipe'], + ]); + + it('falls back to comma when no delimiter detected', function (): void { $csvPath = Storage::disk('local')->path('single.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); + file_put_contents($csvPath, "single_column\nvalue1\nvalue2"); - // League\CSV defaults to comma when no delimiter is explicitly set - expect($reader->getDelimiter())->toBe(','); + expect((new CsvReaderFactory)->createFromPath($csvPath)->getDelimiter())->toBe(','); }); }); -describe('Header Parsing', function () { - test('it parses headers from first row by default', function () { - $csvContent = "First Name,Last Name,Email\nJohn,Doe,john@example.com"; - $csvPath = Storage::disk('local')->path('headers.csv'); - file_put_contents($csvPath, $csvContent); +describe('Header Parsing', function (): void { + it('parses headers from :dataset', function (string $scenario, string $content, ?int $offset, array $expected): void { + $csvPath = Storage::disk('local')->path("{$scenario}.csv"); + file_put_contents($csvPath, $content); - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); + $reader = $offset !== null + ? (new CsvReaderFactory)->createFromPath($csvPath, headerOffset: $offset) + : (new CsvReaderFactory)->createFromPath($csvPath); - expect($reader->getHeader())->toBe(['First Name', 'Last Name', 'Email']); - }); - - test('it handles headers with spaces', function () { - $csvContent = "First Name,Last Name,Email Address\nJohn,Doe,john@example.com"; - $csvPath = Storage::disk('local')->path('spaced-headers.csv'); - file_put_contents($csvPath, $csvContent); + expect($reader->getHeader())->toBe($expected); + })->with([ + 'first row by default' => ['headers', "First Name,Last Name,Email\nJohn,Doe,john@example.com", null, ['First Name', 'Last Name', 'Email']], + 'custom offset' => ['custom-offset', "# Comment line\nname,email\njohn,john@example.com", 1, ['name', 'email']], + ]); +}); - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); +describe('Edge Cases', function (): void { + it('handles :dataset', function (string $scenario, string $content, array $expectedResult): void { + $csvPath = Storage::disk('local')->path("{$scenario}.csv"); + file_put_contents($csvPath, $content); - expect($reader->getHeader())->toBe(['First Name', 'Last Name', 'Email Address']); - }); + $reader = (new CsvReaderFactory)->createFromPath($csvPath); + $records = iterator_to_array($reader->getRecords()); - test('it handles custom header offset', function () { - $csvContent = "# Comment line\nname,email\njohn,john@example.com"; - $csvPath = Storage::disk('local')->path('custom-offset.csv'); - file_put_contents($csvPath, $csvContent); + expect($reader->getHeader())->toBe($expectedResult['header']); - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath, headerOffset: 1); + if (isset($expectedResult['records'])) { + expect($records)->toBe($expectedResult['records']); + } - expect($reader->getHeader())->toBe(['name', 'email']); - }); -}); + foreach ($expectedResult['record_check'] ?? [] as $index => $check) { + foreach ($check as $key => $value) { + expect($records[$index][$key])->toBe($value); + } + } + })->with([ + 'empty CSV with only headers' => ['empty', 'name,email', ['header' => ['name', 'email'], 'records' => []]], + 'quoted values with delimiters' => ['quoted', "name,description\n\"Acme, Inc.\",\"A company, with commas\"", ['header' => ['name', 'description'], 'record_check' => [1 => ['name' => 'Acme, Inc.', 'description' => 'A company, with commas']]]], + 'UTF-8 content' => ['utf8', "name,city\nCafé,São Paulo\nMüller,München", ['header' => ['name', 'city'], 'record_check' => [1 => ['name' => 'Café', 'city' => 'São Paulo'], 2 => ['name' => 'Müller', 'city' => 'München']]]], + ]); -describe('Record Iteration', function () { - test('it iterates over records correctly', function () { - $csvContent = "name,value\nAlpha,100\nBeta,200\nGamma,300"; + it('iterates over records correctly', function (): void { $csvPath = Storage::disk('local')->path('records.csv'); - file_put_contents($csvPath, $csvContent); + file_put_contents($csvPath, "name,value\nAlpha,100\nBeta,200\nGamma,300"); - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); - - $records = iterator_to_array($reader->getRecords()); + $records = iterator_to_array((new CsvReaderFactory)->createFromPath($csvPath)->getRecords()); expect($records)->toHaveCount(3) ->and($records[1])->toBe(['name' => 'Alpha', 'value' => '100']) @@ -126,89 +84,3 @@ ->and($records[3])->toBe(['name' => 'Gamma', 'value' => '300']); }); }); - -describe('Reader Caching', function () { - test('it caches readers when enabled', function () { - $csvContent = "name\nTest"; - $csvPath = Storage::disk('local')->path('cached.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - - $reader1 = $factory->createFromPath($csvPath, useCache: true); - $reader2 = $factory->createFromPath($csvPath, useCache: true); - - expect($reader1)->toBe($reader2); - }); - - test('it does not cache readers when disabled', function () { - $csvContent = "name\nTest"; - $csvPath = Storage::disk('local')->path('uncached.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - - $reader1 = $factory->createFromPath($csvPath, useCache: false); - $reader2 = $factory->createFromPath($csvPath, useCache: false); - - expect($reader1)->not->toBe($reader2); - }); - - test('it clears cache correctly', function () { - $csvContent = "name\nTest"; - $csvPath = Storage::disk('local')->path('clear-cache.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - - $reader1 = $factory->createFromPath($csvPath, useCache: true); - CsvReaderFactory::clearCache(); - $reader2 = $factory->createFromPath($csvPath, useCache: true); - - expect($reader1)->not->toBe($reader2); - }); -}); - -describe('Edge Cases', function () { - test('it handles empty CSV files', function () { - $csvContent = 'name,email'; - $csvPath = Storage::disk('local')->path('empty.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); - - expect($reader->getHeader())->toBe(['name', 'email']) - ->and(iterator_to_array($reader->getRecords()))->toBe([]); - }); - - test('it handles CSV with quoted values containing delimiters', function () { - $csvContent = "name,description\n\"Acme, Inc.\",\"A company, with commas\""; - $csvPath = Storage::disk('local')->path('quoted.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); - - $records = iterator_to_array($reader->getRecords()); - - expect($records[1]['name'])->toBe('Acme, Inc.') - ->and($records[1]['description'])->toBe('A company, with commas'); - }); - - test('it handles CSV with UTF-8 content', function () { - $csvContent = "name,city\nCafé,São Paulo\nMüller,München"; - $csvPath = Storage::disk('local')->path('utf8.csv'); - file_put_contents($csvPath, $csvContent); - - $factory = new CsvReaderFactory; - $reader = $factory->createFromPath($csvPath); - - $records = iterator_to_array($reader->getRecords()); - - expect($records[1]['name'])->toBe('Café') - ->and($records[1]['city'])->toBe('São Paulo') - ->and($records[2]['name'])->toBe('Müller') - ->and($records[2]['city'])->toBe('München'); - }); -}); diff --git a/tests/Feature/Filament/App/Imports/EndToEndImportTest.php b/tests/Feature/Filament/App/Imports/EndToEndImportTest.php index 0d53d3a1..bce3e1b5 100644 --- a/tests/Feature/Filament/App/Imports/EndToEndImportTest.php +++ b/tests/Feature/Filament/App/Imports/EndToEndImportTest.php @@ -4,75 +4,27 @@ use App\Models\Company; use App\Models\CustomField; -use App\Models\CustomFieldSection; use App\Models\People; -use App\Models\Team; -use App\Models\User; -use Filament\Facades\Filament; use Illuminate\Support\Facades\Storage; use Relaticle\ImportWizard\Filament\Imports\PeopleImporter; use Relaticle\ImportWizard\Jobs\StreamingImportCsv; use Relaticle\ImportWizard\Models\Import; beforeEach(function (): void { - $this->team = Team::factory()->create(); - $this->user = User::factory()->withPersonalTeam()->create(); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); - \Relaticle\CustomFields\Services\TenantContextService::setTenantId($this->team->id); - - // Create a custom field section for people - $section = CustomFieldSection::withoutGlobalScopes()->create([ - 'code' => 'contact_information', - 'name' => 'Contact Information', - 'type' => 'section', - 'entity_type' => 'people', - 'tenant_id' => $this->team->id, - 'sort_order' => 1, - ]); - - // Create the emails custom field for people - CustomField::withoutGlobalScopes()->create([ - 'custom_field_section_id' => $section->id, - 'code' => 'emails', - 'name' => 'Emails', - 'type' => 'email', - 'entity_type' => 'people', - 'tenant_id' => $this->team->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, - ]); - Storage::fake('local'); + ['user' => $this->user, 'team' => $this->team] = setupImportTestContext(); + createEmailsCustomField($this->team); }); describe('End-to-End People Import', function (): void { - it('can import 5 people with companies from CSV using real queue jobs', function (): void { - // Generate CSV with 5 people for easier debugging + it('imports people with companies from CSV', function (): void { $csvData = "name,company_name,custom_fields_emails\n"; - $expectedPeople = []; - for ($i = 1; $i <= 5; $i++) { - $name = "Person {$i}"; - $company = "Company {$i}"; - $email = "person{$i}@example.com"; - - $csvData .= "{$name},{$company},{$email}\n"; - $expectedPeople[] = [ - 'name' => $name, - 'company' => $company, - 'email' => $email, - ]; + $csvData .= "Person {$i},Company {$i},person{$i}@example.com\n"; } - // Save CSV to storage - $csvPath = 'test-imports/people-5.csv'; - Storage::disk('local')->put($csvPath, $csvData); + Storage::disk('local')->put($csvPath = 'test-imports/people-5.csv', $csvData); - // Create Import model $import = Import::create([ 'team_id' => $this->team->id, 'user_id' => $this->user->id, @@ -84,63 +36,34 @@ 'successful_rows' => 0, ]); - // Create and dispatch batch of streaming import jobs - $columnMap = [ - 'name' => 'name', - 'company_name' => 'company_name', - 'custom_fields_emails' => 'custom_fields_emails', - ]; - - // Create and execute a single job for all 5 rows - $job = new StreamingImportCsv( + (new StreamingImportCsv( import: $import, startRow: 0, rowCount: 5, - columnMap: $columnMap, + columnMap: ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], options: [], - ); - - $job->handle(); + ))->handle(); - // Verify results - expect(People::count())->toBe(5); - expect(Company::count())->toBe(5); // 5 unique companies + expect(People::count())->toBe(5) + ->and(Company::count())->toBe(5) + ->and($import->fresh())->processed_rows->toBe(5)->successful_rows->toBe(5); - expect($import->fresh()->processed_rows)->toBe(5); - expect($import->fresh()->successful_rows)->toBe(5); - - // Verify each person was created correctly - foreach ($expectedPeople as $expected) { - $person = People::where('name', $expected['name'])->first(); - - expect($person)->not->toBeNull(); - expect($person->company->name)->toBe($expected['company']); + for ($i = 1; $i <= 5; $i++) { + $person = People::where('name', "Person {$i}")->first(); + expect($person)->not->toBeNull()->company->name->toBe("Company {$i}"); - // Verify email custom field - $emailsValue = $person->customFieldValues() + $emailValue = $person->customFieldValues() ->withoutGlobalScopes() ->whereHas('customField', fn ($q) => $q->where('code', 'emails')) ->first(); - - expect($emailsValue)->not->toBeNull(); - expect($emailsValue->json_value)->toContain($expected['email']); + expect($emailValue)->not->toBeNull()->json_value->toContain("person{$i}@example.com"); } }); - it('handles duplicate people correctly based on email', function (): void { - // Create existing person - $existingCompany = Company::factory()->create([ - 'team_id' => $this->team->id, - 'name' => 'Existing Company', - ]); + it('handles duplicate detection by email', function (): void { + $existingCompany = Company::factory()->for($this->team)->create(['name' => 'Existing Company']); + $existingPerson = People::factory()->for($this->team)->create(['name' => 'Old Name', 'company_id' => $existingCompany->id]); - $existingPerson = People::factory()->create([ - 'team_id' => $this->team->id, - 'name' => 'Old Name', - 'company_id' => $existingCompany->id, - ]); - - // Add email custom field value $emailField = CustomField::where('code', 'emails')->where('tenant_id', $this->team->id)->first(); $existingPerson->customFieldValues()->create([ 'custom_field_id' => $emailField->id, @@ -148,12 +71,7 @@ 'json_value' => ['john@example.com'], ]); - // Import CSV with same email but different name - $csvData = "name,company_name,custom_fields_emails\n"; - $csvData .= "John Doe,New Company,john@example.com\n"; - - $csvPath = 'test-imports/duplicate-test.csv'; - Storage::disk('local')->put($csvPath, $csvData); + Storage::disk('local')->put($csvPath = 'test-imports/duplicate-test.csv', "name,custom_fields_emails\nJohn Doe,john@example.com\n"); $import = Import::create([ 'team_id' => $this->team->id, @@ -166,126 +84,76 @@ 'successful_rows' => 0, ]); - $job = new StreamingImportCsv( - import: $import, - startRow: 0, - rowCount: 1, - columnMap: [ - 'name' => 'name', - 'company_name' => 'company_name', - 'custom_fields_emails' => 'custom_fields_emails', - ], - options: [], - ); - - $job->handle(); + (new StreamingImportCsv($import, 0, 1, ['name' => 'name', 'custom_fields_emails' => 'custom_fields_emails'], []))->handle(); - // Should update existing person, not create new one expect(People::count())->toBe(1); $person = People::first(); - expect($person->id)->toBe($existingPerson->id); - expect($person->name)->toBe('John Doe'); // Name updated - expect($person->company->name)->toBe('New Company'); // Company updated + expect($person) + ->id->toBe($existingPerson->id) + ->name->toBe('John Doe') + ->company_id->toBe($existingCompany->id); }); - it('imports large dataset efficiently', function (): void { - // Generate CSV with 1000 people - $csvData = "name,company_name,custom_fields_emails\n"; - - for ($i = 1; $i <= 1000; $i++) { - $csvData .= "Person {$i},Company ".ceil($i / 100).",person{$i}@example.com\n"; - } - - $csvPath = 'test-imports/people-1000.csv'; - Storage::disk('local')->put($csvPath, $csvData); + it('handles partial failures gracefully', function (): void { + Storage::disk('local')->put( + $csvPath = 'test-imports/mixed-validity.csv', + "name,company_name,custom_fields_emails\nValid Person 1,Company A,valid1@example.com\n,Company B,invalid@example.com\nValid Person 2,Company C,valid2@example.com\n" + ); $import = Import::create([ 'team_id' => $this->team->id, 'user_id' => $this->user->id, - 'file_name' => 'people-1000.csv', + 'file_name' => 'mixed-validity.csv', 'file_path' => $csvPath, 'importer' => PeopleImporter::class, - 'total_rows' => 1000, + 'total_rows' => 3, 'processed_rows' => 0, 'successful_rows' => 0, ]); - // Process in chunks of 100 - $chunkSize = 100; - $totalRows = 1000; - $currentOffset = 0; - - while ($currentOffset < $totalRows) { - $rowsInThisChunk = min($chunkSize, $totalRows - $currentOffset); - - $job = new StreamingImportCsv( - import: $import, - startRow: $currentOffset, - rowCount: $rowsInThisChunk, - columnMap: [ - 'name' => 'name', - 'company_name' => 'company_name', - 'custom_fields_emails' => 'custom_fields_emails', - ], - options: [], - ); - - $job->handle(); - - $currentOffset += $rowsInThisChunk; - } - - // Verify all records imported - expect(People::count())->toBe(1000); - expect(Company::count())->toBe(10); // 100 people per company - - expect($import->fresh()->processed_rows)->toBe(1000); - expect($import->fresh()->successful_rows)->toBe(1000); - })->skip('Skip by default for speed - run manually when needed'); + (new StreamingImportCsv( + $import, + 0, + 3, + ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], + [] + ))->handle(); + + expect($import->fresh()) + ->processed_rows->toBe(3) + ->successful_rows->toBe(2) + ->and(People::count())->toBe(2) + ->and(People::where('name', 'Valid Person 1')->exists())->toBeTrue() + ->and(People::where('name', 'Valid Person 2')->exists())->toBeTrue(); + }); - it('handles partial failures gracefully', function (): void { - // CSV with mix of valid and invalid data + it('imports large dataset efficiently', function (): void { $csvData = "name,company_name,custom_fields_emails\n"; - $csvData .= "Valid Person 1,Company A,valid1@example.com\n"; - $csvData .= ",Company B,invalid@example.com\n"; // Missing required name field - $csvData .= "Valid Person 2,Company C,valid2@example.com\n"; + for ($i = 1; $i <= 1000; $i++) { + $csvData .= "Person {$i},Company ".ceil($i / 100).",person{$i}@example.com\n"; + } - $csvPath = 'test-imports/mixed-validity.csv'; - Storage::disk('local')->put($csvPath, $csvData); + Storage::disk('local')->put($csvPath = 'test-imports/people-1000.csv', $csvData); $import = Import::create([ 'team_id' => $this->team->id, 'user_id' => $this->user->id, - 'file_name' => 'mixed-validity.csv', + 'file_name' => 'people-1000.csv', 'file_path' => $csvPath, 'importer' => PeopleImporter::class, - 'total_rows' => 3, + 'total_rows' => 1000, 'processed_rows' => 0, 'successful_rows' => 0, ]); - $job = new StreamingImportCsv( - import: $import, - startRow: 0, - rowCount: 3, - columnMap: [ - 'name' => 'name', - 'company_name' => 'company_name', - 'custom_fields_emails' => 'custom_fields_emails', - ], - options: [], - ); - - $job->handle(); - - // Should import valid rows and skip invalid ones - expect($import->fresh()->processed_rows)->toBe(3); - expect($import->fresh()->successful_rows)->toBe(2); // Only 2 valid rows + $columnMap = ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails']; + for ($offset = 0; $offset < 1000; $offset += 100) { + (new StreamingImportCsv($import, $offset, 100, $columnMap, []))->handle(); + } - // Verify only valid people were created - expect(People::where('name', 'Valid Person 1')->exists())->toBeTrue(); - expect(People::where('name', 'Valid Person 2')->exists())->toBeTrue(); - expect(People::count())->toBe(2); // Only 2 people total - }); + expect(People::count())->toBe(1000) + ->and(Company::count())->toBe(10) + ->and($import->fresh())->processed_rows->toBe(1000)->successful_rows->toBe(1000); + })->skip('Skip by default for speed - run manually when needed'); }); diff --git a/tests/Feature/Filament/App/Imports/IdBasedImportTest.php b/tests/Feature/Filament/App/Imports/IdBasedImportTest.php deleted file mode 100644 index b5ded4ba..00000000 --- a/tests/Feature/Filament/App/Imports/IdBasedImportTest.php +++ /dev/null @@ -1,214 +0,0 @@ - $user->id, - 'team_id' => $team->id, - 'importer' => $importer, - 'file_name' => 'test.csv', - 'file_path' => '/tmp/test.csv', - 'total_rows' => 1, - ]); -} - -function setImporterData(object $importer, array $data): void -{ - $reflection = new ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, $data); -} - -beforeEach(function (): void { - $this->team = Team::factory()->create(); - $this->user = User::factory()->withPersonalTeam()->create(); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); -}); - -describe('ID-Based Company Import', function (): void { - it('resolves existing company when valid ID provided', function (): void { - $company = Company::factory()->create(['team_id' => $this->team->id, 'name' => 'Original Name']); - - $import = createTestImportRecord($this->user, $this->team, CompanyImporter::class); - $importer = new CompanyImporter($import, ['id' => 'id', 'name' => 'name'], []); - - setImporterData($importer, ['id' => $company->id, 'name' => 'Updated Name']); - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($company->id) - ->and($record->exists)->toBeTrue(); - }); - - it('fails when ID not found', function (): void { - $fakeId = (string) Str::ulid(); - - $import = createTestImportRecord($this->user, $this->team, CompanyImporter::class); - $importer = new CompanyImporter($import, ['id' => 'id', 'name' => 'name'], []); - - setImporterData($importer, ['id' => $fakeId, 'name' => 'Test Company']); - - expect(fn () => $importer->resolveRecord())->toThrow(Exception::class); - }); - - it('prevents cross-team ID access', function (): void { - $otherTeam = Team::factory()->create(); - $otherCompany = Company::factory()->create(['team_id' => $otherTeam->id, 'name' => 'Other Company']); - - $import = createTestImportRecord($this->user, $this->team, CompanyImporter::class); - $importer = new CompanyImporter($import, ['id' => 'id', 'name' => 'name'], []); - - setImporterData($importer, ['id' => $otherCompany->id, 'name' => 'Hacked Name']); - - expect(fn () => $importer->resolveRecord())->toThrow(Exception::class); - expect($otherCompany->refresh()->name)->toBe('Other Company'); - }); - - it('creates new record when ID is blank', function (): void { - $import = createTestImportRecord($this->user, $this->team, CompanyImporter::class); - $importer = new CompanyImporter($import, ['id' => 'id', 'name' => 'name'], []); - - setImporterData($importer, ['id' => '', 'name' => 'New Company']); - $record = $importer->resolveRecord(); - - expect($record->exists)->toBeFalse(); - }); - - it('ID takes precedence over duplicate strategy', function (): void { - $company = Company::factory()->create(['team_id' => $this->team->id, 'name' => 'Original']); - - $import = createTestImportRecord($this->user, $this->team, CompanyImporter::class); - $importer = new CompanyImporter($import, ['id' => 'id', 'name' => 'name'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW, - ]); - - setImporterData($importer, ['id' => $company->id, 'name' => 'Updated via ID']); - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($company->id) - ->and(Company::count())->toBe(1); - }); -}); - -describe('ID-Based People Import', function (): void { - it('resolves existing person when valid ID provided', function (): void { - $company = Company::factory()->create(['team_id' => $this->team->id]); - $person = People::factory()->create(['team_id' => $this->team->id, 'name' => 'John Doe', 'company_id' => $company->id]); - - $import = createTestImportRecord($this->user, $this->team, PeopleImporter::class); - $importer = new PeopleImporter($import, ['id' => 'id', 'name' => 'name'], []); - - setImporterData($importer, ['id' => $person->id, 'name' => 'Jane Doe']); - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($person->id) - ->and($record->exists)->toBeTrue(); - }); -}); - -describe('ID-Based Opportunity Import', function (): void { - it('resolves existing opportunity when valid ID provided', function (): void { - $opportunity = Opportunity::factory()->create(['team_id' => $this->team->id, 'name' => 'Old Deal']); - - $import = createTestImportRecord($this->user, $this->team, OpportunityImporter::class); - $importer = new OpportunityImporter($import, ['id' => 'id', 'name' => 'name'], []); - - setImporterData($importer, ['id' => $opportunity->id, 'name' => 'Updated Deal']); - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($opportunity->id) - ->and($record->exists)->toBeTrue(); - }); -}); - -describe('ID-Based Task Import', function (): void { - it('resolves existing task when valid ID provided', function (): void { - $task = Task::factory()->create(['team_id' => $this->team->id, 'title' => 'Old Task']); - - $import = createTestImportRecord($this->user, $this->team, TaskImporter::class); - $importer = new TaskImporter($import, ['id' => 'id', 'title' => 'title'], []); - - setImporterData($importer, ['id' => $task->id, 'title' => 'Updated Task']); - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($task->id) - ->and($record->exists)->toBeTrue(); - }); -}); - -describe('ID-Based Note Import', function (): void { - it('resolves existing note when valid ID provided', function (): void { - $note = Note::factory()->create(['team_id' => $this->team->id, 'title' => 'Old Note']); - - $import = createTestImportRecord($this->user, $this->team, NoteImporter::class); - $importer = new NoteImporter($import, ['id' => 'id', 'title' => 'title'], []); - - setImporterData($importer, ['id' => $note->id, 'title' => 'Updated Note']); - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($note->id) - ->and($record->exists)->toBeTrue(); - }); -}); - -describe('ID Column Validation', function (): void { - it('validates ULID format', function (): void { - $import = createTestImportRecord($this->user, $this->team, CompanyImporter::class); - $importer = new CompanyImporter($import, ['id' => 'id', 'name' => 'name'], []); - - // Invalid ULID should throw exception - setImporterData($importer, ['id' => 'invalid-ulid', 'name' => 'Test']); - - expect(fn () => $importer->resolveRecord())->toThrow(Exception::class); - }); -}); - -describe('Security & Team Isolation', function (): void { - it('enforces team isolation across all entity types', function (): void { - $otherTeam = Team::factory()->create(); - - $otherCompany = Company::factory()->create(['team_id' => $otherTeam->id]); - $otherPerson = People::factory()->create(['team_id' => $otherTeam->id]); - $otherOpportunity = Opportunity::factory()->create(['team_id' => $otherTeam->id]); - $otherTask = Task::factory()->create(['team_id' => $otherTeam->id]); - $otherNote = Note::factory()->create(['team_id' => $otherTeam->id]); - - $testCases = [ - ['importer' => CompanyImporter::class, 'id' => $otherCompany->id, 'column' => 'name', 'value' => 'Hacked'], - ['importer' => PeopleImporter::class, 'id' => $otherPerson->id, 'column' => 'name', 'value' => 'Hacked'], - ['importer' => OpportunityImporter::class, 'id' => $otherOpportunity->id, 'column' => 'name', 'value' => 'Hacked'], - ['importer' => TaskImporter::class, 'id' => $otherTask->id, 'column' => 'title', 'value' => 'Hacked'], - ['importer' => NoteImporter::class, 'id' => $otherNote->id, 'column' => 'title', 'value' => 'Hacked'], - ]; - - foreach ($testCases as $case) { - $import = createTestImportRecord(test()->user, test()->team, $case['importer']); - $importer = new $case['importer']($import, ['id' => 'id', $case['column'] => $case['column']], []); - - setImporterData($importer, ['id' => $case['id'], $case['column'] => $case['value']]); - - expect(fn () => $importer->resolveRecord())->toThrow(Exception::class); - } - }); -}); diff --git a/tests/Feature/Filament/App/Imports/ImportCenterTest.php b/tests/Feature/Filament/App/Imports/ImportCenterTest.php deleted file mode 100644 index eb04fccb..00000000 --- a/tests/Feature/Filament/App/Imports/ImportCenterTest.php +++ /dev/null @@ -1,79 +0,0 @@ -team = Team::factory()->create(); - $this->user = User::factory()->create(['current_team_id' => $this->team->id]); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); -}); - -test('import companies page renders successfully', function () { - Livewire::test(ImportCompanies::class) - ->assertSuccessful() - ->assertSee('Import Companies'); -}); - -test('import companies page renders import wizard', function () { - Livewire::test(ImportCompanies::class) - ->assertSeeLivewire(ImportWizard::class); -}); - -test('import people page renders successfully', function () { - Livewire::test(ImportPeople::class) - ->assertSuccessful() - ->assertSee('Import People'); -}); - -test('import opportunities page renders successfully', function () { - Livewire::test(ImportOpportunities::class) - ->assertSuccessful() - ->assertSee('Import Opportunities'); -}); - -test('import tasks page renders successfully', function () { - Livewire::test(ImportTasks::class) - ->assertSuccessful() - ->assertSee('Import Tasks'); -}); - -test('import notes page renders successfully', function () { - Livewire::test(ImportNotes::class) - ->assertSuccessful() - ->assertSee('Import Notes'); -}); - -test('import pages are not registered in navigation', function () { - expect(ImportCompanies::shouldRegisterNavigation())->toBeFalse() - ->and(ImportPeople::shouldRegisterNavigation())->toBeFalse() - ->and(ImportOpportunities::shouldRegisterNavigation())->toBeFalse() - ->and(ImportTasks::shouldRegisterNavigation())->toBeFalse() - ->and(ImportNotes::shouldRegisterNavigation())->toBeFalse(); -}); - -test('import pages have correct entity types', function () { - expect(ImportCompanies::getEntityType())->toBe('companies') - ->and(ImportPeople::getEntityType())->toBe('people') - ->and(ImportOpportunities::getEntityType())->toBe('opportunities') - ->and(ImportTasks::getEntityType())->toBe('tasks') - ->and(ImportNotes::getEntityType())->toBe('notes'); -}); diff --git a/tests/Feature/Filament/App/Imports/ImportPreviewServiceTest.php b/tests/Feature/Filament/App/Imports/ImportPreviewServiceTest.php deleted file mode 100644 index 315a38d1..00000000 --- a/tests/Feature/Filament/App/Imports/ImportPreviewServiceTest.php +++ /dev/null @@ -1,265 +0,0 @@ -team = Team::factory()->create(); - $this->user = User::factory()->create(['current_team_id' => $this->team->id]); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); -}); - -describe('Preview Generation', function () { - test('it generates preview for company import', function () { - // Create test CSV - $csvContent = "name\nAcme Corp\nGlobex Inc\nInitech"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'skip'], - teamId: $this->team->id, - userId: $this->user->id, - ); - - expect($result->totalRows)->toBe(3) - ->and($result->createCount)->toBe(3) - ->and($result->updateCount)->toBe(0) - ->and($result->rows)->toHaveCount(3) - ->and($result->isSampled)->toBeFalse(); - }); - - test('it detects updates for existing companies', function () { - // Create existing company - Company::factory()->create([ - 'name' => 'Acme Corp', - 'team_id' => $this->team->id, - ]); - - // Create test CSV - $csvContent = "name\nAcme Corp\nNew Company"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'update'], - teamId: $this->team->id, - userId: $this->user->id, - ); - - expect($result->createCount)->toBe(1) - ->and($result->updateCount)->toBe(1); - }); - - test('it samples large files and scales counts', function () { - // Create CSV with more than sample size rows - $rows = ['name']; - for ($i = 1; $i <= 1500; $i++) { - $rows[] = "Company $i"; - } - $csvContent = implode("\n", $rows); - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'skip'], - teamId: $this->team->id, - userId: $this->user->id, - sampleSize: 1000, - ); - - expect($result->totalRows)->toBe(1500) - ->and($result->isSampled)->toBeTrue() - ->and($result->sampleSize)->toBe(1000) - ->and($result->rows)->toHaveCount(1000); - }); -}); - -describe('Value Corrections', function () { - test('it applies value corrections to preview data', function () { - // Create test CSV with values to correct - $csvContent = "name,status\nAcme Corp,active\nGlobex Inc,inactive"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - // Apply corrections: 'active' -> 'Active', 'inactive' -> 'Inactive' - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'skip'], - teamId: $this->team->id, - userId: $this->user->id, - valueCorrections: [ - 'name' => ['Acme Corp' => 'ACME Corporation'], - ], - ); - - // The correction should be applied - expect($result->rows[0]['name'])->toBe('ACME Corporation') - ->and($result->rows[1]['name'])->toBe('Globex Inc'); - }); -}); - -describe('Row Metadata', function () { - test('it includes row index in preview data', function () { - $csvContent = "name\nFirst\nSecond\nThird"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'skip'], - teamId: $this->team->id, - userId: $this->user->id, - ); - - expect($result->rows[0]['_row_index'])->toBe(1) - ->and($result->rows[1]['_row_index'])->toBe(2) - ->and($result->rows[2]['_row_index'])->toBe(3); - }); - - test('it marks new records correctly', function () { - $csvContent = "name\nNew Company"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'skip'], - teamId: $this->team->id, - userId: $this->user->id, - ); - - expect($result->rows[0]['_is_new'])->toBeTrue() - ->and($result->rows[0]['_update_method'])->toBeNull() - ->and($result->rows[0]['_record_id'])->toBeNull(); - }); - - test('it marks existing records with update method', function () { - $company = Company::factory()->create([ - 'name' => 'Existing Corp', - 'team_id' => $this->team->id, - ]); - - $csvContent = "name\nExisting Corp"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'update'], - teamId: $this->team->id, - userId: $this->user->id, - ); - - expect($result->rows[0]['_is_new'])->toBeFalse() - ->and($result->rows[0]['_update_method'])->toBe('attribute') - ->and($result->rows[0]['_record_id'])->toBe($company->id); - }); -}); - -describe('Company Match Enrichment', function () { - test('it enriches people import with company match data', function () { - // Create a company to match against - Company::factory()->create([ - 'name' => 'Acme Corp', - 'team_id' => $this->team->id, - ]); - - $csvContent = "first_name,last_name,company_name\nJohn,Doe,Acme Corp"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: PeopleImporter::class, - csvPath: $csvPath, - columnMap: [ - 'first_name' => 'first_name', - 'last_name' => 'last_name', - 'company_name' => 'company_name', - ], - options: ['duplicate_handling' => 'skip'], - teamId: $this->team->id, - userId: $this->user->id, - ); - - // Should have company match metadata - expect($result->rows[0])->toHaveKeys([ - '_company_name', - '_company_match_type', - '_company_match_count', - '_company_id', - ]); - }); - - test('it does not enrich company import with company match data', function () { - $csvContent = "name\nSome Company"; - $csvPath = Storage::disk('local')->path('test-import.csv'); - file_put_contents($csvPath, $csvContent); - - $service = app(ImportPreviewService::class); - - $result = $service->preview( - importerClass: CompanyImporter::class, - csvPath: $csvPath, - columnMap: ['name' => 'name'], - options: ['duplicate_handling' => 'skip'], - teamId: $this->team->id, - userId: $this->user->id, - ); - - // Company import should not have company match metadata - expect($result->rows[0])->not->toHaveKey('_company_name') - ->and($result->rows[0])->not->toHaveKey('_company_match_type'); - }); -}); diff --git a/tests/Feature/Filament/App/Imports/ImportSecurityTest.php b/tests/Feature/Filament/App/Imports/ImportSecurityTest.php deleted file mode 100644 index 96c91e80..00000000 --- a/tests/Feature/Filament/App/Imports/ImportSecurityTest.php +++ /dev/null @@ -1,320 +0,0 @@ - $user->id, - 'team_id' => $team->id, - 'importer' => $importer, - 'file_name' => 'test.csv', - 'file_path' => '/tmp/test.csv', - 'total_rows' => 10, - ]); -} - -/** - * Security tests for import tenant isolation. - * - * These tests ensure that import operations are properly scoped to the - * current team and cannot access or modify data from other teams. - */ -describe('Import Security - Team Isolation', function (): void { - beforeEach(function (): void { - $this->user = User::factory()->withPersonalTeam()->create(); - $this->team = $this->user->currentTeam; - $this->actingAs($this->user); - Filament::setTenant($this->team); - - // Create another team for cross-team testing - $this->otherUser = User::factory()->withPersonalTeam()->create(); - $this->otherTeam = $this->otherUser->currentTeam; - }); - - describe('CompanyImporter team isolation', function (): void { - it('creates companies in current team only', function (): void { - $import = createTestImport($this->user, $this->team, CompanyImporter::class); - - $importer = new CompanyImporter($import, ['name' => 'New Company'], []); - $record = $importer->resolveRecord(); - - expect($record)->toBeInstanceOf(Company::class) - ->and($record->exists)->toBeFalse(); - - // Simulate filling the record as the importer does - $record->name = 'New Company'; - $record->team_id = $import->team_id; - $record->save(); - - expect($record->team_id)->toBe($this->team->id); - }); - - it('resolves duplicates only from current team', function (): void { - // Create company in other team with same name - Company::factory()->for($this->otherTeam)->create(['name' => 'Duplicate Test']); - - $import = createTestImport($this->user, $this->team, CompanyImporter::class); - - $importer = new CompanyImporter($import, ['name' => 'Duplicate Test'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - $record = $importer->resolveRecord(); - - // Should return new record since no duplicate exists in current team - expect($record->exists)->toBeFalse(); - }); - - it('finds existing company in same team for duplicate handling', function (): void { - // Create company in current team - $existing = Company::factory()->for($this->team)->create(['name' => 'My Company']); - - $import = createTestImport($this->user, $this->team, CompanyImporter::class); - - // Create importer and invoke it with the data (simulating import process) - $importer = new CompanyImporter($import, ['name' => 'name'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - // Use reflection to set the data property directly (as __invoke would do) - $reflection = new ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, ['name' => 'My Company']); - - $record = $importer->resolveRecord(); - - // Should return existing record from same team - expect($record->exists)->toBeTrue() - ->and($record->id)->toBe($existing->id); - }); - }); - - describe('PeopleImporter team isolation', function (): void { - it('creates people in current team only', function (): void { - $import = createTestImport($this->user, $this->team, PeopleImporter::class); - - $importer = new PeopleImporter($import, ['name' => 'name'], []); - - // Use reflection to set the data and originalData properties - $reflection = new ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, ['name' => 'New Person']); - $originalDataProperty = $reflection->getProperty('originalData'); - $originalDataProperty->setValue($importer, ['name' => 'New Person']); - - $record = $importer->resolveRecord(); - - expect($record)->toBeInstanceOf(People::class) - ->and($record->exists)->toBeFalse(); - }); - }); - - describe('OpportunityImporter team isolation', function (): void { - it('creates opportunities in current team only', function (): void { - $import = createTestImport($this->user, $this->team, OpportunityImporter::class); - - $importer = new OpportunityImporter($import, ['name' => 'New Opportunity'], []); - $record = $importer->resolveRecord(); - - expect($record)->toBeInstanceOf(Opportunity::class) - ->and($record->exists)->toBeFalse(); - }); - - it('resolves duplicates only from current team', function (): void { - // Create opportunity in other team with same name - Opportunity::factory()->for($this->otherTeam)->create(['name' => 'Big Deal']); - - $import = createTestImport($this->user, $this->team, OpportunityImporter::class); - - $importer = new OpportunityImporter($import, ['name' => 'Big Deal'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - $record = $importer->resolveRecord(); - - // Should return new record since no duplicate exists in current team - expect($record->exists)->toBeFalse(); - }); - - it('finds existing opportunity in same team for duplicate handling', function (): void { - // Create opportunity in current team - $existing = Opportunity::factory()->for($this->team)->create(['name' => 'My Deal']); - - $import = createTestImport($this->user, $this->team, OpportunityImporter::class); - - $importer = new OpportunityImporter($import, ['name' => 'name'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - // Use reflection to set the data property directly - $reflection = new ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, ['name' => 'My Deal']); - - $record = $importer->resolveRecord(); - - // Should return existing record from same team - expect($record->exists)->toBeTrue() - ->and($record->id)->toBe($existing->id); - }); - }); - - describe('TaskImporter team isolation', function (): void { - it('creates tasks in current team only', function (): void { - $import = createTestImport($this->user, $this->team, TaskImporter::class); - - $importer = new TaskImporter($import, ['title' => 'New Task'], []); - $record = $importer->resolveRecord(); - - expect($record)->toBeInstanceOf(Task::class) - ->and($record->exists)->toBeFalse(); - }); - - it('resolves duplicates only from current team', function (): void { - // Create task in other team with same title - Task::factory()->for($this->otherTeam)->create(['title' => 'Follow Up']); - - $import = createTestImport($this->user, $this->team, TaskImporter::class); - - $importer = new TaskImporter($import, ['title' => 'Follow Up'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - $record = $importer->resolveRecord(); - - // Should return new record since no duplicate exists in current team - expect($record->exists)->toBeFalse(); - }); - - it('finds existing task in same team for duplicate handling', function (): void { - // Create task in current team - $existing = Task::factory()->for($this->team)->create(['title' => 'My Task']); - - $import = createTestImport($this->user, $this->team, TaskImporter::class); - - $importer = new TaskImporter($import, ['title' => 'title'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - // Use reflection to set the data property directly - $reflection = new ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, ['title' => 'My Task']); - - $record = $importer->resolveRecord(); - - // Should return existing record from same team - expect($record->exists)->toBeTrue() - ->and($record->id)->toBe($existing->id); - }); - }); - - describe('NoteImporter team isolation', function (): void { - it('creates notes in current team only', function (): void { - $import = createTestImport($this->user, $this->team, NoteImporter::class); - - $importer = new NoteImporter($import, ['title' => 'New Note'], []); - $record = $importer->resolveRecord(); - - expect($record)->toBeInstanceOf(Note::class) - ->and($record->exists)->toBeFalse(); - }); - - it('resolves duplicates only from current team', function (): void { - // Create note in other team with same title - Note::factory()->for($this->otherTeam)->create(['title' => 'Meeting Notes']); - - $import = createTestImport($this->user, $this->team, NoteImporter::class); - - $importer = new NoteImporter($import, ['title' => 'Meeting Notes'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - $record = $importer->resolveRecord(); - - // Should return new record since no duplicate exists in current team - expect($record->exists)->toBeFalse(); - }); - - it('finds existing note in same team for duplicate handling', function (): void { - // Create note in current team - $existing = Note::factory()->for($this->team)->create(['title' => 'My Note']); - - $import = createTestImport($this->user, $this->team, NoteImporter::class); - - $importer = new NoteImporter($import, ['title' => 'title'], [ - 'duplicate_handling' => DuplicateHandlingStrategy::SKIP, - ]); - - // Use reflection to set the data property directly - $reflection = new ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, ['title' => 'My Note']); - - $record = $importer->resolveRecord(); - - // Should return existing record from same team - expect($record->exists)->toBeTrue() - ->and($record->id)->toBe($existing->id); - }); - }); - - describe('Import model team isolation', function (): void { - it('imports are properly scoped by team_id', function (): void { - // Create import in other team - $otherImport = createTestImport($this->otherUser, $this->otherTeam); - - // Create import in my team - $myImport = createTestImport($this->user, $this->team); - - // Verify imports have correct team_id - expect($myImport->team_id)->toBe($this->team->id) - ->and($otherImport->team_id)->toBe($this->otherTeam->id); - - // Query imports for current team should only return my imports - $teamImports = Import::where('team_id', $this->team->id)->get(); - - expect($teamImports)->toHaveCount(1) - ->and($teamImports->first()->id)->toBe($myImport->id); - - // Query imports for other team should only return their imports - $otherTeamImports = Import::where('team_id', $this->otherTeam->id)->get(); - - expect($otherTeamImports)->toHaveCount(1) - ->and($otherTeamImports->first()->id)->toBe($otherImport->id); - }); - - it('cannot access imports from other teams', function (): void { - // Create import in other team - $otherImport = createTestImport($this->otherUser, $this->otherTeam); - - // Try to query without team filter - should see all - $allImports = Import::all(); - expect($allImports)->toHaveCount(1); - - // With team filter - should not see other team's import - $myTeamImports = Import::where('team_id', $this->team->id)->get(); - expect($myTeamImports)->toHaveCount(0); - }); - }); -}); diff --git a/tests/Feature/Filament/App/Imports/ImportWizardUITest.php b/tests/Feature/Filament/App/Imports/ImportWizardUITest.php index 7b24c80a..e2c8419d 100644 --- a/tests/Feature/Filament/App/Imports/ImportWizardUITest.php +++ b/tests/Feature/Filament/App/Imports/ImportWizardUITest.php @@ -3,541 +3,165 @@ declare(strict_types=1); use App\Models\Company; -use App\Models\CustomField; -use App\Models\CustomFieldSection; -use App\Models\Team; -use App\Models\User; -use Filament\Facades\Filament; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; -use Relaticle\CustomFields\Services\TenantContextService; +use Relaticle\ImportWizard\Filament\Pages\ImportCompanies; +use Relaticle\ImportWizard\Filament\Pages\ImportNotes; +use Relaticle\ImportWizard\Filament\Pages\ImportOpportunities; +use Relaticle\ImportWizard\Filament\Pages\ImportPeople; +use Relaticle\ImportWizard\Filament\Pages\ImportTasks; use Relaticle\ImportWizard\Jobs\StreamingImportCsv; use Relaticle\ImportWizard\Livewire\ImportWizard; -/** - * Comprehensive UI tests for ImportWizard Livewire component. - * - * Tests the complete import workflow through UI interactions: - * 1. Upload Step - File upload and validation - * 2. Map Columns Step - Column mapping and preview - * 3. Review Values Step - Value analysis, corrections, validation - * 4. Preview Step - Import summary and execution - */ beforeEach(function (): void { Storage::fake('local'); Queue::fake(); + ['user' => $this->user, 'team' => $this->team] = setupImportTestContext(); + createEmailsCustomField($this->team); +}); - $this->team = Team::factory()->create(); - $this->user = User::factory()->withPersonalTeam()->create(); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); - TenantContextService::setTenantId($this->team->id); - - // Create emails custom field for People imports - $section = CustomFieldSection::withoutGlobalScopes()->create([ - 'code' => 'contact_information', - 'name' => 'Contact Information', - 'type' => 'section', - 'entity_type' => 'people', - 'tenant_id' => $this->team->id, - 'sort_order' => 1, - ]); - - CustomField::withoutGlobalScopes()->create([ - 'custom_field_section_id' => $section->id, - 'code' => 'emails', - 'name' => 'Emails', - 'type' => 'email', - 'entity_type' => 'people', - 'tenant_id' => $this->team->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, +describe('Import Pages', function (): void { + it('renders :dataset import page correctly', function (string $pageClass, string $expectedTitle, string $expectedType): void { + Livewire::test($pageClass) + ->assertSuccessful() + ->assertSee($expectedTitle); + + expect($pageClass::shouldRegisterNavigation())->toBeFalse() + ->and($pageClass::getEntityType())->toBe($expectedType); + })->with([ + 'companies' => [ImportCompanies::class, 'Import Companies', 'companies'], + 'people' => [ImportPeople::class, 'Import People', 'people'], + 'opportunities' => [ImportOpportunities::class, 'Import Opportunities', 'opportunities'], + 'tasks' => [ImportTasks::class, 'Import Tasks', 'tasks'], + 'notes' => [ImportNotes::class, 'Import Notes', 'notes'], ]); }); -/** - * Helper: Create a CSV file with given content. - */ -function createTestCsv(string $content, string $filename = 'test.csv'): UploadedFile -{ - return UploadedFile::fake()->createWithContent($filename, $content); -} - -/** - * Helper: Get return URL for given entity type. - */ -function getReturnUrl(Team $team, string $entityType): string -{ - return match ($entityType) { - 'companies' => route('filament.app.resources.companies.index', ['tenant' => $team]), - 'people' => route('filament.app.resources.people.index', ['tenant' => $team]), - 'opportunities' => route('filament.app.resources.opportunities.index', ['tenant' => $team]), - 'tasks' => route('filament.app.resources.tasks.index', ['tenant' => $team]), - 'notes' => route('filament.app.resources.notes.index', ['tenant' => $team]), - default => '/', - }; -} - -describe('Upload Step - File Upload and Validation', function (): void { - it('uploads valid CSV file and extracts headers', function (): void { - $csv = "name,email,phone\nAcme Corp,contact@acme.com,555-1234\nTech Inc,info@tech.com,555-5678"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) +describe('Upload Step', function (): void { + it('uploads valid CSV and extracts headers', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name,email,phone\nAcme Corp,a@a.com,555-1234\nTech Inc,b@b.com,555-5678")) ->assertSet('currentStep', ImportWizard::STEP_UPLOAD) ->assertSet('csvHeaders', ['name', 'email', 'phone']) ->assertSet('rowCount', 2); }); - it('shows error for file with too many rows', function (): void { - // Generate CSV with 10,001 rows (exceeds limit) - $csv = "name\n"; - for ($i = 1; $i <= 10001; $i++) { - $csv .= "Company {$i}\n"; - } - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertHasErrors('uploadedFile'); - }); - - it('shows error for invalid file type', function (): void { - $file = UploadedFile::fake()->create('test.pdf', 100); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertHasErrors('uploadedFile'); - }); - - it('shows error for CSV with duplicate column names', function (): void { - $csv = "name,email,name\nAcme Corp,contact@acme.com,Duplicate Name"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('currentStep', ImportWizard::STEP_UPLOAD); - - // Should detect duplicate headers (implementation checks for this) - // The exact error display may vary, but duplicate headers prevent progression - }); + it('rejects :dataset', function (UploadedFile $file): void { + wizardTest($this->team)->set('uploadedFile', $file)->assertHasErrors('uploadedFile'); + })->with([ + 'too many rows' => [fn () => createTestCsv("name\n".implode("\n", array_map(fn ($i) => "Company {$i}", range(1, 10001))))], + 'invalid file type' => [fn () => UploadedFile::fake()->create('test.pdf', 100)], + ]); - it('allows removing uploaded file and restarting', function (): void { - $csv = "name,email\nAcme Corp,contact@acme.com"; - $file = createTestCsv($csv); + it('handles :dataset CSV edge case', function (string $scenario, string $csv, array $expected): void { + $component = wizardTest($this->team)->set('uploadedFile', createTestCsv($csv)); - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('csvHeaders', ['name', 'email']) - ->call('resetWizard') - ->assertSet('csvHeaders', []) - ->assertSet('rowCount', 0) - ->assertSet('persistedFilePath', null); - }); - - it('advances to Map step when file is valid', function (): void { - $csv = "name,email,phone\nAcme Corp,contact@acme.com,555-1234"; - $file = createTestCsv($csv); + if (isset($expected['rowCount'])) { + $component->assertSet('rowCount', $expected['rowCount']); + } + if (isset($expected['csvHeaders'])) { + $component->assertSet('csvHeaders', $expected['csvHeaders']); + } + if (isset($expected['step'])) { + $component->call('nextStep')->assertSet('currentStep', $expected['step']); + } + if (isset($expected['hasErrors'])) { + $component->assertHasErrors('uploadedFile'); + } + })->with([ + 'duplicate columns rejected' => ['duplicate', "name,email,name\nAcme,a@a.com,Dup", ['hasErrors' => true, 'rowCount' => 0]], + 'empty with headers' => ['empty', 'name,email,phone', ['rowCount' => 0, 'csvHeaders' => ['name', 'email', 'phone']]], + 'special characters' => ['special', "name\n\"Company, Inc.\"\n\"Company \"\"Quoted\"\" Name\"", ['rowCount' => 2, 'step' => ImportWizard::STEP_MAP]], + 'unicode' => ['unicode', "name\n日本会社\nДоверитель\nشركة", ['rowCount' => 3, 'step' => ImportWizard::STEP_MAP]], + 'blank values' => ['blank', "name,phone\nAcme Corp,\nTech Inc,", ['rowCount' => 2]], + ]); - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('currentStep', ImportWizard::STEP_UPLOAD) + it('handles large CSV efficiently', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name\n".implode("\n", array_map(fn ($i) => "Company {$i}", range(1, 1000))))) + ->assertSet('rowCount', 1000) ->call('nextStep') ->assertSet('currentStep', ImportWizard::STEP_MAP); }); - - it('handles empty CSV with only headers', function (): void { - $csv = 'name,email,phone'; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('rowCount', 0) - ->assertSet('csvHeaders', ['name', 'email', 'phone']); - }); }); -describe('Column Mapping Step - Field Mapping and Validation', function (): void { - it('auto-maps CSV columns to fields using guesses', function (): void { - $csv = "name,email,phone\nAcme Corp,contact@acme.com,555-1234"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP) - ->assertSet('columnMap.name', 'name'); // Should auto-map 'name' column - }); - - it('allows manual column mapping via property update', function (): void { - $csv = "company_name,contact_email\nAcme Corp,contact@acme.com"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP) - ->set('columnMap.name', 'company_name') // Manually map - ->assertSet('columnMap.name', 'company_name'); - }); - - it('allows unmapping a column', function (): void { - $csv = "name,email\nAcme Corp,contact@acme.com"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) +describe('Column Mapping Step', function (): void { + it('handles :dataset mapping operation', function (string $scenario, string $csv, array $mappingOps, string $expected): void { + $component = wizardTest($this->team) + ->set('uploadedFile', createTestCsv($csv)) ->call('nextStep') - ->set('columnMap.name', 'name') - ->set('columnMap.name', '') // Unmap - ->assertSet('columnMap.name', ''); - }); - - it('blocks advancement when required fields are not mapped', function (): void { - $csv = "email,phone\ncontact@acme.com,555-1234"; // Missing 'name' column - $file = createTestCsv($csv); + ->assertSet('currentStep', ImportWizard::STEP_MAP); - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP); // Should stay at MAP step - }); + foreach ($mappingOps as $op) { + $component->set($op['field'], $op['value']); + } - it('shows modal warning when unique identifier is not mapped', function (): void { - $csv = "email,phone\ncontact@acme.com,555-1234"; - $file = createTestCsv($csv); + $component->assertSet($mappingOps[array_key_last($mappingOps)]['field'], $expected); + })->with([ + 'auto-maps matching' => ['auto-map', "name,email\nAcme,a@a.com", [['field' => 'columnMap.name', 'value' => 'name']], 'name'], + 'manual mapping' => ['manual', "company_name,contact_email\nAcme,a@a.com", [['field' => 'columnMap.name', 'value' => 'company_name']], 'company_name'], + 'unmapping' => ['unmap', "name,email\nAcme,a@a.com", [['field' => 'columnMap.name', 'value' => 'name'], ['field' => 'columnMap.name', 'value' => '']], ''], + ]); - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) + it('blocks advancement when required fields not mapped', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("email,phone\na@a.com,555-1234")) ->call('nextStep') - ->set('columnMap.account_owner_email', 'email') // Map required field ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP); // Blocked by unique identifier warning + ->assertSet('currentStep', ImportWizard::STEP_MAP); }); - it('advances to Review after confirming unique identifier warning', function (): void { - $csv = "email,phone\ncontact@acme.com,555-1234"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) + it('handles unique identifier warning flow', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("email\na@a.com")) ->call('nextStep') ->set('columnMap.account_owner_email', 'email') ->call('nextStep') - ->callAction('proceedWithoutUniqueIdentifiers') // Confirm warning - ->assertSet('currentStep', ImportWizard::STEP_REVIEW); // Should advance - }); - - it('navigates back to Upload step from Map step', function (): void { - $csv = "name,email\nAcme Corp,contact@acme.com"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') ->assertSet('currentStep', ImportWizard::STEP_MAP) - ->call('previousStep') - ->assertSet('currentStep', ImportWizard::STEP_UPLOAD); - }); - - it('preserves column mappings when navigating back and forward', function (): void { - $csv = "name,email\nAcme Corp,contact@acme.com"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->call('previousStep') - ->call('nextStep') - ->assertSet('columnMap.name', 'name'); // Mapping should be preserved - }); -}); - -describe('Review Values Step - Value Analysis and Corrections', function (): void { - it('analyzes columns and shows unique values with counts', function (): void { - $csv = "name\nAcme Corp\nTech Inc\nAcme Corp"; // Duplicate 'Acme Corp' - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') ->callAction('proceedWithoutUniqueIdentifiers') ->assertSet('currentStep', ImportWizard::STEP_REVIEW); - // Value analysis happens automatically on step entry - // columnAnalysesData should contain unique value counts }); +}); - it('detects and displays validation errors for invalid values', function (): void { - // Note: This test requires actual validation errors - // The exact implementation depends on field validators - $csv = "name,account_owner_email\nAcme Corp,invalid-email"; // Invalid email - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->set('columnMap.account_owner_email', 'account_owner_email') - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW); - // Should detect email validation error - }); - - it('allows correcting an invalid value', function (): void { - $csv = "name\nAcme Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('correctValue', 'name', 'Acme Corp', 'Acme Corporation'); - // Correction should be stored in valueCorrections property - }); - - it('allows skipping a problematic value', function (): void { - $csv = "name\nAcme Corp\nBad Company"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) +describe('Review and Preview Steps', function (): void { + it('allows value corrections and skipping', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name\nAcme Corp\nBad Company")) ->call('nextStep') ->set('columnMap.name', 'name') ->callAction('proceedWithoutUniqueIdentifiers') ->assertSet('currentStep', ImportWizard::STEP_REVIEW) + ->call('correctValue', 'name', 'Acme Corp', 'Acme Corporation') ->call('skipValue', 'name', 'Bad Company'); - // Value should be marked as skipped (empty string correction) - }); - - it('allows unskipping a previously skipped value via toggle', function (): void { - $csv = "name\nAcme Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->call('skipValue', 'name', 'Acme Corp') // Skip - ->call('skipValue', 'name', 'Acme Corp'); // Unskip (toggle) - // skipValue is a toggle - calling it twice unskips the value - }); - - it('blocks advancement when validation errors exist', function (): void { - // This test assumes there are validation errors that prevent progression - // The exact behavior depends on the validator implementation - $csv = "name,account_owner_email\nAcme Corp,invalid-email"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->set('columnMap.account_owner_email', 'account_owner_email') - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('nextStep'); - // Should either stay at REVIEW or show modal to proceed with errors }); - it('advances to Preview after confirming to proceed with errors', function (): void { - $csv = "name\nAcme Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('nextStep'); - // If there are errors, use: ->callAction('proceedWithErrors') - // Otherwise should advance directly - }); - - it('supports pagination for loading more values', function (): void { - // Generate CSV with many unique values to test pagination - $csv = "name\n"; - for ($i = 1; $i <= 150; $i++) { - $csv .= "Company {$i}\n"; - } - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) + it('supports pagination for many values', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name\n".implode("\n", array_map(fn ($i) => "Company {$i}", range(1, 150))))) ->call('nextStep') ->set('columnMap.name', 'name') ->callAction('proceedWithoutUniqueIdentifiers') ->assertSet('currentStep', ImportWizard::STEP_REVIEW) ->call('loadMoreValues', 'name'); - // Should load additional values beyond initial page }); - it('navigates back to Map step from Review step', function (): void { - $csv = "name\nAcme Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('previousStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP); - }); -}); - -describe('Preview Step - Import Summary and Execution', function (): void { - it('displays summary statistics for import', function (): void { - $csv = "name\nAcme Corp\nTech Inc"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_PREVIEW); - // previewResultData should contain totalRows, createCount, updateCount - }); - - it('displays sample rows with mapped data', function (): void { - $csv = "name\nAcme Corp\nTech Inc\nStartup LLC"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_PREVIEW); - // previewRows should contain sample rows with mapped values - }); - - it('shows create vs update badges correctly', function (): void { - // Create an existing company + it('reaches preview and shows badges', function (): void { Company::factory()->for($this->team)->create(['name' => 'Existing Corp']); - $csv = "name\nExisting Corp\nNew Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name\nExisting Corp\nNew Corp")) ->call('nextStep') ->set('columnMap.name', 'name') ->callAction('proceedWithoutUniqueIdentifiers') ->call('nextStep') ->assertSet('currentStep', ImportWizard::STEP_PREVIEW); - // Preview should show 1 update (Existing Corp) and 1 create (New Corp) }); - it('dispatches import batch jobs when starting import', function (): void { - Queue::fake(); - - $csv = "name\nAcme Corp\nTech Inc"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) + it('dispatches import jobs on execute', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name\nAcme Corp\nTech Inc")) ->call('nextStep') ->set('columnMap.name', 'name') ->callAction('proceedWithoutUniqueIdentifiers') @@ -547,119 +171,44 @@ function getReturnUrl(Team $team, string $entityType): string Queue::assertPushed(StreamingImportCsv::class); }); - - it('navigates back to Review step from Preview step', function (): void { - $csv = "name\nAcme Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_PREVIEW) - ->call('previousStep') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW); - }); }); -describe('Complete Workflow Tests - Happy Paths', function (): void { - it('completes full company import workflow without errors', function (): void { - Queue::fake(); - - $csv = "name,account_owner_email\nAcme Corp,owner@acme.com\nTech Inc,owner@tech.com"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('currentStep', ImportWizard::STEP_UPLOAD) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP) - ->set('columnMap.name', 'name') - ->set('columnMap.account_owner_email', 'account_owner_email') - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_PREVIEW) - ->call('executeImport'); - - Queue::assertPushed(StreamingImportCsv::class); - }); +describe('Complete Workflow', function (): void { + it('completes full :dataset import', function (string $entityType, string $csv, array $mappings): void { + if ($entityType === 'people') { + Company::factory()->for($this->team)->create(['name' => 'Acme Corp']); + } - it('completes full people import workflow with email matching', function (): void { - Queue::fake(); + $component = wizardTest($this->team, $entityType) + ->set('uploadedFile', createTestCsv($csv)) + ->call('nextStep'); - // Create a company for association - Company::factory()->for($this->team)->create(['name' => 'Acme Corp']); + foreach ($mappings as $field => $value) { + $component->set("columnMap.{$field}", $value); + } - $csv = "name,company_name,custom_fields_emails\nJohn Doe,Acme Corp,john@acme.com\nJane Smith,Acme Corp,jane@acme.com"; - $file = createTestCsv($csv); + $component->call('nextStep'); + if ($component->get('mountedActions')) { + $component->callMountedAction(); + } - Livewire::test(ImportWizard::class, [ - 'entityType' => 'people', - 'returnUrl' => getReturnUrl($this->team, 'people'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP) - ->set('columnMap.name', 'name') - ->set('columnMap.company_name', 'company_name') - ->set('columnMap.custom_fields_emails', 'custom_fields_emails') - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('nextStep') + $component->call('nextStep') ->assertSet('currentStep', ImportWizard::STEP_PREVIEW) ->call('executeImport'); Queue::assertPushed(StreamingImportCsv::class); - }); + })->with([ + 'company' => ['companies', "name,account_owner_email\nAcme,a@a.com\nTech,b@b.com", ['name' => 'name', 'account_owner_email' => 'account_owner_email']], + 'people' => ['people', "name,company_name,custom_fields_emails\nJohn,Acme Corp,john@a.com", ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails']], + ]); it('completes workflow with value corrections', function (): void { - Queue::fake(); - - $csv = "name\nAcme Corp\nTech Inc"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name\nAcme Corp\nTech Inc")) ->call('nextStep') ->set('columnMap.name', 'name') ->callAction('proceedWithoutUniqueIdentifiers') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('correctValue', 'name', 'Acme Corp', 'Acme Corporation') // Apply correction - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_PREVIEW) - ->call('executeImport'); - - Queue::assertPushed(StreamingImportCsv::class); - }); - - it('completes workflow with unique identifier warning confirmation', function (): void { - Queue::fake(); - - $csv = "account_owner_email\nowner@acme.com"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.account_owner_email', 'account_owner_email') - ->call('nextStep') - ->callAction('proceedWithoutUniqueIdentifiers') // Confirm warning - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) + ->call('correctValue', 'name', 'Acme Corp', 'Acme Corporation') ->call('nextStep') ->assertSet('currentStep', ImportWizard::STEP_PREVIEW) ->call('executeImport'); @@ -668,138 +217,31 @@ function getReturnUrl(Team $team, string $entityType): string }); }); -describe('Navigation and State Management', function (): void { - it('preserves state when navigating back through all steps', function (): void { - $csv = "name,email\nAcme Corp,contact@acme.com"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) +describe('Navigation and State', function (): void { + it('preserves state when navigating and resets on resetWizard', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name,email\nAcme,a@a.com")) ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP) ->set('columnMap.name', 'name') - ->set('columnMap.account_owner_email', 'email') // Map required field to avoid warning + ->set('columnMap.account_owner_email', 'email') ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW) - ->call('previousStep') // Back to MAP + ->callMountedAction() + ->call('previousStep') ->assertSet('currentStep', ImportWizard::STEP_MAP) - ->assertSet('columnMap.name', 'name') // Mapping preserved - ->call('previousStep') // Back to UPLOAD + ->assertSet('columnMap.name', 'name') + ->call('previousStep') ->assertSet('currentStep', ImportWizard::STEP_UPLOAD) - ->assertSet('csvHeaders', ['name', 'email']); // Headers preserved - }); - - it('resets all wizard state when reset is called', function (): void { - $csv = "name\nAcme Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') + ->assertSet('csvHeaders', ['name', 'email']) ->call('resetWizard') ->assertSet('currentStep', ImportWizard::STEP_UPLOAD) ->assertSet('csvHeaders', []) ->assertSet('columnMap', []); }); - it('prevents skipping steps via direct navigation', function (): void { - $csv = "name\nAcme Corp"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('currentStep', ImportWizard::STEP_UPLOAD) - ->call('goToStep', ImportWizard::STEP_PREVIEW) // Try to skip to preview - ->assertSet('currentStep', ImportWizard::STEP_UPLOAD); // Should stay at UPLOAD - }); -}); - -describe('Edge Cases', function (): void { - it('handles CSV with special characters in values', function (): void { - $csv = "name\n\"Company, Inc.\"\n\"Company \"\"Quoted\"\" Name\""; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('rowCount', 2) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP); - }); - - it('handles CSV with all blank values in a column', function (): void { - $csv = "name,phone\nAcme Corp,\nTech Inc,"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->set('columnMap.name', 'name') - ->callAction('proceedWithoutUniqueIdentifiers') - ->assertSet('currentStep', ImportWizard::STEP_REVIEW); - // Should handle blank phone values gracefully - }); - - it('handles mapping all columns as do not import', function (): void { - $csv = "column1,column2,column3\nValue1,Value2,Value3"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP) - // All columns unmapped (do not import) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP); // Should block due to missing required fields - }); - - it('handles large CSV file efficiently', function (): void { - // Generate CSV with 1,000 rows - $csv = "name\n"; - for ($i = 1; $i <= 1000; $i++) { - $csv .= "Company {$i}\n"; - } - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('rowCount', 1000) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP); - }); - - it('handles CSV with unicode characters', function (): void { - $csv = "name\n日本会社\nДоверитель\nشركة"; - $file = createTestCsv($csv); - - Livewire::test(ImportWizard::class, [ - 'entityType' => 'companies', - 'returnUrl' => getReturnUrl($this->team, 'companies'), - ]) - ->set('uploadedFile', $file) - ->assertSet('rowCount', 3) - ->call('nextStep') - ->assertSet('currentStep', ImportWizard::STEP_MAP); + it('prevents skipping steps', function (): void { + wizardTest($this->team) + ->set('uploadedFile', createTestCsv("name\nAcme")) + ->call('goToStep', ImportWizard::STEP_PREVIEW) + ->assertSet('currentStep', ImportWizard::STEP_UPLOAD); }); }); diff --git a/tests/Feature/Filament/App/Imports/ImporterSecurityTest.php b/tests/Feature/Filament/App/Imports/ImporterSecurityTest.php new file mode 100644 index 00000000..609fa223 --- /dev/null +++ b/tests/Feature/Filament/App/Imports/ImporterSecurityTest.php @@ -0,0 +1,133 @@ + $this->user, 'team' => $this->team] = setupImportTestContext(); + $this->otherTeam = Team::factory()->create(); +}); + +describe('ID-Based Record Resolution', function (): void { + it('resolves existing :dataset when valid ID provided', function ( + string $importerClass, + string $modelClass, + string $nameField, + array $factoryData + ): void { + if ($modelClass === People::class) { + $factoryData['company_id'] = Company::factory()->for($this->team)->create()->id; + } + + $record = $modelClass::factory()->create(['team_id' => $this->team->id, ...$factoryData]); + $importer = createImporter($importerClass, $this->user, $this->team, ['id' => 'id', $nameField => $nameField], ['id' => $record->id, $nameField => 'Updated'], []); + + expect($importer->resolveRecord())->id->toBe($record->id)->exists->toBeTrue(); + })->with([ + 'company' => [CompanyImporter::class, Company::class, 'name', ['name' => 'Original']], + 'opportunity' => [OpportunityImporter::class, Opportunity::class, 'name', ['name' => 'Original']], + 'task' => [TaskImporter::class, Task::class, 'title', ['title' => 'Original']], + 'note' => [NoteImporter::class, Note::class, 'title', ['title' => 'Original']], + 'person' => [PeopleImporter::class, People::class, 'name', ['name' => 'John Doe']], + ]); +}); + +describe('ID-Based Import Behaviors', function (): void { + it(':dataset ID handling', function (string $scenario, string $id, string $expectation): void { + if ($scenario === 'precedence') { + $company = Company::factory()->for($this->team)->create(['name' => 'Original']); + $id = $company->id; + } + + $importer = createImporter( + CompanyImporter::class, + $this->user, + $this->team, + ['id' => 'id', 'name' => 'name'], + ['id' => $id, 'name' => 'Test'], + $scenario === 'precedence' ? ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] : [] + ); + + match ($expectation) { + 'throws' => expect(fn () => $importer->resolveRecord())->toThrow(Exception::class), + 'creates_new' => expect($importer->resolveRecord()->exists)->toBeFalse(), + 'uses_existing' => expect($importer->resolveRecord())->id->toBe($id)->and(Company::count())->toBe(1), + }; + })->with([ + 'fails when ID not found' => ['not found', (string) Str::ulid(), 'throws'], + 'validates ULID format' => ['invalid', 'invalid-ulid', 'throws'], + 'creates new when ID blank' => ['blank', '', 'creates_new'], + 'ID takes precedence over CREATE_NEW' => ['precedence', '', 'uses_existing'], + ]); +}); + +describe('Team Isolation', function (): void { + it('prevents cross-team :dataset ID access', function (string $importerClass, string $modelClass, string $nameField): void { + $otherRecord = $modelClass::factory()->create(['team_id' => $this->otherTeam->id]); + $importer = createImporter($importerClass, $this->user, $this->team, ['id' => 'id', $nameField => $nameField], ['id' => $otherRecord->id, $nameField => 'Hacked'], []); + + expect(fn () => $importer->resolveRecord())->toThrow(Exception::class); + })->with([ + 'company' => [CompanyImporter::class, Company::class, 'name'], + 'people' => [PeopleImporter::class, People::class, 'name'], + 'opportunity' => [OpportunityImporter::class, Opportunity::class, 'name'], + 'task' => [TaskImporter::class, Task::class, 'title'], + 'note' => [NoteImporter::class, Note::class, 'title'], + ]); + + it('creates :dataset records scoped to current team', function (string $importerClass, string $modelClass, array $columnMap): void { + $import = createImportRecord($this->user, $this->team, $importerClass); + $importer = new $importerClass($import, $columnMap, []); + + expect($importer->resolveRecord())->toBeInstanceOf($modelClass)->exists->toBeFalse(); + })->with([ + 'companies' => [CompanyImporter::class, Company::class, ['name' => 'New Company']], + 'opportunities' => [OpportunityImporter::class, Opportunity::class, ['name' => 'New Opportunity']], + 'tasks' => [TaskImporter::class, Task::class, ['title' => 'New Task']], + 'notes' => [NoteImporter::class, Note::class, ['title' => 'New Note']], + 'people' => [PeopleImporter::class, People::class, ['name' => 'New Person']], + ]); + + it('resolves :dataset duplicates only from current team', function (string $importerClass, string $modelClass, string $nameField, string $testValue): void { + $modelClass::factory()->for($this->otherTeam)->create([$nameField => $testValue]); + + $import = createImportRecord($this->user, $this->team, $importerClass); + $importer = new $importerClass($import, [$nameField => $testValue], ['duplicate_handling' => DuplicateHandlingStrategy::SKIP]); + + expect($importer->resolveRecord()->exists)->toBeFalse(); + })->with([ + 'companies' => [CompanyImporter::class, Company::class, 'name', 'Duplicate Test'], + 'opportunities' => [OpportunityImporter::class, Opportunity::class, 'name', 'Big Deal'], + 'tasks' => [TaskImporter::class, Task::class, 'title', 'Follow Up'], + 'notes' => [NoteImporter::class, Note::class, 'title', 'Meeting Notes'], + ]); +}); + +describe('Import Model Team Isolation', function (): void { + it('scopes imports by team_id and prevents cross-team access', function (): void { + $otherUser = User::factory()->withPersonalTeam()->create(); + $otherImport = createImportRecord($otherUser, $this->otherTeam); + $myImport = createImportRecord($this->user, $this->team); + + expect($myImport->team_id)->toBe($this->team->id) + ->and($otherImport->team_id)->toBe($this->otherTeam->id) + ->and(Import::where('team_id', $this->team->id)->get()) + ->toHaveCount(1) + ->first()->id->toBe($myImport->id); + }); +}); diff --git a/tests/Feature/Filament/App/Imports/NoteImporterTest.php b/tests/Feature/Filament/App/Imports/NoteImporterTest.php deleted file mode 100644 index 34399f80..00000000 --- a/tests/Feature/Filament/App/Imports/NoteImporterTest.php +++ /dev/null @@ -1,149 +0,0 @@ - $user->id, - 'team_id' => $team->id, - 'importer' => NoteImporter::class, - 'file_name' => 'test.csv', - 'file_path' => '/tmp/test.csv', - 'total_rows' => 1, - ]); -} - -function setNoteImporterData(object $importer, array $data): void -{ - $reflection = new \ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, $data); -} - -beforeEach(function () { - Storage::fake('local'); - - $this->team = Team::factory()->create(); - $this->user = User::factory()->create(['current_team_id' => $this->team->id]); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); - TenantContextService::setTenantId($this->team->id); -}); - -test('note importer has correct columns defined', function () { - $columns = NoteImporter::getColumns(); - - $columnNames = collect($columns)->map(fn ($column) => $column->getName())->all(); - - expect($columnNames) - ->toContain('title') - ->toContain('company_name') - ->toContain('person_name') - ->toContain('opportunity_name'); -}); - -test('note importer has required title column', function () { - $columns = NoteImporter::getColumns(); - - $titleColumn = collect($columns)->first(fn ($column) => $column->getName() === 'title'); - - expect($titleColumn)->not->toBeNull() - ->and($titleColumn->isMappingRequired())->toBeTrue(); -}); - -test('note importer has options form with duplicate handling', function () { - $components = NoteImporter::getOptionsFormComponents(); - - $duplicateHandlingComponent = collect($components)->first( - fn ($component) => $component->getName() === 'duplicate_handling' - ); - - expect($duplicateHandlingComponent)->not->toBeNull() - ->and($duplicateHandlingComponent->isRequired())->toBeTrue(); -}); - -test('import action exists on manage notes page', function () { - Livewire::test(ManageNotes::class) - ->assertSuccessful() - ->assertActionExists('import'); -}); - -test('note importer guesses column names correctly', function () { - $columns = NoteImporter::getColumns(); - - $titleColumn = collect($columns)->first(fn ($column) => $column->getName() === 'title'); - $companyColumn = collect($columns)->first(fn ($column) => $column->getName() === 'company_name'); - - expect($titleColumn->getGuesses()) - ->toContain('title') - ->toContain('note_title') - ->toContain('subject'); - - expect($companyColumn->getGuesses()) - ->toContain('company_name') - ->toContain('company') - ->toContain('organization'); -}); - -test('note importer provides example values', function () { - $columns = NoteImporter::getColumns(); - - $titleColumn = collect($columns)->first(fn ($column) => $column->getName() === 'title'); - $companyColumn = collect($columns)->first(fn ($column) => $column->getName() === 'company_name'); - - expect($titleColumn->getExample())->toBe('Meeting Notes - Q1 Review') - ->and($companyColumn->getExample())->toBe('Acme Corporation'); -}); - -test('note importer returns completed notification body', function () { - $import = new Import; - $import->successful_rows = 20; - - $body = NoteImporter::getCompletedNotificationBody($import); - - expect($body)->toContain('20') - ->and($body)->toContain('note') - ->and($body)->toContain('imported'); -}); - -test('note importer includes failed rows in notification', function () { - $import = Import::create([ - 'team_id' => $this->team->id, - 'user_id' => $this->user->id, - 'successful_rows' => 18, - 'total_rows' => 20, - 'processed_rows' => 20, - 'importer' => NoteImporter::class, - 'file_name' => 'test.csv', - 'file_path' => 'imports/test.csv', - ]); - - $import->failedRows()->createMany([ - ['data' => ['title' => 'Failed 1'], 'validation_error' => 'Invalid data'], - ['data' => ['title' => 'Failed 2'], 'validation_error' => 'Invalid data'], - ]); - - $body = NoteImporter::getCompletedNotificationBody($import); - - expect($body)->toContain('18') - ->and($body)->toContain('2') - ->and($body)->toContain('failed'); -}); diff --git a/tests/Feature/Filament/App/Imports/PeopleImporterTest.php b/tests/Feature/Filament/App/Imports/PeopleImporterTest.php index b603ac80..5ceb56e7 100644 --- a/tests/Feature/Filament/App/Imports/PeopleImporterTest.php +++ b/tests/Feature/Filament/App/Imports/PeopleImporterTest.php @@ -2,434 +2,137 @@ declare(strict_types=1); -use App\Enums\CustomFields\CompanyField; use App\Models\Company; -use App\Models\CustomField; -use App\Models\CustomFieldValue; use App\Models\People; use App\Models\Team; -use App\Models\User; -use Filament\Facades\Filament; -use Relaticle\CustomFields\Services\TenantContextService; use Relaticle\ImportWizard\Enums\DuplicateHandlingStrategy; use Relaticle\ImportWizard\Filament\Imports\PeopleImporter; -use Relaticle\ImportWizard\Models\Import; - -function createPeopleTestImportRecord(User $user, Team $team): Import -{ - return Import::create([ - 'user_id' => $user->id, - 'team_id' => $team->id, - 'importer' => PeopleImporter::class, - 'file_name' => 'test.csv', - 'file_path' => '/tmp/test.csv', - 'total_rows' => 1, - ]); -} - -function setPeopleImporterData(object $importer, array $data): void -{ - $reflection = new ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, $data); -} - -function createEmailsCustomField(Team $team): CustomField -{ - return CustomField::withoutGlobalScopes()->create([ - 'code' => 'emails', - 'name' => 'Emails', - 'type' => 'email', - 'entity_type' => 'people', - 'tenant_id' => $team->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, - ]); -} - -function setPersonEmail(People $person, string $email, CustomField $field): void -{ - CustomFieldValue::withoutGlobalScopes()->create([ - 'entity_type' => 'people', - 'entity_id' => $person->id, - 'custom_field_id' => $field->id, - 'tenant_id' => $person->team_id, - 'json_value' => [$email], // Don't JSON encode - the model cast handles it - ]); -} beforeEach(function (): void { - $this->team = Team::factory()->create(); - $this->user = User::factory()->withPersonalTeam()->create(); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); - TenantContextService::setTenantId($this->team->id); - - $this->company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Corp']); + ['user' => $this->user, 'team' => $this->team] = setupImportTestContext(); + $this->company = Company::factory()->for($this->team)->create(['name' => 'Acme Corp']); $this->emailsField = createEmailsCustomField($this->team); }); describe('Email-Based Duplicate Detection', function (): void { - it('finds existing person by email with UPDATE strategy', function (): void { - // Create existing person with email - $existingPerson = People::factory()->for($this->team, 'team')->create([ - 'name' => 'John Doe', - 'company_id' => $this->company->id, - ]); - setPersonEmail($existingPerson, 'john@acme.com', $this->emailsField); - - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, + it('finds existing person by email with :dataset strategy', function (DuplicateHandlingStrategy $strategy): void { + $person = People::factory()->for($this->team)->create(['name' => 'John Doe', 'company_id' => $this->company->id]); + setPersonEmail($person, 'john@acme.com', $this->emailsField); + + $importer = createImporter( + PeopleImporter::class, + $this->user, + $this->team, ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], - ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] + ['name' => 'John Updated', 'company_name' => 'Acme Corp', 'custom_fields_emails' => 'john@acme.com'], + ['duplicate_handling' => $strategy] ); - setPeopleImporterData($importer, [ - 'name' => 'John Updated', - 'company_name' => 'Acme Corp', - 'custom_fields_emails' => 'john@acme.com', - ]); + expect($importer->resolveRecord())->id->toBe($person->id)->exists->toBeTrue(); + })->with([DuplicateHandlingStrategy::UPDATE, DuplicateHandlingStrategy::SKIP]); - $record = $importer->resolveRecord(); + it(':dataset creates new person', function (string $scenario, array $data): void { + $columnMap = ['name' => 'name', 'company_name' => 'company_name']; + if (isset($data['custom_fields_emails'])) { + $columnMap['custom_fields_emails'] = 'custom_fields_emails'; + } - expect($record->id)->toBe($existingPerson->id) - ->and($record->exists)->toBeTrue() - ->and(People::query()->where('team_id', $this->team->id)->count())->toBe(1); - }); + $import = createImportRecord($this->user, $this->team, PeopleImporter::class); + $importer = new PeopleImporter($import, $columnMap, ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE]); + setImporterData($importer, $data); - it('finds existing person by email with SKIP strategy', function (): void { - $existingPerson = People::factory()->for($this->team, 'team')->create([ - 'name' => 'Jane Smith', - 'company_id' => $this->company->id, - ]); - setPersonEmail($existingPerson, 'jane@acme.com', $this->emailsField); - - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], - ['duplicate_handling' => DuplicateHandlingStrategy::SKIP] - ); - - setPeopleImporterData($importer, [ - 'name' => 'Jane Updated', - 'company_name' => 'Acme Corp', - 'custom_fields_emails' => 'jane@acme.com', - ]); - - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($existingPerson->id) - ->and($record->exists)->toBeTrue(); - }); + expect($importer->resolveRecord()->exists)->toBeFalse(); + })->with([ + 'email not matching' => ['mismatch', ['name' => 'New Person', 'company_name' => 'Acme Corp', 'custom_fields_emails' => 'newperson@acme.com']], + 'no email provided' => ['no email', ['name' => 'Person Without Email', 'company_name' => 'Acme Corp']], + ]); - it('creates new person with CREATE_NEW strategy even with matching email', function (): void { - $existingPerson = People::factory()->for($this->team, 'team')->create([ - 'name' => 'Bob Johnson', - 'company_id' => $this->company->id, - ]); - setPersonEmail($existingPerson, 'bob@acme.com', $this->emailsField); + it('creates new with CREATE_NEW even with matching email', function (): void { + $person = People::factory()->for($this->team)->create(['name' => 'Bob Johnson', 'company_id' => $this->company->id]); + setPersonEmail($person, 'bob@acme.com', $this->emailsField); - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, + $importer = createImporter( + PeopleImporter::class, + $this->user, + $this->team, ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], + ['name' => 'Bob Duplicate', 'company_name' => 'Acme Corp', 'custom_fields_emails' => 'bob@acme.com'], ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] ); - setPeopleImporterData($importer, [ - 'name' => 'Bob Duplicate', - 'company_name' => 'Acme Corp', - 'custom_fields_emails' => 'bob@acme.com', - ]); - - $record = $importer->resolveRecord(); - - expect($record->exists)->toBeFalse() - ->and(People::query()->where('team_id', $this->team->id)->count())->toBe(1); // Still 1 because record not saved yet + expect($importer->resolveRecord()->exists)->toBeFalse(); }); - it('creates new person when email does not match any existing person', function (): void { - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], - ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] - ); - - setPeopleImporterData($importer, [ - 'name' => 'New Person', - 'company_name' => 'Acme Corp', - 'custom_fields_emails' => 'newperson@acme.com', - ]); - - $record = $importer->resolveRecord(); - - expect($record->exists)->toBeFalse(); - }); - - it('creates new person when no email provided', function (): void { - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name'], - ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] - ); - - setPeopleImporterData($importer, [ - 'name' => 'Person Without Email', - 'company_name' => 'Acme Corp', - ]); - - $record = $importer->resolveRecord(); + it('matches on any of multiple emails', function (): void { + $person = People::factory()->for($this->team)->create(['name' => 'Multi Email Person', 'company_id' => $this->company->id]); + setPersonEmail($person, ['primary@acme.com', 'secondary@acme.com'], $this->emailsField); - expect($record->exists)->toBeFalse(); - }); - - it('handles multiple emails and matches on any of them', function (): void { - $existingPerson = People::factory()->for($this->team, 'team')->create([ - 'name' => 'Multi Email Person', - 'company_id' => $this->company->id, - ]); - - // Person has two emails - CustomFieldValue::withoutGlobalScopes()->create([ - 'entity_type' => 'people', - 'entity_id' => $existingPerson->id, - 'custom_field_id' => $this->emailsField->id, - 'tenant_id' => $this->team->id, - 'json_value' => ['primary@acme.com', 'secondary@acme.com'], // Don't JSON encode - ]); - - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, + $importer = createImporter( + PeopleImporter::class, + $this->user, + $this->team, ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], + ['name' => 'Updated Name', 'company_name' => 'Acme Corp', 'custom_fields_emails' => 'secondary@acme.com'], ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] ); - // Import data has only the secondary email - setPeopleImporterData($importer, [ - 'name' => 'Updated Name', - 'company_name' => 'Acme Corp', - 'custom_fields_emails' => 'secondary@acme.com', - ]); - - $record = $importer->resolveRecord(); - - expect($record->id)->toBe($existingPerson->id) - ->and($record->exists)->toBeTrue(); + expect($importer->resolveRecord())->id->toBe($person->id)->exists->toBeTrue(); }); it('does not match people from other teams', function (): void { $otherTeam = Team::factory()->create(); - $otherCompany = Company::factory()->for($otherTeam, 'team')->create(['name' => 'Other Corp']); - - // Create emails field for other team - $otherEmailsField = CustomField::withoutGlobalScopes()->create([ - 'code' => 'emails', - 'name' => 'Emails', - 'type' => 'email', - 'entity_type' => 'people', - 'tenant_id' => $otherTeam->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, - ]); - - $otherPerson = People::factory()->for($otherTeam, 'team')->create([ - 'name' => 'Other Team Person', - 'company_id' => $otherCompany->id, - ]); + $otherCompany = Company::factory()->for($otherTeam)->create(['name' => 'Other Corp']); + $otherEmailsField = createEmailsCustomField($otherTeam); + $otherPerson = People::factory()->for($otherTeam)->create(['name' => 'Other Team Person', 'company_id' => $otherCompany->id]); setPersonEmail($otherPerson, 'shared@email.com', $otherEmailsField); - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, + $importer = createImporter( + PeopleImporter::class, + $this->user, + $this->team, ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], + ['name' => 'My Team Person', 'company_name' => 'Acme Corp', 'custom_fields_emails' => 'shared@email.com'], ['duplicate_handling' => DuplicateHandlingStrategy::UPDATE] ); - setPeopleImporterData($importer, [ - 'name' => 'My Team Person', - 'company_name' => 'Acme Corp', - 'custom_fields_emails' => 'shared@email.com', - ]); - - $record = $importer->resolveRecord(); - - // Should create new record, not match other team's person - expect($record->exists)->toBeFalse(); + expect($importer->resolveRecord()->exists)->toBeFalse(); }); }); describe('Email Domain → Company Auto-Linking', function (): void { - function createDomainsField(Team $team): CustomField - { - return CustomField::withoutGlobalScopes()->create([ - 'code' => CompanyField::DOMAINS->value, - 'name' => CompanyField::DOMAINS->getDisplayName(), - 'type' => 'link', - 'entity_type' => 'company', - 'tenant_id' => $team->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, - ]); - } - - function setCompanyDomain(Company $company, string $domain): void - { - $field = CustomField::withoutGlobalScopes() - ->where('code', CompanyField::DOMAINS->value) - ->where('entity_type', 'company') - ->where('tenant_id', $company->team_id) - ->first(); - - CustomFieldValue::withoutGlobalScopes()->create([ - 'entity_type' => 'company', - 'entity_id' => $company->id, - 'custom_field_id' => $field->id, - 'tenant_id' => $company->team_id, - 'json_value' => [$domain], - ]); - } - - it('auto-links person to company by email domain when company_name is empty', function (): void { - createDomainsField($this->team); - setCompanyDomain($this->company, 'acme.com'); - - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); - - setPeopleImporterData($importer, [ - 'name' => 'John Doe', - 'company_name' => '', // Empty company name - should trigger domain matching - 'custom_fields_emails' => 'john@acme.com', - ]); - - // Run full import with empty company_name - ($importer)(['name' => 'John Doe', 'company_name' => '', 'custom_fields_emails' => 'john@acme.com']); - - // Person should be linked to the company matched by domain - $person = People::query()->where('name', 'John Doe')->first(); - expect($person)->not->toBeNull() - ->and($person->company_id)->toBe($this->company->id); + beforeEach(function (): void { + $this->domainField = createDomainsField($this->team); + setCompanyDomain($this->company, 'acme.com', $this->domainField); }); - it('prefers explicit company_name over domain match', function (): void { - createDomainsField($this->team); - setCompanyDomain($this->company, 'acme.com'); - - // Create another company - $otherCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Different Corp']); - - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); - - setPeopleImporterData($importer, [ - 'name' => 'John Doe', - 'company_name' => 'Different Corp', // Explicit company name - 'custom_fields_emails' => 'john@acme.com', // Email domain matches Acme Corp - ]); - - // Run full import - ($importer)(['name' => 'John Doe', 'company_name' => 'Different Corp', 'custom_fields_emails' => 'john@acme.com']); - - // Should use explicit company_name, not domain match - $person = People::query()->where('name', 'John Doe')->first(); - expect($person)->not->toBeNull() - ->and($person->company_id)->toBe($otherCompany->id); - }); - - it('creates new company when company_name provided and no match', function (): void { - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); - - setPeopleImporterData($importer, [ - 'name' => 'John Doe', - 'company_name' => 'Brand New Company', - ]); - - // Run full import - ($importer)(['name' => 'John Doe', 'company_name' => 'Brand New Company']); - - // Company should have been created - $createdCompany = Company::query()->where('name', 'Brand New Company')->first(); - $person = People::query()->where('name', 'John Doe')->first(); - expect($createdCompany)->not->toBeNull() - ->and($person)->not->toBeNull() - ->and($person->company_id)->toBe($createdCompany->id); - }); - - it('leaves company_id null when no company_name and no domain match', function (): void { - createDomainsField($this->team); - // Company has domain 'acme.com', but we'll use a different email domain - - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); - - setPeopleImporterData($importer, [ - 'name' => 'John Doe', - 'company_name' => '', - 'custom_fields_emails' => 'john@unknown-company.com', // No company with this domain - ]); - - // Run full import - ($importer)(['name' => 'John Doe', 'company_name' => '', 'custom_fields_emails' => 'john@unknown-company.com']); - - $person = People::query()->where('name', 'John Doe')->first(); - expect($person)->not->toBeNull() - ->and($person->company_id)->toBeNull(); - }); - - it('handles ambiguous domain match by leaving company unlinked', function (): void { - createDomainsField($this->team); - setCompanyDomain($this->company, 'shared.com'); - - // Create another company with the same domain - $company2 = Company::factory()->for($this->team, 'team')->create(['name' => 'Second Shared Inc']); - setCompanyDomain($company2, 'shared.com'); - - $import = createPeopleTestImportRecord($this->user, $this->team); - $importer = new PeopleImporter( - $import, - ['name' => 'name', 'company_name' => 'company_name', 'custom_fields_emails' => 'custom_fields_emails'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); - - setPeopleImporterData($importer, [ - 'name' => 'Ambiguous Person', - 'company_name' => '', - 'custom_fields_emails' => 'user@shared.com', - ]); - - // Run full import - ($importer)(['name' => 'Ambiguous Person', 'company_name' => '', 'custom_fields_emails' => 'user@shared.com']); - - // Should not link to either company due to ambiguity - $person = People::query()->where('name', 'Ambiguous Person')->first(); - expect($person)->not->toBeNull() - ->and($person->company_id)->toBeNull(); - }); + it(':dataset company linking behavior', function (string $scenario, array $data, ?string $expectedCompanyName): void { + if ($scenario === 'explicit company') { + Company::factory()->for($this->team)->create(['name' => 'Different Corp']); + } + + $columnMap = ['name' => 'name', 'company_name' => 'company_name']; + if (isset($data['custom_fields_emails'])) { + $columnMap['custom_fields_emails'] = 'custom_fields_emails'; + } + + $import = createImportRecord($this->user, $this->team, PeopleImporter::class); + $importer = new PeopleImporter($import, $columnMap, ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW]); + setImporterData($importer, $data); + ($importer)($data); + + $person = People::where('name', $data['name'])->first(); + expect($person)->not->toBeNull(); + + if ($expectedCompanyName === null) { + expect($person->company_id)->toBeNull(); + } else { + $expectedCompany = Company::where('name', $expectedCompanyName)->first(); + expect($person->company_id)->toBe($expectedCompany?->id); + } + })->with([ + 'auto-links by email domain' => ['domain match', ['name' => 'John Doe', 'company_name' => '', 'custom_fields_emails' => 'john@acme.com'], 'Acme Corp'], + 'prefers explicit company_name' => ['explicit company', ['name' => 'John Doe', 'company_name' => 'Different Corp', 'custom_fields_emails' => 'john@acme.com'], 'Different Corp'], + 'creates new company when no match' => ['new company', ['name' => 'John Doe', 'company_name' => 'Brand New Company'], 'Brand New Company'], + 'leaves null when no company_name and no domain' => ['no match', ['name' => 'John Doe', 'company_name' => '', 'custom_fields_emails' => 'john@unknown-company.com'], null], + ]); }); diff --git a/tests/Feature/Filament/App/Imports/TaskImporterTest.php b/tests/Feature/Filament/App/Imports/TaskImporterTest.php index 9f7aee00..0a852b2b 100644 --- a/tests/Feature/Filament/App/Imports/TaskImporterTest.php +++ b/tests/Feature/Filament/App/Imports/TaskImporterTest.php @@ -2,200 +2,56 @@ declare(strict_types=1); -namespace Tests\Feature\Filament\App\Imports; - -use App\Filament\Resources\TaskResource\Pages\ManageTasks; use App\Models\Company; use App\Models\Opportunity; use App\Models\People; use App\Models\Task; -use App\Models\Team; use App\Models\User; -use Filament\Facades\Filament; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; -use Livewire\Livewire; -use Relaticle\CustomFields\Services\TenantContextService; use Relaticle\ImportWizard\Enums\DuplicateHandlingStrategy; use Relaticle\ImportWizard\Filament\Imports\TaskImporter; -use Relaticle\ImportWizard\Models\Import; - -uses(RefreshDatabase::class); - -function createTaskTestImportRecord(User $user, Team $team): Import -{ - return Import::create([ - 'user_id' => $user->id, - 'team_id' => $team->id, - 'importer' => TaskImporter::class, - 'file_name' => 'test.csv', - 'file_path' => '/tmp/test.csv', - 'total_rows' => 1, - ]); -} - -function setTaskImporterData(object $importer, array $data): void -{ - $reflection = new \ReflectionClass($importer); - $dataProperty = $reflection->getProperty('data'); - $dataProperty->setValue($importer, $data); -} -beforeEach(function () { +beforeEach(function (): void { Storage::fake('local'); - - $this->team = Team::factory()->create(); - $this->user = User::factory()->create(['current_team_id' => $this->team->id]); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); - TenantContextService::setTenantId($this->team->id); -}); - -test('task importer has correct columns defined', function () { - $columns = TaskImporter::getColumns(); - - $columnNames = collect($columns)->map(fn ($column) => $column->getName())->all(); - - // Core database columns and relationship columns are defined explicitly - // Custom fields like description, status, priority are handled by CustomFields::importer() - expect($columnNames) - ->toContain('title') - ->toContain('company_name') - ->toContain('person_name') - ->toContain('opportunity_name') - ->toContain('assignee_email'); -}); - -test('task importer has required title column', function () { - $columns = TaskImporter::getColumns(); - - $titleColumn = collect($columns)->first(fn ($column) => $column->getName() === 'title'); - - expect($titleColumn)->not->toBeNull() - ->and($titleColumn->isMappingRequired())->toBeTrue(); -}); - -test('task importer has options form with duplicate handling', function () { - $components = TaskImporter::getOptionsFormComponents(); - - $duplicateHandlingComponent = collect($components)->first( - fn ($component) => $component->getName() === 'duplicate_handling' - ); - - expect($duplicateHandlingComponent)->not->toBeNull() - ->and($duplicateHandlingComponent->isRequired())->toBeTrue(); -}); - -test('import action exists on manage tasks page', function () { - Livewire::test(ManageTasks::class) - ->assertSuccessful() - ->assertActionExists('import'); -}); - -test('task importer guesses column names correctly', function () { - $columns = TaskImporter::getColumns(); - - $titleColumn = collect($columns)->first(fn ($column) => $column->getName() === 'title'); - $assigneeColumn = collect($columns)->first(fn ($column) => $column->getName() === 'assignee_email'); - - expect($titleColumn->getGuesses()) - ->toContain('title') - ->toContain('task_title') - ->toContain('task_name'); - - expect($assigneeColumn->getGuesses()) - ->toContain('assignee_email') - ->toContain('assignee') - ->toContain('assigned_to'); -}); - -test('task importer provides example values', function () { - $columns = TaskImporter::getColumns(); - - $titleColumn = collect($columns)->first(fn ($column) => $column->getName() === 'title'); - - expect($titleColumn->getExample())->toBe('Follow up with client'); -}); - -test('task importer returns completed notification body', function () { - $import = new Import; - $import->successful_rows = 15; - - $body = TaskImporter::getCompletedNotificationBody($import); - - expect($body)->toContain('15') - ->and($body)->toContain('task') - ->and($body)->toContain('imported'); -}); - -test('task importer includes failed rows in notification', function () { - $import = Import::create([ - 'team_id' => $this->team->id, - 'user_id' => $this->user->id, - 'successful_rows' => 12, - 'total_rows' => 15, - 'processed_rows' => 15, - 'importer' => TaskImporter::class, - 'file_name' => 'test.csv', - 'file_path' => 'imports/test.csv', - ]); - - $import->failedRows()->createMany([ - ['data' => ['title' => 'Failed 1'], 'validation_error' => 'Invalid data'], - ['data' => ['title' => 'Failed 2'], 'validation_error' => 'Invalid data'], - ['data' => ['title' => 'Failed 3'], 'validation_error' => 'Invalid data'], - ]); - - $body = TaskImporter::getCompletedNotificationBody($import); - - expect($body)->toContain('12') - ->and($body)->toContain('3') - ->and($body)->toContain('failed'); + ['user' => $this->user, 'team' => $this->team] = setupImportTestContext(); }); describe('Polymorphic Relationship Attachment', function (): void { - it('attaches task to existing company', function (): void { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Corp']); + it('attaches task to :dataset', function (string $relationMethod, string $entityFactory, string $columnKey, string $entityName): void { + $entity = match ($entityFactory) { + 'company' => Company::factory()->for($this->team, 'team')->create(['name' => $entityName]), + 'person' => People::factory()->for($this->team, 'team')->create(['name' => $entityName]), + 'opportunity' => Opportunity::factory()->for($this->team, 'team')->create(['name' => $entityName]), + }; - $import = createTaskTestImportRecord($this->user, $this->team); - $importer = new TaskImporter( - $import, - ['title' => 'title', 'company_name' => 'company_name'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); + $import = createImportRecord($this->user, $this->team, TaskImporter::class); + $importer = new TaskImporter($import, ['title' => 'title', $columnKey => $columnKey], ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW]); - setTaskImporterData($importer, [ - 'title' => 'Follow up', - 'company_name' => 'Acme Corp', - ]); + $data = ['title' => 'Test Task', $columnKey => $entityName]; + setImporterData($importer, $data); + ($importer)($data); - ($importer)(['title' => 'Follow up', 'company_name' => 'Acme Corp']); + $task = Task::where('title', 'Test Task')->first(); - $task = Task::query()->where('title', 'Follow up')->first(); expect($task)->not->toBeNull() - ->and($task->companies)->toHaveCount(1) - ->and($task->companies->first()->id)->toBe($company->id); - }); - - it('creates and attaches new company when it does not exist', function (): void { - $import = createTaskTestImportRecord($this->user, $this->team); - $importer = new TaskImporter( - $import, - ['title' => 'title', 'company_name' => 'company_name'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); + ->and($task->{$relationMethod})->toHaveCount(1) + ->and($task->{$relationMethod}->first()->id)->toBe($entity->id); + })->with([ + 'existing company' => ['companies', 'company', 'company_name', 'Acme Corp'], + 'person' => ['people', 'person', 'person_name', 'John Doe'], + 'opportunity' => ['opportunities', 'opportunity', 'opportunity_name', 'Big Deal'], + ]); - setTaskImporterData($importer, [ - 'title' => 'New Task', - 'company_name' => 'New Company Inc', - ]); + it('creates and attaches new company when nonexistent', function (): void { + $import = createImportRecord($this->user, $this->team, TaskImporter::class); + $importer = new TaskImporter($import, ['title' => 'title', 'company_name' => 'company_name'], ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW]); - ($importer)(['title' => 'New Task', 'company_name' => 'New Company Inc']); + $data = ['title' => 'New Task', 'company_name' => 'New Company Inc']; + setImporterData($importer, $data); + ($importer)($data); - $task = Task::query()->where('title', 'New Task')->first(); - $company = Company::query()->where('name', 'New Company Inc')->first(); + $task = Task::where('title', 'New Task')->first(); + $company = Company::where('name', 'New Company Inc')->first(); expect($task)->not->toBeNull() ->and($company)->not->toBeNull() @@ -203,84 +59,24 @@ function setTaskImporterData(object $importer, array $data): void ->and($task->companies->first()->id)->toBe($company->id); }); - it('attaches task to person', function (): void { - $person = People::factory()->for($this->team, 'team')->create(['name' => 'John Doe']); - - $import = createTaskTestImportRecord($this->user, $this->team); - $importer = new TaskImporter( - $import, - ['title' => 'title', 'person_name' => 'person_name'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); - - setTaskImporterData($importer, [ - 'title' => 'Contact John', - 'person_name' => 'John Doe', - ]); - - ($importer)(['title' => 'Contact John', 'person_name' => 'John Doe']); - - $task = Task::query()->where('title', 'Contact John')->first(); - expect($task)->not->toBeNull() - ->and($task->people)->toHaveCount(1) - ->and($task->people->first()->id)->toBe($person->id); - }); - - it('attaches task to opportunity', function (): void { - $opportunity = Opportunity::factory()->for($this->team, 'team')->create(['name' => 'Big Deal']); - - $import = createTaskTestImportRecord($this->user, $this->team); - $importer = new TaskImporter( - $import, - ['title' => 'title', 'opportunity_name' => 'opportunity_name'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); - - setTaskImporterData($importer, [ - 'title' => 'Follow up on deal', - 'opportunity_name' => 'Big Deal', - ]); - - ($importer)(['title' => 'Follow up on deal', 'opportunity_name' => 'Big Deal']); - - $task = Task::query()->where('title', 'Follow up on deal')->first(); - expect($task)->not->toBeNull() - ->and($task->opportunities)->toHaveCount(1) - ->and($task->opportunities->first()->id)->toBe($opportunity->id); - }); - it('attaches task to multiple entities', function (): void { $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Corp']); $person = People::factory()->for($this->team, 'team')->create(['name' => 'John Doe']); $opportunity = Opportunity::factory()->for($this->team, 'team')->create(['name' => 'Big Deal']); - $import = createTaskTestImportRecord($this->user, $this->team); + $import = createImportRecord($this->user, $this->team, TaskImporter::class); $importer = new TaskImporter( $import, - [ - 'title' => 'title', - 'company_name' => 'company_name', - 'person_name' => 'person_name', - 'opportunity_name' => 'opportunity_name', - ], + ['title' => 'title', 'company_name' => 'company_name', 'person_name' => 'person_name', 'opportunity_name' => 'opportunity_name'], ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] ); - setTaskImporterData($importer, [ - 'title' => 'Complex Task', - 'company_name' => 'Acme Corp', - 'person_name' => 'John Doe', - 'opportunity_name' => 'Big Deal', - ]); + $data = ['title' => 'Complex Task', 'company_name' => 'Acme Corp', 'person_name' => 'John Doe', 'opportunity_name' => 'Big Deal']; + setImporterData($importer, $data); + ($importer)($data); - ($importer)([ - 'title' => 'Complex Task', - 'company_name' => 'Acme Corp', - 'person_name' => 'John Doe', - 'opportunity_name' => 'Big Deal', - ]); + $task = Task::where('title', 'Complex Task')->first(); - $task = Task::query()->where('title', 'Complex Task')->first(); expect($task)->not->toBeNull() ->and($task->companies)->toHaveCount(1) ->and($task->people)->toHaveCount(1) @@ -288,25 +84,18 @@ function setTaskImporterData(object $importer, array $data): void }); it('attaches assignee by email', function (): void { - // The user must be a member of the team $assignee = User::factory()->create(); $assignee->teams()->attach($this->team); - $import = createTaskTestImportRecord($this->user, $this->team); - $importer = new TaskImporter( - $import, - ['title' => 'title', 'assignee_email' => 'assignee_email'], - ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW] - ); + $import = createImportRecord($this->user, $this->team, TaskImporter::class); + $importer = new TaskImporter($import, ['title' => 'title', 'assignee_email' => 'assignee_email'], ['duplicate_handling' => DuplicateHandlingStrategy::CREATE_NEW]); - setTaskImporterData($importer, [ - 'title' => 'Assigned Task', - 'assignee_email' => $assignee->email, - ]); + $data = ['title' => 'Assigned Task', 'assignee_email' => $assignee->email]; + setImporterData($importer, $data); + ($importer)($data); - ($importer)(['title' => 'Assigned Task', 'assignee_email' => $assignee->email]); + $task = Task::where('title', 'Assigned Task')->first(); - $task = Task::query()->where('title', 'Assigned Task')->first(); expect($task)->not->toBeNull() ->and($task->assignees)->toHaveCount(1) ->and($task->assignees->first()->id)->toBe($assignee->id); diff --git a/tests/Feature/Services/Import/CompanyMatcherTest.php b/tests/Feature/Services/Import/CompanyMatcherTest.php deleted file mode 100644 index 0cca78c8..00000000 --- a/tests/Feature/Services/Import/CompanyMatcherTest.php +++ /dev/null @@ -1,316 +0,0 @@ -team = Team::factory()->create(); - $this->user = User::factory()->create(['current_team_id' => $this->team->id]); - $this->user->teams()->attach($this->team); - - $this->actingAs($this->user); - Filament::setTenant($this->team); - TenantContextService::setTenantId($this->team->id); - - // Create the domains custom field for Company (without section - query uses withoutGlobalScopes) - // Uses 'company' morph alias (not Company::class) to match Laravel's morph map - CustomField::withoutGlobalScopes()->create([ - 'code' => CompanyField::DOMAINS->value, - 'name' => CompanyField::DOMAINS->getDisplayName(), - 'type' => 'link', - 'entity_type' => 'company', - 'tenant_id' => $this->team->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, - ]); - - $this->matcher = app(CompanyMatcher::class); - - // Helper to set domains custom field value directly (stored as json_value array) - // Uses 'company' morph alias (not Company::class) to match Laravel's morph map - $this->setCompanyDomain = function (Company $company, string $domain, ?Team $team = null): void { - $team = $team ?? $this->team; - $field = CustomField::withoutGlobalScopes() - ->where('code', CompanyField::DOMAINS->value) - ->where('entity_type', 'company') - ->where('tenant_id', $team->id) - ->first(); - - CustomFieldValue::withoutGlobalScopes()->create([ - 'entity_type' => 'company', - 'entity_id' => $company->id, - 'custom_field_id' => $field->id, - 'tenant_id' => $team->id, - 'json_value' => [$domain], - ]); - }; -}); - -test('matches company by domain from email', function () { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - ($this->setCompanyDomain)($company, 'acme.com'); - - $result = $this->matcher->match('', 'Acme Inc', ['john@acme.com'], $this->team->id); - - expect($result) - ->toBeInstanceOf(CompanyMatchResult::class) - ->companyName->toBe('Acme Inc') - ->matchType->toBe('domain') - ->matchCount->toBe(1) - ->companyId->toBe($company->id); -}); - -test('returns new when company name provided with no domain match', function () { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - - $result = $this->matcher->match('', 'Acme Inc', [], $this->team->id); - - expect($result) - ->companyName->toBe('Acme Inc') - ->matchType->toBe('new') - ->matchCount->toBe(0) - ->companyId->toBeNull(); -}); - -test('returns new when company name provided without ID or domain', function () { - Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - - $result = $this->matcher->match('', 'Acme Inc', [], $this->team->id); - - expect($result) - ->companyName->toBe('Acme Inc') - ->matchType->toBe('new') - ->matchCount->toBe(0) - ->companyId->toBeNull(); -}); - -test('takes first match when multiple companies match by domain', function () { - $company1 = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - ($this->setCompanyDomain)($company1, 'acme.com'); - - $company2 = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Corp']); - ($this->setCompanyDomain)($company2, 'acme.com'); - - $result = $this->matcher->match('', 'Something Else', ['john@acme.com'], $this->team->id); - - expect($result) - ->matchType->toBe('domain') - ->matchCount->toBe(2) - ->companyId->toBeIn([$company1->id, $company2->id]); -}); - -test('prioritizes domain match over company name', function () { - // Company that would match only if name matching existed - Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - - // Company that matches by domain - $domainCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Corporation']); - ($this->setCompanyDomain)($domainCompany, 'acme.com'); - - $result = $this->matcher->match('', 'Acme Inc', ['john@acme.com'], $this->team->id); - - // Should match by domain, ignoring company_name parameter - expect($result) - ->companyName->toBe('Acme Corporation') - ->matchType->toBe('domain') - ->matchCount->toBe(1) - ->companyId->toBe($domainCompany->id); -}); - -test('does not match companies from other teams', function () { - $otherTeam = Team::factory()->create(); - - // Create company in other team - $otherCompany = Company::factory()->for($otherTeam, 'team')->create(['name' => 'Acme Inc']); - - // Create domains field for the other team (without section - query uses withoutGlobalScopes) - // Uses 'company' morph alias (not Company::class) to match Laravel's morph map - CustomField::withoutGlobalScopes()->create([ - 'code' => CompanyField::DOMAINS->value, - 'name' => CompanyField::DOMAINS->getDisplayName(), - 'type' => 'link', - 'entity_type' => 'company', - 'tenant_id' => $otherTeam->id, - 'sort_order' => 1, - 'active' => true, - 'system_defined' => true, - ]); - ($this->setCompanyDomain)($otherCompany, 'acme.com', $otherTeam); - - $result = $this->matcher->match('', 'Acme Inc', ['john@acme.com'], $this->team->id); - - // Should not find the other team's company - expect($result) - ->matchType->toBe('new') - ->matchCount->toBe(0) - ->companyId->toBeNull(); -}); - -test('returns new for personal emails with company name provided', function () { - // Gmail domain won't match any company (no company has domains containing gmail.com) - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - - $result = $this->matcher->match('', 'Acme Inc', ['john@gmail.com'], $this->team->id); - - // Should return new since no domain match and name matching is removed - expect($result) - ->companyName->toBe('Acme Inc') - ->matchType->toBe('new') - ->matchCount->toBe(0) - ->companyId->toBeNull(); -}); - -test('returns new when no company matches', function () { - $result = $this->matcher->match('', 'Unknown Company', ['contact@unknown.com'], $this->team->id); - - expect($result) - ->companyName->toBe('Unknown Company') - ->matchType->toBe('new') - ->matchCount->toBe(0) - ->companyId->toBeNull(); -}); - -test('returns none for empty company name', function () { - $result = $this->matcher->match('', '', ['john@acme.com'], $this->team->id); - - expect($result) - ->companyName->toBe('') - ->matchType->toBe('none') - ->matchCount->toBe(0) - ->companyId->toBeNull(); -}); - -test('extracts domain correctly from multiple emails', function () { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - ($this->setCompanyDomain)($company, 'acme.com'); - - // Multiple emails, one with the matching domain - $result = $this->matcher->match('', 'Some Company', ['john@gmail.com', 'jane@acme.com', 'bob@hotmail.com'], $this->team->id); - - expect($result) - ->companyName->toBe('Acme Inc') - ->matchType->toBe('domain') - ->companyId->toBe($company->id); -}); - -test('handles invalid email formats gracefully', function () { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - - $result = $this->matcher->match('', 'Acme Inc', ['not-an-email', 'also.not.valid', ''], $this->team->id); - - // Should return new since no valid domains extracted and name matching is removed - expect($result) - ->companyName->toBe('Acme Inc') - ->matchType->toBe('new') - ->matchCount->toBe(0); -}); - -test('normalizes email domains to lowercase', function () { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - ($this->setCompanyDomain)($company, 'acme.com'); - - // Email with uppercase domain - $result = $this->matcher->match('', 'Acme Inc', ['JOHN@ACME.COM'], $this->team->id); - - expect($result) - ->matchType->toBe('domain') - ->companyId->toBe($company->id); -}); - -test('matches company by ID with highest priority', function () { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - - $result = $this->matcher->match($company->id, 'Different Name', [], $this->team->id); - - expect($result) - ->companyName->toBe('Acme Inc') - ->matchType->toBe('id') - ->matchCount->toBe(1) - ->companyId->toBe($company->id); -}); - -test('prioritizes ID match over domain match', function () { - // Company matched by ID - $idCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'ID Company']); - - // Company matched by domain - $domainCompany = Company::factory()->for($this->team, 'team')->create(['name' => 'Domain Company']); - ($this->setCompanyDomain)($domainCompany, 'acme.com'); - - $result = $this->matcher->match($idCompany->id, 'Some Name', ['john@acme.com'], $this->team->id); - - // Should match by ID, not domain - expect($result) - ->companyName->toBe('ID Company') - ->matchType->toBe('id') - ->companyId->toBe($idCompany->id); -}); - -test('ignores invalid company ID and falls back to domain', function () { - $company = Company::factory()->for($this->team, 'team')->create(['name' => 'Acme Inc']); - ($this->setCompanyDomain)($company, 'acme.com'); - - $result = $this->matcher->match('not-a-valid-ulid', 'Some Name', ['john@acme.com'], $this->team->id); - - expect($result) - ->matchType->toBe('domain') - ->companyId->toBe($company->id); -}); - -test('returns new when ID not found and no other matches', function () { - $result = $this->matcher->match('01KCCNZ9T2X1R00369K6WM6WK2', 'New Company', [], $this->team->id); - - expect($result) - ->companyName->toBe('New Company') - ->matchType->toBe('new') - ->matchCount->toBe(0) - ->companyId->toBeNull(); -}); - -test('does not match company ID from other team', function () { - $otherTeam = Team::factory()->create(); - $otherCompany = Company::factory()->for($otherTeam, 'team')->create(['name' => 'Other Team Company']); - - $result = $this->matcher->match($otherCompany->id, 'Some Name', [], $this->team->id); - - expect($result) - ->matchType->toBe('new') - ->companyId->toBeNull(); -}); - -test('CompanyMatchResult helper methods work correctly', function () { - $newResult = new CompanyMatchResult(companyName: 'Test', matchType: 'new', matchCount: 0); - expect($newResult->isNew())->toBeTrue() - ->and($newResult->isNone())->toBeFalse() - ->and($newResult->isDomainMatch())->toBeFalse() - ->and($newResult->isIdMatch())->toBeFalse(); - - $domainResult = new CompanyMatchResult(companyName: 'Test', matchType: 'domain', matchCount: 1, companyId: '01kccnz9t2x1r00369k6wm6wk2'); - expect($domainResult->isDomainMatch())->toBeTrue() - ->and($domainResult->isNew())->toBeFalse(); - - $idResult = new CompanyMatchResult(companyName: 'Test', matchType: 'id', matchCount: 1, companyId: '01kccnz9t2x1r00369k6wm6wk2'); - expect($idResult->isIdMatch())->toBeTrue() - ->and($idResult->isDomainMatch())->toBeFalse(); - - $noneResult = new CompanyMatchResult(companyName: '', matchType: 'none', matchCount: 0); - expect($noneResult->isNone())->toBeTrue() - ->and($noneResult->isNew())->toBeFalse(); -}); diff --git a/tests/Pest.php b/tests/Pest.php index 5e6b596d..c572eec3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -50,7 +50,191 @@ | */ -function something() +/* +|-------------------------------------------------------------------------- +| Import Test Helpers +|-------------------------------------------------------------------------- +| +| Shared helper functions for ImportWizard module tests. +| +*/ + +use App\Enums\CustomFields\CompanyField; +use App\Models\Company; +use App\Models\CustomField; +use App\Models\CustomFieldSection; +use App\Models\CustomFieldValue; +use App\Models\People; +use App\Models\Team; +use App\Models\User; +use Filament\Facades\Filament; +use Illuminate\Http\UploadedFile; +use Relaticle\CustomFields\Services\TenantContextService; +use Relaticle\ImportWizard\Livewire\ImportWizard; +use Relaticle\ImportWizard\Models\Import; + +/** + * Create an Import record for testing. + */ +function createImportRecord(User $user, Team $team, string $importerClass = \Relaticle\ImportWizard\Filament\Imports\CompanyImporter::class): Import +{ + return Import::create([ + 'user_id' => $user->id, + 'team_id' => $team->id, + 'importer' => $importerClass, + 'file_name' => 'test.csv', + 'file_path' => '/tmp/test.csv', + 'total_rows' => 1, + ]); +} + +/** + * Set data on an importer instance via reflection. + */ +function setImporterData(object $importer, array $data): void +{ + $reflection = new ReflectionClass($importer); + $dataProperty = $reflection->getProperty('data'); + $dataProperty->setValue($importer, $data); + + if ($reflection->hasProperty('originalData')) { + $reflection->getProperty('originalData')->setValue($importer, $data); + } +} + +/** + * Setup common import test context (user, team, tenant). + */ +function setupImportTestContext(): array +{ + $team = Team::factory()->create(); + $user = User::factory()->withPersonalTeam()->create(); + $user->teams()->attach($team); + + test()->actingAs($user); + Filament::setTenant($team); + TenantContextService::setTenantId($team->id); + + return ['user' => $user, 'team' => $team]; +} + +/** + * Create a test CSV file with given content. + */ +function createTestCsv(string $content, string $filename = 'test.csv'): UploadedFile +{ + return UploadedFile::fake()->createWithContent($filename, $content); +} + +/** + * Get return URL for given entity type. + */ +function getReturnUrl(Team $team, string $entityType): string +{ + return match ($entityType) { + 'companies' => route('filament.app.resources.companies.index', ['tenant' => $team]), + 'people' => route('filament.app.resources.people.index', ['tenant' => $team]), + 'opportunities' => route('filament.app.resources.opportunities.index', ['tenant' => $team]), + 'tasks' => route('filament.app.resources.tasks.index', ['tenant' => $team]), + 'notes' => route('filament.app.resources.notes.index', ['tenant' => $team]), + default => '/', + }; +} + +/** + * Create emails custom field for People entity. + */ +function createEmailsCustomField(Team $team): CustomField +{ + $section = CustomFieldSection::withoutGlobalScopes()->firstOrCreate( + ['code' => 'contact_information', 'tenant_id' => $team->id, 'entity_type' => 'people'], + ['name' => 'Contact Information', 'type' => 'section', 'sort_order' => 1] + ); + + return CustomField::withoutGlobalScopes()->firstOrCreate( + ['code' => 'emails', 'tenant_id' => $team->id, 'entity_type' => 'people'], + [ + 'custom_field_section_id' => $section->id, + 'name' => 'Emails', + 'type' => 'email', + 'sort_order' => 1, + 'active' => true, + 'system_defined' => true, + ] + ); +} + +/** + * Set email value on a Person. + */ +function setPersonEmail(People $person, string|array $email, CustomField $field): void +{ + $emails = is_array($email) ? $email : [$email]; + CustomFieldValue::withoutGlobalScopes()->updateOrCreate( + ['entity_type' => 'people', 'entity_id' => $person->id, 'custom_field_id' => $field->id], + ['tenant_id' => $person->team_id, 'json_value' => $emails] + ); +} + +/** + * Create domains custom field for Company entity. + */ +function createDomainsField(Team $team): CustomField +{ + return CustomField::withoutGlobalScopes()->firstOrCreate( + ['code' => CompanyField::DOMAINS->value, 'tenant_id' => $team->id, 'entity_type' => 'company'], + [ + 'name' => CompanyField::DOMAINS->getDisplayName(), + 'type' => 'link', + 'sort_order' => 1, + 'active' => true, + 'system_defined' => true, + ] + ); +} + +/** + * Set domain value on a Company. + */ +function setCompanyDomain(Company $company, string|array $domain, ?CustomField $field = null): void +{ + $field ??= CustomField::withoutGlobalScopes() + ->where('code', CompanyField::DOMAINS->value) + ->where('tenant_id', $company->team_id) + ->first(); + + $domains = is_array($domain) ? $domain : [$domain]; + CustomFieldValue::withoutGlobalScopes()->updateOrCreate( + ['entity_type' => 'company', 'entity_id' => $company->id, 'custom_field_id' => $field->id], + ['tenant_id' => $company->team_id, 'json_value' => $domains] + ); +} + +/** + * Create an importer instance with data set via reflection. + */ +function createImporter( + string $importerClass, + User $user, + Team $team, + array $columnMap, + array $data, + array $options = [] +): object { + $import = createImportRecord($user, $team, $importerClass); + $importer = new $importerClass($import, $columnMap, $options); + setImporterData($importer, $data); + + return $importer; +} + +/** + * Create ImportWizard Livewire component for testing. + */ +function wizardTest(Team $team, string $entityType = 'companies'): \Livewire\Features\SupportTesting\Testable { - // .. + return Livewire\Livewire::test(ImportWizard::class, [ + 'entityType' => $entityType, + 'returnUrl' => getReturnUrl($team, $entityType), + ]); }