mivita/tests/Feature/AboCardUpdateLinkTest.php
Kevin 671022a28d Abo: Kreditkarte aktualisieren (CRM, Portal, Berater-Link) + Admin-Abo-Löschung
- Karten-Update ohne Abbuchung via PAYONE creditcardcheck/pseudocardpan
  (AboPaymentMethodService, CardUpdateResult/CreditCardCheckData-DTOs)
- Eingabe nur durch Abo-Inhaber: CRM- und Portal-Formular + zeitlich
  begrenzter Berater→Kunde-Link (AboCardUpdateLink + Mail)
- Änderungshistorie in user_abo_card_changes, Anzeige im Admin-Abo-Detail
- Admin: Abo löschen (destroy) inkl. Test
- Portal-Fix: myOrderCreate wirft 404 statt TypeError bei ungültiger ID
- VIP-Sichtbarkeitstests für Einmalartikel-Fenster ergänzt
- Enthält bereits die Freeze-Guards für den Stripe-Notbetrieb (Folge-Commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:56:19 +00:00

167 lines
6 KiB
PHP

<?php
use App\Http\Controllers\User\AboController;
use App\Http\Controllers\Web\AboCardUpdateController;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Mail\AboCardUpdateLinkMail;
use App\Models\AboCardUpdateLink;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\Services\AboPaymentMethodService;
use App\Services\Payment\Payone\PayonePaymentService;
use App\User;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function stubLinkRoutes(): void
{
URL::resolveMissingNamedRoutesUsing(
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
);
}
function linkCcAbo(string $isFor = 'ot'): UserAbo
{
$shoppingUser = ShoppingUser::create([
'auth_user_id' => 1,
'billing_firstname' => 'Karl',
'billing_lastname' => 'Kunde',
'billing_address' => 'Weg 1',
'billing_zipcode' => '11111',
'billing_city' => 'Stadt',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'billing_email' => 'kunde@example.com',
'same_as_billing' => true,
'mode' => 'test',
'wp_order_number' => 0,
]);
return UserAbo::create([
'user_id' => 1,
'member_id' => 1,
'shopping_user_id' => $shoppingUser->id,
'email' => 'kunde@example.com',
'is_for' => $isFor,
'clearingtype' => 'cc',
'wallettype' => '',
'payone_userid' => 111,
'carddata' => ['pseudocardpan' => 'OLD', 'truncatedcardpan' => '411111XXXXXX0000', 'cardexpiredate' => '2501'],
'abo_interval' => 5,
'status' => 2,
]);
}
// --- Model / token hashing ---
it('stores only the SHA-256 hash of the token, never the plaintext', function () {
$abo = linkCcAbo();
[$link, $token] = AboCardUpdateLink::generateFor($abo, 7);
expect($link->token_hash)->toBe(hash('sha256', $token))
->and($link->token_hash)->not->toBe($token)
->and($link->created_by_user_id)->toBe(7)
->and($link->isRedeemable())->toBeTrue();
// Plaintext-Token darf nirgends in der DB stehen.
expect(AboCardUpdateLink::where('token_hash', $token)->exists())->toBeFalse();
});
it('treats expired and used links as not redeemable', function () {
$abo = linkCcAbo();
[$expired] = AboCardUpdateLink::generateFor($abo);
$expired->forceFill(['expires_at' => now()->subHour()])->save();
expect($expired->fresh()->isRedeemable())->toBeFalse();
[$used] = AboCardUpdateLink::generateFor($abo);
$used->forceFill(['used_at' => now()])->save();
expect($used->fresh()->isRedeemable())->toBeFalse();
});
// --- Login-free controller ---
it('returns 410 Gone for an unknown, expired or used token', function () {
stubLinkRoutes();
expect(fn () => (new AboCardUpdateController)->show('does-not-exist'))
->toThrow(HttpException::class);
});
it('applies the new card and marks the link used on a successful submission', function () {
stubLinkRoutes();
$abo = linkCcAbo();
[$link, $token] = AboCardUpdateLink::generateFor($abo);
$ct = ['Content-Type' => 'text/plain;charset=UTF-8'];
$mock = new MockHandler([
new Response(200, $ct, "status=VALID\npseudocardpan=NEW_LINK\ncardexpiredate=3012\ntruncatedcardpan=555555XXXXXX9999"),
new Response(200, $ct, "status=APPROVED\ntxid=TX1\nuserid=999"),
new Response(200, $ct, 'status=APPROVED'), // release capture amount=0
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$service = new AboPaymentMethodService(new PayonePaymentService(
defaults: ['mid' => '1', 'aid' => '1', 'portalid' => '1', 'key' => 'abc', 'api_version' => '3.11'],
client: $client,
));
$request = UpdateAboCreditCardRequest::create('/abo/karte/'.$token, 'POST', [
'cc_cardholder_first' => 'Karl',
'cc_cardholder_last' => 'Kunde',
'cc_cardpan' => '4111111111111111',
'cc_cardexpiremonth' => '12',
'cc_cardexpireyear' => '2030',
'cc_cardtype' => 'V',
'cc_cardcvc2' => '123',
]);
$request->setContainer(app());
$request->validateResolved();
(new AboCardUpdateController)->store($request, $token, $service);
$abo->refresh();
expect($abo->carddata['pseudocardpan'])->toBe('NEW_LINK')
->and($abo->payone_userid)->toEqual(999)
->and($abo->card_changes()->first()->source)->toBe('link')
->and($link->fresh()->used_at)->not->toBeNull();
});
// --- CRM: consultant sends link ---
it('lets a consultant send a card update link for a customer abo', function () {
stubLinkRoutes();
Mail::fake();
$berater = User::forceCreate(['email' => 'b-'.uniqid().'@example.com', 'password' => bcrypt('x'), 'lang' => 'de']);
$abo = linkCcAbo('ot');
$abo->forceFill(['user_id' => $berater->id, 'member_id' => $berater->id])->save();
$this->actingAs($berater);
(new AboController(app(AboRepository::class)))->sendCardUpdateLink('ot', $abo->id);
Mail::assertSent(AboCardUpdateLinkMail::class);
expect(AboCardUpdateLink::where('user_abo_id', $abo->id)->exists())->toBeTrue();
});
it('does not send a link for the consultants own abo (me)', function () {
stubLinkRoutes();
Mail::fake();
$berater = User::forceCreate(['email' => 'b-'.uniqid().'@example.com', 'password' => bcrypt('x'), 'lang' => 'de']);
$abo = linkCcAbo('me');
$abo->forceFill(['user_id' => $berater->id, 'member_id' => $berater->id])->save();
$this->actingAs($berater);
(new AboController(app(AboRepository::class)))->sendCardUpdateLink('me', $abo->id);
Mail::assertNothingSent();
expect(AboCardUpdateLink::where('user_abo_id', $abo->id)->exists())->toBeFalse();
});