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>
282 lines
12 KiB
PHP
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);
|
|
});
|