Files
SkinbaseNova/tests/Feature/Studio/ArtworkEvolutionApiTest.php

198 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
use function Pest\Laravel\putJson;
uses(RefreshDatabase::class);
function makeEvolutionPublishedArtwork(User $user, string $title, string $publishedAt, array $overrides = []): Artwork
{
return Artwork::factory()->create(array_merge([
'user_id' => $user->id,
'title' => $title,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_public' => true,
'is_approved' => true,
'artwork_status' => 'published',
'published_at' => Carbon::parse($publishedAt),
], $overrides));
}
it('syncs a primary artwork evolution relation from the studio save endpoint', function (): void {
$user = User::factory()->create();
$original = makeEvolutionPublishedArtwork($user, 'Original version', '2021-04-11 09:00:00');
$updated = makeEvolutionPublishedArtwork($user, 'Updated version', '2025-04-11 09:00:00');
actingAs($user);
putJson('/api/studio/artworks/' . $updated->id, [
'evolution_target_artwork_id' => $original->id,
'evolution_relation_type' => ArtworkRelation::TYPE_REMASTER_OF,
'evolution_note' => 'Much cleaner materials and a stronger silhouette.',
])
->assertOk()
->assertJsonPath('artwork.evolution_relation.relation_type', ArtworkRelation::TYPE_REMASTER_OF)
->assertJsonPath('artwork.evolution_relation.target_artwork.id', $original->id)
->assertJsonPath('artwork.evolution_relation.note', 'Much cleaner materials and a stronger silhouette.');
$this->assertDatabaseHas('artwork_relations', [
'source_artwork_id' => $updated->id,
'target_artwork_id' => $original->id,
'relation_type' => ArtworkRelation::TYPE_REMASTER_OF,
'note' => 'Much cleaner materials and a stronger silhouette.',
'created_by_user_id' => $user->id,
]);
});
it('rejects linking an artwork the actor does not manage', function (): void {
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$updated = makeEvolutionPublishedArtwork($owner, 'My newer piece', '2025-04-11 09:00:00');
$foreignOriginal = makeEvolutionPublishedArtwork($otherUser, 'Someone else original', '2020-04-11 09:00:00');
actingAs($owner);
putJson('/api/studio/artworks/' . $updated->id, [
'evolution_target_artwork_id' => $foreignOriginal->id,
'evolution_relation_type' => ArtworkRelation::TYPE_REMAKE_OF,
])
->assertStatus(422)
->assertJsonPath('errors.evolution_target_artwork_id.0', 'You can only link artworks that you are allowed to manage.');
$this->assertDatabaseCount('artwork_relations', 0);
});
it('limits evolution options to older manageable public artworks', function (): void {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00');
$eligible = makeEvolutionPublishedArtwork($user, 'Older eligible version', '2021-04-11 09:00:00');
$tooNew = makeEvolutionPublishedArtwork($user, 'Too new version', '2026-04-11 09:00:00');
makeEvolutionPublishedArtwork($user, 'Private original', '2020-04-11 09:00:00', [
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_public' => false,
]);
makeEvolutionPublishedArtwork($otherUser, 'Other user original', '2020-04-11 09:00:00');
actingAs($user);
getJson('/api/studio/artworks/' . $source->id . '/evolution-options')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $eligible->id)
->assertJsonMissing(['id' => $tooNew->id]);
});
it('sorts evolution options by vector similarity when available', function (): void {
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
config()->set('cdn.files_url', 'https://files.skinbase.org');
$user = User::factory()->create();
$source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00', [
'hash' => 'sourcehash1234',
'thumb_ext' => 'webp',
]);
$recentButLessSimilar = makeEvolutionPublishedArtwork($user, 'Recent candidate', '2024-04-11 09:00:00');
$olderButMoreSimilar = makeEvolutionPublishedArtwork($user, 'Most similar original', '2021-04-11 09:00:00');
Http::fake([
'https://vision.klevze.net/vectors/search' => Http::response([
'results' => [
['id' => $source->id, 'score' => 1.0],
['id' => $olderButMoreSimilar->id, 'score' => 0.98123],
['id' => $recentButLessSimilar->id, 'score' => 0.61234],
],
], 200),
]);
actingAs($user);
getJson('/api/studio/artworks/' . $source->id . '/evolution-options')
->assertOk()
->assertJsonPath('data.0.id', $olderButMoreSimilar->id)
->assertJsonPath('data.0.sort_source', 'vector_similarity')
->assertJsonPath('data.0.similarity_score', 0.98123)
->assertJsonPath('data.1.id', $recentButLessSimilar->id);
});
it('keeps deeper vector matches ahead of recency fallback in evolution options', function (): void {
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
config()->set('cdn.files_url', 'https://files.skinbase.org');
$user = User::factory()->create();
$source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00', [
'hash' => 'sourcehash5678',
'thumb_ext' => 'webp',
]);
$recentFallback = makeEvolutionPublishedArtwork($user, 'Recent fallback candidate', '2024-04-11 09:00:00');
$deepSimilarMatch = makeEvolutionPublishedArtwork($user, 'Deep similar original', '2020-04-11 09:00:00');
$results = collect(range(1, 28))->map(fn (int $offset): array => [
'id' => 900000 + $offset,
'score' => 0.95 - ($offset * 0.01),
])->all();
array_unshift($results, ['id' => $source->id, 'score' => 1.0]);
$results[] = ['id' => $deepSimilarMatch->id, 'score' => 0.73123];
$results[] = ['id' => $recentFallback->id, 'score' => 0.51234];
Http::fake([
'https://vision.klevze.net/vectors/search' => Http::response([
'results' => $results,
], 200),
]);
actingAs($user);
getJson('/api/studio/artworks/' . $source->id . '/evolution-options')
->assertOk()
->assertJsonPath('data.0.id', $deepSimilarMatch->id)
->assertJsonPath('data.0.sort_source', 'vector_similarity')
->assertJsonPath('data.1.id', $recentFallback->id);
});
it('includes bidirectional artwork evolution payloads on the public page api', function (): void {
$user = User::factory()->create();
$original = makeEvolutionPublishedArtwork($user, 'Original scene', '2019-04-11 09:00:00');
$updated = makeEvolutionPublishedArtwork($user, 'Updated scene', '2025-04-11 09:00:00');
ArtworkRelation::query()->create([
'source_artwork_id' => $updated->id,
'target_artwork_id' => $original->id,
'relation_type' => ArtworkRelation::TYPE_REMAKE_OF,
'note' => 'Rebuilt from scratch with a new lighting pass.',
'sort_order' => 0,
'created_by_user_id' => $user->id,
]);
getJson('/api/artworks/' . $updated->id . '/page')
->assertOk()
->assertJsonPath('evolution.primary.before.id', $original->id)
->assertJsonPath('evolution.primary.after.id', $updated->id)
->assertJsonPath('evolution.primary.heading', 'Then & Now');
getJson('/api/artworks/' . $original->id . '/page')
->assertOk()
->assertJsonPath('evolution.updates.0.before.id', $original->id)
->assertJsonPath('evolution.updates.0.after.id', $updated->id)
->assertJsonPath('evolution.updates.0.heading', 'Updated Version');
});