seed(RolesAndPermissionsSeeder::class); $this->wallet = app(CreditWalletService::class); $this->proof = app(ProofPdfService::class); $this->extraPm = app(ExtraPmPurchaseService::class); }); function customerWithRole(): User { $user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]); $user->assignRole('customer'); return $user; } // ---------------------------------------------------------------- Proof-PDF test('buying a proof debits three credits and records a paid purchase', function () { $user = customerWithRole(); $this->wallet->credit($user, 10); $pr = PressRelease::factory()->published()->create(['user_id' => $user->id]); $purchase = $this->proof->purchase($user, $pr); expect($purchase->type)->toBe(SinglePurchaseType::ProofPdf); expect($purchase->status)->toBe(SinglePurchaseStatus::Paid); expect($this->wallet->balance($user))->toBe(7); expect($this->proof->hasPurchased($user, $pr))->toBeTrue(); }); test('re-buying the same proof does not charge again', function () { $user = customerWithRole(); $this->wallet->credit($user, 10); $pr = PressRelease::factory()->published()->create(['user_id' => $user->id]); $first = $this->proof->purchase($user, $pr); $second = $this->proof->purchase($user, $pr); expect($second->id)->toBe($first->id); expect($this->wallet->balance($user))->toBe(7); expect($user->singlePurchases()->where('type', SinglePurchaseType::ProofPdf->value)->count())->toBe(1); }); test('a proof for an unpublished release is rejected', function () { $user = customerWithRole(); $this->wallet->credit($user, 10); $draft = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]); expect(fn () => $this->proof->purchase($user, $draft)) ->toThrow(ProofPdfNotAvailableException::class); expect($this->wallet->balance($user))->toBe(10); }); test('a proof without enough credits throws and records nothing', function () { $user = customerWithRole(); $this->wallet->credit($user, 2); $pr = PressRelease::factory()->published()->create(['user_id' => $user->id]); expect(fn () => $this->proof->purchase($user, $pr)) ->toThrow(InsufficientCreditsException::class); expect($user->singlePurchases()->count())->toBe(0); }); test('the rendered proof is a non-trivial pdf document', function () { $pr = PressRelease::factory()->published()->create(['title' => 'Testmeldung', 'text' => str_repeat('Inhalt. ', 50)]); $pdf = app(ProofPdfRenderer::class)->render($pr); expect($pdf)->toStartWith('%PDF-1.4'); expect($pdf)->toContain('%%EOF'); expect(strlen($pdf))->toBeGreaterThan(800); }); test('the rendered proof contains the full release, boilerplate, press contact and publisher', function () { $company = Company::factory()->create([ 'name' => 'Musterfirma GmbH', 'address' => 'Beispielweg 1, 10115 Berlin', 'boilerplate' => 'Die Musterfirma ist ein Beispielunternehmen mit Standardabbinder.', 'website' => 'https://musterfirma.example', ]); $contact = Contact::factory()->create([ 'company_id' => $company->id, 'first_name' => 'Max', 'last_name' => 'Mustermann', 'responsibility' => 'Pressesprecher', 'email' => 'max@musterfirma.example', 'phone' => '030 1234567', ]); $pr = PressRelease::factory()->published()->create([ 'company_id' => $company->id, 'title' => 'Grosse Ankuendigung', 'subtitle' => 'Ein aussagekraeftiger Untertitel', 'text' => '

Erster Absatz der Meldung.

Zweiter Absatz mit Schlusswort.

', ]); $pr->contacts()->attach($contact->id); $pdf = app(ProofPdfRenderer::class)->render($pr); expect($pdf) ->toContain('Grosse Ankuendigung') ->toContain('Untertitel') ->toContain('Erster Absatz der Meldung.') ->toContain('Schlusswort.') ->toContain('Standardabbinder.') ->toContain('Mustermann') ->toContain('Pressesprecher') ->toContain('max@musterfirma.example') ->toContain('Musterfirma GmbH') ->toContain('10115 Berlin') ->toContain('https://musterfirma.example'); }); test('a long release flows over multiple numbered pages', function () { $pr = PressRelease::factory()->published()->create([ 'title' => 'Lange Meldung', 'text' => implode('', array_map( static fn (int $i): string => '

Absatz '.$i.': '.str_repeat('Dies ist ein ausfuehrlicher Beispielsatz. ', 12).'

', range(1, 12), )), ]); $pdf = app(ProofPdfRenderer::class)->render($pr); expect(substr_count($pdf, '/Type /Page /Parent'))->toBeGreaterThanOrEqual(2); expect($pdf)->toContain('Seite 1 von'); expect($pdf)->toContain('Fortsetzung'); }); test('continuation pages start their content below the page top', function () { $pr = PressRelease::factory()->published()->create([ 'title' => 'Lange Meldung', 'text' => implode('', array_map( static fn (int $i): string => '

Absatz '.$i.': '.str_repeat('Dies ist ein ausfuehrlicher Beispielsatz. ', 12).'

