Abo Einmalprodukte: Phasen 5-7, konfigurierbare Sperrfristen, Lokalisierung & Dashboard
Rechnung/Lieferschein: - PDF-Gruppierung Abo- vs. Einmal-Artikel (is_abo_addon), rueckwaertskompatibel - Vorschau-Command sales:preview-invoice (umgeht Test-System-Gate, ohne Rechnungsnummer) Reminder-Mail (vorbereitet, Cron inaktiv): - MailAboExecutionReminder + Template, abo:send-execution-reminder, Spalte onetime_reminder_sent_for - Berater -> Sales Center, Kunde -> Portal; Vorschau abo:preview-reminder - Datum-Fix: formatDateDB() statt formatDate() (ICU-Format) Sperrfristen konfigurierbar (Admin-Settings): - abo-lock-days-change (Liefertag + Abo-Produkte), abo-lock-days-pause-cancel - Meldungen mit :lock-Platzhalter; Pausieren/Kuendigen vom Change-Lock entkoppelt - Abo-Produkt-Aenderungen im Lock-Fenster server- + clientseitig eingefroren - Selbstbedienung: Add-only/Lock nach $view (kein Admin-Bypass), Stale-Cache-Fix beim Mengen-Update Dashboard: - Einmalprodukte-Card + Team-Abo-KPI-Card, News-Card responsiv (col-xl-6) Lokalisierung: - Sprachdateien en/es/fr vollstaendig mit de synchronisiert (inkl. marketingplan/portal) - LocalizationSyncTest sichert Key-Gleichheit ab Tests: AboOneTimeInvoiceGrouping, AboExecutionReminder, AboLockDaysSetting, AboOnetimeDashboardAndMail, PreviewInvoiceCommand, TeamAboOverview + erweiterte View-Tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9f996a7f1b
commit
65b867f8ee
96 changed files with 4154 additions and 1189 deletions
115
tests/Feature/AboExecutionReminderTest.php
Normal file
115
tests/Feature/AboExecutionReminderTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\MailAboExecutionReminder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Phase 6 – Reminder-E-Mail vor der Abo-Ausführung (Einmalprodukte-Fenster).
|
||||
* Default-Fenster = 4 Tage. Berater (is_for=me) → Sales Center, Endkunde (ot) → Portal.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
makeShopEnv();
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function makeOtAbo(?\Carbon\Carbon $nextDate = null): UserAbo
|
||||
{
|
||||
$shoppingUser = ShoppingUser::forceCreate([
|
||||
'auth_user_id' => 1,
|
||||
'billing_firstname' => 'Erika',
|
||||
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
|
||||
'billing_country_id' => 1,
|
||||
'shipping_country_id' => 1,
|
||||
'wp_order_number' => 0,
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
return UserAbo::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'is_for' => 'ot',
|
||||
'email' => $shoppingUser->billing_email,
|
||||
'payone_userid' => 900200,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 5,
|
||||
'next_date' => $nextDate ?? now()->addDays(4),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('AboSendExecutionReminder Selektion', function () {
|
||||
it('versendet an ein Abo, dessen Ausführung im Fenster (heute + 4 Tage) liegt', function () {
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['next_date' => now()->addDays(4)]);
|
||||
|
||||
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
|
||||
|
||||
Mail::assertSent(MailAboExecutionReminder::class, 1);
|
||||
});
|
||||
|
||||
it('versendet NICHT außerhalb des Fensters', function () {
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['next_date' => now()->addDays(10)]);
|
||||
|
||||
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('versendet NICHT bei inaktivem oder falschem Status', function () {
|
||||
makeMeAbo()->update(['next_date' => now()->addDays(4), 'active' => false]);
|
||||
makeMeAbo()->update(['next_date' => now()->addDays(4), 'status' => 3]);
|
||||
|
||||
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AboSendExecutionReminder Link-Wahl', function () {
|
||||
it('Berater-Abo verlinkt ins Sales Center', function () {
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['next_date' => now()->addDays(4)]);
|
||||
|
||||
$html = (new MailAboExecutionReminder($abo->fresh()))->render();
|
||||
|
||||
expect($html)->toContain(rtrim(Util::getMyMivitaUrl(), '/').'/user/abos/detail/me/'.$abo->id);
|
||||
});
|
||||
|
||||
it('Endkunden-Abo verlinkt ins Kundenportal', function () {
|
||||
$abo = makeOtAbo();
|
||||
|
||||
$html = (new MailAboExecutionReminder($abo->fresh()))->render();
|
||||
|
||||
expect($html)->toContain(rtrim(Util::getMyMivitaPortalUrl(), '/').'/portal/my-subscriptions');
|
||||
});
|
||||
|
||||
it('formatiert das Ausführungsdatum korrekt (kein doppeltes ICU-Format)', function () {
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['next_date' => '2026-07-10']);
|
||||
|
||||
$html = (new MailAboExecutionReminder($abo->fresh()))->render();
|
||||
|
||||
expect($html)->toContain('10.07.2026')
|
||||
->and($html)->not->toContain('1010.0707');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AboSendExecutionReminder Duplikat-Schutz', function () {
|
||||
it('versendet pro Zyklus nur einmal', function () {
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['next_date' => now()->addDays(4)]);
|
||||
|
||||
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
|
||||
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
|
||||
|
||||
Mail::assertSent(MailAboExecutionReminder::class, 1);
|
||||
expect($abo->fresh()->onetime_reminder_sent_for)->not->toBeNull();
|
||||
});
|
||||
});
|
||||
192
tests/Feature/AboLockDaysSettingTest.php
Normal file
192
tests/Feature/AboLockDaysSettingTest.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Setting;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Repositories\AboRepository;
|
||||
use App\Services\AboHelper;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
describe('Abo-Sperrfristen als Setting', function () {
|
||||
it('nutzt die Defaults, wenn kein Setting gesetzt ist', function () {
|
||||
expect(AboRepository::getLockDaysChange())->toBe(10)
|
||||
->and(AboRepository::getLockDaysPauseCancel())->toBe(3);
|
||||
});
|
||||
|
||||
it('liest die konfigurierten Sperrfristen aus den Settings', function () {
|
||||
Setting::setContentBySlug('abo-lock-days-change', 5, 'int');
|
||||
Setting::setContentBySlug('abo-lock-days-pause-cancel', 7, 'int');
|
||||
|
||||
expect(AboRepository::getLockDaysChange())->toBe(5)
|
||||
->and(AboRepository::getLockDaysPauseCancel())->toBe(7);
|
||||
});
|
||||
|
||||
it('fällt bei 0 / leer auf den Default zurück', function () {
|
||||
Setting::setContentBySlug('abo-lock-days-change', 0, 'int');
|
||||
|
||||
expect(AboRepository::getLockDaysChange())->toBe(10);
|
||||
});
|
||||
|
||||
it('gibt eine Änderung frei, die bei Default gesperrt wäre, wenn die Sperrfrist reduziert wird', function () {
|
||||
Carbon::setTestNow('2026-03-19 00:00:00');
|
||||
|
||||
// Liefertag 20, next_date in 6 Tagen → Liefertag-WECHSEL auf 25 testen.
|
||||
$abo = makeLockTestAbo([
|
||||
'abo_interval' => 20,
|
||||
'next_date' => '2026-03-25',
|
||||
]);
|
||||
|
||||
$this->actingAs($abo->user);
|
||||
$repository = new AboRepository;
|
||||
$repository->setModel($abo);
|
||||
|
||||
// Mit Default (10) ist der Wechsel gesperrt (6 < 10):
|
||||
expect($repository->update([
|
||||
'action' => 'abo_update_settings',
|
||||
'id' => $abo->id,
|
||||
'view' => 'me',
|
||||
'abo_interval' => 25,
|
||||
'abo_is_active' => 'true',
|
||||
]))->toBeFalse();
|
||||
|
||||
// Sperrfrist auf 3 Tage reduzieren → Wechsel jetzt erlaubt (6 ≥ 3, neues Datum 2026-03-25):
|
||||
Setting::setContentBySlug('abo-lock-days-change', 3, 'int');
|
||||
|
||||
expect($repository->update([
|
||||
'action' => 'abo_update_settings',
|
||||
'id' => $abo->id,
|
||||
'view' => 'me',
|
||||
'abo_interval' => 25,
|
||||
'abo_is_active' => 'true',
|
||||
]))->toBeTrue()
|
||||
->and($abo->refresh()->abo_interval)->toBe(25);
|
||||
});
|
||||
|
||||
it('zeigt die konfigurierte Sperrfrist in der Fehlermeldung (nicht den Default 10)', function () {
|
||||
Carbon::setTestNow('2026-03-19 00:00:00');
|
||||
app()->setLocale('de');
|
||||
Setting::setContentBySlug('abo-lock-days-change', 3, 'int');
|
||||
|
||||
// Liefertag-WECHSEL (20 → 25), next_date morgen (1 Tag) → bei Sperrfrist 3 weiterhin gesperrt.
|
||||
$abo = makeLockTestAbo(['abo_interval' => 20, 'next_date' => '2026-03-20']);
|
||||
$this->actingAs($abo->user);
|
||||
$repository = new AboRepository;
|
||||
$repository->setModel($abo);
|
||||
|
||||
expect($repository->update([
|
||||
'action' => 'abo_update_settings',
|
||||
'id' => $abo->id,
|
||||
'view' => 'me',
|
||||
'abo_interval' => 25,
|
||||
'abo_is_active' => 'true',
|
||||
]))->toBeFalse()
|
||||
->and(session('alert-error'))->toContain('mindestens 3 Tage')
|
||||
->and(session('alert-error'))->not->toContain('mindestens 10 Tage');
|
||||
});
|
||||
|
||||
it('erlaubt Pausieren im Change-Lock-Fenster, wenn außerhalb der Pause-Sperre (Intervall unverändert)', function () {
|
||||
Carbon::setTestNow('2026-03-19 00:00:00');
|
||||
// 5 Tage entfernt: innerhalb Change-Lock (10), außerhalb Pause-Lock (3).
|
||||
$abo = makeLockTestAbo(['abo_interval' => 20, 'next_date' => '2026-03-24', 'active' => true, 'status' => 2]);
|
||||
$this->actingAs($abo->user);
|
||||
$repository = new AboRepository;
|
||||
$repository->setModel($abo);
|
||||
|
||||
// Intervall unverändert (20), kein abo_is_active => Pausieren.
|
||||
$result = $repository->update([
|
||||
'action' => 'abo_update_settings',
|
||||
'id' => $abo->id,
|
||||
'view' => 'me',
|
||||
'abo_interval' => 20,
|
||||
]);
|
||||
|
||||
expect($result)->toBeTrue()
|
||||
->and($abo->refresh()->active)->toBeFalse()
|
||||
->and($abo->status)->toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAboChangeLocked (UI-Sperre Liefertag)', function () {
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
it('ist gesperrt im Fenster, frei außerhalb (User-Ansicht)', function () {
|
||||
Carbon::setTestNow('2026-03-19 00:00:00');
|
||||
$abo = makeLockTestAbo(['next_date' => '2026-03-20']); // 1 Tag → < Default 10
|
||||
|
||||
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue();
|
||||
|
||||
$abo->next_date = '2026-04-19'; // weit entfernt
|
||||
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('respektiert die konfigurierte Sperrfrist', function () {
|
||||
Carbon::setTestNow('2026-03-19 00:00:00');
|
||||
$abo = makeLockTestAbo(['next_date' => '2026-03-24']); // 5 Tage entfernt
|
||||
|
||||
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue(); // 5 < 10 (Default)
|
||||
|
||||
Setting::setContentBySlug('abo-lock-days-change', 3, 'int');
|
||||
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse(); // 5 >= 3
|
||||
});
|
||||
|
||||
it('gilt nicht für Admin und nicht ohne next_date', function () {
|
||||
Carbon::setTestNow('2026-03-19 00:00:00');
|
||||
$abo = makeLockTestAbo(['next_date' => '2026-03-20']);
|
||||
|
||||
expect(AboHelper::isAboChangeLocked($abo, 'admin'))->toBeFalse();
|
||||
|
||||
$abo->next_date = null;
|
||||
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function makeLockTestAbo(array $overrides = []): UserAbo
|
||||
{
|
||||
$country = Country::create([
|
||||
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
||||
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Deutschland',
|
||||
]);
|
||||
|
||||
$user = User::forceCreate([
|
||||
'email' => 'abo-lock-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'admin' => 0,
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $user->id,
|
||||
'member_id' => $user->id,
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
'billing_email' => $user->email,
|
||||
'is_for' => 'me',
|
||||
'is_from' => 'user_order',
|
||||
]);
|
||||
|
||||
return UserAbo::create(array_merge([
|
||||
'user_id' => $user->id,
|
||||
'member_id' => $user->id,
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'is_for' => 'me',
|
||||
'email' => $user->email,
|
||||
'payone_userid' => random_int(100000, 999999),
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 5,
|
||||
'start_date' => '2026-01-05',
|
||||
'last_date' => '2026-02-05',
|
||||
'next_date' => '2026-03-05',
|
||||
], $overrides));
|
||||
}
|
||||
110
tests/Feature/AboOneTimeInvoiceGroupingTest.php
Normal file
110
tests/Feature/AboOneTimeInvoiceGroupingTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Phase 5 – Rechnung & Lieferschein: Positionen werden nach `is_abo_addon`
|
||||
* gruppiert (Abo-Produkte oben, abgegrenzter Block „Einmalig hinzugefügte
|
||||
* Artikel" darunter). Bestellungen ohne Einmal-Artikel bleiben unverändert.
|
||||
*/
|
||||
beforeEach(fn () => makeShopEnv());
|
||||
|
||||
function makeMixedOrder(bool $withAddon = true): ShoppingOrder
|
||||
{
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => 1,
|
||||
'auth_user_id' => 1,
|
||||
'country_id' => 1,
|
||||
'user_shop_id' => 0,
|
||||
'payment_for' => 6,
|
||||
'shipping' => 0,
|
||||
'shipping_net' => 0,
|
||||
'subtotal_ws' => 100,
|
||||
'tax' => 0,
|
||||
'total_shipping' => 100,
|
||||
'is_abo' => true,
|
||||
]);
|
||||
|
||||
$aboProduct = makeProduct(['12'], 50, 0);
|
||||
$order->shopping_order_items()->create([
|
||||
'product_id' => $aboProduct->id,
|
||||
'is_abo_addon' => false,
|
||||
'qty' => 1,
|
||||
'price' => 50,
|
||||
'price_net' => 50,
|
||||
'tax_rate' => 0,
|
||||
'tax' => 0,
|
||||
'price_vk_net' => 50,
|
||||
'discount' => 0,
|
||||
'points' => 0,
|
||||
]);
|
||||
|
||||
if ($withAddon) {
|
||||
$addonProduct = makeProduct(['2'], 30, 0);
|
||||
$order->shopping_order_items()->create([
|
||||
'product_id' => $addonProduct->id,
|
||||
'is_abo_addon' => true,
|
||||
'qty' => 1,
|
||||
'price' => 30,
|
||||
'price_net' => 30,
|
||||
'tax_rate' => 0,
|
||||
'tax' => 0,
|
||||
'price_vk_net' => 30,
|
||||
'discount' => 0,
|
||||
'points' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order->fresh('shopping_order_items.product');
|
||||
}
|
||||
|
||||
describe('Rechnung-PDF Gruppierung', function () {
|
||||
it('zeigt bei gemischter Bestellung beide Block-Überschriften, Abo vor Einmal', function () {
|
||||
$order = makeMixedOrder();
|
||||
|
||||
$html = view('pdf.invoice-detail', ['shopping_order' => $order])->render();
|
||||
|
||||
expect($html)->toContain(__('pdf.abo_items_heading'))
|
||||
->and($html)->toContain(__('pdf.abo_addon_items_heading'));
|
||||
|
||||
$aboPos = strpos($html, __('pdf.abo_items_heading'));
|
||||
$addonPos = strpos($html, __('pdf.abo_addon_items_heading'));
|
||||
expect($aboPos)->toBeLessThan($addonPos);
|
||||
});
|
||||
|
||||
it('zeigt KEINE Block-Überschriften bei reiner Bestellung ohne Einmal-Artikel', function () {
|
||||
$order = makeMixedOrder(withAddon: false);
|
||||
|
||||
$html = view('pdf.invoice-detail', ['shopping_order' => $order])->render();
|
||||
|
||||
expect($html)->not->toContain(__('pdf.abo_addon_items_heading'))
|
||||
->and($html)->not->toContain(__('pdf.abo_items_heading'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lieferschein-PDF Gruppierung', function () {
|
||||
it('zeigt bei gemischter Bestellung beide Block-Überschriften, Abo vor Einmal', function () {
|
||||
$order = makeMixedOrder();
|
||||
|
||||
$html = view('pdf.delivery-detail', ['shopping_order' => $order])->render();
|
||||
|
||||
expect($html)->toContain(__('pdf.abo_items_heading'))
|
||||
->and($html)->toContain(__('pdf.abo_addon_items_heading'));
|
||||
|
||||
$aboPos = strpos($html, __('pdf.abo_items_heading'));
|
||||
$addonPos = strpos($html, __('pdf.abo_addon_items_heading'));
|
||||
expect($aboPos)->toBeLessThan($addonPos);
|
||||
});
|
||||
|
||||
it('zeigt KEINE Block-Überschriften bei reiner Bestellung ohne Einmal-Artikel', function () {
|
||||
$order = makeMixedOrder(withAddon: false);
|
||||
|
||||
$html = view('pdf.delivery-detail', ['shopping_order' => $order])->render();
|
||||
|
||||
expect($html)->not->toContain(__('pdf.abo_addon_items_heading'))
|
||||
->and($html)->not->toContain(__('pdf.abo_items_heading'));
|
||||
});
|
||||
});
|
||||
|
|
@ -238,4 +238,74 @@ describe('Abo Einmal-Produkte Views', function () {
|
|||
]))
|
||||
->and($html)->toContain(__('abo.confirm_add_ok'));
|
||||
});
|
||||
|
||||
it('rendert nach einer Mengenänderung die aktuelle (persistierte) Menge – kein Stale-Cache', function () {
|
||||
$abo = makeMeAbo();
|
||||
$product = makeProduct(['12'], 50, 0);
|
||||
$item = \App\Models\UserAboItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1,
|
||||
]);
|
||||
|
||||
// Relation cachen (wie initYard/makeOrderYard im Controller)
|
||||
$abo->load('user_abo_items');
|
||||
expect($abo->user_abo_items->first()->qty)->toBe(1);
|
||||
|
||||
// Menge über separate Instanz erhöhen (wie der Controller in updateCart/addProduct)
|
||||
\App\Models\UserAboItem::find($item->id)->update(['qty' => 3]);
|
||||
|
||||
// Fix: Relation vor dem Render neu laden → gerenderte Menge = gespeicherter Stand
|
||||
$abo->load('user_abo_items');
|
||||
$html = view('admin.abo._order_abo_show', [
|
||||
'user_abo' => $abo,
|
||||
'split_mode' => true,
|
||||
'summary' => ['abo' => ['gross' => 150], 'total_with_shipping' => 150],
|
||||
])->render();
|
||||
|
||||
expect($html)->toContain('value="3"');
|
||||
});
|
||||
|
||||
it('blendet Minus/Entfernen im Add-only-Modus aus (Mindestlaufzeit nicht erfüllt)', function () {
|
||||
$abo = makeMeAbo();
|
||||
$product = makeProduct(['12'], 50, 0);
|
||||
\App\Models\UserAboItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
|
||||
]);
|
||||
$abo->load('user_abo_items');
|
||||
|
||||
$summary = ['abo' => ['gross' => 100], 'total_with_shipping' => 100];
|
||||
|
||||
$addOnly = view('admin.abo._order_abo_show', [
|
||||
'user_abo' => $abo, 'split_mode' => true, 'summary' => $summary, 'add_only_mode' => true,
|
||||
])->render();
|
||||
expect($addOnly)->not->toContain('remove-from-basket')
|
||||
->and($addOnly)->not->toContain('auto-delete-product')
|
||||
->and($addOnly)->toContain('add-from-basket') // Hinzufügen bleibt möglich
|
||||
->and($addOnly)->toContain('readonly');
|
||||
|
||||
$free = view('admin.abo._order_abo_show', [
|
||||
'user_abo' => $abo, 'split_mode' => true, 'summary' => $summary, 'add_only_mode' => false,
|
||||
])->render();
|
||||
expect($free)->toContain('remove-from-basket')
|
||||
->and($free)->toContain('auto-delete-product');
|
||||
});
|
||||
|
||||
it('friert alle Produkt-Steuerungen ein, wenn der Change-Lock greift', function () {
|
||||
$abo = makeMeAbo();
|
||||
$product = makeProduct(['12'], 50, 0);
|
||||
\App\Models\UserAboItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
|
||||
]);
|
||||
$abo->load('user_abo_items');
|
||||
|
||||
$html = view('admin.abo._order_abo_show', [
|
||||
'user_abo' => $abo, 'split_mode' => true,
|
||||
'summary' => ['abo' => ['gross' => 100], 'total_with_shipping' => 100],
|
||||
'change_locked' => true,
|
||||
])->render();
|
||||
|
||||
expect($html)->not->toContain('add-from-basket')
|
||||
->and($html)->not->toContain('remove-from-basket')
|
||||
->and($html)->not->toContain('auto-delete-product')
|
||||
->and($html)->toContain('readonly');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,10 +84,10 @@ describe('isOneTimeWindowOpen', function () {
|
|||
});
|
||||
|
||||
describe('isOneTimeFeatureVisible', function () {
|
||||
it('ist sichtbar, wenn das Fenster offen ist und ein VIP-User (Admin) eingeloggt ist', function () {
|
||||
$vip = new User;
|
||||
$vip->admin = 1;
|
||||
$this->actingAs($vip, 'user');
|
||||
it('ist sichtbar, wenn das Fenster offen ist (Sales Center, eingeloggter User)', function () {
|
||||
$user = new User;
|
||||
$user->admin = 0;
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(2); // innerhalb des 4-Tage-Fensters
|
||||
|
|
@ -95,29 +95,14 @@ describe('isOneTimeFeatureVisible', function () {
|
|||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
|
||||
});
|
||||
|
||||
it('ist nicht sichtbar für eingeloggte Nicht-VIP-User', function () {
|
||||
$user = new User;
|
||||
$user->admin = 0;
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
it('ist sichtbar ohne User auf dem user-Guard (Portal/Endkunde), solange das Fenster offen ist', function () {
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(2);
|
||||
|
||||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
|
||||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
|
||||
});
|
||||
|
||||
it('ist nicht sichtbar ohne User auf dem user-Guard (Portal/Endkunde)', function () {
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(2);
|
||||
|
||||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
|
||||
});
|
||||
|
||||
it('ist nicht sichtbar bei geschlossenem Fenster, auch für VIP-User', function () {
|
||||
$vip = new User;
|
||||
$vip->admin = 1;
|
||||
$this->actingAs($vip, 'user');
|
||||
|
||||
it('ist nicht sichtbar bei geschlossenem Fenster', function () {
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(30); // außerhalb des Fensters
|
||||
|
||||
|
|
|
|||
64
tests/Feature/AboOnetimeDashboardAndMailTest.php
Normal file
64
tests/Feature/AboOnetimeDashboardAndMailTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\MailAboExecutionReminder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(fn () => makeShopEnv());
|
||||
|
||||
describe('Dashboard-Card Einmalprodukte', function () {
|
||||
it('zeigt die Card mit Link zum eigenen Abo', function () {
|
||||
// Domain-aware Route wird im Test-Kontext nicht geladen -> route() mocken
|
||||
URL::shouldReceive('route')->andReturnUsing(
|
||||
fn ($name, $params = [], $absolute = true) => '/user/abos/detail/'.($params['view'] ?? '').'/'.($params['id'] ?? '')
|
||||
);
|
||||
|
||||
$abo = makeMeAbo();
|
||||
|
||||
$html = view('dashboard._abo_onetime', ['oneTimeAbo' => $abo])->render();
|
||||
|
||||
expect($html)->toContain(__('abo.onetime_card_hl'))
|
||||
->and($html)->toContain(__('abo.add_onetime_product'))
|
||||
->and($html)->toContain('/user/abos/detail/me/'.$abo->id);
|
||||
});
|
||||
|
||||
it('rendert nichts, wenn kein Abo übergeben wird', function () {
|
||||
$html = view('dashboard._abo_onetime', ['oneTimeAbo' => null])->render();
|
||||
|
||||
expect(trim($html))->toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reminder-Mail-Vorschau Command', function () {
|
||||
it('rendert die Reminder-Mail als HTML ohne Versand', function () {
|
||||
Storage::fake('public');
|
||||
Mail::fake();
|
||||
$abo = makeMeAbo();
|
||||
|
||||
$this->artisan('abo:preview-reminder', ['abo' => $abo->id])->assertExitCode(0);
|
||||
|
||||
Storage::disk('public')->assertExists('preview/abo-reminder-'.$abo->id.'.html');
|
||||
$html = Storage::disk('public')->get('preview/abo-reminder-'.$abo->id.'.html');
|
||||
expect($html)->toContain('/user/abos/detail/me/'.$abo->id);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('versendet zusätzlich bei --send (Mailpit-Test)', function () {
|
||||
Storage::fake('public');
|
||||
Mail::fake();
|
||||
$abo = makeMeAbo();
|
||||
|
||||
$this->artisan('abo:preview-reminder', ['abo' => $abo->id, '--send' => 'test@example.com'])
|
||||
->assertExitCode(0);
|
||||
|
||||
Mail::assertSent(MailAboExecutionReminder::class, 1);
|
||||
});
|
||||
|
||||
it('lehnt ein unbekanntes Abo ab', function () {
|
||||
$this->artisan('abo:preview-reminder', ['abo' => 999999])->assertExitCode(1);
|
||||
});
|
||||
});
|
||||
41
tests/Feature/LocalizationSyncTest.php
Normal file
41
tests/Feature/LocalizationSyncTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass alle Übersetzungsdateien (en/es/fr) exakt dieselben Keys
|
||||
* wie die deutsche Quelle haben – keine fehlenden und keine überzähligen Keys.
|
||||
* Deutsch ist die maßgebliche Quelle.
|
||||
*/
|
||||
it('keeps translation files in sync with the german source keys', function (string $locale) {
|
||||
foreach (glob(resource_path('lang/de/*.php')) as $germanFile) {
|
||||
$fileName = basename($germanFile);
|
||||
$localeFile = resource_path('lang/'.$locale.'/'.$fileName);
|
||||
|
||||
expect($localeFile)->toBeFile("Datei {$fileName} fehlt für Locale {$locale}");
|
||||
expect(localizationSyncFlattenKeys(require $localeFile))
|
||||
->toEqualCanonicalizing(localizationSyncFlattenKeys(require $germanFile));
|
||||
}
|
||||
})->with(['en', 'es', 'fr']);
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function localizationSyncFlattenKeys(array $translations, string $prefix = ''): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
foreach ($translations as $key => $value) {
|
||||
$translationKey = $prefix.(string) $key;
|
||||
|
||||
if (is_array($value)) {
|
||||
$keys = [...$keys, ...localizationSyncFlattenKeys($value, $translationKey.'.')];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys[] = $translationKey;
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
75
tests/Feature/PreviewInvoiceCommandTest.php
Normal file
75
tests/Feature/PreviewInvoiceCommandTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserInvoice;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(fn () => makeShopEnv());
|
||||
|
||||
function makePreviewOrder(): ShoppingOrder
|
||||
{
|
||||
$shoppingUser = ShoppingUser::forceCreate([
|
||||
'auth_user_id' => 0,
|
||||
'billing_firstname' => 'Max',
|
||||
'billing_lastname' => 'Mustermann',
|
||||
'billing_email' => 'preview@example.com',
|
||||
'billing_country_id' => 1,
|
||||
'shipping_country_id' => 1,
|
||||
'wp_order_number' => 0,
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => 0,
|
||||
'country_id' => 1,
|
||||
'user_shop_id' => 0,
|
||||
'payment_for' => 6,
|
||||
'shipping' => 0,
|
||||
'shipping_net' => 0,
|
||||
'subtotal_ws' => 80,
|
||||
'subtotal' => 80,
|
||||
'tax' => 0,
|
||||
'total_shipping' => 80,
|
||||
'points' => 0,
|
||||
'is_abo' => true,
|
||||
]);
|
||||
|
||||
$aboProduct = makeProduct(['12'], 50, 0);
|
||||
$order->shopping_order_items()->create([
|
||||
'product_id' => $aboProduct->id, 'is_abo_addon' => false, 'qty' => 1,
|
||||
'price' => 50, 'price_net' => 50, 'tax_rate' => 0, 'tax' => 0, 'price_vk_net' => 50, 'discount' => 0, 'points' => 0,
|
||||
]);
|
||||
$addonProduct = makeProduct(['2'], 30, 0);
|
||||
$order->shopping_order_items()->create([
|
||||
'product_id' => $addonProduct->id, 'is_abo_addon' => true, 'qty' => 1,
|
||||
'price' => 30, 'price_net' => 30, 'tax_rate' => 0, 'tax' => 0, 'price_vk_net' => 30, 'discount' => 0, 'points' => 0,
|
||||
]);
|
||||
|
||||
return $order->fresh('shopping_order_items.product');
|
||||
}
|
||||
|
||||
it('lehnt eine unbekannte Bestellung ab', function () {
|
||||
$this->artisan('sales:preview-invoice', ['order' => 999999])
|
||||
->expectsOutputToContain('nicht gefunden')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
it('erzeugt Vorschau-PDFs ohne offizielle Rechnung oder Nummernverbrauch', function () {
|
||||
Storage::fake('public');
|
||||
$order = makePreviewOrder();
|
||||
|
||||
$this->artisan('sales:preview-invoice', ['order' => $order->id])
|
||||
->assertExitCode(0);
|
||||
|
||||
// Keine offizielle Rechnung angelegt, keine Rechnungsnummer verbraucht.
|
||||
expect(UserInvoice::count())->toBe(0);
|
||||
|
||||
// Vorschau-PDFs liegen im Preview-Ordner.
|
||||
Storage::disk('public')->assertExists('invoice/preview/VORSCHAU-'.$order->id.'-MIVITA-Rechnung.pdf');
|
||||
Storage::disk('public')->assertExists('delivery/preview/VORSCHAU-'.$order->id.'-MIVITA-Lieferschein.pdf');
|
||||
});
|
||||
266
tests/Feature/TeamAboOverviewTest.php
Normal file
266
tests/Feature/TeamAboOverviewTest.php
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
|
||||
Schema::connection('sqlite')->table('products', function ($table) {
|
||||
$table->string('slug')->nullable();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function makeUser(?int $sponsorId = null): User
|
||||
{
|
||||
$user = User::forceCreate([
|
||||
'email' => 'u-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsorId,
|
||||
'payment_account' => now()->addYear(),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function makeAbo(int $userId, int $memberId, string $isFor, string $startDate, int $points = 0): UserAbo
|
||||
{
|
||||
$abo = UserAbo::create([
|
||||
'user_id' => $userId,
|
||||
'member_id' => $memberId,
|
||||
'shopping_user_id' => 0,
|
||||
'email' => 'a-'.uniqid('', true).'@example.com',
|
||||
'is_for' => $isFor,
|
||||
'payone_userid' => 0,
|
||||
'clearingtype' => 'cc',
|
||||
'amount' => 5000,
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 5,
|
||||
'start_date' => $startDate,
|
||||
'last_date' => $startDate,
|
||||
'next_date' => '2026-12-10',
|
||||
]);
|
||||
|
||||
if ($points > 0) {
|
||||
$product = Product::forceCreate([
|
||||
'name' => 'P-'.uniqid('', true),
|
||||
'title' => 'Test',
|
||||
'price' => 10.0,
|
||||
'tax' => 19.0,
|
||||
'active' => true,
|
||||
'pos' => 1,
|
||||
'weight' => 100,
|
||||
'points' => $points,
|
||||
'slug' => 'p-'.uniqid('', true),
|
||||
]);
|
||||
|
||||
UserAboItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $product->id,
|
||||
'comp' => 0,
|
||||
'qty' => 1,
|
||||
'status' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $abo->fresh();
|
||||
}
|
||||
|
||||
it('gruppiert Team-Abos nach Ebenen mit Eigen- und Kundenabos', function (): void {
|
||||
$root = makeUser();
|
||||
|
||||
// 1. Ebene: zwei direkte Partner
|
||||
$anna = makeUser($root->id);
|
||||
$lena = makeUser($root->id);
|
||||
// 2. Ebene: ein Partner unter Anna
|
||||
$lisa = makeUser($anna->id);
|
||||
|
||||
// Anna hat ein Eigen-Abo (50 Punkte) und 2 Kundenabos
|
||||
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-05-10', 30);
|
||||
// Lena hat kein Eigen-Abo, aber 1 Kundenabo
|
||||
makeAbo($lena->id, $lena->id, 'ot', '2026-06-12', 15);
|
||||
// Lisa (Ebene 2) hat ein Eigen-Abo
|
||||
makeAbo($lisa->id, $lisa->id, 'me', '2026-06-01', 40);
|
||||
|
||||
$service = app(TeamAboOverviewService::class);
|
||||
$overview = $service->linesOverview($root, 6, 2026);
|
||||
|
||||
expect($overview['lines'])->toHaveKey(1)
|
||||
->and($overview['lines'])->toHaveKey(2);
|
||||
|
||||
$line1 = $overview['lines'][1];
|
||||
expect($line1['consultants'])->toBe(2)
|
||||
->and($line1['members_with_own_abo'])->toBe(1) // nur Anna
|
||||
->and($line1['own_abos'])->toBe(1)
|
||||
->and($line1['customer_abos'])->toBe(3) // Anna 2 + Lena 1
|
||||
->and($line1['customer_abos_new'])->toBe(2) // im Juni: Anna 1 + Lena 1 (Mai zählt nicht)
|
||||
->and($line1['own_abos_new'])->toBe(1);
|
||||
|
||||
$line2 = $overview['lines'][2];
|
||||
expect($line2['consultants'])->toBe(1)
|
||||
->and($line2['own_abos'])->toBe(1)
|
||||
->and($line2['customer_abos'])->toBe(0);
|
||||
|
||||
$totals = $overview['totals'];
|
||||
expect($totals['own_abos'])->toBe(2)
|
||||
->and($totals['customer_abos'])->toBe(3)
|
||||
->and($totals['total_abos'])->toBe(5);
|
||||
});
|
||||
|
||||
it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void {
|
||||
$root = makeUser();
|
||||
$anna = makeUser($root->id);
|
||||
|
||||
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
|
||||
|
||||
$service = app(TeamAboOverviewService::class);
|
||||
$overview = $service->linesOverview($root, 6, 2026);
|
||||
|
||||
$member = collect($overview['lines'][1]['members'])->firstWhere('user_id', $anna->id);
|
||||
|
||||
expect($member['has_own_abo'])->toBeTrue()
|
||||
->and($member['own_abo_points'])->toBe(50.0)
|
||||
->and($member['customer_abo_count'])->toBe(1)
|
||||
->and($member['customer_abo_points'])->toBe(20.0)
|
||||
->and($member['total_abos'])->toBe(2);
|
||||
});
|
||||
|
||||
it('rendert die Team-Abo-Linienansicht mit Ebenen und Summen', function (): void {
|
||||
URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters));
|
||||
|
||||
$root = makeUser();
|
||||
$anna = makeUser($root->id);
|
||||
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
|
||||
|
||||
$this->actingAs($root);
|
||||
|
||||
$service = app(TeamAboOverviewService::class);
|
||||
$overview = $service->linesOverview($root, 6, 2026);
|
||||
$growth = $service->monthlyGrowth($root, 2026);
|
||||
|
||||
$html = view('user.team.abos', [
|
||||
'currentMonthName' => HTMLHelper::getTransMonths()[6],
|
||||
'overview' => $overview,
|
||||
'growth' => $growth,
|
||||
'growthYear' => 2026,
|
||||
'filterYears' => HTMLHelper::getYearRange(2022),
|
||||
])->render();
|
||||
|
||||
expect($html)
|
||||
->toContain(__('abo.team_by_level'))
|
||||
->toContain(__('abo.team_total_abos'))
|
||||
->toContain(__('abo.team_growth'))
|
||||
->toContain($anna->email);
|
||||
});
|
||||
|
||||
it('berechnet das monatliche Abo-Wachstum je Jahr', function (): void {
|
||||
$root = makeUser();
|
||||
$anna = makeUser($root->id);
|
||||
$lena = makeUser($root->id);
|
||||
|
||||
// Juni 2026: 1 Eigen-Abo + 2 Kundenabos
|
||||
makeAbo($anna->id, $anna->id, 'me', '2026-06-05');
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10');
|
||||
makeAbo($lena->id, $lena->id, 'ot', '2026-06-20');
|
||||
// März 2026: 1 Kundenabo
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-03-15');
|
||||
// Anderes Jahr: zählt nicht
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2025-06-01');
|
||||
|
||||
$growth = app(TeamAboOverviewService::class)->monthlyGrowth($root, 2026);
|
||||
|
||||
expect($growth['year'])->toBe(2026)
|
||||
->and($growth['months'])->toHaveCount(12);
|
||||
|
||||
$june = collect($growth['months'])->firstWhere('month', 6);
|
||||
$march = collect($growth['months'])->firstWhere('month', 3);
|
||||
|
||||
expect($june['own_new'])->toBe(1)
|
||||
->and($june['customer_new'])->toBe(2)
|
||||
->and($june['total_new'])->toBe(3)
|
||||
->and($march['customer_new'])->toBe(1)
|
||||
->and($march['own_new'])->toBe(0);
|
||||
|
||||
expect($growth['totals']['own_new'])->toBe(1)
|
||||
->and($growth['totals']['customer_new'])->toBe(3)
|
||||
->and($growth['totals']['total_new'])->toBe(4);
|
||||
});
|
||||
|
||||
it('liefert leichtgewichtige Dashboard-KPIs inkl. 1000-Punkte-Shop', function (): void {
|
||||
$root = makeUser();
|
||||
$anna = makeUser($root->id);
|
||||
$lena = makeUser($root->id);
|
||||
|
||||
// Abos
|
||||
makeAbo($anna->id, $anna->id, 'me', '2026-06-05'); // 1 Eigen-Abo
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10'); // 1 Kundenabo
|
||||
makeAbo($lena->id, $lena->id, 'ot', '2026-06-12'); // 1 Kundenabo
|
||||
|
||||
// Anna erreicht 1000 Punkte Kundenumsatz im Juni 2026
|
||||
UserSalesVolume::create([
|
||||
'user_id' => $anna->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'month_KP_points' => 600,
|
||||
'month_shop_points' => 500,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$kpis = app(TeamAboOverviewService::class)->teamKpis($root, 6, 2026);
|
||||
|
||||
expect($kpis['team_partner_abos'])->toBe(1)
|
||||
->and($kpis['team_customer_abos'])->toBe(2)
|
||||
->and($kpis['team_total_abos'])->toBe(3)
|
||||
->and($kpis['shop_1000'])->toBe(1);
|
||||
});
|
||||
|
||||
it('liefert die Abo-Zusammenfassung eines Team-Mitglieds inkl. Ausführungen', function (): void {
|
||||
$root = makeUser();
|
||||
$anna = makeUser($root->id);
|
||||
|
||||
$ownAbo = makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
|
||||
makeAbo($anna->id, $anna->id, 'ot', '2026-05-01', 30);
|
||||
|
||||
// Zwei ausgeführte (bezahlte) Lieferungen für das Eigen-Abo
|
||||
UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
|
||||
UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
|
||||
|
||||
$summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $anna->id);
|
||||
|
||||
expect($summary)->not->toBeNull()
|
||||
->and($summary['email'])->toBe($anna->email)
|
||||
->and($summary['own_abo'])->not->toBeNull()
|
||||
->and($summary['own_abo']['points'])->toBe(50.0)
|
||||
->and($summary['own_abo']['executions'])->toBe(2)
|
||||
->and($summary['customer_abos']['count'])->toBe(2)
|
||||
->and($summary['customer_abos']['total_points'])->toBe(50.0)
|
||||
->and($summary['customer_abos']['items'])->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('liefert null, wenn das Mitglied nicht im Team liegt', function (): void {
|
||||
$root = makeUser();
|
||||
$stranger = makeUser(); // kein Sponsor → nicht in der Downline
|
||||
|
||||
$summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $stranger->id);
|
||||
|
||||
expect($summary)->toBeNull();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue