seed(RolesAndPermissionsSeeder::class); $this->seed(LegalDocumentSeeder::class); Mail::fake(); config()->set('billing.enforce_booking', false); }); /** * @return array{owner: User, pr: PressRelease} */ function legalGateDraft(): array { $owner = User::factory()->create(['is_active' => true]); $owner->assignRole('customer'); $company = Company::factory()->presseecho()->create(); $owner->companies()->attach($company->id, ['role' => 'owner']); $pr = PressRelease::factory()->create([ 'user_id' => $owner->id, 'company_id' => $company->id, 'category_id' => Category::factory()->create()->id, 'portal' => $company->portal->value, 'status' => 'draft', ]); return compact('owner', 'pr'); } test('with the gate off nothing is required and submitting works as before', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); expect(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty(); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertDontSee('Neue Fassung unserer Bedingungen') ->set('aiTextDeclaration', 'none') ->set('aiDeclarationConfirmed', true) ->call('submitForReview') ->assertHasNoErrors(); expect($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft) ->and(LegalDocumentAcceptance::query()->count())->toBe(0); }); test('with the gate on the submit requires an active acceptance of agb and guidelines and records both', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('type')->all())->toBe(['agb', 'richtlinien']); $component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Neue Fassung unserer Bedingungen') ->assertSee('Version 0.6') ->set('aiTextDeclaration', 'none') ->set('aiDeclarationConfirmed', true) ->call('submitForReview') ->assertHasErrors(['legalAcceptanceConfirmed']); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); $component->set('legalAcceptanceConfirmed', true)->call('submitForReview')->assertHasNoErrors(); // Der synchrone Prüf-Job kann die Meldung direkt veröffentlichen — entscheidend ist: nicht mehr Entwurf. expect($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft) ->and(LegalDocumentAcceptance::query()->where('user_id', $owner->id)->pluck('type')->sort()->values()->all())->toBe(['agb', 'richtlinien']) ->and(LegalDocumentAcceptance::query()->first()->context)->toBe('submit') ->and(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty(); // Neue AGB-Fassung: das Gate fordert genau diese eine Fassung erneut. LegalDocument::factory()->agb()->create(['version' => '0.7', 'effective_from' => now()->subDay()->toDateString()]); expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('version')->all())->toBe(['0.7']); }); test('a confirmation is bound to the versions that were shown, a newer version must be read and accepted again', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); $component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Version 0.6') ->set('aiTextDeclaration', 'none') ->set('aiDeclarationConfirmed', true) ->set('legalAcceptanceConfirmed', true); expect($component->get('legalAcceptanceVersions')['agb'])->toBe('0.6'); // Zwischen Anzeige und Absenden wird eine neue AGB-Fassung wirksam. LegalDocument::factory()->agb()->create(['version' => '0.7', 'effective_from' => now()->subDay()->toDateString()]); $component->call('submitForReview')->assertHasErrors(['legalAcceptanceConfirmed']); expect(LegalDocumentAcceptance::query()->count())->toBe(0) ->and($pr->fresh()->status)->toBe(PressReleaseStatus::Draft) ->and($component->get('legalAcceptanceConfirmed'))->toBeFalse() ->and($component->get('legalAcceptanceVersions')['agb'])->toBe('0.7'); // Neu gelesen und bestätigt: jetzt wird genau 0.7 protokolliert. $component->assertSee('Version 0.7') ->set('aiDeclarationConfirmed', true) ->set('legalAcceptanceConfirmed', true) ->call('submitForReview') ->assertHasNoErrors(); expect(LegalDocumentAcceptance::query()->where('type', 'agb')->pluck('version')->all())->toBe(['0.7']) ->and($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft); }); test('a missing required document is a configuration error while the gate is on, not a smaller gate', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner] = legalGateDraft(); LegalDocument::query()->where('type', 'richtlinien')->delete(); expect(fn () => app(LegalAcceptanceGate::class)->requiredFor($owner))->toThrow(RuntimeException::class, 'richtlinien'); }); test('acceptances are insert-only', function () { config()->set('legal.acceptance_gate', true); ['owner' => $owner] = legalGateDraft(); $gate = app(LegalAcceptanceGate::class); $rows = $gate->accept($owner, $gate->requiredFor($owner), 'gate', '203.0.113.5', 'Pest'); expect($rows)->toHaveCount(2) ->and($rows[0]->ip_hmac)->toStartWith('v1:') ->and(fn () => $rows[0]->delete())->toThrow(LogicException::class) ->and($gate->accept($owner, $gate->requiredFor($owner), 'gate'))->toBe([]); }); test('the api answers 428 with the required documents until they are accepted', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']); $this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none']) ->assertStatus(428) ->assertJsonPath('required_documents.0.type', 'agb') ->assertJsonPath('required_documents.1.version', '0.2'); $this->postJson('/api/v1/legal/acceptances', ['documents' => [['type' => 'agb', 'version' => '0.5']]]) ->assertUnprocessable(); $this->postJson('/api/v1/legal/acceptances', ['documents' => [ ['type' => 'agb', 'version' => '0.6'], ['type' => 'richtlinien', 'version' => '0.2'], ]])->assertCreated()->assertJsonPath('required', []); expect(LegalDocumentAcceptance::query()->where('context', 'api')->count())->toBe(2); $this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])->assertSuccessful(); $this->getJson('/api/v1/legal/acceptances')->assertSuccessful()->assertJsonPath('gate_enabled', true)->assertJsonCount(2, 'accepted'); }); /* * K-07 / B-13 (Umsetzungsaudit 29.09.2026): Die gezeigten Fassungen sind * gesperrt, ein Versionswechsel zwischen Prüfung und Speichern wird nicht * still übergangen, und der Rendering-Hook fragt je Request höchstens einmal * (bei ausgeschaltetem Gate gar nicht) und wirft nie beim Rendern. */ test('the shown versions cannot be changed from the browser', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); expect(fn () => LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->set('legalAcceptanceVersions', ['agb' => '9.9', 'richtlinien' => '9.9'])) ->toThrow(CannotUpdateLockedPropertyException::class); }); test('a version change between validation and saving is rejected instead of silently skipped', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); $current = app(LegalAcceptanceGate::class)->requiredFor($owner); $newer = LegalDocument::factory()->agb()->make(['version' => '0.7']); $submitCalls = null; // Anzeige und Validierung sehen 0.6, beim Speichern gilt schon 0.7. $this->partialMock(LegalAcceptanceGate::class) ->shouldReceive('requiredFor') ->andReturnUsing(function () use (&$submitCalls, $current, $newer) { if ($submitCalls === null) { return $current; } $submitCalls++; return $submitCalls === 1 ? $current : collect([$newer]); }); $component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->set('aiTextDeclaration', 'none') ->set('aiDeclarationConfirmed', true) ->set('legalAcceptanceConfirmed', true); $submitCalls = 0; $component->call('submitForReview') ->assertHasErrors(['legalAcceptanceConfirmed']); expect(LegalDocumentAcceptance::query()->count())->toBe(0) ->and($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); }); test('with the gate off rendering the submit pages asks nothing about legal documents', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); DB::enableQueryLog(); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])->assertOk(); expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toBeEmpty(); }); test('with the gate on a render asks once per request, not once per modal', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); DB::enableQueryLog(); LivewireVolt::test('customer.press-releases.index')->assertOk(); // Eine Abfrage je Dokumenttyp (AGB, Richtlinien) — nicht zusätzlich je Modal. expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toHaveCount(2); }); test('a missing required document fails the submit, not the rendering of the page', function () { /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); $this->actingAs($owner); LegalDocument::query()->where('type', 'richtlinien')->delete(); Exceptions::fake(); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertOk() ->set('aiTextDeclaration', 'none') ->set('aiDeclarationConfirmed', true) ->set('legalAcceptanceConfirmed', true) ->call('submitForReview') ->assertHasErrors(['legalAcceptanceConfirmed']); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); Exceptions::assertReported(RuntimeException::class); });