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),
+ ]);
}