Änderungen (relevant):

- Add‑on Checkout auf Transactions + Transaction‑ID speichern: app/Services/Addons/EventAddonCheckoutService.php
  - Paket/Marketing Checkout auf Transactions: app/Services/Paddle/PaddleCheckoutService.php
  - Gift‑Voucher Checkout: Customer anlegen/finden + Transactions: app/Services/GiftVouchers/
    GiftVoucherCheckoutService.php
  - Tests aktualisiert: tests/Feature/Tenant/EventAddonCheckoutTest.php, tests/Unit/PaddleCheckoutServiceTest.php,
tests/Unit/GiftVoucherCheckoutServiceTest.php
This commit is contained in:
Codex Agent
2025-12-29 18:04:28 +01:00
parent 795e37ee12
commit 5f521d055f
26 changed files with 783 additions and 102 deletions

View File

@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Api\Tenant;
use Illuminate\Support\Arr;
use Tests\Feature\Tenant\TenantTestCase;
class OnboardingStatusTest extends TenantTestCase
{
public function test_tenant_can_dismiss_onboarding(): void
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
'step' => 'dismissed',
]);
$response->assertOk();
$this->tenant->refresh();
$dismissedAt = Arr::get($this->tenant->settings ?? [], 'onboarding.dismissed_at');
$this->assertNotNull($dismissedAt);
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
$show->assertOk();
$show->assertJsonPath('steps.dismissed_at', $dismissedAt);
}
public function test_tenant_can_mark_onboarding_completed(): void
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
'step' => 'completed',
'meta' => [],
]);
$response->assertOk();
$this->tenant->refresh();
$completedAt = Arr::get($this->tenant->settings ?? [], 'onboarding.completed_at');
$this->assertNotNull($completedAt);
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
$show->assertOk();
$show->assertJsonPath('steps.completed_at', $completedAt);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
use Tests\TestCase;
class ScheduleConfigurationTest extends TestCase
{
public function test_storage_and_checkout_commands_are_scheduled(): void
{
Artisan::call('schedule:list', [
'--json' => true,
'--no-interaction' => true,
]);
$tasks = json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR);
$this->assertScheduledCommand($tasks, 'storage:monitor', '*/5 * * * *');
$this->assertScheduledCommand($tasks, 'storage:check-upload-queues', '*/5 * * * *');
$this->assertScheduledCommand($tasks, 'storage:archive-pending', '0 1 * * *');
$this->assertScheduledCommand($tasks, 'checkout:send-reminders', '0 * * * *');
}
/**
* @param array<int, array<string, mixed>> $tasks
*/
private function assertScheduledCommand(array $tasks, string $command, string $expression): void
{
foreach ($tasks as $task) {
$taskCommand = (string) ($task['command'] ?? '');
if ($taskCommand !== '' && Str::contains($taskCommand, $command)) {
$this->assertSame($expression, $task['expression'] ?? null);
return;
}
}
$this->fail(sprintf('Scheduled command not found: %s', $command));
}
}

View File

@@ -2,6 +2,10 @@
namespace Tests\Feature;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
@@ -55,4 +59,156 @@ class SentryReportingTest extends TestCase
$this->assertSame(0, $fake->captured, 'Sentry should ignore 4xx errors');
}
public function test_reports_scheduled_task_failures_to_sentry(): void
{
$fake = new class
{
public int $captured = 0;
public function captureException(mixed $exception = null): void
{
$this->captured++;
}
};
$this->app->instance('sentry', $fake);
Artisan::call('schedule:list', [
'--json' => true,
'--no-interaction' => true,
]);
$schedule = $this->app->make(Schedule::class);
$task = collect($schedule->events())
->first(fn ($event) => str_contains((string) $event->command, 'storage:monitor'));
$this->assertNotNull($task, 'Expected scheduled storage:monitor task');
$task->finish($this->app, 1);
$this->assertSame(1, $fake->captured, 'Sentry should receive scheduled task failures');
}
public function test_reports_queue_failures_to_sentry(): void
{
$fake = new class
{
public int $captured = 0;
public function captureException(mixed $exception = null): void
{
$this->captured++;
}
};
$this->app->instance('sentry', $fake);
$job = new class implements \Illuminate\Contracts\Queue\Job
{
public function uuid(): ?string
{
return null;
}
public function getJobId(): string
{
return 'job-id';
}
public function payload(): array
{
return [];
}
public function fire(): void {}
public function release($delay = 0): void {}
public function isReleased(): bool
{
return false;
}
public function delete(): void {}
public function isDeleted(): bool
{
return false;
}
public function isDeletedOrReleased(): bool
{
return false;
}
public function attempts(): int
{
return 1;
}
public function hasFailed(): bool
{
return false;
}
public function markAsFailed(): void {}
public function fail($e = null): void {}
public function maxTries(): ?int
{
return null;
}
public function maxExceptions(): ?int
{
return null;
}
public function timeout(): ?int
{
return null;
}
public function retryUntil(): ?int
{
return null;
}
public function getName(): string
{
return 'FakeJob';
}
public function resolveName(): string
{
return 'FakeJob';
}
public function resolveQueuedJobClass(): string
{
return 'FakeJob';
}
public function getConnectionName(): string
{
return 'redis';
}
public function getQueue(): string
{
return 'default';
}
public function getRawBody(): string
{
return '{}';
}
};
Event::dispatch(new JobFailed('redis', $job, new \RuntimeException('Queue failure')));
$this->assertSame(1, $fake->captured, 'Sentry should receive queue failures');
}
}

