seed(CustomFieldDefinitionSeeder::class); }); /** * @param array> $rows */ function importCsv(array $rows): string { return implode("\n", array_map(static fn (array $r): string => implode(';', $r), $rows))."\n"; } function importFile(string $contents, string $name = 'import.csv'): UploadedFile { return UploadedFile::fake()->createWithContent($name, $contents); } function postImport($test, UploadedFile $file, User $user) { return $test->actingAs($user)->post('/api/clients/import', ['file' => $file], ['Accept' => 'application/json']); } const IMPORT_HEADER = [ 'Mandantennr.', 'Mandant', 'Anschrift 1', 'Anschrift 2', 'Telefon', 'E-Mail', 'Ansprechpartner 1', 'Ueberschreiben', ]; it('creates a new client with mapped custom fields and writes no history', function () { $admin = User::factory()->create(['role' => 'admin']); $csv = importCsv([ IMPORT_HEADER, ['1001', 'Mustermann GmbH', 'Hauptstr. 1', '12345 Musterstadt', '0123456', 'a@b.de', 'Herr Müller', ''], ]); postImport($this, importFile($csv), $admin) ->assertStatus(200) ->assertJsonPath('data.created', 1) ->assertJsonPath('data.skipped', 0) ->assertJsonPath('data.updated', 0); $client = Client::where('client_number', '1001')->firstOrFail(); expect($client->name)->toBe('Mustermann GmbH'); $tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail(); $address = CustomFieldDefinition::where('key', 'anschrift')->firstOrFail(); $this->assertDatabaseHas('custom_field_values', [ 'client_id' => $client->id, 'custom_field_definition_id' => $tel->id, 'value' => '0123456', ]); $this->assertDatabaseHas('custom_field_values', [ 'client_id' => $client->id, 'custom_field_definition_id' => $address->id, 'value' => "Hauptstr. 1\n12345 Musterstadt", ]); // New clients are imported silently. $this->assertDatabaseCount('history_entries', 0); }); it('skips an existing client when the overwrite flag is absent', function () { $admin = User::factory()->create(['role' => 'admin']); Client::factory()->create(['client_number' => '2002', 'name' => 'Bestand AG']); $csv = importCsv([ IMPORT_HEADER, ['2002', 'Geändert AG', '', '', '0999', 'neu@x.de', 'Herr Neu', ''], ]); postImport($this, importFile($csv), $admin) ->assertStatus(200) ->assertJsonPath('data.skipped', 1) ->assertJsonPath('data.created', 0) ->assertJsonPath('data.updated', 0); expect(Client::where('client_number', '2002')->first()->name)->toBe('Bestand AG'); $this->assertDatabaseCount('custom_field_values', 0); $this->assertDatabaseCount('history_entries', 0); }); it('overwrites custom fields of an existing client and audits each change', function () { $admin = User::factory()->create(['role' => 'admin']); $client = Client::factory()->create(['client_number' => '3003', 'name' => 'Original Name']); $csv = importCsv([ IMPORT_HEADER, ['3003', 'Neuer Name', 'Weg 5', '', '0555', 'mail@z.de', 'Frau Schmidt', 'Ja'], ]); postImport($this, importFile($csv), $admin) ->assertStatus(200) ->assertJsonPath('data.updated', 1) ->assertJsonPath('data.created', 0) ->assertJsonPath('data.skipped', 0); // Name / number are never touched on overwrite — custom fields only. expect($client->fresh()->name)->toBe('Original Name'); // Four non-blank fields → four field-change audit rows. $this->assertDatabaseCount('history_entries', 4); $importUser = User::where('name', 'Mandanten-Import')->firstOrFail(); $this->assertDatabaseHas('history_entries', [ 'client_id' => $client->id, 'type' => 'field_change', 'author_id' => $importUser->id, ]); // Author renders as "Mandanten-Import" through the history API. $this->actingAs($admin)->getJson("/api/clients/{$client->id}/history") ->assertStatus(200) ->assertJsonPath('data.0.author_name', 'Mandanten-Import') ->assertJsonPath('data.0.type_name', 'Stammdatenänderung'); }); it('preserves existing values when an overwrite row has blanks', function () { $admin = User::factory()->create(['role' => 'admin']); $client = Client::factory()->create(['client_number' => '4004', 'name' => 'Firma']); $tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail(); CustomFieldValue::create([ 'client_id' => $client->id, 'custom_field_definition_id' => $tel->id, 'value' => 'ALT-TELEFON', 'author_id' => $admin->id, ]); // Telefon column blank → must not wipe the existing value. $csv = importCsv([ IMPORT_HEADER, ['4004', 'Firma', '', '', '', 'only@mail.de', '', 'ja'], ]); postImport($this, importFile($csv), $admin) ->assertStatus(200) ->assertJsonPath('data.updated', 1); $this->assertDatabaseHas('custom_field_values', [ 'client_id' => $client->id, 'custom_field_definition_id' => $tel->id, 'value' => 'ALT-TELEFON', ]); }); it('strips Excel leading apostrophes and decodes ISO-8859-1 umlauts', function () { $admin = User::factory()->create(['role' => 'admin']); $csv = importCsv([ IMPORT_HEADER, ['5005', 'Bäcker Schröder', 'Straße 1', '', "'0301234", 'm@n.de', 'Herr Groß', ''], ]); $latin1 = mb_convert_encoding($csv, 'ISO-8859-1', 'UTF-8'); postImport($this, importFile($latin1), $admin) ->assertStatus(200) ->assertJsonPath('data.created', 1); $client = Client::where('client_number', '5005')->firstOrFail(); expect($client->name)->toBe('Bäcker Schröder'); $tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail(); $this->assertDatabaseHas('custom_field_values', [ 'client_id' => $client->id, 'custom_field_definition_id' => $tel->id, 'value' => '0301234', // leading apostrophe stripped ]); }); it('handles ragged rows shorter than the header', function () { $admin = User::factory()->create(['role' => 'admin']); // Row stops after "Mandant" — every mapped column beyond it is missing. $csv = "Mandantennr.;Mandant;Anschrift 1;Telefon;E-Mail;Ansprechpartner 1;Ueberschreiben\n6006;Kurz GmbH\n"; postImport($this, importFile($csv), $admin) ->assertStatus(200) ->assertJsonPath('data.created', 1); expect(Client::where('client_number', '6006')->first()->name)->toBe('Kurz GmbH'); }); it('forbids non-admin users', function () { $staff = User::factory()->create(['role' => 'staff']); $csv = importCsv([IMPORT_HEADER, ['7007', 'X', '', '', '', '', '', '']]); postImport($this, importFile($csv), $staff)->assertStatus(403); $this->assertDatabaseCount('clients', 0); }); it('rejects a non-csv upload', function () { $admin = User::factory()->create(['role' => 'admin']); $file = UploadedFile::fake()->createWithContent('data.pdf', 'irrelevant'); postImport($this, $file, $admin)->assertStatus(422); }); it('reports an error row for a missing client number', function () { $admin = User::factory()->create(['role' => 'admin']); $csv = importCsv([ IMPORT_HEADER, ['', 'Ohne Nummer', '', '', '', '', '', ''], ]); postImport($this, importFile($csv), $admin) ->assertStatus(200) ->assertJsonPath('data.created', 0) ->assertJsonCount(1, 'data.errors') ->assertJsonPath('data.errors.0.row', 2); });