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)
This commit is contained in:
2026-03-01 14:56:46 +01:00
parent a875203482
commit 1266f81d35
33 changed files with 3710 additions and 1298 deletions

View File

@@ -0,0 +1,239 @@
<?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);
});