added various tests for playwright
This commit is contained in:
@@ -25,6 +25,10 @@ SENTRY_ENVIRONMENT=local
|
|||||||
SENTRY_TRACES_SAMPLE_RATE=0.0
|
SENTRY_TRACES_SAMPLE_RATE=0.0
|
||||||
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||||
SENTRY_RELEASE=
|
SENTRY_RELEASE=
|
||||||
|
SENTRY_AUTH_TOKEN=
|
||||||
|
SENTRY_ORG=
|
||||||
|
SENTRY_PROJECT=
|
||||||
|
SENTRY_URL=https://logsder.fotospiel.app
|
||||||
|
|
||||||
VITE_SENTRY_DSN=
|
VITE_SENTRY_DSN=
|
||||||
VITE_SENTRY_ENV=local
|
VITE_SENTRY_ENV=local
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ class Handler extends ExceptionHandler
|
|||||||
|
|
||||||
private function configureSentryScope(): void
|
private function configureSentryScope(): void
|
||||||
{
|
{
|
||||||
|
if (! function_exists('\Sentry\configureScope')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
\Sentry\configureScope(function (Scope $scope): void {
|
\Sentry\configureScope(function (Scope $scope): void {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ class Kernel extends HttpKernel
|
|||||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
\Illuminate\Session\Middleware\StartSession::class,
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
|
\App\Http\Middleware\EnsureXsrfCookie::class,
|
||||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
\App\Http\Middleware\SetLocaleFromRequest::class,
|
\App\Http\Middleware\SetLocaleFromRequest::class,
|
||||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
\App\Http\Middleware\ContentSecurityPolicy::class,
|
||||||
|
\App\Http\Middleware\ResponseSecurityHeaders::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'api' => [
|
'api' => [
|
||||||
|
|||||||
17
app/Http/Middleware/EncryptCookies.php
Normal file
17
app/Http/Middleware/EncryptCookies.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
|
||||||
|
|
||||||
|
class EncryptCookies extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The names of the cookies that should not be encrypted.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
'XSRF-TOKEN',
|
||||||
|
];
|
||||||
|
}
|
||||||
34
app/Http/Middleware/EnsureXsrfCookie.php
Normal file
34
app/Http/Middleware/EnsureXsrfCookie.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureXsrfCookie
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
/** @var Response $response */
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
if ($request->isMethod('GET') && ! $request->cookies->has('XSRF-TOKEN')) {
|
||||||
|
$response->headers->setCookie(
|
||||||
|
cookie(
|
||||||
|
name: 'XSRF-TOKEN',
|
||||||
|
value: csrf_token(),
|
||||||
|
minutes: 120,
|
||||||
|
path: '/',
|
||||||
|
domain: null,
|
||||||
|
secure: $request->isSecure(),
|
||||||
|
httpOnly: false,
|
||||||
|
raw: false,
|
||||||
|
sameSite: 'lax'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,9 @@ class ResponseSecurityHeaders
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->isSecure() && ! app()->environment(['local', 'testing'])) {
|
$forceHsts = (bool) config('security_headers.force_hsts', false);
|
||||||
|
|
||||||
|
if ($forceHsts || ($request->isSecure() && ! app()->environment(['local', 'testing']))) {
|
||||||
$hsts = 'max-age=31536000; includeSubDomains';
|
$hsts = 'max-age=31536000; includeSubDomains';
|
||||||
if (! $response->headers->has('Strict-Transport-Security')) {
|
if (! $response->headers->has('Strict-Transport-Security')) {
|
||||||
$response->headers->set('Strict-Transport-Security', $hsts);
|
$response->headers->set('Strict-Transport-Security', $hsts);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ x-app-env: &app-env
|
|||||||
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-0.0}
|
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-0.0}
|
||||||
SENTRY_PROFILES_SAMPLE_RATE: ${SENTRY_PROFILES_SAMPLE_RATE:-0.0}
|
SENTRY_PROFILES_SAMPLE_RATE: ${SENTRY_PROFILES_SAMPLE_RATE:-0.0}
|
||||||
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
|
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
|
||||||
|
SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN:-}
|
||||||
|
SENTRY_ORG: ${SENTRY_ORG:-}
|
||||||
|
SENTRY_PROJECT: ${SENTRY_PROJECT:-}
|
||||||
|
SENTRY_URL: ${SENTRY_URL:-https://logsder.fotospiel.app}
|
||||||
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
|
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
|
||||||
VITE_SENTRY_ENV: ${VITE_SENTRY_ENV:-}
|
VITE_SENTRY_ENV: ${VITE_SENTRY_ENV:-}
|
||||||
VITE_SENTRY_TRACES_SAMPLE_RATE: ${VITE_SENTRY_TRACES_SAMPLE_RATE:-}
|
VITE_SENTRY_TRACES_SAMPLE_RATE: ${VITE_SENTRY_TRACES_SAMPLE_RATE:-}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Notes:
|
|||||||
- DSN is Sentry-format; projects are created in GlitchTip UI. Use per-environment DSNs when possible.
|
- DSN is Sentry-format; projects are created in GlitchTip UI. Use per-environment DSNs when possible.
|
||||||
- Keep sampling conservative in production to avoid noise and cost.
|
- Keep sampling conservative in production to avoid noise and cost.
|
||||||
- If you do **not** want PII, set `SENTRY_SEND_DEFAULT_PII=false` in Laravel config (or handle in `config/sentry.php`).
|
- If you do **not** want PII, set `SENTRY_SEND_DEFAULT_PII=false` in Laravel config (or handle in `config/sentry.php`).
|
||||||
|
- For source map uploads via the Vite plugin, also set: `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_URL=https://logsder.fotospiel.app`.
|
||||||
|
|
||||||
## 2) Backend (Laravel 12, PHP 8.3)
|
## 2) Backend (Laravel 12, PHP 8.3)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- banner [ref=e4]:
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- link "Fotospiel App Logo Die Fotospiel App" [ref=e6] [cursor=pointer]:
|
||||||
|
- /url: /de
|
||||||
|
- img "Fotospiel App Logo" [ref=e7]
|
||||||
|
- generic [ref=e8]: Die Fotospiel App
|
||||||
|
- navigation [ref=e9]:
|
||||||
|
- link "So funktioniert's" [ref=e10] [cursor=pointer]:
|
||||||
|
- /url: /de/so-funktionierts
|
||||||
|
- link "Packages" [ref=e11] [cursor=pointer]:
|
||||||
|
- /url: /de/packages
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- text: Anlässe
|
||||||
|
- img [ref=e14]
|
||||||
|
- link "Blog" [ref=e16] [cursor=pointer]:
|
||||||
|
- /url: /de/blog
|
||||||
|
- link "Kontakt" [ref=e17] [cursor=pointer]:
|
||||||
|
- /url: /de/kontakt
|
||||||
|
- generic [ref=e18]:
|
||||||
|
- link "Jetzt ausprobieren" [ref=e19] [cursor=pointer]:
|
||||||
|
- /url: /de/demo
|
||||||
|
- button "Einstellungen" [ref=e20]:
|
||||||
|
- img
|
||||||
|
- generic [ref=e21]: Einstellungen
|
||||||
|
- main [ref=e22]:
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- generic [ref=e26]:
|
||||||
|
- button "Weiter" [disabled]
|
||||||
|
- generic [ref=e27]:
|
||||||
|
- progressbar [ref=e28]
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- generic [ref=e31]:
|
||||||
|
- generic [ref=e32]: "1"
|
||||||
|
- generic [ref=e33]:
|
||||||
|
- paragraph [ref=e34]: Paket wählen
|
||||||
|
- paragraph [ref=e35]: Auswahl und Vergleich
|
||||||
|
- generic [ref=e36]:
|
||||||
|
- generic [ref=e37]: "2"
|
||||||
|
- generic [ref=e38]:
|
||||||
|
- paragraph [ref=e39]: Konto
|
||||||
|
- paragraph [ref=e40]: Anmelden oder Registrieren
|
||||||
|
- paragraph [ref=e41]: Erstellen Sie ein Konto oder melden Sie sich an, um mit dem Kauf fortzufahren.
|
||||||
|
- generic [ref=e42]:
|
||||||
|
- generic [ref=e43]: "3"
|
||||||
|
- generic [ref=e44]:
|
||||||
|
- paragraph [ref=e45]: Zahlung
|
||||||
|
- paragraph [ref=e46]: Sichere Zahlung
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- generic [ref=e48]: "4"
|
||||||
|
- generic [ref=e49]:
|
||||||
|
- paragraph [ref=e50]: Bestätigung
|
||||||
|
- paragraph [ref=e51]: Alles erledigt!
|
||||||
|
- generic [ref=e53]:
|
||||||
|
- generic [ref=e55]:
|
||||||
|
- button "Registrieren" [ref=e56]
|
||||||
|
- button "Anmelden" [ref=e57]
|
||||||
|
- generic [ref=e58]:
|
||||||
|
- button "Mit Google fortfahren" [ref=e59]:
|
||||||
|
- img
|
||||||
|
- text: Mit Google fortfahren
|
||||||
|
- button "Warum Google?" [ref=e60]:
|
||||||
|
- text: Warum Google?
|
||||||
|
- img [ref=e61]
|
||||||
|
- generic [ref=e64]:
|
||||||
|
- generic [ref=e65]:
|
||||||
|
- generic [ref=e66]:
|
||||||
|
- generic [ref=e67]: Vorname *
|
||||||
|
- generic [ref=e68]:
|
||||||
|
- img [ref=e69]
|
||||||
|
- textbox "Vorname *" [ref=e72]:
|
||||||
|
- /placeholder: Vorname
|
||||||
|
- text: Play
|
||||||
|
- generic [ref=e73]:
|
||||||
|
- generic [ref=e74]: Nachname *
|
||||||
|
- generic [ref=e75]:
|
||||||
|
- img [ref=e76]
|
||||||
|
- textbox "Nachname *" [ref=e79]:
|
||||||
|
- /placeholder: Nachname
|
||||||
|
- text: Wright
|
||||||
|
- generic [ref=e80]:
|
||||||
|
- generic [ref=e81]: E-Mail-Adresse *
|
||||||
|
- generic [ref=e82]:
|
||||||
|
- img [ref=e83]
|
||||||
|
- textbox "E-Mail-Adresse *" [ref=e86]:
|
||||||
|
- /placeholder: beispiel@email.de
|
||||||
|
- text: playwright-buyer@example.com
|
||||||
|
- generic [ref=e87]:
|
||||||
|
- generic [ref=e88]: Adresse *
|
||||||
|
- generic [ref=e89]:
|
||||||
|
- img [ref=e90]
|
||||||
|
- textbox "Adresse *" [ref=e93]:
|
||||||
|
- /placeholder: Straße Hausnummer, PLZ Ort
|
||||||
|
- text: Teststrasse 1, 12345 Berlin
|
||||||
|
- generic [ref=e94]:
|
||||||
|
- generic [ref=e95]: Telefonnummer *
|
||||||
|
- generic [ref=e96]:
|
||||||
|
- img [ref=e97]
|
||||||
|
- textbox "Telefonnummer *" [ref=e99]:
|
||||||
|
- /placeholder: +49 170 1234567
|
||||||
|
- text: "+49123456789"
|
||||||
|
- generic [ref=e100]:
|
||||||
|
- generic [ref=e101]: Username *
|
||||||
|
- generic [ref=e102]:
|
||||||
|
- img [ref=e103]
|
||||||
|
- textbox "Username *" [ref=e106]:
|
||||||
|
- /placeholder: z. B. hochzeit_julia
|
||||||
|
- text: playwright-buyer
|
||||||
|
- generic [ref=e107]:
|
||||||
|
- generic [ref=e108]: Passwort *
|
||||||
|
- generic [ref=e109]:
|
||||||
|
- img [ref=e110]
|
||||||
|
- textbox "Passwort *" [ref=e113]:
|
||||||
|
- /placeholder: Mindestens 8 Zeichen
|
||||||
|
- text: Password123!
|
||||||
|
- generic [ref=e114]:
|
||||||
|
- generic [ref=e115]: Passwort bestätigen *
|
||||||
|
- generic [ref=e116]:
|
||||||
|
- img [ref=e117]
|
||||||
|
- textbox "Passwort bestätigen *" [active] [ref=e120]:
|
||||||
|
- /placeholder: Passwort erneut eingeben
|
||||||
|
- text: Password123!
|
||||||
|
- generic [ref=e121]:
|
||||||
|
- checkbox "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten. Datenschutzerklärung ." [ref=e122]
|
||||||
|
- generic [ref=e123]:
|
||||||
|
- text: Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.
|
||||||
|
- button "Datenschutzerklärung" [ref=e124] [cursor=pointer]
|
||||||
|
- text: .
|
||||||
|
- button "Registrieren" [disabled] [ref=e125]
|
||||||
|
- generic [ref=e126]:
|
||||||
|
- button "Zurück" [ref=e127]
|
||||||
|
- button "Weiter" [disabled]
|
||||||
|
- contentinfo [ref=e128]:
|
||||||
|
- generic [ref=e129]:
|
||||||
|
- generic [ref=e130]:
|
||||||
|
- generic [ref=e131]:
|
||||||
|
- generic [ref=e132]:
|
||||||
|
- img "Fotospiel App Logo" [ref=e133]
|
||||||
|
- generic [ref=e134]:
|
||||||
|
- link "Die Fotospiel App" [ref=e135] [cursor=pointer]:
|
||||||
|
- /url: /de
|
||||||
|
- paragraph [ref=e136]: S.E.B. Fotografie
|
||||||
|
- generic [ref=e137]:
|
||||||
|
- paragraph [ref=e138]: Gutscheine
|
||||||
|
- link "Gutscheine" [ref=e139] [cursor=pointer]:
|
||||||
|
- /url: /de/gutschein
|
||||||
|
- generic [ref=e140]:
|
||||||
|
- heading "Rechtliches" [level=3] [ref=e141]
|
||||||
|
- list [ref=e142]:
|
||||||
|
- listitem [ref=e143]:
|
||||||
|
- link "Impressum" [ref=e144] [cursor=pointer]:
|
||||||
|
- /url: /de/impressum
|
||||||
|
- listitem [ref=e145]:
|
||||||
|
- link "Datenschutzerklärung" [ref=e146] [cursor=pointer]:
|
||||||
|
- /url: /de/datenschutz
|
||||||
|
- listitem [ref=e147]:
|
||||||
|
- link "Allgemeine Geschäftsbedingungen" [ref=e148] [cursor=pointer]:
|
||||||
|
- /url: /de/agb
|
||||||
|
- listitem [ref=e149]:
|
||||||
|
- link "Widerrufsbelehrung" [ref=e150] [cursor=pointer]:
|
||||||
|
- /url: /de/widerrufsbelehrung
|
||||||
|
- listitem [ref=e151]:
|
||||||
|
- button "Cookie-Einstellungen" [ref=e152]
|
||||||
|
- generic [ref=e153]:
|
||||||
|
- heading "Social" [level=3] [ref=e154]
|
||||||
|
- list [ref=e155]:
|
||||||
|
- listitem [ref=e156]:
|
||||||
|
- link "Kontakt" [ref=e157] [cursor=pointer]:
|
||||||
|
- /url: /de/kontakt
|
||||||
|
- listitem [ref=e158]:
|
||||||
|
- link "Instagram" [ref=e159] [cursor=pointer]:
|
||||||
|
- /url: "#"
|
||||||
|
- listitem [ref=e160]:
|
||||||
|
- link "Facebook" [ref=e161] [cursor=pointer]:
|
||||||
|
- /url: "#"
|
||||||
|
- listitem [ref=e162]:
|
||||||
|
- link "YouTube" [ref=e163] [cursor=pointer]:
|
||||||
|
- /url: "#"
|
||||||
|
- generic [ref=e164]: © 2025 Die Fotospiel App – Alle Rechte vorbehalten.
|
||||||
|
```
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://fotospiel-app.test',
|
baseURL: process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
headless: true,
|
headless: true,
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
|||||||
@@ -68,6 +68,45 @@ class PaddleWebhookControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_duplicate_transaction_is_idempotent(): void
|
||||||
|
{
|
||||||
|
config(['paddle.webhook_secret' => 'test_secret']);
|
||||||
|
|
||||||
|
[$tenant, $package, $session] = $this->prepareSession();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'event_type' => 'transaction.completed',
|
||||||
|
'data' => [
|
||||||
|
'id' => 'txn_dup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'checkout_id' => 'chk_dup',
|
||||||
|
'metadata' => [
|
||||||
|
'checkout_session_id' => $session->id,
|
||||||
|
'tenant_id' => (string) $tenant->id,
|
||||||
|
'package_id' => (string) $package->id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$signature = hash_hmac('sha256', json_encode($payload), 'test_secret');
|
||||||
|
|
||||||
|
$first = $this->withHeader('Paddle-Webhook-Signature', $signature)
|
||||||
|
->postJson('/paddle/webhook', $payload);
|
||||||
|
|
||||||
|
$first->assertOk()->assertJson(['status' => 'processed']);
|
||||||
|
|
||||||
|
$second = $this->withHeader('Paddle-Webhook-Signature', $signature)
|
||||||
|
->postJson('/paddle/webhook', $payload);
|
||||||
|
|
||||||
|
$second->assertStatus(200)->assertJson(['status' => 'processed']);
|
||||||
|
|
||||||
|
$this->assertSame(1, PackagePurchase::query()->count());
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
$this->assertEquals(CheckoutSession::STATUS_COMPLETED, $session->status);
|
||||||
|
$this->assertEquals('txn_dup', Arr::get($session->provider_metadata, 'paddle_transaction_id'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_rejects_invalid_signature(): void
|
public function test_rejects_invalid_signature(): void
|
||||||
{
|
{
|
||||||
config(['paddle.webhook_secret' => 'secret']);
|
config(['paddle.webhook_secret' => 'secret']);
|
||||||
|
|||||||
103
tests/Feature/SecurityHeadersTest.php
Normal file
103
tests/Feature/SecurityHeadersTest.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Fruitcake\Cors\CorsService;
|
||||||
|
use Illuminate\Http\Middleware\HandleCors;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SecurityHeadersTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_marketing_and_auth_responses_have_security_headers_and_csrf_cookie(): void
|
||||||
|
{
|
||||||
|
$originalEnv = app()->environment();
|
||||||
|
app()->detectEnvironment(fn () => 'production');
|
||||||
|
config([
|
||||||
|
'app.debug' => false,
|
||||||
|
'app.url' => 'https://test-y0k0.fotospiel.app',
|
||||||
|
'security_headers.force_hsts' => true,
|
||||||
|
'cors.allowed_origins' => ['https://test-y0k0.fotospiel.app'],
|
||||||
|
'cors.paths' => ['*'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/__test/marketing', fn () => response('ok'));
|
||||||
|
Route::middleware('web')->get('/__test/auth', fn () => response('ok'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->withServerVariables([
|
||||||
|
'HTTPS' => 'on',
|
||||||
|
'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app',
|
||||||
|
])->get('/__test/marketing');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
$response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
$response->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
$response->assertHeader('Content-Security-Policy');
|
||||||
|
$response->assertHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
|
$response->assertCookie('XSRF-TOKEN');
|
||||||
|
|
||||||
|
$login = $this->withServerVariables([
|
||||||
|
'HTTPS' => 'on',
|
||||||
|
'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app',
|
||||||
|
])->get('/__test/auth');
|
||||||
|
|
||||||
|
$login->assertOk();
|
||||||
|
$login->assertHeader('Content-Security-Policy');
|
||||||
|
$login->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
$login->assertCookie('XSRF-TOKEN');
|
||||||
|
} finally {
|
||||||
|
app()->detectEnvironment(fn () => $originalEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cors_headers_present_for_api_requests_and_error_responses_keep_headers(): void
|
||||||
|
{
|
||||||
|
$originalEnv = app()->environment();
|
||||||
|
app()->detectEnvironment(fn () => 'production');
|
||||||
|
$corsConfig = config('cors');
|
||||||
|
$corsConfig['paths'] = ['*'];
|
||||||
|
$corsConfig['allowed_origins'] = ['https://test-y0k0.fotospiel.app'];
|
||||||
|
|
||||||
|
config([
|
||||||
|
'app.debug' => false,
|
||||||
|
'app.url' => 'https://test-y0k0.fotospiel.app',
|
||||||
|
'cors' => $corsConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/__test/error', fn () => throw new \RuntimeException('boom'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$corsMiddleware = new HandleCors(app(), new CorsService(config('cors')));
|
||||||
|
$this->assertContains('*', config('cors.paths'));
|
||||||
|
$this->assertContains('https://test-y0k0.fotospiel.app', config('cors.allowed_origins'));
|
||||||
|
$corsRequest = Request::create(
|
||||||
|
uri: '/api/__test/cors',
|
||||||
|
method: 'OPTIONS',
|
||||||
|
server: [
|
||||||
|
'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app',
|
||||||
|
'HTTPS' => 'on',
|
||||||
|
'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('https://test-y0k0.fotospiel.app', $corsRequest->headers->get('Origin'));
|
||||||
|
|
||||||
|
$corsResponse = $corsMiddleware->handle($corsRequest, fn () => response()->noContent());
|
||||||
|
|
||||||
|
$this->assertSame('https://test-y0k0.fotospiel.app', $corsResponse->headers->get('Access-Control-Allow-Origin'));
|
||||||
|
|
||||||
|
$error = $this->withServerVariables([
|
||||||
|
'HTTPS' => 'on',
|
||||||
|
])->get('/__test/error');
|
||||||
|
|
||||||
|
$error->assertStatus(500);
|
||||||
|
$error->assertHeader('Content-Security-Policy');
|
||||||
|
$error->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
} finally {
|
||||||
|
app()->detectEnvironment(fn () => $originalEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/Feature/SentryReportingTest.php
Normal file
58
tests/Feature/SentryReportingTest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SentryReportingTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['sentry.dsn' => 'https://example@sentry.test/1']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reports_server_errors_to_sentry(): void
|
||||||
|
{
|
||||||
|
$fake = new class
|
||||||
|
{
|
||||||
|
public int $captured = 0;
|
||||||
|
|
||||||
|
public function captureException(mixed $exception = null): void
|
||||||
|
{
|
||||||
|
$this->captured++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->app->instance('sentry', $fake);
|
||||||
|
|
||||||
|
$handler = $this->app->make(\App\Exceptions\Handler::class);
|
||||||
|
|
||||||
|
$handler->report(new \RuntimeException('boom'));
|
||||||
|
|
||||||
|
$this->assertSame(1, $fake->captured, 'Sentry should receive server errors');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ignores_client_errors_for_sentry(): void
|
||||||
|
{
|
||||||
|
$fake = new class
|
||||||
|
{
|
||||||
|
public int $captured = 0;
|
||||||
|
|
||||||
|
public function captureException(mixed $exception = null): void
|
||||||
|
{
|
||||||
|
$this->captured++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->app->instance('sentry', $fake);
|
||||||
|
|
||||||
|
$handler = $this->app->make(\App\Exceptions\Handler::class);
|
||||||
|
|
||||||
|
$handler->report(new HttpException(404));
|
||||||
|
|
||||||
|
$this->assertSame(0, $fake->captured, 'Sentry should ignore 4xx errors');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/ui/auth/login-bruteforce.test.ts
Normal file
30
tests/ui/auth/login-bruteforce.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const shouldRun = process.env.E2E_BRUTEFORCE === '1';
|
||||||
|
|
||||||
|
test.describe('Login brute-force throttle', () => {
|
||||||
|
test.skip(!shouldRun, 'Set E2E_BRUTEFORCE=1 to run brute-force throttle check against the live/staging site.');
|
||||||
|
|
||||||
|
test('repeated bad logins eventually trigger throttle', async ({ request }) => {
|
||||||
|
const attemptPayload = {
|
||||||
|
email: 'nonexistent-user@example.com',
|
||||||
|
password: 'WrongPass123!',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses: number[] = [];
|
||||||
|
const bodies: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const response = await request.post('/login', {
|
||||||
|
form: attemptPayload,
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
statuses.push(response.status());
|
||||||
|
bodies.push(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const hitThrottle = statuses.includes(429) || bodies.some((body) => /too many.+attempt/i.test(body));
|
||||||
|
|
||||||
|
expect(hitThrottle).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
tests/ui/purchase/contact-form-spam.test.ts
Normal file
41
tests/ui/purchase/contact-form-spam.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const shouldRun = process.env.E2E_CONTACT_SPAM === '1';
|
||||||
|
const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app';
|
||||||
|
|
||||||
|
test.describe('Marketing contact form spam/throttle', () => {
|
||||||
|
test.skip(!shouldRun, 'Set E2E_CONTACT_SPAM=1 to run contact spam/throttle check on staging.');
|
||||||
|
|
||||||
|
test('honeypot rejects bot submission and throttling kicks in', async ({ page }) => {
|
||||||
|
await page.goto(`${baseUrl}/de#contact`);
|
||||||
|
|
||||||
|
const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i });
|
||||||
|
if (await acceptCookies.isVisible()) {
|
||||||
|
await acceptCookies.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill visible fields
|
||||||
|
await page.fill('input[name="name"]', 'Spam Bot');
|
||||||
|
await page.fill('input[name="email"]', 'spam@example.com');
|
||||||
|
await page.fill('textarea[name="message"]', 'Test spam message');
|
||||||
|
|
||||||
|
// Trip honeypot
|
||||||
|
await page.$eval('input[name="nickname"]', (el: HTMLInputElement) => {
|
||||||
|
el.value = 'bot-field';
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /senden|absenden|submit/i }).first();
|
||||||
|
await submit.click();
|
||||||
|
|
||||||
|
await expect(page.locator('text=/error|ungültig|invalid/i')).toBeVisible();
|
||||||
|
|
||||||
|
// Rapid resubmits to trigger throttle (best-effort)
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
await submit.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either error message or no success flash should be present
|
||||||
|
const success = page.locator('text=/Danke|Erfolg|success/i');
|
||||||
|
await expect(success).not.toBeVisible({ timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { execSync } from 'child_process'; // Für artisan seed
|
|
||||||
|
|
||||||
test.describe('Marketing Package Flow: Auswahl → Registrierung → Kauf (Free & Paid)', () => {
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
// Seed Test-Tenant (einmalig)
|
|
||||||
execSync('php artisan tenant:add-dummy --email=test@example.com --password=password123 --first_name=Test --last_name=User --address="Teststr. 1" --phone="+49123"');
|
|
||||||
// Mock Verifizierung: Update DB (in Test-Env)
|
|
||||||
execSync('php artisan tinker --execute="App\\Models\\User::where(\'email\', \'test@example.com\')->update([\'email_verified_at\' => now()]);"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Free-Paket-Flow mit Wizard (ID=1, Starter, eingeloggter User)', async ({ page }) => {
|
|
||||||
// Login first
|
|
||||||
await page.goto('http://localhost:8000/de/login');
|
|
||||||
await page.fill('[name="email"]', 'test@example.com');
|
|
||||||
await page.fill('[name="password"]', 'password123');
|
|
||||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
|
|
||||||
// Go to Wizard
|
|
||||||
await page.goto('http://localhost:8000/purchase-wizard/10');
|
|
||||||
await expect(page.locator('text=Sie sind bereits eingeloggt')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Weiter zum Zahlungsschritt' }).click();
|
|
||||||
await expect(page).toHaveURL(/\/purchase-wizard\/1/); // Next step
|
|
||||||
await page.screenshot({ path: 'wizard-logged-in.png', fullPage: true });
|
|
||||||
|
|
||||||
// Payment (Free: Success)
|
|
||||||
await expect(page.locator('text=Free package assigned')).toBeVisible();
|
|
||||||
await page.screenshot({ path: 'wizard-free-success.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Wizard Login-Fehler mit Toast', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8000/purchase-wizard/10');
|
|
||||||
// Switch to Login
|
|
||||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
|
||||||
await page.fill('[name="email"]', 'wrong@example.com');
|
|
||||||
await page.fill('[name="password"]', 'wrong');
|
|
||||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
|
||||||
await expect(page.locator('[data-testid="toast"]')).toBeVisible(); // Toast for error
|
|
||||||
await expect(page.locator('text=Ungültige Anmeldedaten')).toBeVisible(); // Inline error
|
|
||||||
await page.screenshot({ path: 'wizard-login-error.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Wizard Registrierung-Fehler mit Toast', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8000/purchase-wizard/10');
|
|
||||||
// Reg form with invalid data
|
|
||||||
await page.fill('[name="email"]', 'invalid');
|
|
||||||
await page.getByRole('button', { name: 'Registrieren' }).click();
|
|
||||||
await expect(page.locator('[data-testid="toast"]')).toBeVisible();
|
|
||||||
await expect(page.locator('text=Das E-Mail muss eine gültige E-Mail-Adresse sein')).toBeVisible();
|
|
||||||
await page.screenshot({ path: 'wizard-reg-error.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Wizard Erfolgreiche Reg mit Success-Message', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8000/purchase-wizard/10');
|
|
||||||
// Fill valid reg data (use unique email)
|
|
||||||
await page.fill('[name="first_name"]', 'TestReg');
|
|
||||||
await page.fill('[name="last_name"]', 'User');
|
|
||||||
await page.fill('[name="email"]', 'testreg@example.com');
|
|
||||||
await page.fill('[name="username"]', 'testreguser');
|
|
||||||
await page.fill('[name="address"]', 'Teststr. 1');
|
|
||||||
await page.fill('[name="phone"]', '+49123');
|
|
||||||
await page.fill('[name="password"]', 'Password123!');
|
|
||||||
await page.fill('[name="password_confirmation"]', 'Password123!');
|
|
||||||
await page.check('[name="privacy_consent"]');
|
|
||||||
await page.getByRole('button', { name: 'Registrieren' }).click();
|
|
||||||
await expect(page.locator('text=Sie sind nun eingeloggt')).toBeVisible(); // Success message
|
|
||||||
await page.waitForTimeout(2000); // Auto-next
|
|
||||||
await expect(page).toHaveURL(/\/purchase-wizard\/1/); // Payment step
|
|
||||||
await page.screenshot({ path: 'wizard-reg-success.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Paid-Paket-Flow (ID=2, Pro mit Paddle)', async ({ page }) => {
|
|
||||||
// Ähnlich wie Free, aber package_id=2
|
|
||||||
await page.goto('http://localhost:8000/de/packages');
|
|
||||||
await page.getByRole('button', { name: 'Details anzeigen' }).nth(1).click(); // Zweites Paket (Paid)
|
|
||||||
// ... (Modal, Register/Login wie oben)
|
|
||||||
await expect(page).toHaveURL(/\/buy-packages\/2/);
|
|
||||||
|
|
||||||
await expect(page.getByAltText('Paddle')).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
44
tests/ui/purchase/marketing-smoke.test.ts
Normal file
44
tests/ui/purchase/marketing-smoke.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Marketing frontend smoke', () => {
|
||||||
|
test('landing renders without console errors and CTA leads to packages', async ({ page }) => {
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
consoleErrors.push(msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await page.goto('/');
|
||||||
|
expect(response?.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i });
|
||||||
|
if (await acceptCookies.isVisible()) {
|
||||||
|
await acceptCookies.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: /Dein Event|Fotospiel/i })).toBeVisible();
|
||||||
|
const heroCta = page.getByRole('link', { name: /paket|packages|starten|ausprobieren/i }).first();
|
||||||
|
await expect(heroCta).toBeVisible();
|
||||||
|
|
||||||
|
await heroCta.click();
|
||||||
|
await expect(page).toHaveURL(/\/packages/);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||||
|
|
||||||
|
expect(consoleErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('packages page lists packages and register CTAs', async ({ page }) => {
|
||||||
|
const response = await page.goto('/packages');
|
||||||
|
expect(response?.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i });
|
||||||
|
if (await acceptCookies.isVisible()) {
|
||||||
|
await acceptCookies.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageCards = page.locator('section >> text=/Starter|Standard|Premium/');
|
||||||
|
await expect(packageCards.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
tests/ui/purchase/paddle-sandbox-checkout.test.ts
Normal file
38
tests/ui/purchase/paddle-sandbox-checkout.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const shouldRun = process.env.E2E_PADDLE_SANDBOX === '1';
|
||||||
|
|
||||||
|
test.describe('Paddle sandbox checkout (staging)', () => {
|
||||||
|
test.skip(!shouldRun, 'Set E2E_PADDLE_SANDBOX=1 to run live sandbox checkout on staging.');
|
||||||
|
|
||||||
|
test('creates Paddle checkout session from packages page', async ({ page }) => {
|
||||||
|
const base = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app';
|
||||||
|
|
||||||
|
await page.goto(`${base}/packages`);
|
||||||
|
|
||||||
|
const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i });
|
||||||
|
if (await acceptCookies.isVisible()) {
|
||||||
|
await acceptCookies.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutButtons = page.locator('a:has-text("Paket") , a:has-text("Checkout"), a:has-text("Jetzt"), button:has-text("Checkout")');
|
||||||
|
const count = await checkoutButtons.count();
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
test.skip('No checkout CTA found on packages page');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [requestPromise] = await Promise.all([
|
||||||
|
page.waitForRequest('**/paddle/create-checkout'),
|
||||||
|
checkoutButtons.first().click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checkoutRequest = await requestPromise.response();
|
||||||
|
expect(checkoutRequest, 'Expected paddle/create-checkout request to resolve').toBeTruthy();
|
||||||
|
expect(checkoutRequest!.status()).toBeLessThan(400);
|
||||||
|
|
||||||
|
const body = await checkoutRequest!.json();
|
||||||
|
const checkoutUrl = body.checkout_url ?? body.url ?? '';
|
||||||
|
expect(checkoutUrl).toContain('paddle');
|
||||||
|
});
|
||||||
|
});
|
||||||
79
tests/ui/purchase/paddle-sandbox-full.test.ts
Normal file
79
tests/ui/purchase/paddle-sandbox-full.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { test as base } from '../helpers/test-fixtures';
|
||||||
|
|
||||||
|
const shouldRun = process.env.E2E_PADDLE_SANDBOX === '1';
|
||||||
|
const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app';
|
||||||
|
const sandboxEmail = process.env.E2E_PADDLE_EMAIL ?? 'playwright-buyer@example.com';
|
||||||
|
|
||||||
|
test.describe('Paddle sandbox full flow (staging)', () => {
|
||||||
|
test.skip(!shouldRun, 'Set E2E_PADDLE_SANDBOX=1 to run live sandbox checkout on staging.');
|
||||||
|
|
||||||
|
test('create checkout, simulate webhook completion, and verify session completion', async ({ page, request }) => {
|
||||||
|
// Jump directly into wizard for Standard package (2)
|
||||||
|
await page.goto(`${baseUrl}/purchase-wizard/2`);
|
||||||
|
|
||||||
|
const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i });
|
||||||
|
if (await acceptCookies.isVisible()) {
|
||||||
|
await acceptCookies.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If login/register step is present, choose guest path or continue
|
||||||
|
const continueButtons = page.getByRole('button', { name: /weiter|continue/i });
|
||||||
|
if (await continueButtons.first().isVisible()) {
|
||||||
|
await continueButtons.first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill minimal registration form to reach payment step
|
||||||
|
await page.fill('input[name="first_name"]', 'Play');
|
||||||
|
await page.fill('input[name="last_name"]', 'Wright');
|
||||||
|
await page.fill('input[name="email"]', sandboxEmail);
|
||||||
|
await page.fill('input[name="address"]', 'Teststrasse 1, 12345 Berlin');
|
||||||
|
await page.fill('input[name="phone"]', '+49123456789');
|
||||||
|
await page.fill('input[name="username"]', 'playwright-buyer');
|
||||||
|
await page.fill('input[name="password"]', 'Password123!');
|
||||||
|
await page.fill('input[name="password_confirmation"]', 'Password123!');
|
||||||
|
|
||||||
|
const checkoutCta = page.getByRole('button', { name: /weiter zum zahlungsschritt|continue to payment|Weiter/i }).first();
|
||||||
|
await expect(checkoutCta).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
const [apiResponse] = await Promise.all([
|
||||||
|
page.waitForResponse((resp) => resp.url().includes('/paddle/create-checkout') && resp.status() < 500),
|
||||||
|
checkoutCta.click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checkoutPayload = await apiResponse.json();
|
||||||
|
const checkoutUrl: string = checkoutPayload.checkout_url ?? checkoutPayload.url ?? '';
|
||||||
|
|
||||||
|
expect(checkoutUrl).toContain('paddle');
|
||||||
|
|
||||||
|
// Navigate to checkout to ensure it loads (hosted page). Use sandbox card data if needed later.
|
||||||
|
await page.goto(checkoutUrl);
|
||||||
|
await expect(page).toHaveURL(/paddle/);
|
||||||
|
|
||||||
|
// Fetch latest session for this buyer
|
||||||
|
const latestSession = await request.get('/api/_testing/checkout/sessions/latest', {
|
||||||
|
params: { email: sandboxEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestSession.status()).toBe(200);
|
||||||
|
const sessionJson = await latestSession.json();
|
||||||
|
const sessionId: string | undefined = sessionJson?.data?.id;
|
||||||
|
expect(sessionId, 'checkout session id').toBeTruthy();
|
||||||
|
|
||||||
|
// Simulate Paddle webhook completion
|
||||||
|
const simulate = await request.post(`/api/_testing/checkout/sessions/${sessionId}/simulate-paddle`, {
|
||||||
|
data: {
|
||||||
|
status: 'completed',
|
||||||
|
transaction_id: 'txn_playwright_' + Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(simulate.status()).toBe(200);
|
||||||
|
|
||||||
|
// Confirm session is marked completed
|
||||||
|
const latestCompleted = await request.get('/api/_testing/checkout/sessions/latest', {
|
||||||
|
params: { status: 'completed', email: sandboxEmail },
|
||||||
|
});
|
||||||
|
expect(latestCompleted.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,10 @@ import { wayfinder } from '@laravel/vite-plugin-wayfinder';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, type PluginOption } from 'vite';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { tamaguiPlugin } from '@tamagui/vite-plugin';
|
import { tamaguiPlugin } from '@tamagui/vite-plugin';
|
||||||
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||||
|
|
||||||
const devServerHost = process.env.VITE_DEV_SERVER_HOST ?? 'fotospiel-app.test';
|
const devServerHost = process.env.VITE_DEV_SERVER_HOST ?? 'fotospiel-app.test';
|
||||||
const devServerPort = Number.parseInt(process.env.VITE_DEV_SERVER_PORT ?? '5173', 10);
|
const devServerPort = Number.parseInt(process.env.VITE_DEV_SERVER_PORT ?? '5173', 10);
|
||||||
@@ -12,6 +13,49 @@ const devServerOrigin = process.env.VITE_DEV_SERVER_URL ?? `http://fotospiel-app
|
|||||||
const parsedOrigin = new URL(devServerOrigin);
|
const parsedOrigin = new URL(devServerOrigin);
|
||||||
const hmrPort = parsedOrigin.port === '' ? devServerPort : Number.parseInt(parsedOrigin.port, 10);
|
const hmrPort = parsedOrigin.port === '' ? devServerPort : Number.parseInt(parsedOrigin.port, 10);
|
||||||
const appUrl = process.env.APP_URL ?? 'http://fotospiel-app.test';
|
const appUrl = process.env.APP_URL ?? 'http://fotospiel-app.test';
|
||||||
|
const sentryEnabled = Boolean(
|
||||||
|
process.env.SENTRY_AUTH_TOKEN &&
|
||||||
|
process.env.SENTRY_ORG &&
|
||||||
|
process.env.SENTRY_PROJECT &&
|
||||||
|
process.env.SENTRY_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
const plugins: PluginOption[] = [
|
||||||
|
laravel({
|
||||||
|
input: ['resources/css/app.css','resources/js/app.js', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'],
|
||||||
|
ssr: 'resources/js/ssr.tsx',
|
||||||
|
refresh: [
|
||||||
|
'resources/views/**/*.blade.php',
|
||||||
|
'resources/lang/**/*.php',
|
||||||
|
'app/Http/Livewire/**', // falls genutzt
|
||||||
|
// NICHT beobachten: storage/logs, vendor, public/build, etc.
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
wayfinder({
|
||||||
|
formVariants: true,
|
||||||
|
}),
|
||||||
|
tamaguiPlugin({
|
||||||
|
config: './tamagui.config.ts',
|
||||||
|
components: ['@tamagui/core', '@tamagui/stacks', '@tamagui/text', '@tamagui/button'],
|
||||||
|
optimize: false,
|
||||||
|
disableExtraction: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (sentryEnabled) {
|
||||||
|
plugins.push(
|
||||||
|
sentryVitePlugin({
|
||||||
|
org: process.env.SENTRY_ORG as string,
|
||||||
|
project: process.env.SENTRY_PROJECT as string,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN as string,
|
||||||
|
url: process.env.SENTRY_URL as string,
|
||||||
|
release: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE,
|
||||||
|
telemetry: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
@@ -64,29 +108,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins,
|
||||||
laravel({
|
|
||||||
input: ['resources/css/app.css','resources/js/app.js', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'],
|
|
||||||
ssr: 'resources/js/ssr.tsx',
|
|
||||||
refresh: [
|
|
||||||
'resources/views/**/*.blade.php',
|
|
||||||
'resources/lang/**/*.php',
|
|
||||||
'app/Http/Livewire/**', // falls genutzt
|
|
||||||
// NICHT beobachten: storage/logs, vendor, public/build, etc.
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
wayfinder({
|
|
||||||
formVariants: true,
|
|
||||||
}),
|
|
||||||
tamaguiPlugin({
|
|
||||||
config: './tamagui.config.ts',
|
|
||||||
components: ['@tamagui/core', '@tamagui/stacks', '@tamagui/text', '@tamagui/button'],
|
|
||||||
optimize: false,
|
|
||||||
disableExtraction: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
esbuild: {
|
esbuild: {
|
||||||
jsx: 'automatic',
|
jsx: 'automatic',
|
||||||
},
|
},
|
||||||
@@ -110,7 +132,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
// Build-Optionen wirken vor allem bei `vite build`, schaden aber nicht:
|
// Build-Optionen wirken vor allem bei `vite build`, schaden aber nicht:
|
||||||
build: {
|
build: {
|
||||||
sourcemap: false,
|
sourcemap: sentryEnabled,
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// keine externen Monster-Globs
|
// keine externen Monster-Globs
|
||||||
|
|||||||
Reference in New Issue
Block a user