create(); $pressRelease = PressRelease::factory()->create([ 'user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value, ]); Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']); $uploadResponse = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('press-release.jpg', 1200, 800)->size(5120), 'ai_declaration' => 'original', 'title' => 'Pressefoto', 'is_preview' => true, ]); // Pressebilder werden auf das Hero-/Cover-Format normalisiert (harte // Obergrenze 1280x580, siehe ImageService::PRESS_RELEASE_IMAGE_VARIANTS). // Gespeichert werden daher die Cover-Maße, nicht die Originalgröße. $uploadResponse ->assertCreated() ->assertJsonPath('data.title', 'Pressefoto') ->assertJsonPath('data.is_preview', true) ->assertJsonPath('data.width', 1280) ->assertJsonPath('data.height', 580) ->assertJsonStructure(['data' => ['variants', 'urls']]); $image = PressReleaseImage::query()->firstOrFail(); expect($image->disk)->toBe('local'); expect(Storage::disk('local')->exists($image->path))->toBeTrue(); expect($image->variants)->toBeArray(); expect($image->variants)->toHaveKeys(['thumb', 'medium', 'large']); foreach ($image->variants as $variantPath) { expect(Storage::disk('local')->exists($variantPath))->toBeTrue(); } $this->getJson("/api/v1/press-releases/{$pressRelease->id}/images") ->assertOk() ->assertJsonPath('data.0.id', $image->id) ->assertJsonPath('data.0.url', route('media.press-release-images.show', ['image' => $image->id])) // Variant-URLs müssen über die Media-Route zeigen (Disk 'local'), nicht // auf /storage/ — sonst 404 für private Bilder. ->assertJsonPath('data.0.urls.thumb', route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'thumb'])) ->assertJsonPath('data.0.urls.large', route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'large'])); $listedUrls = $this->getJson("/api/v1/press-releases/{$pressRelease->id}/images")->json('data.0.urls'); foreach ($listedUrls as $variantUrl) { expect($variantUrl)->not->toContain('/storage/'); } $this->getJson("/api/v1/press-releases/{$pressRelease->id}") ->assertOk() ->assertJsonPath('data.images.0.id', $image->id); $variantPaths = $image->variants ?? []; $this->deleteJson("/api/v1/press-release-images/{$image->id}") ->assertNoContent(); expect(Storage::disk('local')->exists($image->path))->toBeFalse(); foreach ($variantPaths as $variantPath) { expect(Storage::disk('local')->exists($variantPath))->toBeFalse(); } expect(PressReleaseImage::withTrashed()->find($image->id)?->trashed())->toBeTrue(); }); test('image upload is limited to eight megabytes', function () { /** @var TestCase $this */ Storage::fake('local'); $user = User::factory()->create(); $pressRelease = PressRelease::factory()->create([ 'user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value, ]); Sanctum::actingAs($user, ['press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('too-large.jpg')->size(8193), ])->assertUnprocessable() ->assertInvalid(['image']); }); test('api user cannot manage another users press release images', function () { /** @var TestCase $this */ Storage::fake('local'); $owner = User::factory()->create(); $otherUser = User::factory()->create(); $pressRelease = PressRelease::factory()->create([ 'user_id' => $owner->id, 'status' => PressReleaseStatus::Draft->value, ]); Sanctum::actingAs($otherUser, ['press-releases:read', 'press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('foreign.jpg'), 'ai_declaration' => 'original', ])->assertForbidden(); }); test('published press release images cannot be changed via api', function () { /** @var TestCase $this */ Storage::fake('local'); $user = User::factory()->create(); $pressRelease = PressRelease::factory()->published()->create([ 'user_id' => $user->id, ]); Sanctum::actingAs($user, ['press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('published.jpg'), 'ai_declaration' => 'original', ])->assertConflict() ->assertJsonPath('message', 'Only draft or rejected press releases may be edited.'); }); test('api image upload requires the per-image ai declaration', function () { /** @var TestCase $this */ Storage::fake('local'); $user = User::factory()->create(); $pressRelease = PressRelease::factory()->create([ 'user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value, ]); Sanctum::actingAs($user, ['press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('undeklariert.jpg'), ])->assertUnprocessable() ->assertInvalid(['ai_declaration']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('ki-bild.jpg'), 'ai_declaration' => 'ai_generated', ])->assertCreated(); expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('ai_generated'); }); test('private press release images follow publication and ownership access', function () { /** @var TestCase $this */ Storage::fake('local'); $owner = User::factory()->create(); $draft = PressRelease::factory()->create([ 'user_id' => $owner->id, 'status' => PressReleaseStatus::Draft->value, ]); $image = $draft->images()->create([ 'disk' => 'local', 'path' => "press-releases/{$draft->id}/images/private.jpg", 'variants' => [], 'mime' => 'image/jpeg', ]); Storage::disk('local')->put($image->path, 'image data'); $url = "https://pressekonto.test/media/press-release-images/{$image->id}"; $this->get($url)->assertNotFound(); $this->actingAs($owner)->get($url)->assertSuccessful(); $draft->update([ 'status' => PressReleaseStatus::Published->value, 'published_at' => now()->subMinute(), ]); auth()->forgetGuards(); $this->get("https://presseecho.test/media/press-release-images/{$image->id}") ->assertSuccessful(); });