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>
This commit is contained in:
Kevin 2026-09-23 15:44:09 +00:00
parent ef7e333bd4
commit d21d94a060
26 changed files with 3664 additions and 0 deletions

View file

@ -0,0 +1,153 @@
<?php
use App\Exports\xExport;
use App\Http\Controllers\Admin\NewsletterListController;
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
use App\Models\ShoppingUser;
use App\Services\Newsletter\NewsletterListService;
use App\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Redirector;
use Illuminate\Validation\ValidationException;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function newsletterAdmin(int $admin = 2): User
{
return User::forceCreate([
'email' => 'admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'admin' => $admin,
'lang' => 'de',
'active' => 1,
'blocked' => 0,
]);
}
/**
* @param array<string, mixed> $params
*/
function newsletterRequest(User $user, array $params = []): NewsletterListFilterRequest
{
$request = NewsletterListFilterRequest::create('/admin/newsletter-lists/datatable', 'GET', $params);
$request->setUserResolver(fn () => $user);
$request->setContainer(app())->setRedirector(app(Redirector::class));
$request->validateResolved();
app()->instance('request', $request);
return $request;
}
function newsletterController(): NewsletterListController
{
return new NewsletterListController(app(NewsletterListService::class));
}
/**
* @param array<string, mixed> $attributes
*/
function newsletterListCustomer(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' => 1,
'shipping_country_id' => 1,
'language' => 'de',
'subscribed' => 1,
'has_buyed' => 1,
'faker_mail' => 0,
], $attributes));
}
it('verweigert Nicht-Admins den Zugriff auf die Filter-Anfrage', function () {
$this->actingAs(newsletterAdmin(0));
newsletterRequest(auth()->user(), ['source' => 'customers']);
})->throws(AuthorizationException::class);
it('lehnt ungültige Filterwerte ab', function () {
$this->actingAs(newsletterAdmin());
newsletterRequest(auth()->user(), ['source' => 'alle', 'created_from' => '2026-02-01', 'created_to' => '2026-01-01']);
})->throws(ValidationException::class);
it('liefert die Vorschau als Datatable-JSON mit Kennzahlen', function () {
$admin = newsletterAdmin();
$this->actingAs($admin);
newsletterListCustomer(['billing_email' => 'a@example.com']);
newsletterListCustomer(['billing_email' => 'A@example.com']);
newsletterListCustomer(['billing_email' => 'b@example.com', 'subscribed' => 0]);
$request = newsletterRequest($admin, ['source' => 'customers', 'draw' => 1, 'start' => 0, 'length' => 50]);
$json = newsletterController()->datatable($request)->getData(true);
expect($json['recordsTotal'])->toBe(2)
->and($json['stats']['total'])->toBe(2)
->and($json['stats']['duplicates'])->toBe(1)
->and(collect($json['data'])->pluck('email')->all())->toBe(['a@example.com', 'b@example.com'])
->and($json['data'][0]['name'])->toBe('Erika Muster')
->and($json['data'][0]['optin_label'])->toContain('badge-success')
->and($json['data'][1]['optin_label'])->toContain('badge-danger')
->and(get_user_attr('newsletter_list_filter')['source'])->toBe('customers');
});
it('exportiert die Liste als CSV mit BOM, Semikolon und Injection-Schutz', function () {
$admin = newsletterAdmin();
$this->actingAs($admin);
newsletterListCustomer(['billing_email' => 'csv@example.com', 'billing_firstname' => '=SUMME(A1)']);
$response = newsletterController()->export(newsletterRequest($admin, ['source' => 'customers', 'template_only' => 1]), 'csv');
expect($response)->toBeInstanceOf(StreamedResponse::class);
ob_start();
$response->sendContent();
$content = (string) ob_get_clean();
$lines = explode("\n", trim($content));
expect($content)->toStartWith("\xEF\xBB\xBF")
->and($lines[0])->toBe("\xEF\xBB\xBF".'Emailadresse;Vorname;Nachname;Anrede;Straße;PLZ;Wohnort;Firma;Berater')
->and($lines[1])->toBe("csv@example.com;'=SUMME(A1);Muster;Frau;\"Musterweg 1\";12345;Musterstadt;;")
->and($response->headers->get('content-disposition'))->toContain('newsletter_customers_de_'.now()->format('Y-m-d').'.csv');
});
it('exportiert die Liste als XLSX über die Export-Klasse', function () {
Excel::fake();
$admin = newsletterAdmin();
$this->actingAs($admin);
newsletterListCustomer(['billing_email' => 'xlsx@example.com']);
newsletterController()->export(newsletterRequest($admin, ['source' => 'customers', 'language' => '']), 'xlsx');
Excel::assertDownloaded('newsletter_customers_alle_'.now()->format('Y-m-d').'.xlsx', function (xExport $export) {
$rows = $export->collection();
return $export->headings()[0][0] === 'Emailadresse'
&& count($export->headings()[0]) === 17
&& $rows->count() === 1
&& $rows->first()[0] === 'xlsx@example.com';
});
});
it('zeigt die Index-Seite mit gemerktem Filter', function () {
$admin = newsletterAdmin();
$this->actingAs($admin);
set_user_attr('newsletter_list_filter', ['source' => 'both', 'language' => 'es', 'optin' => 'subscribed']);
$view = newsletterController()->index(newsletterRequest($admin));
expect($view->getName())->toBe('admin.newsletter.index')
->and($view->getData()['filter']['source'])->toBe('both')
->and($view->getData()['filter']['language'])->toBe('es')
->and($view->getData()['filter']['optin'])->toBe('subscribed');
});

