KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration für Text & Bilder
Selbstdeklaration des KI-Einsatzes je PM (Text) und je Bild inkl. Submit-Gate im PressReleaseService, versioniertem Audit-Trail (press_release_ai_declarations) und öffentlichem KI-Label in Einreicher-Stimme. Bestätigung im Submit-Modal ist Pflicht. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1822dcbcd1
commit
c387048d7e
16 changed files with 833 additions and 3 deletions
66
app/Enums/AiImageDeclaration.php
Normal file
66
app/Enums/AiImageDeclaration.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Selbst-Deklaration des KI-Einsatzes je PM-Bild (Art. 50 Abs. 4 KI-VO).
|
||||
*
|
||||
* Pflichtauswahl beim Bild-Upload (Decision-Update KI-Kennzeichnung,
|
||||
* 08.07.2026). Bewusste Über-Erfüllung: gelabelt wird jedes deklarierte
|
||||
* KI-Bild — auch stilisiert/abstrakt, nicht nur fotorealistische Deepfakes.
|
||||
*/
|
||||
enum AiImageDeclaration: string
|
||||
{
|
||||
case Original = 'original';
|
||||
case AiGenerated = 'ai_generated';
|
||||
case AiEdited = 'ai_edited';
|
||||
|
||||
/**
|
||||
* Wortlaut der Auswahloption im Upload-Formular.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Original => __('Originalfoto'),
|
||||
self::AiGenerated => __('KI-generiertes Bild'),
|
||||
self::AiEdited => __('KI-bearbeitetes Bild'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlicher Bildhinweis direkt am Bild — null bei Originalfotos
|
||||
* (keine Nicht-KI-Zertifizierung in Portalstimme).
|
||||
*/
|
||||
public function publicLabel(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Original => null,
|
||||
self::AiGenerated => __('Bildhinweis: KI-generierte Illustration.'),
|
||||
self::AiEdited => __('Bildhinweis: KI-bearbeitetes Bild.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionsliste für Radios/Selects: value => label.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach (self::cases() as $case) {
|
||||
$options[$case->value] = $case->label();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
70
app/Enums/AiTextDeclaration.php
Normal file
70
app/Enums/AiTextDeclaration.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Selbst-Deklaration des KI-Einsatzes beim PM-Text (Art. 50 Abs. 4 KI-VO).
|
||||
*
|
||||
* Pflichtauswahl im Submit-Flow (Decision-Update KI-Kennzeichnung, 08.07.2026):
|
||||
* gelabelt wird allein nach dem deklarierten KI-Grad, nicht nach der
|
||||
* Public-Interest-Frage — bewusste Über-Erfüllung. Das öffentliche Label
|
||||
* spricht in Einreicher-Stimme, nie in Portalstimme.
|
||||
*/
|
||||
enum AiTextDeclaration: string
|
||||
{
|
||||
case None = 'none';
|
||||
case Assisted = 'assisted';
|
||||
case MostlyAi = 'mostly_ai';
|
||||
case FullyAi = 'fully_ai';
|
||||
|
||||
/**
|
||||
* Wortlaut der Auswahloption im Submit-Flow.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::None => __('Ohne generative KI erstellt'),
|
||||
self::Assisted => __('Mit KI als Formulierungshilfe erstellt und redaktionell überarbeitet'),
|
||||
self::MostlyAi => __('Überwiegend mit generativer KI erstellt und anschließend geprüft'),
|
||||
self::FullyAi => __('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliches Label in Einreicher-Stimme — null, wenn kein Pflicht-Label
|
||||
* fällig ist ("Ohne KI" wird bewusst nicht als Portal-Aussage zertifiziert,
|
||||
* die Formulierungshilfe entspricht dem Entlastungsfall).
|
||||
*/
|
||||
public function publicLabel(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::None, self::Assisted => null,
|
||||
self::MostlyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.'),
|
||||
self::FullyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionsliste für Radios/Selects: value => label.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach (self::cases() as $case) {
|
||||
$options[$case->value] = $case->label();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
52
app/Livewire/Concerns/InteractsWithAiDeclaration.php
Normal file
52
app/Livewire/Concerns/InteractsWithAiDeclaration.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026).
|
||||
*
|
||||
* Gemeinsame Properties + Validierung der vier Customer-Submit-Pfade
|
||||
* (Erstellen, Bearbeiten, Detailansicht, Liste). Die Selbst-Deklaration ist
|
||||
* Pflicht bei JEDEM Einreichen: ohne KI-Grad des Textes und verbindliche
|
||||
* Bestätigung kein Submit. Die Bestätigung wird nicht vorbelegt — sie muss
|
||||
* bei jedem Submit erneut gesetzt werden (Audit-Trail pro Einreichung).
|
||||
*/
|
||||
trait InteractsWithAiDeclaration
|
||||
{
|
||||
public ?string $aiTextDeclaration = null;
|
||||
|
||||
public bool $aiDeclarationConfirmed = false;
|
||||
|
||||
/**
|
||||
* Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im
|
||||
* Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht.
|
||||
*/
|
||||
protected function validateAiDeclaration(): void
|
||||
{
|
||||
$this->validate([
|
||||
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())],
|
||||
'aiDeclarationConfirmed' => ['accepted'],
|
||||
], [
|
||||
'aiTextDeclaration.required' => __('Bitte angeben, ob und wie generative KI beim Text eingesetzt wurde.'),
|
||||
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spaltenwerte für die PM-Persistenz. Der Bestätigungszeitpunkt wird nur
|
||||
* gestempelt, wenn die Checkbox tatsächlich gesetzt wurde.
|
||||
*
|
||||
* @return array{ai_text_declaration: ?string, ai_declaration_confirmed_at: ?Carbon}
|
||||
*/
|
||||
protected function aiDeclarationAttributes(): array
|
||||
{
|
||||
return [
|
||||
'ai_text_declaration' => $this->aiTextDeclaration,
|
||||
'ai_declaration_confirmed_at' => $this->aiDeclarationConfirmed ? now() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseContentTier;
|
||||
|
|
@ -71,6 +72,8 @@ class PressRelease extends Model
|
|||
'placeholder_variant',
|
||||
'backlink_url',
|
||||
'keywords',
|
||||
'ai_text_declaration',
|
||||
'ai_declaration_confirmed_at',
|
||||
'status',
|
||||
'classification',
|
||||
'classified_at',
|
||||
|
|
@ -95,6 +98,8 @@ class PressRelease extends Model
|
|||
return [
|
||||
'portal' => Portal::class,
|
||||
'placeholder_variant' => PressReleasePlaceholder::class,
|
||||
'ai_text_declaration' => AiTextDeclaration::class,
|
||||
'ai_declaration_confirmed_at' => 'datetime',
|
||||
'status' => PressReleaseStatus::class,
|
||||
'classification' => PressReleaseClassification::class,
|
||||
'classified_at' => 'datetime',
|
||||
|
|
@ -166,6 +171,25 @@ class PressRelease extends Model
|
|||
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) —
|
||||
* jeder Submit erzeugt einen neuen, unveränderlichen Eintrag.
|
||||
*/
|
||||
public function aiDeclarations(): HasMany
|
||||
{
|
||||
return $this->hasMany(PressReleaseAiDeclaration::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliches KI-Label in Einreicher-Stimme — null, wenn nach dem
|
||||
* deklarierten KI-Grad kein Pflicht-Label fällig ist (ohne KI /
|
||||
* Formulierungshilfe) oder noch keine Deklaration vorliegt.
|
||||
*/
|
||||
public function aiTextPublicLabel(): ?string
|
||||
{
|
||||
return $this->ai_text_declaration?->publicLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Detailseiten je Portal, auf dem die PM erscheint
|
||||
* (bei Portal::Both beide Spokes). Zentrale Quelle für Mail,
|
||||
|
|
|
|||
55
app/Models/PressReleaseAiDeclaration.php
Normal file
55
app/Models/PressReleaseAiDeclaration.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Unveränderlicher Audit-Eintrag einer KI-Deklaration (Art. 50 KI-VO).
|
||||
*
|
||||
* Wird bei jedem Submit geschrieben (Versionierung durch neue Zeilen) und
|
||||
* hält fest, welcher User wann welchen KI-Grad für Text und Bilder deklariert
|
||||
* und bestätigt hat. Einträge werden nie aktualisiert oder gelöscht — sie
|
||||
* sind der belastbare Nachweis gegenüber Art. 50 Abs. 4 KI-VO.
|
||||
*/
|
||||
class PressReleaseAiDeclaration extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'press_release_id',
|
||||
'user_id',
|
||||
'company_id',
|
||||
'text_declaration',
|
||||
'image_declarations',
|
||||
'confirmed_at',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'text_declaration' => AiTextDeclaration::class,
|
||||
'image_declarations' => 'array',
|
||||
'confirmed_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function pressRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ImageLicenseType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -31,6 +32,7 @@ class PressReleaseImage extends Model
|
|||
'rights_notes',
|
||||
'rights_confirmed_at',
|
||||
'is_ai_generated',
|
||||
'ai_declaration',
|
||||
'is_preview',
|
||||
'sort_order',
|
||||
'width',
|
||||
|
|
@ -48,6 +50,7 @@ class PressReleaseImage extends Model
|
|||
'persons_consent' => 'boolean',
|
||||
'rights_confirmed_at' => 'datetime',
|
||||
'is_ai_generated' => 'boolean',
|
||||
'ai_declaration' => AiImageDeclaration::class,
|
||||
'is_preview' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'width' => 'integer',
|
||||
|
|
|
|||
19
app/Services/PressRelease/AiDeclarationRequiredException.php
Normal file
19
app/Services/PressRelease/AiDeclarationRequiredException.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026): Ohne
|
||||
* vollständige Selbst-Deklaration — KI-Grad des Textes, KI-Kennzeichnung
|
||||
* jedes Bildes und verbindliche Bestätigung — kein Submit. Speichern als
|
||||
* Entwurf bleibt immer frei.
|
||||
*/
|
||||
class AiDeclarationRequiredException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message = 'Für das Einreichen ist die vollständige KI-Kennzeichnung erforderlich (Angabe zum Text, zu jedem Bild sowie die Bestätigung).')
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ use App\Mail\PressReleasePublished;
|
|||
use App\Mail\PressReleaseRejected;
|
||||
use App\Models\AdminPreset;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseAiDeclaration;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
|
@ -52,6 +54,11 @@ class PressReleaseService
|
|||
throw new QuotaExceededException;
|
||||
}
|
||||
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO): ohne vollständige Deklaration
|
||||
// kein Submit — die Editoren validieren das bereits im Modal, dieser
|
||||
// Guard sichert alle Aufrufpfade (API, ältere Entwürfe) ab.
|
||||
$this->assertAiDeclarationComplete($pressRelease);
|
||||
|
||||
$previous = $pressRelease->status;
|
||||
|
||||
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
|
||||
|
|
@ -75,6 +82,11 @@ class PressReleaseService
|
|||
]);
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
|
||||
|
||||
// Versionierter Audit-Trail der KI-Deklaration: jeder Submit erzeugt
|
||||
// einen neuen, unveränderlichen Eintrag (Abs.-50-Verteidigung —
|
||||
// "Kunde hat am Datum X Option Y deklariert und bestätigt").
|
||||
$this->logAiDeclaration($pressRelease);
|
||||
|
||||
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
|
||||
// des Ergebnisses übernimmt der Job über routeByClassification().
|
||||
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||
|
|
@ -397,6 +409,52 @@ class PressReleaseService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KI-Kennzeichnung vollständig? Pflicht sind der deklarierte KI-Grad des
|
||||
* Textes, die verbindliche Bestätigung sowie eine Kennzeichnung für
|
||||
* jedes Bild (Art. 50 KI-VO, Decision-Update 08.07.2026).
|
||||
*/
|
||||
private function assertAiDeclarationComplete(PressRelease $pressRelease): void
|
||||
{
|
||||
if ($pressRelease->ai_text_declaration === null || $pressRelease->ai_declaration_confirmed_at === null) {
|
||||
throw new AiDeclarationRequiredException;
|
||||
}
|
||||
|
||||
if ($pressRelease->images()->whereNull('ai_declaration')->exists()) {
|
||||
throw new AiDeclarationRequiredException(
|
||||
__('Bitte für jedes Bild die KI-Kennzeichnung angeben (Originalfoto, KI-generiert oder KI-bearbeitet) — im Bild-Manager der Bearbeiten-Seite.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt den unveränderlichen Audit-Eintrag der aktuellen Deklaration —
|
||||
* inkl. Bild-Snapshot zum Submit-Zeitpunkt.
|
||||
*/
|
||||
private function logAiDeclaration(PressRelease $pressRelease): void
|
||||
{
|
||||
$imageDeclarations = $pressRelease->images()
|
||||
->orderBy('sort_order')
|
||||
->get(['id', 'path', 'ai_declaration'])
|
||||
->map(fn (PressReleaseImage $image): array => [
|
||||
'image_id' => $image->id,
|
||||
'path' => $image->path,
|
||||
'declaration' => $image->ai_declaration?->value,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
PressReleaseAiDeclaration::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'user_id' => $pressRelease->user_id,
|
||||
'company_id' => $pressRelease->company_id,
|
||||
'text_declaration' => $pressRelease->ai_text_declaration?->value,
|
||||
'image_declarations' => $imageDeclarations ?: null,
|
||||
'confirmed_at' => $pressRelease->ai_declaration_confirmed_at,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function logStatusChange(
|
||||
PressRelease $pressRelease,
|
||||
?PressReleaseStatus $from,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
|
|
@ -35,9 +36,25 @@ class PressReleaseFactory extends Factory
|
|||
'status' => PressReleaseStatus::Draft->value,
|
||||
'hits' => 0,
|
||||
'no_export' => false,
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO): Standard „ohne KI", bestätigt —
|
||||
// damit erfüllen Test-PMs den Submit-Guard. Gate-Tests setzen die
|
||||
// Felder explizit auf null (siehe aiUndeclared()).
|
||||
'ai_text_declaration' => AiTextDeclaration::None->value,
|
||||
'ai_declaration_confirmed_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* PM ohne KI-Deklaration — zum Testen des Submit-Gates (Art. 50 KI-VO).
|
||||
*/
|
||||
public function aiUndeclared(): static
|
||||
{
|
||||
return $this->state([
|
||||
'ai_text_declaration' => null,
|
||||
'ai_declaration_confirmed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function published(): static
|
||||
{
|
||||
return $this->state([
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
|
||||
* Selbst-Deklaration des KI-Grads am PM-Text sowie je Bild. Die Deklaration
|
||||
* ist Pflicht im Submit-Flow; der Bestätigungszeitpunkt wird zur
|
||||
* Nachweisbarkeit gespeichert.
|
||||
*
|
||||
* Bewusst OHNE ->after(): mit Positionierung kopiert MySQL die große
|
||||
* press_releases-Tabelle komplett (30+ Min auf Dev), am Tabellenende greift
|
||||
* ALGORITHM=INSTANT. Die hasColumn-Guards machen die Migration nach einem
|
||||
* Abbruch mitten im Lauf (08.07.2026 auf Dev passiert) wiederaufnehmbar.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('press_releases', 'ai_text_declaration')) {
|
||||
Schema::table('press_releases', function (Blueprint $table): void {
|
||||
$table->string('ai_text_declaration', 20)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('press_releases', 'ai_declaration_confirmed_at')) {
|
||||
Schema::table('press_releases', function (Blueprint $table): void {
|
||||
$table->timestamp('ai_declaration_confirmed_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('press_release_images', 'ai_declaration')) {
|
||||
Schema::table('press_release_images', function (Blueprint $table): void {
|
||||
$table->string('ai_declaration', 20)->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table): void {
|
||||
$table->dropColumn(['ai_text_declaration', 'ai_declaration_confirmed_at']);
|
||||
});
|
||||
|
||||
Schema::table('press_release_images', function (Blueprint $table): void {
|
||||
$table->dropColumn('ai_declaration');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Audit-Trail der KI-Deklarationen (Art. 50 KI-VO): Jeder Submit schreibt
|
||||
* einen unveränderlichen Eintrag — "Kunde hat am Datum X Option Y deklariert
|
||||
* und bestätigt". Re-Submits erzeugen neue Einträge (Versionierung).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('press_release_ai_declarations', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('press_release_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('company_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('text_declaration', 20);
|
||||
// Snapshot je Bild zum Submit-Zeitpunkt:
|
||||
// [{image_id, path, declaration}, ...]
|
||||
$table->json('image_declarations')->nullable();
|
||||
$table->timestamp('confirmed_at');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('press_release_ai_declarations');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Backfill (DML, getrennt vom DDL): Bilder, die beim Upload bereits als
|
||||
* KI-generiert lizenziert wurden (is_ai_generated), gelten als deklariert.
|
||||
* Alle übrigen Bestandsbilder bleiben bewusst NULL — wir deklarieren nicht
|
||||
* stellvertretend "Originalfoto" für den Kunden; die Deklaration wird beim
|
||||
* nächsten Submit nachgefordert.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('press_release_images')
|
||||
->where('is_ai_generated', true)
|
||||
->whereNull('ai_declaration')
|
||||
->update(['ai_declaration' => 'ai_generated']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Bewusst leer: der Backfill ist eine reine Datenanreicherung; das
|
||||
// Entfernen der Spalte übernimmt die zugehörige DDL-Migration.
|
||||
}
|
||||
};
|
||||
17
resources/views/components/portal/ai-public-label.blade.php
Normal file
17
resources/views/components/portal/ai-public-label.blade.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{{--
|
||||
Öffentliches KI-Label (Art. 50 KI-VO) in Einreicher-Stimme.
|
||||
|
||||
Neutrales ℹ-Icon, bewusst KEIN grünes Häkchen — das würde als redaktionelle
|
||||
Prüfung/Freigabe durch das Portal gelesen. Rendert nichts, wenn kein
|
||||
Pflicht-Label fällig ist (label = null).
|
||||
|
||||
Verwendung: <x-portal.ai-public-label :label="$pr->aiTextPublicLabel()" />
|
||||
--}}
|
||||
@props(['label'])
|
||||
|
||||
@if ($label)
|
||||
<div {{ $attributes->merge(['class' => 'flex items-start gap-2 rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12px] leading-[1.5] text-[color:var(--color-ink-2)]']) }}>
|
||||
<flux:icon name="information-circle" variant="mini" class="size-4 shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
|
||||
<span>{{ $label }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false, waiver: false }">
|
||||
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false, waiver: false, ai: false }">
|
||||
<div>
|
||||
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
|
||||
|
|
@ -104,13 +104,40 @@
|
|||
</article>
|
||||
@endif
|
||||
|
||||
{{-- KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
|
||||
Pflicht-Selbstdeklaration in jedem Veröffentlichungsprozess.
|
||||
Die Eltern-Komponente stellt die Properties über den Trait
|
||||
App\Livewire\Concerns\InteractsWithAiDeclaration bereit. --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head" style="padding:10px 16px;">
|
||||
<span class="section-eyebrow">
|
||||
{{ __('KI-Kennzeichnung') }}
|
||||
<span class="text-[color:var(--color-err)]">*</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<p class="m-0 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Wie wurde der Text dieser Pressemitteilung erstellt? Diese Angabe ist gesetzlich verpflichtend (Art. 50 KI-VO).') }}
|
||||
</p>
|
||||
<flux:radio.group wire:model="aiTextDeclaration">
|
||||
@foreach (\App\Enums\AiTextDeclaration::cases() as $aiOption)
|
||||
<flux:radio :value="$aiOption->value" :label="$aiOption->label()" />
|
||||
@endforeach
|
||||
</flux:radio.group>
|
||||
<flux:error name="aiTextDeclaration" />
|
||||
<p class="m-0 pt-2 border-t border-[color:var(--color-bg-rule)] text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Bei überwiegend oder vollständig KI-generierten Texten erscheint an der Veröffentlichung ein neutraler Hinweis in Ihrer Stimme („Nach Angaben des einreichenden Unternehmens …"). KI-generierte oder KI-bearbeitete Bilder kennzeichnen Sie direkt beim Bild-Upload.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Bestätigungen — Flux-Switches (Einheitlichkeit, 12.06.2026);
|
||||
der Alpine-State steuert weiterhin den Bestätigen-Button. --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head" style="padding:10px 16px;">
|
||||
<span class="section-eyebrow">{{ __('Ihre Bestätigungen') }}</span>
|
||||
<span class="shrink-0 whitespace-nowrap text-[11.5px] tabular-nums text-[color:var(--color-ink-3)]"
|
||||
x-text="[agb, images, contact, waiver].filter(Boolean).length + ' / 4'"></span>
|
||||
x-text="[agb, images, contact, waiver, ai].filter(Boolean).length + ' / 5'"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
<div class="px-4 py-3">
|
||||
|
|
@ -145,6 +172,18 @@
|
|||
:label="__('Mir ist bekannt, dass veröffentlichte Pressemitteilungen dauerhaft archiviert werden; eine spätere Entfernung ist nur als kostenpflichtige Serviceleistung möglich (gesetzliche Ansprüche bleiben unberührt).')"
|
||||
/>
|
||||
</div>
|
||||
{{-- Verbindliche KI-Bestätigung (Art. 50 KI-VO): wire-gebunden,
|
||||
weil der Bestätigungszeitpunkt zur Nachweisbarkeit im
|
||||
Audit-Trail persistiert wird. --}}
|
||||
<div class="px-4 py-3">
|
||||
<flux:switch
|
||||
align="left"
|
||||
wire:model="aiDeclarationConfirmed"
|
||||
x-on:change="ai = $event.target.checked"
|
||||
:label="__('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.')"
|
||||
/>
|
||||
<flux:error name="aiDeclarationConfirmed" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -154,7 +193,7 @@
|
|||
</flux:modal.close>
|
||||
<flux:button variant="primary" wire:click="{{ $action }}"
|
||||
wire:loading.attr="disabled"
|
||||
x-bind:disabled="! (agb && images && contact && waiver)">
|
||||
x-bind:disabled="! (agb && images && contact && waiver && ai)">
|
||||
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -463,8 +463,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
@if ($pr->ai_text_declaration)
|
||||
<span class="text-[11px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('KI-Deklaration:') }} {{ $pr->ai_text_declaration->label() }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{{-- KI-Label (Art. 50 KI-VO): erscheint so auch in der
|
||||
Veröffentlichung — am Anfang des Inhalts, in Einreicher-Stimme. --}}
|
||||
<x-portal.ai-public-label :label="$pr->aiTextPublicLabel()" class="mb-4 max-w-[760px]" />
|
||||
<div class="pr-content max-w-[760px]">
|
||||
{!! $pr->renderedText() !!}
|
||||
</div>
|
||||
|
|
|
|||
289
tests/Feature/PressReleaseAiDeclarationTest.php
Normal file
289
tests/Feature/PressReleaseAiDeclarationTest.php
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseAiDeclaration;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
|
||||
* Pflicht-Selbstdeklaration im Submit-Flow, versionierter Audit-Trail,
|
||||
* öffentliches Label in Einreicher-Stimme.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
function makeAiDeclarationCustomer(): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
return [$customer, $company];
|
||||
}
|
||||
|
||||
test('the text declaration maps to the attributed public label per decision update', function () {
|
||||
expect(AiTextDeclaration::None->publicLabel())->toBeNull();
|
||||
expect(AiTextDeclaration::Assisted->publicLabel())->toBeNull();
|
||||
expect(AiTextDeclaration::MostlyAi->publicLabel())
|
||||
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.');
|
||||
expect(AiTextDeclaration::FullyAi->publicLabel())
|
||||
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.');
|
||||
});
|
||||
|
||||
test('the image declaration maps to the public image hint per decision update', function () {
|
||||
expect(AiImageDeclaration::Original->publicLabel())->toBeNull();
|
||||
expect(AiImageDeclaration::AiGenerated->publicLabel())->toBe('Bildhinweis: KI-generierte Illustration.');
|
||||
expect(AiImageDeclaration::AiEdited->publicLabel())->toBe('Bildhinweis: KI-bearbeitetes Bild.');
|
||||
});
|
||||
|
||||
test('submit is blocked without a complete ai declaration', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer] = makeAiDeclarationCustomer();
|
||||
|
||||
$pr = PressRelease::factory()->aiUndeclared()->create([
|
||||
'user_id' => $customer->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
|
||||
->toThrow(AiDeclarationRequiredException::class);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||||
expect(PressReleaseAiDeclaration::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('submit is blocked while any image lacks its ai declaration', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer] = makeAiDeclarationCustomer();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||||
'author' => 'Jane Doe',
|
||||
'license_type' => ImageLicenseType::Own->value,
|
||||
'rights_confirmed_at' => now(),
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
'ai_declaration' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
|
||||
->toThrow(AiDeclarationRequiredException::class);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||||
});
|
||||
|
||||
test('each submit writes a versioned immutable audit entry with the image snapshot', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $company] = makeAiDeclarationCustomer();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
'ai_text_declaration' => AiTextDeclaration::MostlyAi->value,
|
||||
'ai_declaration_confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
$image = $pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||||
'author' => 'Beispiel GmbH',
|
||||
'license_type' => ImageLicenseType::AiGenerated->value,
|
||||
'rights_confirmed_at' => now(),
|
||||
'is_ai_generated' => true,
|
||||
'ai_declaration' => AiImageDeclaration::AiGenerated->value,
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
|
||||
$entry = PressReleaseAiDeclaration::query()->sole();
|
||||
|
||||
expect($entry->press_release_id)->toBe($pr->id);
|
||||
expect($entry->user_id)->toBe($customer->id);
|
||||
expect($entry->company_id)->toBe($company->id);
|
||||
expect($entry->text_declaration)->toBe(AiTextDeclaration::MostlyAi);
|
||||
expect($entry->confirmed_at)->not->toBeNull();
|
||||
expect($entry->created_at)->not->toBeNull();
|
||||
expect($entry->image_declarations)->toBe([
|
||||
['image_id' => $image->id, 'path' => $image->path, 'declaration' => 'ai_generated'],
|
||||
]);
|
||||
|
||||
// Re-Submit nach Ablehnung erzeugt einen ZWEITEN Eintrag (Versionierung).
|
||||
$pr->fresh()->update(['status' => PressReleaseStatus::Rejected->value]);
|
||||
app(PressReleaseService::class)->submitForReview($pr->fresh());
|
||||
|
||||
expect(PressReleaseAiDeclaration::query()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('the create editor requires the ai declaration only when submitting for review', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer] = makeAiDeclarationCustomer();
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
$component = LivewireVolt::test('customer.press-releases.create')
|
||||
->set('categoryId', $category->id)
|
||||
->set('title', 'Ein gültiger Titel für die PM')
|
||||
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5));
|
||||
|
||||
// Einreichen ohne Deklaration → Fehler im Modal, keine PM angelegt.
|
||||
$component
|
||||
->call('save', 'review')
|
||||
->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']);
|
||||
|
||||
expect(PressRelease::query()->where('user_id', $customer->id)->count())->toBe(0);
|
||||
|
||||
// Entwurf-Speichern bleibt ohne Deklaration möglich.
|
||||
$component
|
||||
->call('save', 'draft')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$draft = PressRelease::query()->where('user_id', $customer->id)->sole();
|
||||
expect($draft->ai_text_declaration)->toBeNull();
|
||||
expect($draft->ai_declaration_confirmed_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('submitting from the create editor persists the declaration and the audit entry', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer] = makeAiDeclarationCustomer();
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('categoryId', $category->id)
|
||||
->set('title', 'Ein gültiger Titel für die PM')
|
||||
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5))
|
||||
->set('aiTextDeclaration', AiTextDeclaration::FullyAi->value)
|
||||
->set('aiDeclarationConfirmed', true)
|
||||
->call('save', 'review')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr = PressRelease::query()->where('user_id', $customer->id)->sole();
|
||||
|
||||
expect($pr->status)->toBe(PressReleaseStatus::Review);
|
||||
expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::FullyAi);
|
||||
expect($pr->ai_declaration_confirmed_at)->not->toBeNull();
|
||||
expect($pr->aiTextPublicLabel())
|
||||
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.');
|
||||
|
||||
$entry = PressReleaseAiDeclaration::query()->sole();
|
||||
expect($entry->press_release_id)->toBe($pr->id);
|
||||
expect($entry->text_declaration)->toBe(AiTextDeclaration::FullyAi);
|
||||
});
|
||||
|
||||
test('submitting from the detail page validates and persists the declaration', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $company] = makeAiDeclarationCustomer();
|
||||
|
||||
$pr = PressRelease::factory()->aiUndeclared()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->call('submitForReview')
|
||||
->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->set('aiTextDeclaration', AiTextDeclaration::Assisted->value)
|
||||
->set('aiDeclarationConfirmed', true)
|
||||
->call('submitForReview')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pr->refresh();
|
||||
expect($pr->status)->toBe(PressReleaseStatus::Review);
|
||||
expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::Assisted);
|
||||
// Formulierungshilfe = Entlastungsfall → kein öffentliches Pflicht-Label.
|
||||
expect($pr->aiTextPublicLabel())->toBeNull();
|
||||
expect(PressReleaseAiDeclaration::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the submit modal renders the declaration options and the binding confirmation', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $company] = makeAiDeclarationCustomer();
|
||||
|
||||
$pr = PressRelease::factory()->aiUndeclared()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('KI-Kennzeichnung')
|
||||
->assertSee('Ohne generative KI erstellt')
|
||||
->assertSee('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)')
|
||||
->assertSee('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.');
|
||||
});
|
||||
|
||||
test('image uploads require the per-image ai declaration', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $company] = makeAiDeclarationCustomer();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => Category::factory()->create()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
Storage::fake('public');
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newCopyright', 'Foto: Jane Doe')
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
->set('newPropertyRightsStatus', 'none')
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newAiDeclaration']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue