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:
239
tests/Feature/ArtworkVersioningTest.php
Normal file
239
tests/Feature/ArtworkVersioningTest.php
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user