View file

@ -0,0 +1,256 @@
<?php
use App\Http\Controllers\Admin\NewsletterListController;
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
use App\Http\Requests\Newsletter\StoreNewsletterListRequest;
use App\Models\NewsletterList;
use App\Models\NewsletterListExport;
use App\Models\ShoppingUser;
use App\Services\Newsletter\NewsletterListFilter;
use App\Services\Newsletter\NewsletterListService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\ValidationException;
use Maatwebsite\Excel\Facades\Excel;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Domain-Routen werden im Test-Kontext nicht geladen (Host ist nicht die CRM-Domain).
* Für die Weiterleitungen und Links des Controllers registrieren wir die CRM-Routen hier.
*/
beforeEach(function () {
if (! app('router')->has('admin_newsletter_lists_load')) {
Route::group([], base_path('routes/domains/crm.php'));
app('router')->getRoutes()->refreshNameLookups();
}
});
function newsletterSavedAdmin(): User
{
return User::forceCreate([
'email' => 'admin-saved-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'admin' => 2,
'lang' => 'de',
'active' => 1,
'blocked' => 0,
]);
}
/**
* @param array<string, mixed> $params
*/
function newsletterStoreRequest(User $user, array $params = []): StoreNewsletterListRequest
{
$request = StoreNewsletterListRequest::create('/admin/newsletter-lists/save', 'POST', $params);
$request->setUserResolver(fn () => $user);
$request->setContainer(app())->setRedirector(app(Redirector::class));
$request->validateResolved();
app()->instance('request', $request);
return $request;
}
/**
* @param array<string, mixed> $filters
*/
function newsletterSavedList(User $user, array $filters = [], string $name = 'Testliste'): NewsletterList
{
$list = new NewsletterList(['name' => $name, 'created_by' => $user->id]);
$list->applyFilter(NewsletterListFilter::fromArray($filters));
$list->save();
return $list;
}
function newsletterSavedCustomer(string $email): ShoppingUser
{
return ShoppingUser::forceCreate([
'auth_user_id' => 0,
'billing_email' => $email,
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_salutation' => 'ms',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'language' => 'de',
'subscribed' => 1,
'has_buyed' => 1,
'faker_mail' => 0,
]);
}
it('speichert die aktuelle Filterkombination unter einem Namen', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$request = newsletterStoreRequest($admin, ['name' => ' Newsletter DE Kunden ', 'source' => 'both', 'language' => 'de', 'optin' => 'subscribed', 'level_ids' => ['2']]);
$response = (new NewsletterListController(app(NewsletterListService::class)))->store($request);
$list = NewsletterList::first();
expect($list->name)->toBe('Newsletter DE Kunden')
->and($list->source)->toBe('both')
->and($list->created_by)->toBe($admin->id)
->and($list->filters['optin'])->toBe('subscribed')
->and($list->filters['level_ids'])->toBe([2])
->and($list->toFilter()->optin)->toBe('subscribed')
->and($response->getTargetUrl())->toContain('/admin/newsletter-lists/load/'.$list->id);
});
it('verlangt beim Speichern einen Namen', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
newsletterStoreRequest($admin, ['source' => 'customers']);
})->throws(ValidationException::class);
it('aktualisiert Name und Filter einer gespeicherten Liste', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin, ['source' => 'customers']);
$request = newsletterStoreRequest($admin, ['name' => 'Berater ES', 'source' => 'consultants', 'language' => 'es', 'consultant_status' => 'all']);
(new NewsletterListController(app(NewsletterListService::class)))->update($request, $list);
$list->refresh();
expect($list->name)->toBe('Berater ES')
->and($list->source)->toBe('consultants')
->and($list->filters['language'])->toBe('es')
->and($list->filters['consultant_status'])->toBe('all');
});
it('lädt eine gespeicherte Liste in die Filter-Seite und merkt den Filter', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin, ['source' => 'consultants', 'language' => '']);
$view = (new NewsletterListController(app(NewsletterListService::class)))->load($list);
expect($view->getName())->toBe('admin.newsletter.index')
->and($view->getData()['list']->id)->toBe($list->id)
->and($view->getData()['filter']['source'])->toBe('consultants')
->and($view->getData()['filter']['language'])->toBe('')
->and(get_user_attr('newsletter_list_filter')['source'])->toBe('consultants');
});
it('protokolliert Exporte und aktualisiert die gespeicherte Liste', function () {
Excel::fake();
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
newsletterSavedCustomer('eins@example.com');
newsletterSavedCustomer('zwei@example.com');
$list = newsletterSavedList($admin, ['source' => 'customers', 'language' => 'de']);
$request = NewsletterListFilterRequest::create('/x', 'GET', []);
$request->setUserResolver(fn () => $admin);
(new NewsletterListController(app(NewsletterListService::class)))->exportSaved($request, $list, 'xlsx');
$export = NewsletterListExport::first();
$list->refresh();
expect($export->newsletter_list_id)->toBe($list->id)
->and($export->user_id)->toBe($admin->id)
->and($export->format)->toBe('xlsx')
->and($export->row_count)->toBe(2)
->and($export->filename)->toBe('newsletter_customers_de_'.now()->format('Y-m-d').'.xlsx')
->and($export->filters['source'])->toBe('customers')
->and($export->stats['total'])->toBe(2)
->and($list->last_row_count)->toBe(2)
->and($list->last_exported_at)->not->toBeNull();
Excel::assertDownloaded($export->filename);
});
it('protokolliert Ad-hoc-Exporte ohne Liste und ordnet sie bei list_id der Liste zu', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
newsletterSavedCustomer('csv@example.com');
$list = newsletterSavedList($admin);
$controller = new NewsletterListController(app(NewsletterListService::class));
$adhoc = NewsletterListFilterRequest::create('/x', 'GET', ['source' => 'customers']);
$adhoc->setUserResolver(fn () => $admin);
$adhoc->setContainer(app())->setRedirector(app(Redirector::class));
$adhoc->validateResolved();
$controller->export($adhoc, 'csv');
$linked = NewsletterListFilterRequest::create('/x', 'GET', ['source' => 'customers', 'list_id' => (string) $list->id]);
$linked->setUserResolver(fn () => $admin);
$linked->setContainer(app())->setRedirector(app(Redirector::class));
$linked->validateResolved();
$controller->export($linked, 'csv');
$exports = NewsletterListExport::orderBy('id')->get();
expect($exports)->toHaveCount(2)
->and($exports[0]->newsletter_list_id)->toBeNull()
->and($exports[0]->format)->toBe('csv')
->and($exports[1]->newsletter_list_id)->toBe($list->id)
->and($list->fresh()->last_row_count)->toBe(1);
});
it('löscht eine Liste und behält das Protokoll', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin);
NewsletterListExport::create([
'newsletter_list_id' => $list->id,
'user_id' => $admin->id,
'format' => 'csv',
'row_count' => 3,
'filename' => 'x.csv',
'filters' => $list->filters,
]);
$response = (new NewsletterListController(app(NewsletterListService::class)))->destroy($list);
expect(NewsletterList::count())->toBe(0)
->and(NewsletterListExport::first()->newsletter_list_id)->toBeNull()
->and($response->getTargetUrl())->toContain('/admin/newsletter-lists/saved');
});
it('liefert gespeicherte Listen und Protokoll als Datatable-JSON', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin, ['source' => 'customers', 'optin' => 'subscribed', 'purchase' => 'months_6'], 'Kunden Opt-in');
NewsletterListExport::create([
'newsletter_list_id' => $list->id,
'user_id' => $admin->id,
'format' => 'xlsx',
'row_count' => 7,
'filename' => 'newsletter_customers_de.xlsx',
'filters' => $list->filters,
'stats' => ['total' => 7],
]);
$request = Request::create('/x', 'GET', ['draw' => 1, 'start' => 0, 'length' => 50]);
$request->setUserResolver(fn () => $admin);
app()->instance('request', $request);
$controller = new NewsletterListController(app(NewsletterListService::class));
$saved = $controller->savedDatatable()->getData(true);
$exports = $controller->exportsDatatable()->getData(true);
expect($saved['recordsTotal'])->toBe(1)
->and($saved['data'][0]['name'])->toContain('Kunden Opt-in')
->and($saved['data'][0]['source'])->toBe('Kunden')
->and($saved['data'][0]['summary'])->toContain('nur mit Opt-in')
->and($saved['data'][0]['summary'])->toContain('6 Monaten')
->and($saved['data'][0]['actions'])->toContain('/admin/newsletter-lists/saved/'.$list->id.'/export/xlsx')
->and($exports['recordsTotal'])->toBe(1)
->and($exports['data'][0]['format'])->toBe('XLSX')
->and($exports['data'][0]['row_count'])->toBe(7)
->and($exports['data'][0]['list'])->toContain('Kunden Opt-in');
});
it('beschreibt einen Filter lesbar', function () {
$service = app(NewsletterListService::class);
$text = $service->describe(NewsletterListFilter::fromArray([
'source' => 'both', 'language' => 'de', 'optin' => 'unsubscribed', 'member_id' => 'none',
'created_from' => '2026-01-01', 'company' => 'with', 'consultant_status' => 'all', 'include_admins' => 1,
]));
expect($text)->toBe('Kunden und Berater · Sprache: DE · Newsletter-Opt-in: nur ohne Opt-in · Zugeordneter Berater / Sponsor: ohne Zuordnung · Angelegt von: 01.01.2026 – … · Firma: nur mit Firma · Berater-Status: alle · Admin-Accounts einbeziehen');
});

View file

@ -0,0 +1,364 @@
<?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());
});