mivita/tests/Feature/Newsletter/NewsletterListServiceTest.php
Kevin c858bd6ed0 Newsletter-Listen: Empfaengerlisten filtern, speichern und exportieren
Admin-Bereich "Newsletter-Listen" (Plan dev/_tickets/entwicklungsplan-
newsletter-listen.md): Berater und Kunden nach Filtern zusammenstellen,
als Liste speichern und wieder laden, Export als XLSX/CSV. Jeder Export
wird protokolliert (newsletter_list_exports).

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

364 lines
17 KiB
PHP

<?php
use App\Models\Country;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserLevel;
use App\Services\Newsletter\NewsletterListFilter;
use App\Services\Newsletter\NewsletterListResult;
use App\Services\Newsletter\NewsletterListService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function newsletterCountry(string $code = 'DE'): Country
{
return Country::firstOrCreate(['code' => $code], [
'phone' => '49', 'en' => $code, 'de' => $code, 'es' => $code, 'fr' => $code, 'it' => $code, 'ru' => $code,
]);
}
/**
* @param array<string, mixed> $attributes
*/
function newsletterCustomer(array $attributes = []): ShoppingUser
{
return ShoppingUser::forceCreate(array_merge([
'auth_user_id' => 0,
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_salutation' => 'ms',
'billing_address' => 'Musterweg 1',
'billing_zipcode' => '12345',
'billing_city' => 'Musterstadt',
'billing_country_id' => newsletterCountry()->id,
'shipping_country_id' => 1,
'language' => 'de',
'subscribed' => 1,
'has_buyed' => 1,
'faker_mail' => 0,
'number' => 'K-'.uniqid('', true),
], $attributes));
}
/**
* @param array<string, mixed> $userAttributes
* @param array<string, mixed> $accountAttributes
*/
function newsletterConsultant(array $userAttributes = [], array $accountAttributes = []): User
{
$account = UserAccount::forceCreate(array_merge([
'm_account' => 'B-'.uniqid('', true),
'salutation' => 'mr',
'first_name' => 'Max',
'last_name' => 'Berater',
'address' => 'Beraterstraße 2',
'zipcode' => '54321',
'city' => 'Beraterstadt',
'country_id' => newsletterCountry()->id,
], $accountAttributes));
return User::forceCreate(array_merge([
'email' => 'berater-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'account_id' => $account->id,
'active' => 1,
'blocked' => 0,
'admin' => 0,
'test_mode' => 0,
], $userAttributes))->fresh();
}
/**
* @param array<string, mixed> $input
*/
function newsletterBuild(array $input = []): NewsletterListResult
{
return app(NewsletterListService::class)->build(NewsletterListFilter::fromArray($input));
}
it('liefert Kunden mit den Vorlage-Spalten und dem zugeordneten Berater', function () {
$consultant = newsletterConsultant([], ['first_name' => 'Anna', 'last_name' => 'Sponsor']);
$customer = newsletterCustomer(['billing_email' => 'Erika.Muster@Example.com ', 'member_id' => $consultant->id, 'billing_address_2' => 'Hinterhaus']);
$result = newsletterBuild(['source' => 'customers']);
expect($result->rows)->toHaveCount(1);
$row = $result->rows->first();
expect($row['email'])->toBe('erika.muster@example.com')
->and($row['firstname'])->toBe('Erika')
->and($row['salutation_label'])->toBe('Frau')
->and($row['street'])->toBe('Musterweg 1, Hinterhaus')
->and($row['consultant'])->toBe('Anna Sponsor')
->and($row['consultant_number'])->toBe($consultant->account->m_account)
->and($row['country'])->toBe('DE')
->and($row['source'])->toBe('customer')
->and($row['optin'])->toBe('yes')
->and($row['number'])->toBe($customer->number);
});
it('schließt gelöschte, anonymisierte, Fake-, Test- und Berater-Kundendatensätze immer aus', function () {
newsletterCustomer(['billing_email' => 'ok@example.com']);
newsletterCustomer(['billing_email' => 'faker@example.com', 'faker_mail' => 1]);
newsletterCustomer(['billing_email' => 'geloescht_5@dsgvo.invalid']);
newsletterCustomer(['billing_email' => 'x-faker@mivita.care']);
newsletterCustomer(['billing_email' => 'dev@example.com', 'mode' => 'dev']);
newsletterCustomer(['billing_email' => 'prev@example.com', 'mode' => 'prev']);
newsletterCustomer(['billing_email' => 'live@example.com', 'mode' => 'live']);
newsletterCustomer(['billing_email' => 'berater-shop@example.com', 'auth_user_id' => 99]);
newsletterCustomer(['billing_email' => 'userdeleted@example.com', 'user_deleted_at' => now()]);
newsletterCustomer(['billing_email' => 'softdeleted@example.com'])->delete();
$emails = newsletterBuild(['source' => 'customers'])->rows->pluck('email')->all();
expect($emails)->toEqualCanonicalizing(['ok@example.com', 'live@example.com']);
});
it('schließt gesperrte, gelöschte, Test- und Admin-Berater aus und kann Admins optional einbeziehen', function () {
newsletterConsultant(['email' => 'aktiv@example.com']);
newsletterConsultant(['email' => 'vip@example.com', 'admin' => 1]);
newsletterConsultant(['email' => 'admin@example.com', 'admin' => 2]);
newsletterConsultant(['email' => 'sysadmin@example.com', 'admin' => 5]);
newsletterConsultant(['email' => 'blocked@example.com', 'blocked' => 1]);
newsletterConsultant(['email' => 'testmode@example.com', 'test_mode' => 1]);
newsletterConsultant(['email' => 'predeleted@example.com', 'pre_deleted_at' => now()]);
newsletterConsultant(['email' => 'delete-7-alt@example.com']);
newsletterConsultant(['email' => 'softdeleted@example.com'])->delete();
$default = newsletterBuild(['source' => 'consultants'])->rows->pluck('email')->all();
$withAdmins = newsletterBuild(['source' => 'consultants', 'include_admins' => 1])->rows->pluck('email')->all();
expect($default)->toEqualCanonicalizing(['aktiv@example.com', 'vip@example.com'])
->and($withAdmins)->toEqualCanonicalizing(['aktiv@example.com', 'vip@example.com', 'admin@example.com']);
});
it('entfernt Duplikate über beide Quellen, Berater gewinnt vor Kunde und Opt-in vor ohne Opt-in', function () {
newsletterConsultant(['email' => 'Doppelt@Example.com']);
newsletterCustomer(['billing_email' => 'doppelt@example.com', 'billing_firstname' => 'Kunde']);
newsletterCustomer(['billing_email' => 'zweimal@example.com', 'billing_firstname' => 'Ohne', 'subscribed' => 0]);
newsletterCustomer(['billing_email' => 'ZWEIMAL@example.com', 'billing_firstname' => 'Mit', 'subscribed' => 1]);
newsletterCustomer(['billing_email' => 'zweimal@example.com', 'billing_firstname' => 'Ohne2', 'subscribed' => 0]);
$result = newsletterBuild(['source' => 'both', 'consultant_status' => 'all']);
$byEmail = $result->rows->keyBy('email');
expect($result->rows)->toHaveCount(2)
->and($byEmail['doppelt@example.com']['source'])->toBe('consultant')
->and($byEmail['zweimal@example.com']['firstname'])->toBe('Mit')
->and($result->stats['duplicates'])->toBe(3)
->and($result->stats['total'])->toBe(2)
->and($result->stats['customers'])->toBe(1)
->and($result->stats['consultants'])->toBe(1);
});
it('verwirft ungültige E-Mail-Adressen und zählt sie', function () {
newsletterCustomer(['billing_email' => 'kontakt@ kaputt.de']);
newsletterCustomer(['billing_email' => '#']);
newsletterCustomer(['billing_email' => 'gut@example.com']);
$result = newsletterBuild(['source' => 'customers']);
expect($result->rows->pluck('email')->all())->toBe(['gut@example.com'])
->and($result->stats['invalid'])->toBe(2);
});
it('filtert nach Sprache und behandelt fehlende Sprache wie Deutsch', function () {
newsletterCustomer(['billing_email' => 'de@example.com', 'language' => 'de']);
newsletterCustomer(['billing_email' => 'null@example.com', 'language' => null]);
newsletterCustomer(['billing_email' => 'es@example.com', 'language' => 'es']);
newsletterConsultant(['email' => 'berater-es@example.com', 'lang' => 'es']);
expect(newsletterBuild(['source' => 'both', 'language' => 'de'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['de@example.com', 'null@example.com'])
->and(newsletterBuild(['source' => 'both', 'language' => 'es'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['es@example.com', 'berater-es@example.com'])
->and(newsletterBuild(['source' => 'both', 'language' => ''])->rows)->toHaveCount(4);
});
it('filtert nach Land bei Kunden und Beratern', function () {
$at = newsletterCountry('AT');
newsletterCustomer(['billing_email' => 'de@example.com']);
newsletterCustomer(['billing_email' => 'at@example.com', 'billing_country_id' => $at->id]);
newsletterConsultant(['email' => 'berater-at@example.com'], ['country_id' => $at->id]);
newsletterConsultant(['email' => 'berater-de@example.com']);
$emails = newsletterBuild(['source' => 'both', 'country_ids' => [$at->id]])->rows->pluck('email')->all();
expect($emails)->toEqualCanonicalizing(['at@example.com', 'berater-at@example.com']);
});
it('filtert nach Opt-in', function () {
newsletterCustomer(['billing_email' => 'ja@example.com', 'subscribed' => 1]);
newsletterCustomer(['billing_email' => 'nein@example.com', 'subscribed' => 0]);
expect(newsletterBuild(['optin' => 'subscribed'])->rows->pluck('email')->all())->toBe(['ja@example.com'])
->and(newsletterBuild(['optin' => 'unsubscribed'])->rows->pluck('email')->all())->toBe(['nein@example.com'])
->and(newsletterBuild(['optin' => 'all'])->rows)->toHaveCount(2);
});
it('filtert nach Kaufstatus inklusive Zeitraum und aktivem Abo', function () {
$country = newsletterCountry();
$never = newsletterCustomer(['billing_email' => 'nie@example.com', 'has_buyed' => 0]);
$recent = newsletterCustomer(['billing_email' => 'kuerzlich@example.com']);
$old = newsletterCustomer(['billing_email' => 'alt@example.com']);
$abo = newsletterCustomer(['billing_email' => 'abo@example.com']);
foreach ([[$recent, now()->subMonths(2)], [$old, now()->subMonths(4)]] as [$shoppingUser, $createdAt]) {
ShoppingOrder::forceCreate([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => 0,
'country_id' => $country->id,
'language' => 'de',
'user_shop_id' => 1,
'payment_for' => 6,
'total' => 50,
'subtotal' => 50,
'points' => 25,
'paid' => true,
'shipped' => 0,
'mode' => 'live',
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
}
UserAbo::create([
'user_id' => 1,
'shopping_user_id' => $abo->id,
'is_for' => 'customer',
'email' => $abo->billing_email,
'payone_userid' => 1,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
]);
UserAbo::create([
'user_id' => 1,
'shopping_user_id' => $never->id,
'is_for' => 'customer',
'email' => $never->billing_email,
'payone_userid' => 2,
'clearingtype' => 'cc',
'active' => false,
'status' => 4,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
]);
$emails = fn (string $purchase) => newsletterBuild(['purchase' => $purchase])->rows->pluck('email')->all();
expect($emails('has_buyed'))->toEqualCanonicalizing(['kuerzlich@example.com', 'alt@example.com', 'abo@example.com'])
->and($emails('none'))->toBe(['nie@example.com'])
->and($emails('months_3'))->toBe(['kuerzlich@example.com'])
->and($emails('months_6'))->toEqualCanonicalizing(['kuerzlich@example.com', 'alt@example.com'])
->and($emails('active_abo'))->toBe(['abo@example.com']);
});
it('filtert nach zugeordnetem Berater bzw. Sponsor', function () {
$sponsor = newsletterConsultant(['email' => 'sponsor@example.com']);
newsletterCustomer(['billing_email' => 'zugeordnet@example.com', 'member_id' => $sponsor->id]);
newsletterCustomer(['billing_email' => 'ohne@example.com', 'member_id' => null]);
newsletterConsultant(['email' => 'firstline@example.com', 'm_sponsor' => $sponsor->id]);
expect(newsletterBuild(['source' => 'both', 'member_id' => (string) $sponsor->id])->rows->pluck('email')->all())
->toEqualCanonicalizing(['zugeordnet@example.com', 'firstline@example.com'])
->and(newsletterBuild(['source' => 'customers', 'member_id' => 'none'])->rows->pluck('email')->all())
->toBe(['ohne@example.com']);
});
it('filtert nach Anlagedatum und Firma', function () {
newsletterCustomer(['billing_email' => 'alt@example.com', 'billing_company' => 'Firma GmbH', 'created_at' => '2025-01-15 10:00:00']);
newsletterCustomer(['billing_email' => 'neu@example.com', 'billing_company' => '', 'created_at' => '2026-03-01 10:00:00']);
newsletterConsultant(['email' => 'berater-firma@example.com'], ['company' => 'Beratung UG']);
newsletterConsultant(['email' => 'berater-privat@example.com'], ['company' => null]);
expect(newsletterBuild(['created_from' => '2026-01-01', 'created_to' => '2026-12-31'])->rows->pluck('email')->all())
->toBe(['neu@example.com'])
->and(newsletterBuild(['source' => 'both', 'company' => 'with'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['alt@example.com', 'berater-firma@example.com'])
->and(newsletterBuild(['source' => 'both', 'company' => 'without'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['neu@example.com', 'berater-privat@example.com']);
});
it('filtert Berater nach Status und Level und gibt den Status in der Zeile aus', function () {
$level = UserLevel::forceCreate(['name' => 'Starter', 'pos' => 1]);
newsletterConsultant(['email' => 'aktiv@example.com', 'm_level' => $level->id]);
newsletterConsultant(['email' => 'inaktiv@example.com', 'active' => 0]);
newsletterConsultant(['email' => 'anderes-level@example.com']);
$active = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'active']);
$inactive = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'inactive']);
$all = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'all']);
$byLevel = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'all', 'level_ids' => [$level->id]]);
expect($active->rows->pluck('email')->all())->toEqualCanonicalizing(['aktiv@example.com', 'anderes-level@example.com'])
->and($inactive->rows->pluck('email')->all())->toBe(['inaktiv@example.com'])
->and($inactive->rows->first()['status'])->toBe('inactive')
->and($inactive->rows->first()['optin'])->toBe('contract')
->and($all->rows)->toHaveCount(3)
->and($byLevel->rows->pluck('email')->all())->toBe(['aktiv@example.com'])
->and($byLevel->rows->first()['level'])->toBe('Starter');
});
it('liefert Export-Kopfzeile und Zeilen in Vorlagen-Reihenfolge, optional nur die neun Vorlage-Spalten', function () {
$consultant = newsletterConsultant([], ['first_name' => 'Anna', 'last_name' => 'Sponsor']);
newsletterCustomer([
'billing_email' => 'export@example.com',
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_company' => 'Muster GmbH',
'member_id' => $consultant->id,
]);
$service = app(NewsletterListService::class);
$rows = newsletterBuild()->rows;
$fullHeaders = $service->exportHeaders(false);
$templateHeaders = $service->exportHeaders(true);
$templateRows = $service->exportRows($rows, true);
$fullRows = $service->exportRows($rows, false);
expect($templateHeaders)->toBe(['Emailadresse', 'Vorname', 'Nachname', 'Anrede', 'Straße', 'PLZ', 'Wohnort', 'Firma', 'Berater'])
->and($fullHeaders)->toHaveCount(17)
->and($fullHeaders[9])->toBe('Land')
->and($templateRows[0])->toBe(['export@example.com', 'Erika', 'Muster', 'Frau', 'Musterweg 1', '12345', 'Musterstadt', 'Muster GmbH', 'Anna Sponsor'])
->and($fullRows[0])->toHaveCount(17)
->and($fullRows[0][9])->toBe('DE')
->and($fullRows[0][11])->toBe('Kunde')
->and($fullRows[0][14])->toBe('ja');
});
it('baut den Filter mit Defaults und akzeptiert nur erlaubte Werte', function () {
$default = NewsletterListFilter::fromArray([]);
$custom = NewsletterListFilter::fromArray([
'source' => 'unsinn',
'language' => '',
'country_ids' => ['3', '', '3', 'x'],
'purchase' => 'months_6',
'member_id' => '12',
'created_from' => '2026-01-01',
'created_to' => 'kein datum',
'include_admins' => '1',
]);
expect($default->source)->toBe('customers')
->and($default->language)->toBe('de')
->and($default->optin)->toBe('all')
->and($default->consultantStatus)->toBe('active')
->and($custom->source)->toBe('customers')
->and($custom->language)->toBeNull()
->and($custom->countryIds)->toBe([3, 0])
->and($custom->purchaseMonths())->toBe(6)
->and($custom->memberIdValue())->toBe(12)
->and($custom->createdFrom?->format('Y-m-d H:i:s'))->toBe('2026-01-01 00:00:00')
->and($custom->createdTo)->toBeNull()
->and($custom->includeAdmins)->toBeTrue()
->and($default->cacheKey())->not->toBe($custom->cacheKey());
});