', range(1, 12), )), ]); $pdf = app(ProofPdfRenderer::class)->render($pr); // Content-Streams in Dokumentreihenfolge extrahieren (Seite 1, 2, …). preg_match_all('/stream\n(.*?)endstream/s', $pdf, $matches); expect(count($matches[1]))->toBeGreaterThanOrEqual(2); // Jede Fortsetzungsseite (ab Seite 2) darf ihren Inhalt nicht direkt am // Seitenrand beginnen — die erste Textposition liegt spürbar tiefer. foreach (array_slice($matches[1], 1) as $continuationStream) { preg_match('/(-?\d+\.\d+) (-?\d+\.\d+) Td/', $continuationStream, $firstTd); expect((float) $firstTd[2])->toBeLessThanOrEqual(760.0); } }); test('a release with a title image embeds it as an image xobject', function () { Storage::fake('local'); $canvas = imagecreatetruecolor(600, 400); imagefilledrectangle($canvas, 0, 0, 600, 400, imagecolorallocate($canvas, 30, 60, 120)); ob_start(); imagejpeg($canvas); $jpeg = (string) ob_get_clean(); imagedestroy($canvas); $pr = PressRelease::factory()->published()->create(['title' => 'Meldung mit Bild']); $path = 'press-releases/'.$pr->id.'/images/foto.jpg'; Storage::disk('local')->put($path, $jpeg); $pr->images()->create([ 'disk' => 'local', 'path' => $path, 'copyright' => 'Foto: Jane Doe', 'author' => 'Jane Doe', 'license_type' => ImageLicenseType::Own->value, 'source_url' => 'https://example.com/bildquelle', 'is_preview' => true, 'sort_order' => 1, ]); $pdf = app(ProofPdfRenderer::class)->render($pr); expect($pdf) ->toContain('/Subtype /Image') ->toContain('/Filter /DCTDecode') ->toContain('/XObject <<') ->toContain(' Do Q') // Rechtliche Bildangaben aus dem Upload unter dem Bild. ->toContain('Bildnachweis: Foto: Jane Doe') ->toContain('Lizenz: Eigene Aufnahme') ->toContain('Quelle: https://example.com/bildquelle'); }); test('a release without a title image embeds no image xobject', function () { $pr = PressRelease::factory()->published()->create(['title' => 'Meldung ohne Bild']); $pdf = app(ProofPdfRenderer::class)->render($pr); expect($pdf)->not->toContain('/Subtype /Image'); }); test('rich text keeps bold and italic formatting', function () { $pr = PressRelease::factory()->published()->create([ 'title' => 'Formatierte Meldung', 'text' => '

Ein fetterText und kursiverText sowie beidesText hier.

', ]); $pdf = app(ProofPdfRenderer::class)->render($pr); // Fett → Helvetica-Bold (F2), kursiv → Helvetica-Oblique (F3), // fett+kursiv → Helvetica-BoldOblique (F4). expect($pdf) ->toMatch('#/F2 [0-9.]+ Tf[^)]*\(fetterText\)#') ->toMatch('#/F3 [0-9.]+ Tf[^)]*\(kursiverText\)#') ->toMatch('#/F4 [0-9.]+ Tf[^)]*\(beidesText\)#') ->toContain('/BaseFont /Helvetica-BoldOblique'); }); test('the rendered proof documents the real public url of the release', function () { $pr = PressRelease::factory()->published()->create([ 'portal' => Portal::Presseecho, 'slug' => 'nachweis-pm', 'language' => 'de', ]); $pdf = app(ProofPdfRenderer::class)->render($pr); // Vorher fehlte das /de/press-release/-Segment — die URL im Nachweis lief ins Leere. expect($pdf)->toContain(config('domains.domain_presseecho_url').'/de/press-release/nachweis-pm'); }); test('the proof download requires a prior purchase', function () { $user = customerWithRole(); $pr = PressRelease::factory()->published()->create(['user_id' => $user->id]); // Ohne Kauf: 403. $this->actingAs($user) ->get(route('me.press-releases.proof', $pr->id)) ->assertForbidden(); // Nach Kauf: PDF-Download. $this->wallet->credit($user, 5); $this->proof->purchase($user, $pr); $this->actingAs($user) ->get(route('me.press-releases.proof', $pr->id)) ->assertOk() ->assertHeader('content-type', 'application/pdf'); }); // ---------------------------------------------------------------- Extra-PM test('an Einzel user pays the full extra-pm rate from the wallet', function () { $user = customerWithRole(); $this->wallet->credit($user, 25); $purchase = $this->extraPm->purchase($user); expect($purchase->type)->toBe(SinglePurchaseType::ExtraPm); expect($purchase->status)->toBe(SinglePurchaseStatus::Paid); expect($purchase->price_cents)->toBe(1900); expect($this->wallet->balance($user))->toBe(6); }); test('an extra-pm purchase extends the press release quota', function () { config()->set('billing.enforce_booking', true); $user = customerWithRole(); $plan = Plan::factory()->create([ 'slug' => 'business', 'press_release_quota' => 3, 'stripe_price_id_monthly' => 'price_biz_'.fake()->unique()->randomNumber(6), ]); subscribeUserToPlan($user, $plan); $user->update(['press_release_quota_used_this_month' => 3]); // Kontingent voll $this->wallet->credit($user, 20); expect($user->pressReleaseQuotaRemaining())->toBe(0); // Business-Tier zahlt 12 Credits für die Extra-PM. $this->extraPm->purchase($user); expect($this->wallet->balance($user))->toBe(8); expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(1); }); test('an extra-pm without enough credits throws the mini-checkout signal', function () { $user = customerWithRole(); $this->wallet->credit($user, 8); try { $this->extraPm->purchase($user); $this->fail('Expected InsufficientCreditsException.'); } catch (InsufficientCreditsException $e) { expect($e->required)->toBe(19); expect($e->available)->toBe(8); expect($e->shortfall())->toBe(11); } expect($user->singlePurchases()->count())->toBe(0); });