mivita/tests/Feature/CustomerDeleteTest.php
Kevin 60f9985313 Kunden loeschen: Modal mit Komplett- oder DSGVO-Loeschung ueber alle Dubletten
Ticket dev/_tickets/2026-09-08.md #2:
- "Kunde loeschen" oeffnet ein Modal statt direkt per GET zu loeschen. Es
  zeigt alle Eintraege desselben Kunden (Kundennummer oder E-Mail, ohne
  Platzhalter-Adressen) mit Anzahl Bestellungen.
- Ohne Bestellungen: Komplett-Loeschung (anonymisiert und soft-geloescht,
  Portal-Logins und Stripe-Kunden entfernt, Zahlungs-Tokens inaktiver Abos
  geleert). Mit Bestellungen nur DSGVO-Loeschung (Superadmin); die arbeitet
  jetzt ebenfalls auf der E-Mail-Gruppe und entfernt Portal-Logins.
- Bei aktivem Abo oder Berater-Datensatz wird abgelehnt.
- Die alte "Loeschung" (CustomerRepository::deleteCustomer) entfaellt.
- Fehlermeldungen nutzen alert-error; alert-danger zeigte kein Layout an.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:43:35 +00:00

282 lines
12 KiB
PHP

<?php
use App\Exceptions\GdprDeletionException;
use App\Models\Country;
use App\Models\Customer;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserShop;
use App\Services\GdprDeletionService;
use App\User;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class);
/**
* Ticket dev/_tickets/2026-09-08.md #2: Kunden löschen (komplett vs. DSGVO),
* Mehrfach-Anlagen werden über die E-Mail-Adresse zusammengefasst.
*
* Die Admin-Routen werden host-abhängig geladen, daher App-Neustart mit CRM-Host.
*/
beforeEach(function () {
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
Mail::fake();
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
$this->refreshApplication();
$this->artisan('migrate');
});
afterEach(function () {
// Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen.
if ($this->previousHttpHost === null) {
unset($_SERVER['HTTP_HOST']);
} else {
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
}
});
function deleteTestUser(array $attributes = []): User
{
return User::forceCreate(array_merge([
'email' => 'user-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'active' => 1,
'wizard' => 100,
'payment_account' => now()->addYear(),
], $attributes));
}
function deleteTestCustomer(User $member, string $email, array $attributes = []): ShoppingUser
{
$country = Country::first() ?? Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
return ShoppingUser::create(array_merge([
'auth_user_id' => 0,
'member_id' => $member->id,
'number' => random_int(1000, 99999),
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_address' => 'Musterweg 1',
'billing_zipcode' => '12345',
'billing_city' => 'Musterstadt',
'billing_phone' => '0123',
'billing_email' => $email,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
], $attributes));
}
function deleteTestOrder(ShoppingUser $customer): ShoppingOrder
{
$shop = UserShop::first() ?? UserShop::create(['user_id' => $customer->member_id, 'name' => 'Shop', 'slug' => 'shop-'.uniqid()]);
return ShoppingOrder::create([
'shopping_user_id' => $customer->id,
'auth_user_id' => 0,
'member_id' => $customer->member_id,
'user_shop_id' => $shop->id,
'country_id' => $customer->billing_country_id,
'total' => 10,
'txaction' => 'appointed',
'mode' => 'test',
]);
}
it('fasst alle Einträge eines Kunden über Kundennummer und E-Mail zusammen', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$sameNumber = deleteTestCustomer($member, 'anders@example.com', ['number' => 61009]);
$sameEmailOtherNumber = deleteTestCustomer($member, 'Erika@Example.com', ['number' => 61010]);
$consultantRow = deleteTestCustomer($member, 'erika@example.com', ['number' => 99, 'auth_user_id' => $member->id]);
$other = deleteTestCustomer($member, 'jemand@example.com', ['number' => 61011]);
$ids = app(GdprDeletionService::class)->getCustomerRows($first)->pluck('id')->all();
expect($ids)->toBe([$first->id, $sameNumber->id, $sameEmailOtherNumber->id])
->and($ids)->not->toContain($consultantRow->id)
->and($ids)->not->toContain($other->id);
});
it('gruppiert nicht über Platzhalter-Adressen', function () {
$member = deleteTestUser();
$faker = deleteTestCustomer($member, 'abc-faker@mivita.care', ['number' => 1, 'faker_mail' => true]);
$otherFaker = deleteTestCustomer($member, 'abc-faker@mivita.care', ['number' => 2, 'faker_mail' => true]);
$service = app(GdprDeletionService::class);
expect($service->groupingEmail($faker))->toBeNull()
->and($service->getCustomerRows($faker)->pluck('id')->all())->toBe([$faker->id])
->and($service->getCustomerRows($otherFaker)->pluck('id')->all())->toBe([$otherFaker->id]);
});
it('löscht einen Kunden ohne Bestellungen komplett, inklusive Mehrfach-Anlagen, Portal-Login und Abo-Daten', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
$abo = UserAbo::create([
'user_id' => null,
'member_id' => $member->id,
'shopping_user_id' => $duplicate->id,
'is_for' => 'ot',
'email' => 'erika@example.com',
'payone_userid' => 4711,
'clearingtype' => 'cc',
'carddata' => ['last4' => '4242'],
'active' => false,
'status' => 4,
'abo_interval' => 1,
]);
$portal = Customer::create(['email' => 'erika@example.com', 'shopping_user_id' => $first->id, 'member_id' => $member->id, 'number' => 61009]);
$summary = app(GdprDeletionService::class)->purgeShoppingUser($first, $member->id);
expect($summary['shopping_users_purged'])->toBe(2)
->and($summary['portal_customers_deleted'])->toBe(1)
->and($summary['abos_anonymized'])->toBe(1);
foreach ([$first->id, $duplicate->id] as $id) {
expect(ShoppingUser::find($id))->toBeNull();
$row = ShoppingUser::withTrashed()->find($id);
expect($row->deleted_at)->not->toBeNull()
->and($row->billing_firstname)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME)
->and($row->billing_lastname)->toBe(GdprDeletionService::ANONYMIZED_LASTNAME)
->and($row->billing_address)->toBeNull()
->and($row->billing_phone)->toBeNull()
->and($row->billing_email)->not->toContain('erika');
}
expect(Customer::find($portal->id))->toBeNull();
$abo = UserAbo::withTrashed()->find($abo->id);
expect($abo->email)->toBeNull()
->and($abo->carddata)->toBeNull()
->and((int) $abo->payone_userid)->toBe(0);
});
it('verweigert die Komplett-Löschung, sobald eine Mehrfach-Anlage eine Bestellung hat', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
deleteTestOrder($duplicate);
expect(fn () => app(GdprDeletionService::class)->purgeShoppingUser($first, $member->id))
->toThrow(GdprDeletionException::class, __('msg.gdpr_error_customer_has_orders'));
expect($first->fresh()->billing_email)->toBe('erika@example.com')
->and($duplicate->fresh()->deleted_at)->toBeNull();
});
it('verweigert die Komplett-Löschung bei Berater-Datensätzen und aktiven Abos', function () {
$member = deleteTestUser();
$consultantRow = deleteTestCustomer($member, 'berater@example.com', ['auth_user_id' => $member->id]);
expect(fn () => app(GdprDeletionService::class)->purgeShoppingUser($consultantRow, $member->id))
->toThrow(GdprDeletionException::class, __('msg.gdpr_error_customer_is_member'));
$customer = deleteTestCustomer($member, 'abo@example.com');
UserAbo::create([
'user_id' => null,
'member_id' => $member->id,
'shopping_user_id' => $customer->id,
'is_for' => 'ot',
'email' => 'abo@example.com',
'payone_userid' => 1,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 1,
]);
expect(fn () => app(GdprDeletionService::class)->purgeShoppingUser($customer, $member->id))
->toThrow(GdprDeletionException::class, __('msg.gdpr_error_active_abo'));
});
it('anonymisiert bei der DSGVO-Löschung auch Mehrfach-Anlagen mit anderer Kundennummer und entfernt den Portal-Login', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
deleteTestOrder($first);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
Customer::create(['email' => 'erika@example.com', 'shopping_user_id' => $duplicate->id, 'member_id' => $member->id]);
$summary = app(GdprDeletionService::class)->deleteShoppingUser($first, $member->id);
expect($summary['shopping_users_anonymized'])->toBe(2)
->and($summary['portal_customers_deleted'])->toBe(1);
$first = $first->fresh();
expect($first->billing_firstname)->toBe('Erika')
->and($first->billing_email)->toBe('geloescht_'.$first->id.'@dsgvo.invalid');
$duplicate = $duplicate->fresh();
expect($duplicate->billing_firstname)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME)
->and($duplicate->billing_email)->not->toContain('erika');
expect(Customer::where('email', 'erika@example.com')->count())->toBe(0);
});
it('bietet im Modal die Komplett-Löschung an, wenn keine Bestellung vorliegt, und führt sie nach Bestätigung aus', function () {
$admin = deleteTestUser(['admin' => 2]);
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
$this->actingAs($admin)->get(route('admin_customer_detail', $first->id))
->assertOk()
->assertSee('id="modals-customer-delete"', false)
->assertSee('2-mal')
->assertSee(__('msg.gdpr_purge_customer_button'))
->assertDontSee(__('msg.gdpr_delete_customer_button'));
$this->actingAs($admin)->post(route('admin_customer_delete', $first->id))
->assertRedirect(route('admin_customer_detail', $first->id))
->assertSessionHas('alert-error', __('msg.delete_confirm_checkbox_missing'));
expect($first->fresh())->not->toBeNull();
$this->actingAs($admin)->post(route('admin_customer_delete', $first->id), ['delete_customer' => 1])
->assertRedirect(route('admin_customers'))
->assertSessionHas('alert-success');
expect(ShoppingUser::find($first->id))->toBeNull()
->and(ShoppingUser::find($duplicate->id))->toBeNull();
});
it('bietet im Modal nur die DSGVO-Löschung an, wenn eine Bestellung vorliegt', function () {
$superadmin = deleteTestUser(['admin' => 3]);
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
deleteTestOrder($duplicate);
$this->actingAs($superadmin)->get(route('admin_customer_detail', $first->id))
->assertOk()
->assertSee('Es ist nur')
->assertSee(__('msg.gdpr_delete_customer_button'))
->assertDontSee(__('msg.gdpr_purge_customer_button'));
$this->actingAs($superadmin)->post(route('admin_customer_delete', $first->id), ['delete_customer' => 1])
->assertRedirect(route('admin_customer_detail', $first->id))
->assertSessionHas('alert-error', __('msg.gdpr_error_customer_has_orders'));
expect($first->fresh()->billing_email)->toBe('erika@example.com');
$this->actingAs($superadmin)->post(route('admin_customer_gdpr_delete', $first->id), ['gdpr_delete_customer' => 1])
->assertRedirect(route('admin_customers'))
->assertSessionHas('alert-success');
expect($duplicate->fresh()->billing_email)->toBe('geloescht_'.$duplicate->id.'@dsgvo.invalid')
->and($first->fresh()->billing_firstname)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME);
});
it('zeigt normalen Admins bei Bestellungen den Hinweis auf den Superadmin', function () {
$admin = deleteTestUser(['admin' => 2]);
$member = deleteTestUser();
$customer = deleteTestCustomer($member, 'erika@example.com');
deleteTestOrder($customer);
$this->actingAs($admin)->get(route('admin_customer_detail', $customer->id))
->assertOk()
->assertSee('nur von einem Superadmin')
->assertDontSee('id="form-customer-gdpr-delete"', false);
});