Files
SkinbaseNova/tests/Feature/ArtworkVersioningTest.php
Gregor Klevze 1266f81d35 feat: upload wizard refactor + vision AI tags + artwork versioning
Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar

Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env

Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
2026-03-01 14:56:46 +01:00

240 lines
9.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\ArtworkVersion;
use App\Models\ArtworkVersionEvent;
use App\Models\User;
use App\Services\ArtworkVersioningService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
// ── Helpers ────────────────────────────────────────────────────────────────────
function versioningArtwork(array $attrs = []): Artwork
{
return Artwork::withoutEvents(fn () => 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);
});