mivita/tests/Feature/CustomerEmailChangeTest.php
Kevin Adametz 9ad69a3218 Kundenhoheit: E-Mail-Aenderung mit Bestaetigung, Zuordnungsfehler behoben, Admin-Uebersicht
- E-Mail-Aenderung durch Berater wird erst nach Bestaetigung durch den Kunden
  aktiv (Link an die neue Adresse, Portal kunde/email-bestaetigen); danach
  ziehen alle Eintraege des Kunden und seine Abos mit. Admins aendern direkt.
- Kundenhoheit: E-Mail eines Beraterkontos oder eines Kunden eines anderen
  Beraters wird abgelehnt (beim Speichern und bei der Bestaetigung).
- Fix CustomerPriority::entryExists: aelteste gepruefte Zuordnung gilt,
  Eintraege in Pruefung (is_like) dienen nicht mehr als Vorlage.
- "Berater zuweisen" voreingestellt auf alle Bestellungen des Kunden.
- Neue Admin-Seite Kundenhoheit: offene Pruefungen, Kunden bei mehreren
  Beratern, abgelehnte/offene E-Mail-Aenderungen; Zaehler im Menue.
- Neue Tabelle shopping_user_email_changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:17 +00:00

372 lines
16 KiB
PHP

<?php
use App\Http\Controllers\CustomerController as AdminCustomerController;
use App\Http\Controllers\User\CustomerController as ConsultantCustomerController;
use App\Http\Controllers\Web\CustomerEmailChangeController;
use App\Mail\CustomerEmailChangeBlockedMail;
use App\Mail\CustomerEmailChangeMail;
use App\Models\Country;
use App\Models\ShoppingUser;
use App\Models\ShoppingUserEmailChange;
use App\Models\UserAbo;
use App\Services\CustomerEmailChangeService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Mail::fake();
URL::resolveMissingNamedRoutesUsing(
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
);
$this->country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
$this->member = User::forceCreate([
'email' => 'cec-member-'.uniqid('', true).'@example.com',
'password' => Hash::make('secret'),
'lang' => 'de',
]);
$this->service = app(CustomerEmailChangeService::class);
});
function cecCustomer(array $attributes = []): ShoppingUser
{
return ShoppingUser::create(array_merge([
'auth_user_id' => null,
'member_id' => test()->member->id,
'number' => 7100,
'language' => 'de',
'billing_salutation' => 'mr',
'billing_firstname' => 'Sascha',
'billing_lastname' => 'Frey',
'billing_address' => 'Weg 1',
'billing_zipcode' => '11111',
'billing_city' => 'Stadt',
'billing_email' => 'alt@example.com',
'billing_country_id' => test()->country->id,
'shipping_country_id' => test()->country->id,
'same_as_billing' => true,
'faker_mail' => false,
], $attributes));
}
function cecAbo(ShoppingUser $customer, array $attributes = []): UserAbo
{
return UserAbo::create(array_merge([
'member_id' => test()->member->id,
'shopping_user_id' => $customer->id,
'email' => $customer->billing_email,
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'stripe',
'abo_interval' => 5,
'status' => 3,
], $attributes));
}
/**
* Calls a controller action that reads its input from the Request facade.
*/
function cecCallStore(object $controller, int $id, array $input): mixed
{
$request = Request::create('/customer/edit/'.$id, 'POST', $input);
$request->setLaravelSession(app('session.store'));
app()->instance('request', $request);
Facade::clearResolvedInstance('request');
return $controller->store($id);
}
function cecFormData(ShoppingUser $customer, string $email): array
{
return [
'action' => 'shopping-user-store',
'billing_salutation' => $customer->billing_salutation,
'billing_firstname' => $customer->billing_firstname,
'billing_lastname' => $customer->billing_lastname,
'billing_address' => $customer->billing_address,
'billing_zipcode' => $customer->billing_zipcode,
'billing_city' => 'Neustadt',
'billing_country_id' => $customer->billing_country_id,
'billing_email' => $email,
'same_as_billing' => '1',
];
}
// --- Wann ist eine Bestätigung nötig? ---
it('verlangt eine Bestätigung nur beim Ersetzen einer echten Adresse', function () {
$customer = cecCustomer();
expect($this->service->requiresConfirmation($customer, 'neu@example.com'))->toBeTrue()
->and($this->service->requiresConfirmation($customer, ' ALT@example.com '))->toBeFalse()
->and($this->service->requiresConfirmation($customer, ''))->toBeFalse()
->and($this->service->requiresConfirmation($customer, null))->toBeFalse();
$placeholder = cecCustomer(['number' => 7101, 'faker_mail' => true, 'billing_email' => '123-faker@mivita.care']);
expect($this->service->requiresConfirmation($placeholder, 'neu@example.com'))->toBeFalse();
});
// --- Anfrage ---
it('schickt den Bestätigungslink an die neue Adresse und lässt die alte aktiv', function () {
$customer = cecCustomer();
$abo = cecAbo($customer);
$change = $this->service->request($customer, ' neu@example.com ', $this->member->id);
expect($change->new_email)->toBe('neu@example.com')
->and($change->old_email)->toBe('alt@example.com')
->and($change->requested_by_user_id)->toBe($this->member->id)
->and($change->isConfirmable())->toBeTrue()
->and($customer->fresh()->billing_email)->toBe('alt@example.com')
->and($abo->fresh()->email)->toBe('alt@example.com')
->and($this->service->pendingFor($customer)?->id)->toBe($change->id);
Mail::assertSent(CustomerEmailChangeMail::class, function (CustomerEmailChangeMail $mail) use ($change) {
return $mail->hasTo('neu@example.com')
&& ShoppingUserEmailChange::hashToken($mail->token) === $change->token_hash;
});
});
it('enthält in der Mail den Bestätigungslink ins Kundenportal', function () {
$html = (new CustomerEmailChangeMail(cecCustomer(), 'abc123'))->render();
expect($html)->toContain('/kunde/email-bestaetigen/abc123')
->and($html)->toContain(__('customer.email_change_mail_button'))
->and($html)->toContain('Sascha');
});
it('ersetzt eine ältere offene Anfrage desselben Kunden', function () {
$customer = cecCustomer();
$first = $this->service->request($customer, 'erst@example.com');
$second = $this->service->request($customer, 'zweit@example.com');
expect(ShoppingUserEmailChange::find($first->id))->toBeNull()
->and($this->service->pendingFor($customer)->id)->toBe($second->id);
});
// --- Bestätigung ---
it('übernimmt nach Bestätigung die Adresse für alle Einträge des Kunden und seine Abos', function () {
$older = cecCustomer();
$latest = cecCustomer();
$abo = cecAbo($older);
$endedAbo = cecAbo($older, ['status' => 5]);
$olderAddress = cecCustomer(['billing_email' => 'ganz-alt@example.com']);
$otherCustomer = cecCustomer(['number' => 7200]);
$otherAbo = cecAbo($otherCustomer);
$change = $this->service->request($latest, 'neu@example.com');
expect($this->service->confirm($change))->toBe(CustomerEmailChangeService::STATUS_CONFIRMED);
expect($older->fresh()->billing_email)->toBe('neu@example.com')
->and($latest->fresh()->billing_email)->toBe('neu@example.com')
->and($abo->fresh()->email)->toBe('neu@example.com')
->and($endedAbo->fresh()->email)->toBe('neu@example.com')
->and($olderAddress->fresh()->billing_email)->toBe('ganz-alt@example.com')
->and($otherCustomer->fresh()->billing_email)->toBe('alt@example.com')
->and($otherAbo->fresh()->email)->toBe('alt@example.com');
$change->refresh();
expect($change->confirmed_at)->not->toBeNull()
->and($change->token_hash)->toBeNull()
->and($this->service->confirm($change))->toBe(CustomerEmailChangeService::STATUS_INVALID);
});
it('lehnt abgelaufene Links ab', function () {
$customer = cecCustomer();
$change = $this->service->request($customer, 'neu@example.com');
$change->forceFill(['expires_at' => now()->subMinute()])->save();
expect($this->service->confirm($change->fresh()))->toBe(CustomerEmailChangeService::STATUS_INVALID)
->and($customer->fresh()->billing_email)->toBe('alt@example.com');
});
it('lehnt einen Link ab, wenn die Adresse inzwischen anders geändert wurde', function () {
$customer = cecCustomer();
$change = $this->service->request($customer, 'neu@example.com');
$this->service->changeDirectly($customer, 'admin@example.com');
expect($this->service->confirm($change->fresh() ?? $change))->toBe(CustomerEmailChangeService::STATUS_INVALID)
->and($customer->fresh()->billing_email)->toBe('admin@example.com');
});
// --- Portal-Seite ---
it('bestätigt erst per POST und zeigt bei ungültigem Link eine Fehlerseite', function () {
$customer = cecCustomer();
$this->service->request($customer, 'neu@example.com');
$token = null;
Mail::assertSent(CustomerEmailChangeMail::class, function (CustomerEmailChangeMail $mail) use (&$token) {
$token = $mail->token;
return true;
});
$controller = new CustomerEmailChangeController;
$invalid = $controller->show('gibt-es-nicht', $this->service);
expect($invalid->name())->toBe('portal.customer.email_change_done')
->and($invalid->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_INVALID);
$page = $controller->show($token, $this->service);
expect($page->name())->toBe('portal.customer.email_change_confirm')
->and($customer->fresh()->billing_email)->toBe('alt@example.com');
$done = $controller->store($token, $this->service);
expect($done->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_CONFIRMED)
->and($customer->fresh()->billing_email)->toBe('neu@example.com');
expect($controller->store($token, $this->service)->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_INVALID);
});
// --- CRM ---
it('übernimmt eine vom Berater geänderte E-Mail erst nach Bestätigung, andere Felder sofort', function () {
$customer = cecCustomer();
$abo = cecAbo($customer);
$this->actingAs($this->member);
cecCallStore(app(ConsultantCustomerController::class), $customer->id, cecFormData($customer, 'neu@example.com'));
$customer->refresh();
expect($customer->billing_email)->toBe('alt@example.com')
->and($customer->billing_city)->toBe('Neustadt')
->and($abo->fresh()->email)->toBe('alt@example.com')
->and($this->service->pendingFor($customer)?->new_email)->toBe('neu@example.com')
->and(session('custom-error'))->toContain('neu@example.com');
Mail::assertSent(CustomerEmailChangeMail::class, fn ($mail) => $mail->hasTo('neu@example.com'));
});
it('startet keine Bestätigung, wenn der Berater die E-Mail nicht ändert', function () {
$customer = cecCustomer();
$this->actingAs($this->member);
cecCallStore(app(ConsultantCustomerController::class), $customer->id, cecFormData($customer, 'alt@example.com'));
expect($this->service->pendingFor($customer))->toBeNull()
->and($customer->fresh()->billing_city)->toBe('Neustadt');
Mail::assertNotSent(CustomerEmailChangeMail::class);
});
it('lässt den Admin die E-Mail direkt ändern und zieht die Abos mit', function () {
$customer = cecCustomer();
$abo = cecAbo($customer);
$admin = User::forceCreate([
'email' => 'cec-admin-'.uniqid('', true).'@example.com',
'password' => Hash::make('secret'),
'lang' => 'de',
'admin' => 1,
]);
$this->actingAs($admin);
cecCallStore(app(AdminCustomerController::class), $customer->id, cecFormData($customer, 'neu@example.com'));
expect($customer->fresh()->billing_email)->toBe('neu@example.com')
->and($abo->fresh()->email)->toBe('neu@example.com')
->and(ShoppingUserEmailChange::where('shopping_user_id', $customer->id)->whereNotNull('confirmed_at')->value('requested_by_user_id'))->toBe($admin->id);
Mail::assertNotSent(CustomerEmailChangeMail::class);
});
// --- Kundenhoheit ---
function cecOtherConsultant(array $attributes = []): User
{
return User::forceCreate(array_merge([
'email' => 'cec-other-'.uniqid('', true).'@example.com',
'password' => Hash::make('secret'),
'lang' => 'de',
'active' => 0,
], $attributes));
}
it('erkennt, wenn die E-Mail einem Kunden eines anderen Beraters oder einem Beraterkonto gehört', function () {
$customer = cecCustomer();
$other = cecOtherConsultant();
cecCustomer(['number' => 7300, 'member_id' => $other->id, 'billing_email' => 'fremd@example.com']);
cecCustomer(['number' => 7301, 'billing_email' => 'eigener@example.com']);
cecCustomer(['number' => 7302, 'member_id' => $other->id, 'billing_email' => 'geloescht@example.com'])
->forceFill(['gdpr_deleted_at' => now()])->save();
cecOtherConsultant(['email' => 'berater@example.com', 'active' => 1]);
cecOtherConsultant(['email' => 'inaktiv@example.com', 'active' => 0]);
expect($this->service->ownershipConflict($customer, 'fremd@example.com'))->toBe(CustomerEmailChangeService::CONFLICT_OTHER_CONSULTANT)
->and($this->service->ownershipConflict($customer, ' berater@example.com '))->toBe(CustomerEmailChangeService::CONFLICT_CONSULTANT_ACCOUNT)
->and($this->service->ownershipConflict($customer, 'eigener@example.com'))->toBeNull()
->and($this->service->ownershipConflict($customer, 'geloescht@example.com'))->toBeNull()
->and($this->service->ownershipConflict($customer, 'inaktiv@example.com'))->toBeNull()
->and($this->service->ownershipConflict($customer, 'unbekannt@example.com'))->toBeNull();
});
it('lehnt die Änderung durch den Berater ab, wenn die E-Mail einem anderen Berater zugeordnet ist', function () {
$customer = cecCustomer();
$abo = cecAbo($customer);
$other = cecOtherConsultant();
cecCustomer(['number' => 7300, 'member_id' => $other->id, 'billing_email' => 'fremd@example.com']);
$this->actingAs($this->member);
cecCallStore(app(ConsultantCustomerController::class), $customer->id, cecFormData($customer, 'fremd@example.com'));
$customer->refresh();
expect($customer->billing_email)->toBe('alt@example.com')
->and($customer->billing_city)->toBe('Neustadt')
->and((bool) $customer->is_like)->toBeFalse()
->and($abo->fresh()->email)->toBe('alt@example.com')
->and(ShoppingUserEmailChange::count())->toBe(1)
->and(ShoppingUserEmailChange::blockedOpen()->first()?->new_email)->toBe('fremd@example.com')
->and(session('custom-error'))->toBe(__('customer.email_change_blocked', ['email' => 'fremd@example.com']));
Mail::assertNotSent(CustomerEmailChangeMail::class);
Mail::assertSent(CustomerEmailChangeBlockedMail::class, function (CustomerEmailChangeBlockedMail $mail) use ($other) {
return $mail->hasTo(config('app.priority_mail'))
&& $mail->newEmail === 'fremd@example.com'
&& $mail->owners === [$other->email]
&& $mail->requestedBy === $this->member->email;
});
});
it('lehnt die Bestätigung ab, wenn die E-Mail inzwischen einem anderen Berater zugeordnet wurde', function () {
$customer = cecCustomer();
$abo = cecAbo($customer);
$this->service->request($customer, 'neu@example.com', $this->member->id);
$token = null;
Mail::assertSent(CustomerEmailChangeMail::class, function (CustomerEmailChangeMail $mail) use (&$token) {
$token = $mail->token;
return true;
});
cecCustomer(['number' => 7300, 'member_id' => cecOtherConsultant()->id, 'billing_email' => 'neu@example.com']);
$controller = new CustomerEmailChangeController;
expect($controller->show($token, $this->service)->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_CONFLICT)
->and($controller->store($token, $this->service)->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_CONFLICT)
->and($customer->fresh()->billing_email)->toBe('alt@example.com')
->and($abo->fresh()->email)->toBe('alt@example.com')
->and($this->service->findConfirmable($token))->toBeNull();
Mail::assertSent(CustomerEmailChangeBlockedMail::class);
});
it('rendert die Info-Mail an die Kundenhoheit', function () {
$html = (new CustomerEmailChangeBlockedMail(cecCustomer(), 'fremd@example.com', 'berater@example.com', ['b2@example.com']))->render();
expect($html)->toContain('fremd@example.com')
->and($html)->toContain('b2@example.com')
->and($html)->toContain('alt@example.com');
});