Mirrors the frontend catch-up in 570f17e: a substantial amount of
finished-but-never-committed backend work had accumulated locally.
- New CustomField module: admin-managed field definitions per client,
reorderable, typed values stored per client (backing the frontend's
ClientCustomFieldCard + settings/fields page)
- History entries switch from a hardcoded type enum to a dynamic,
admin-managed HistoryType module (reorderable, backing
settings/history-types)
- New Import module: CSV client import (backing settings/import)
- Clients table gains soft deletes (deleted_at)
- Supporting factories, seeders, policies, and feature tests for all
of the above
All 63 backend tests pass locally before this commit.
222 lines
7.8 KiB
PHP
222 lines
7.8 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Client\Models\Client;
|
|
use App\Modules\CustomField\Models\CustomFieldDefinition;
|
|
use App\Modules\CustomField\Models\CustomFieldValue;
|
|
use Database\Seeders\CustomFieldDefinitionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->seed(CustomFieldDefinitionSeeder::class);
|
|
});
|
|
|
|
/**
|
|
* @param array<int, array<int, string>> $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);
|
|
});
|