F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der Backfill nimmt das Aktivierungsdatum nur noch ohne jede Registrierungsbestellung (offene Rechnung bleibt leer). F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel (Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig. F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit; eine Ausfuehrung loescht auch den paused_by_*-Grund. F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits committeten Test nachgezogen. F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false). Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal. Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
224 lines
6.5 KiB
PHP
224 lines
6.5 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\Portal\AboController as PortalAboController;
|
|
use App\Http\Controllers\User\AboController;
|
|
use App\Models\Customer;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboOrder;
|
|
use App\Repositories\AboRepository;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow(Carbon::parse('2026-07-22'));
|
|
URL::resolveMissingNamedRoutesUsing(
|
|
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
|
|
);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
function auaMakeUser(): User
|
|
{
|
|
return User::forceCreate([
|
|
'email' => 'aua-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'm_level' => 1,
|
|
'payment_account' => now()->addYear(),
|
|
]);
|
|
}
|
|
|
|
function auaMakeAbo(User $user, int $paidDeliveries = 6, string $nextDate = '2026-08-05', ?string $lastPauseDate = null): UserAbo
|
|
{
|
|
$abo = UserAbo::create([
|
|
'user_id' => $user->id,
|
|
'member_id' => $user->id,
|
|
'shopping_user_id' => 0,
|
|
'email' => 'aua-abo-'.uniqid('', true).'@example.com',
|
|
'is_for' => 'me',
|
|
'payone_userid' => 0,
|
|
'clearingtype' => 'cc',
|
|
'amount' => 5000,
|
|
'active' => true,
|
|
'status' => 2,
|
|
'abo_interval' => 5,
|
|
'start_date' => '2026-01-05',
|
|
'last_date' => '2026-07-05',
|
|
'next_date' => $nextDate,
|
|
'last_pause_date' => $lastPauseDate,
|
|
]);
|
|
|
|
for ($i = 0; $i < $paidDeliveries; $i++) {
|
|
UserAboOrder::create([
|
|
'user_abo_id' => $abo->id,
|
|
'shopping_order_id' => 0,
|
|
'status' => 2,
|
|
'paid' => true,
|
|
]);
|
|
}
|
|
|
|
return $abo->fresh();
|
|
}
|
|
|
|
/**
|
|
* Simuliert den POST des Abo-Einstellungs-Modals an AboController@update.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
function auaCallUpdate(string $routeView, int $routeId, array $payload): mixed
|
|
{
|
|
app('request')->merge($payload);
|
|
|
|
return (new AboController(app(AboRepository::class)))->update($routeView, $routeId);
|
|
}
|
|
|
|
it('blockiert das Kündigen eines fremden Abos über eine manipulierte Body-id', function (): void {
|
|
$attacker = auaMakeUser();
|
|
$victim = auaMakeUser();
|
|
$attackerAbo = auaMakeAbo($attacker);
|
|
$victimAbo = auaMakeAbo($victim);
|
|
|
|
$this->actingAs($attacker);
|
|
|
|
expect(fn () => auaCallUpdate('me', $attackerAbo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $victimAbo->id,
|
|
'view' => 'admin',
|
|
'abo_interval' => 5,
|
|
'abo_next_month' => now()->addMonth()->format('Y-m'),
|
|
'abo_cancel' => 'true',
|
|
]))->toThrow(HttpException::class);
|
|
|
|
expect((int) $victimAbo->fresh()->status)->toBe(2)
|
|
->and($victimAbo->fresh()->active)->toBeTruthy();
|
|
});
|
|
|
|
it('ignoriert ein gespooftes view=admin im Body: Mindestlaufzeit gilt weiterhin', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user, paidDeliveries: 2);
|
|
|
|
$this->actingAs($user);
|
|
|
|
auaCallUpdate('me', $abo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'admin',
|
|
'abo_interval' => 5,
|
|
'abo_next_month' => now()->addMonth()->format('Y-m'),
|
|
'abo_cancel' => 'true',
|
|
]);
|
|
|
|
expect((int) $abo->fresh()->status)->toBe(2)
|
|
->and($abo->fresh()->active)->toBeTruthy();
|
|
});
|
|
|
|
it('ignoriert ein gespooftes view=admin im Body: Pause-Intervall gilt weiterhin', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user, lastPauseDate: '2026-06-01');
|
|
|
|
$this->actingAs($user);
|
|
|
|
auaCallUpdate('me', $abo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'admin',
|
|
'abo_interval' => 5,
|
|
'abo_next_month' => now()->addMonth()->format('Y-m'),
|
|
'abo_pause' => 'true',
|
|
]);
|
|
|
|
expect($abo->fresh()->getRawOriginal('next_date'))->toStartWith('2026-08-05');
|
|
});
|
|
|
|
it('kündigt das eigene Abo nach erfüllter Mindestlaufzeit weiterhin regulär', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user);
|
|
|
|
$this->actingAs($user);
|
|
|
|
auaCallUpdate('me', $abo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'me',
|
|
'abo_interval' => 5,
|
|
'abo_cancel' => 'true',
|
|
'abo_cancel_confirm' => '1',
|
|
]);
|
|
|
|
expect((int) $abo->fresh()->status)->toBe(4)
|
|
->and($abo->fresh()->active)->toBeFalsy();
|
|
});
|
|
|
|
it('blockiert die Kündigung serverseitig vor erreichter Mindestlaufzeit', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user, paidDeliveries: 2);
|
|
|
|
$this->actingAs($user);
|
|
|
|
$repository = new AboRepository;
|
|
$repository->setModel($abo);
|
|
$repository->update([
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'me',
|
|
'abo_interval' => 5,
|
|
'abo_cancel' => 'true',
|
|
]);
|
|
|
|
expect((int) $abo->fresh()->status)->toBe(2)
|
|
->and($abo->fresh()->active)->toBeTruthy();
|
|
});
|
|
|
|
it('zeigt im Portal nach der Kündigung wieder die Abo-Neuanlage an', function (): void {
|
|
$shoppingUser = ShoppingUser::create([
|
|
'auth_user_id' => 1,
|
|
'billing_firstname' => 'Karla',
|
|
'billing_lastname' => 'Kundin',
|
|
'billing_address' => 'Kundenweg 2',
|
|
'billing_zipcode' => '11111',
|
|
'billing_city' => 'Kundenstadt',
|
|
'billing_country_id' => 1,
|
|
'shipping_country_id' => 1,
|
|
'billing_email' => 'aua-kundin@example.com',
|
|
'same_as_billing' => true,
|
|
'mode' => 'test',
|
|
'wp_order_number' => 0,
|
|
]);
|
|
|
|
UserAbo::create([
|
|
'user_id' => 1,
|
|
'member_id' => 1,
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'email' => 'aua-kundin@example.com',
|
|
'is_for' => 'ot',
|
|
'payone_userid' => 0,
|
|
'clearingtype' => 'cc',
|
|
'amount' => 5000,
|
|
'active' => false,
|
|
'status' => 4,
|
|
'abo_interval' => 5,
|
|
'cancel_date' => now(),
|
|
]);
|
|
|
|
$customer = Customer::create([
|
|
'name' => 'Karla Kundin',
|
|
'email' => 'aua-kundin@example.com',
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
]);
|
|
|
|
$this->actingAs($customer, 'customers');
|
|
|
|
$response = (new PortalAboController)->myAbo();
|
|
|
|
expect($response->name())->toBe('portal.abo.my_abo_create');
|
|
});
|