View File

@@ -27,11 +27,17 @@ class EventAddonCheckoutTest extends TenantTestCase
// Fake Paddle response
Http::fake([
'*/checkout/links' => Http::response([
'*/customers' => Http::response([
'data' => [
'url' => 'https://checkout.paddle.test/abcd',
'id' => 'chk_addon_123',
'expires_at' => now()->addHour()->toIso8601String(),
'id' => 'ctm_addon_123',
],
], 200),
'*/transactions' => Http::response([
'data' => [
'id' => 'txn_addon_123',
'checkout' => [
'url' => 'https://checkout.paddle.test/abcd',
],
],
], 200),
]);
@@ -67,14 +73,14 @@ class EventAddonCheckoutTest extends TenantTestCase
]);
$response->assertOk();
$response->assertJsonPath('checkout_id', 'chk_addon_123');
$response->assertJsonPath('checkout_id', 'txn_addon_123');
$this->assertDatabaseHas('event_package_addons', [
'event_package_id' => $eventPackage->id,
'addon_key' => 'extra_photos_small',
'status' => 'pending',
'quantity' => 2,
'checkout_id' => 'chk_addon_123',
'transaction_id' => 'txn_addon_123',
]);
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();

View File

@@ -37,12 +37,18 @@ class GiftVoucherCheckoutServiceTest extends TestCase
$client = Mockery::mock(PaddleClient::class);
$client->shouldReceive('post')
->once()
->with('/checkout/links', Mockery::on(function ($payload) {
->with('/customers', Mockery::on(function ($payload) {
return $payload['email'] === 'buyer@example.com';
}))
->andReturn(['data' => ['id' => 'ctm_123']]);
$client->shouldReceive('post')
->once()
->with('/transactions', Mockery::on(function ($payload) {
return $payload['items'][0]['price_id'] === 'pri_a'
&& $payload['customer_email'] === 'buyer@example.com'
&& $payload['customer_id'] === 'ctm_123'
&& $payload['custom_data']['type'] === 'gift_voucher';
}))
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'expires_at' => '2025-12-31T00:00:00Z', 'id' => 'chk_123']]);
->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]);
$this->app->instance(PaddleClient::class, $client);
@@ -57,6 +63,6 @@ class GiftVoucherCheckoutServiceTest extends TestCase
]);
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
$this->assertSame('chk_123', $checkout['id']);
$this->assertSame('txn_123', $checkout['id']);
}
}

View File

@@ -35,15 +35,18 @@ class PaddleCheckoutServiceTest extends TestCase
$client->shouldReceive('post')
->once()
->with('/checkout/links', Mockery::on(function (array $payload) use ($tenant, $package) {
->with('/transactions', Mockery::on(function (array $payload) use ($tenant, $package) {
return $payload['items'][0]['price_id'] === 'pri_123'
&& $payload['customer_id'] === 'ctm_123'
&& ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id
&& ($payload['custom_data']['package_id'] ?? null) === (string) $package->id
&& ($payload['custom_data']['source'] ?? null) === 'test'
&& ! isset($payload['metadata']);
&& ! isset($payload['metadata'])
&& ! isset($payload['success_url'])
&& ! isset($payload['cancel_url'])
&& ! isset($payload['customer_email']);
}))
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'id' => 'chk_123']]);
->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]);
$this->app->instance(PaddleClient::class, $client);
$this->app->instance(PaddleCustomerService::class, $customers);
@@ -57,6 +60,6 @@ class PaddleCheckoutServiceTest extends TestCase
]);
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
$this->assertSame('chk_123', $checkout['id']);
$this->assertSame('txn_123', $checkout['id']);
}
}