Artwork::factory()->create($attrs)); } beforeEach(function () { // SQLite GREATEST() polyfill for observer compatibility if (DB::connection()->getDriverName() === 'sqlite') { DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) { return max($args); }, -1); } Cache::flush(); $this->user = User::factory()->create(); $this->artwork = versioningArtwork(['user_id' => $this->user->id, 'hash' => 'aaa', 'version_count' => 1]); $this->service = new ArtworkVersioningService(); }); // ────────────────────────────────────────────────────────────────────────────── // ArtworkVersioningService – unit tests // ────────────────────────────────────────────────────────────────────────────── test('createNewVersion inserts a version row and marks it current', function () { $version = $this->service->createNewVersion( $this->artwork, 'path/to/new.webp', 'bbb', 1920, 1080, 204800, $this->user->id, 'First replacement', ); expect($version)->toBeInstanceOf(ArtworkVersion::class) ->and($version->version_number)->toBe(2) ->and($version->is_current)->toBeTrue() ->and($version->file_hash)->toBe('bbb'); $this->artwork->refresh(); expect($this->artwork->current_version_id)->toBe($version->id) ->and($this->artwork->version_count)->toBe(2); }); test('createNewVersion sets previous version is_current = false', function () { // Seed an existing "current" version row $old = ArtworkVersion::create([ 'artwork_id' => $this->artwork->id, 'version_number' => 1, 'file_path' => 'old.webp', 'file_hash' => 'aaahash', 'is_current' => true, ]); $this->service->createNewVersion( $this->artwork, 'new.webp', 'bbbhash', 1920, 1080, 500, $this->user->id, ); expect(ArtworkVersion::findOrFail($old->id)->is_current)->toBeFalse(); }); test('createNewVersion writes an audit log entry', function () { $this->service->createNewVersion( $this->artwork, 'path.webp', 'ccc', 800, 600, 1024, $this->user->id, ); $event = ArtworkVersionEvent::where('artwork_id', $this->artwork->id)->first(); expect($event)->not->toBeNull() ->and($event->action)->toBe('create_version') ->and($event->user_id)->toBe($this->user->id); }); test('createNewVersion rejects identical hash', function () { $this->artwork->update(['hash' => 'same_hash_here']); expect(fn () => $this->service->createNewVersion( $this->artwork, 'path.webp', 'same_hash_here', 800, 600, 1024, $this->user->id, ))->toThrow(\RuntimeException::class, 'identical'); }); test('artworkVersioningService enforces hourly rate limit', function () { // Exhaust rate limit for ($i = 0; $i < 3; $i++) { $hash = 'hash_' . $i; $this->artwork->update(['hash' => 'different_' . $i]); // avoid identical-hash rejection $this->service->createNewVersion( $this->artwork, 'path.webp', $hash, 800, 600, 1024, $this->user->id, ); } $this->artwork->update(['hash' => 'final_different']); expect(fn () => $this->service->createNewVersion( $this->artwork, 'path.webp', 'hash_over_limit', 800, 600, 1024, $this->user->id, ))->toThrow(\Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException::class); }); test('shouldRequireReapproval returns false for first upload', function () { $this->artwork->update(['width' => 0, 'height' => 0]); expect($this->service->shouldRequireReapproval($this->artwork, 1920, 1080))->toBeFalse(); }); test('shouldRequireReapproval returns true when dimensions change drastically', function () { $this->artwork->update(['width' => 1920, 'height' => 1080]); // 300% increase in width → triggers expect($this->service->shouldRequireReapproval($this->artwork, 7680, 4320))->toBeTrue(); }); test('shouldRequireReapproval returns false for small dimension change', function () { $this->artwork->update(['width' => 1920, 'height' => 1080]); // 5 % change → fine expect($this->service->shouldRequireReapproval($this->artwork, 2016, 1134))->toBeFalse(); }); test('applyRankingProtection decays ranking and heat scores', function () { DB::table('artwork_stats')->updateOrInsert( ['artwork_id' => $this->artwork->id], ['ranking_score' => 100.0, 'heat_score' => 50.0, 'engagement_velocity' => 20.0] ); $this->service->applyRankingProtection($this->artwork); $stats = DB::table('artwork_stats')->where('artwork_id', $this->artwork->id)->first(); expect((float) $stats->ranking_score)->toBeLessThan(100.0) ->and((float) $stats->heat_score)->toBeLessThan(50.0); }); test('restoreVersion clones old version as new current version', function () { $old = ArtworkVersion::create([ 'artwork_id' => $this->artwork->id, 'version_number' => 1, 'file_path' => 'original.webp', 'file_hash' => 'oldhash', 'width' => 1920, 'height' => 1080, 'file_size' => 99999, 'is_current' => true, ]); // Simulate artwork being at version 2 with a different hash $this->artwork->update(['hash' => 'currenthash', 'version_count' => 2]); $restored = $this->service->restoreVersion($old, $this->artwork, $this->user->id); expect($restored->version_number)->toBe(3) ->and($restored->file_hash)->toBe('oldhash') ->and($restored->change_note)->toContain('Restored from version 1'); $event = ArtworkVersionEvent::where('action', 'create_version') ->where('artwork_id', $this->artwork->id) ->orderByDesc('id') ->first(); expect($event)->not->toBeNull(); }); // ────────────────────────────────────────────────────────────────────────────── // Version history API endpoint // ────────────────────────────────────────────────────────────────────────────── test('GET studio/artworks/{id}/versions returns version list', function () { $this->actingAs($this->user); ArtworkVersion::create([ 'artwork_id' => $this->artwork->id, 'version_number' => 1, 'file_path' => 'a.webp', 'file_hash' => 'hash1', 'is_current' => false, ]); ArtworkVersion::create([ 'artwork_id' => $this->artwork->id, 'version_number' => 2, 'file_path' => 'b.webp', 'file_hash' => 'hash2', 'is_current' => true, ]); $response = $this->getJson("/api/studio/artworks/{$this->artwork->id}/versions"); $response->assertOk() ->assertJsonCount(2, 'versions') ->assertJsonPath('versions.0.version_number', 2); // newest first }); test('GET studio/artworks/{id}/versions rejects other users', function () { $other = User::factory()->create(); $this->actingAs($other); $this->getJson("/api/studio/artworks/{$this->artwork->id}/versions") ->assertStatus(404); }); test('POST studio/artworks/{id}/restore/{version_id} restores version', function () { $this->actingAs($this->user); $old = ArtworkVersion::create([ 'artwork_id' => $this->artwork->id, 'version_number' => 1, 'file_path' => 'restored.webp', 'file_hash' => 'restorehash', 'width' => 800, 'height' => 600, 'file_size' => 5000, 'is_current' => false, ]); $this->artwork->update(['hash' => 'differenthash123', 'version_count' => 2]); $response = $this->postJson("/api/studio/artworks/{$this->artwork->id}/restore/{$old->id}"); $response->assertOk()->assertJsonPath('success', true); expect(ArtworkVersion::where('artwork_id', $this->artwork->id) ->where('file_hash', 'restorehash') ->where('is_current', true) ->exists() )->toBeTrue(); }); test('POST restore rejects attempt to restore already-current version', function () { $this->actingAs($this->user); $current = ArtworkVersion::create([ 'artwork_id' => $this->artwork->id, 'version_number' => 1, 'file_path' => 'x.webp', 'file_hash' => 'aaa', 'is_current' => true, ]); $this->postJson("/api/studio/artworks/{$this->artwork->id}/restore/{$current->id}") ->assertStatus(422) ->assertJsonPath('success', false); });