Save workspace changes
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
<?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 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('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');
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAiAssist;
|
||||
use App\Models\ArtworkAiAssistEvent;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\StudioAiAssistService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
use function Pest\Laravel\putJson;
|
||||
|
||||
it('returns a not-analyzed payload for artworks without ai assist data', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
getJson('/api/studio/artworks/' . $artwork->id . '/ai')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', 'not_analyzed')
|
||||
->assertJsonPath('data.title_suggestions', [])
|
||||
->assertJsonPath('data.current.sources.title', 'manual');
|
||||
});
|
||||
|
||||
it('queues artwork ai analysis from the studio endpoint', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'aabbcc112233',
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze')
|
||||
->assertStatus(202)
|
||||
->assertJsonPath('status', ArtworkAiAssist::STATUS_QUEUED);
|
||||
|
||||
$this->assertDatabaseHas('artwork_ai_assists', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => ArtworkAiAssist::STATUS_QUEUED,
|
||||
]);
|
||||
|
||||
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class, function (AnalyzeArtworkAiAssistJob $job): bool {
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('can analyze artwork ai suggestions directly without queueing', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
config()->set('vision.enabled', true);
|
||||
config()->set('vision.gateway.base_url', 'https://vision.local');
|
||||
config()->set('cdn.files_url', 'https://files.local');
|
||||
config()->set('vision.image_variant', 'md');
|
||||
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Flowers',
|
||||
'slug' => 'flowers',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'syncaa112233',
|
||||
'file_name' => 'rose-closeup.jpg',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.local/analyze/all' => Http::response([
|
||||
'clip' => [
|
||||
['tag' => 'rose', 'confidence' => 0.96],
|
||||
['tag' => 'flower', 'confidence' => 0.91],
|
||||
],
|
||||
'yolo' => [
|
||||
['label' => 'flower', 'confidence' => 0.79],
|
||||
],
|
||||
'blip' => 'a close up photograph of a rose bud with soft natural background',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
|
||||
'direct' => true,
|
||||
'intent' => 'title',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('direct', true)
|
||||
->assertJsonPath('status', ArtworkAiAssist::STATUS_READY)
|
||||
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
|
||||
->assertJsonPath('data.debug.request.hash', 'syncaa112233')
|
||||
->assertJsonPath('data.debug.request.intent', 'title')
|
||||
->assertJsonPath('data.debug.vision_debug.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
|
||||
->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
|
||||
->assertJsonPath('data.debug.vision_debug.calls.0.service', 'gateway_all');
|
||||
|
||||
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
|
||||
|
||||
$this->assertDatabaseHas('artwork_ai_assist_events', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'event_type' => 'analysis_requested',
|
||||
]);
|
||||
|
||||
$completedEvent = ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'analysis_completed')
|
||||
->first();
|
||||
|
||||
expect($completedEvent)->not->toBeNull();
|
||||
expect($completedEvent?->meta['intent'] ?? null)->toBe('title');
|
||||
});
|
||||
|
||||
it('persists upload-style visibility options from studio save', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'is_public' => true,
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
putJson('/api/studio/artworks/' . $artwork->id, [
|
||||
'visibility' => Artwork::VISIBILITY_UNLISTED,
|
||||
'mode' => 'now',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('artwork.visibility', Artwork::VISIBILITY_UNLISTED)
|
||||
->assertJsonPath('artwork.publish_mode', 'now');
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_UNLISTED)
|
||||
->and($artwork->is_public)->toBeTrue()
|
||||
->and($artwork->artwork_status)->toBe('published');
|
||||
});
|
||||
|
||||
it('can schedule publishing from studio save', function (): void {
|
||||
Carbon::setTestNow('2026-03-28 12:00:00');
|
||||
|
||||
try {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->private()->create([
|
||||
'user_id' => $user->id,
|
||||
'artwork_status' => 'draft',
|
||||
'published_at' => null,
|
||||
'publish_at' => null,
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
putJson('/api/studio/artworks/' . $artwork->id, [
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'mode' => 'schedule',
|
||||
'publish_at' => '2026-03-28T12:10:00Z',
|
||||
'timezone' => 'Europe/Ljubljana',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('artwork.publish_mode', 'schedule')
|
||||
->assertJsonPath('artwork.visibility', Artwork::VISIBILITY_PUBLIC)
|
||||
->assertJsonPath('artwork.artwork_status', 'scheduled');
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_PUBLIC)
|
||||
->and($artwork->is_public)->toBeFalse()
|
||||
->and($artwork->artwork_status)->toBe('scheduled')
|
||||
->and($artwork->artwork_timezone)->toBe('Europe/Ljubljana')
|
||||
->and($artwork->publish_at?->toIso8601String())->toBe('2026-03-28T12:10:00+00:00')
|
||||
->and($artwork->published_at)->toBeNull();
|
||||
} finally {
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
});
|
||||
|
||||
it('can analyze artwork directly when exact and vector similar matches are both present', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
config()->set('vision.enabled', true);
|
||||
config()->set('vision.gateway.base_url', 'https://vision.local');
|
||||
config()->set('vision.vector_gateway.enabled', true);
|
||||
config()->set('vision.vector_gateway.base_url', 'https://vector.local');
|
||||
config()->set('vision.vector_gateway.api_key', 'test-key');
|
||||
config()->set('cdn.files_url', 'https://files.local');
|
||||
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Flowers',
|
||||
'slug' => 'flowers',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'mixaa112233',
|
||||
'file_name' => 'rose-closeup.jpg',
|
||||
]);
|
||||
$exactMatch = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'mixaa112233',
|
||||
'title' => 'Exact match artwork',
|
||||
]);
|
||||
$vectorMatch = Artwork::factory()->create([
|
||||
'title' => 'Vector match artwork',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.local/analyze/all' => Http::response([
|
||||
'clip' => [
|
||||
['tag' => 'rose', 'confidence' => 0.96],
|
||||
['tag' => 'flower', 'confidence' => 0.91],
|
||||
],
|
||||
'yolo' => [
|
||||
['label' => 'flower', 'confidence' => 0.79],
|
||||
],
|
||||
'blip' => 'a close up photograph of a rose bud with soft natural background',
|
||||
], 200),
|
||||
'https://vector.local/vectors/search' => Http::response([
|
||||
'matches' => [
|
||||
[
|
||||
'id' => (string) $vectorMatch->id,
|
||||
'score' => 0.88,
|
||||
'metadata' => [],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
|
||||
'direct' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('direct', true)
|
||||
->assertJsonPath('status', ArtworkAiAssist::STATUS_READY)
|
||||
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
|
||||
->assertJsonCount(2, 'data.similar_candidates')
|
||||
->assertJsonPath('data.similar_candidates.0.artwork_id', $exactMatch->id)
|
||||
->assertJsonPath('data.similar_candidates.1.artwork_id', $vectorMatch->id);
|
||||
|
||||
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
|
||||
});
|
||||
|
||||
it('builds and exposes normalized studio ai suggestions', function (): void {
|
||||
config()->set('vision.enabled', true);
|
||||
config()->set('vision.gateway.base_url', 'https://vision.local');
|
||||
config()->set('cdn.files_url', 'https://files.local');
|
||||
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Flowers',
|
||||
'slug' => 'flowers',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'ddeeff112233',
|
||||
'file_name' => 'rose-closeup.jpg',
|
||||
'title' => 'Untitled Rose',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.local/analyze/all' => Http::response([
|
||||
'clip' => [
|
||||
['tag' => 'rose', 'confidence' => 0.96],
|
||||
['tag' => 'flower', 'confidence' => 0.91],
|
||||
['tag' => 'macro', 'confidence' => 0.72],
|
||||
],
|
||||
'yolo' => [
|
||||
['label' => 'flower', 'confidence' => 0.79],
|
||||
],
|
||||
'blip' => 'a close up photograph of a rose bud with soft natural background',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
app(StudioAiAssistService::class)->analyze($artwork->fresh(), false);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
getJson('/api/studio/artworks/' . $artwork->id . '/ai')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
|
||||
->assertJsonPath('data.mode', 'artwork')
|
||||
->assertJsonPath('data.content_type.value', 'photography')
|
||||
->assertJsonPath('data.category.value', 'flowers')
|
||||
->assertJson(fn ($json) => $json
|
||||
->has('data.title_suggestions', 5)
|
||||
->where('data.tag_suggestions.0.tag', 'rose')
|
||||
->has('data.description_suggestions', 3));
|
||||
});
|
||||
|
||||
it('applies ai suggestions to artwork fields and tracks ai sources', function (): void {
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
$flowers = Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Flowers',
|
||||
'slug' => 'flowers',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Original title',
|
||||
'description' => 'Original description.',
|
||||
]);
|
||||
|
||||
ArtworkAiAssist::query()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => ArtworkAiAssist::STATUS_READY,
|
||||
'similar_candidates_json' => [
|
||||
[
|
||||
'artwork_id' => 998,
|
||||
'title' => 'Possible duplicate',
|
||||
'match_type' => 'exact_hash',
|
||||
'score' => 1,
|
||||
'review_state' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
postJson('/api/studio/artworks/' . $artwork->id . '/ai/apply', [
|
||||
'title' => 'Rose Bud in Soft Focus',
|
||||
'description' => 'A close-up photograph of a rose bud with a soft floral backdrop.',
|
||||
'tags' => ['rose', 'macro'],
|
||||
'tag_mode' => 'replace',
|
||||
'category_id' => $flowers->id,
|
||||
'similar_actions' => [
|
||||
['artwork_id' => 998, 'state' => 'reviewed'],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
|
||||
->assertJsonPath('data.current.sources.title', 'ai_applied')
|
||||
->assertJsonPath('data.current.sources.tags', 'ai_applied');
|
||||
|
||||
$this->assertDatabaseHas('artworks', [
|
||||
'id' => $artwork->id,
|
||||
'title' => 'Rose Bud in Soft Focus',
|
||||
'title_source' => 'ai_applied',
|
||||
'description_source' => 'ai_applied',
|
||||
'tags_source' => 'ai_applied',
|
||||
'category_source' => 'ai_applied',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('artwork_tag', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'source' => 'ai',
|
||||
]);
|
||||
|
||||
expect(ArtworkAiAssist::query()->where('artwork_id', $artwork->id)->first()?->similar_candidates_json[0]['review_state'])
|
||||
->toBe('reviewed');
|
||||
|
||||
expect(ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'suggestions_applied')
|
||||
->exists())
|
||||
->toBeTrue();
|
||||
|
||||
expect(ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'title_suggestion_applied')
|
||||
->exists())
|
||||
->toBeTrue();
|
||||
|
||||
expect(ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'description_suggestion_applied')
|
||||
->exists())
|
||||
->toBeTrue();
|
||||
|
||||
expect(ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'tags_suggestion_applied')
|
||||
->exists())
|
||||
->toBeTrue();
|
||||
|
||||
expect(ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'category_suggestion_applied')
|
||||
->exists())
|
||||
->toBeTrue();
|
||||
|
||||
expect(ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'duplicate_candidate_reviewed')
|
||||
->exists())
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
it('applies ai content type suggestions by resolving a default category', function (): void {
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
$rootCategory = Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography-root',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
ArtworkAiAssist::query()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'status' => ArtworkAiAssist::STATUS_READY,
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
postJson('/api/studio/artworks/' . $artwork->id . '/ai/apply', [
|
||||
'content_type_id' => $photography->id,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.current.content_type_id', $photography->id)
|
||||
->assertJsonPath('data.current.category_id', $rootCategory->id)
|
||||
->assertJsonPath('data.current.sources.category', 'ai_applied');
|
||||
|
||||
expect($artwork->fresh()->categories()->pluck('categories.id')->all())
|
||||
->toBe([$rootCategory->id]);
|
||||
|
||||
expect(ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'content_type_suggestion_applied')
|
||||
->exists())
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
it('updates studio artworks with a content type when no category is provided', function (): void {
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
$rootCategory = Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography-default',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Existing title',
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
putJson('/api/studio/artworks/' . $artwork->id, [
|
||||
'content_type_id' => $photography->id,
|
||||
'category_source' => 'ai_applied',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('artwork.content_type_id', $photography->id)
|
||||
->assertJsonPath('artwork.category_id', $rootCategory->id)
|
||||
->assertJsonPath('artwork.category_source', 'ai_applied');
|
||||
|
||||
expect($artwork->fresh()->categories()->pluck('categories.id')->all())
|
||||
->toBe([$rootCategory->id]);
|
||||
});
|
||||
|
||||
it('removes previously attached ai tags when studio save sends a smaller visible tag set', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'tags_source' => 'mixed',
|
||||
]);
|
||||
|
||||
$tagService = app(\App\Services\TagService::class);
|
||||
$tagService->attachAiTags($artwork, [
|
||||
['tag' => 'rose'],
|
||||
['tag' => 'macro'],
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
putJson('/api/studio/artworks/' . $artwork->id, [
|
||||
'tags' => ['rose'],
|
||||
'tags_source' => 'mixed',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('artwork.tags.0.slug', 'rose');
|
||||
|
||||
expect($artwork->fresh()->tags()->pluck('tags.slug')->sort()->values()->all())
|
||||
->toBe(['rose']);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Illuminate\Support\Carbon;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsCategory;
|
||||
use cPad\Plugins\News\Models\NewsTag;
|
||||
|
||||
function studioNewsCategory(array $attributes = []): NewsCategory
|
||||
{
|
||||
return NewsCategory::query()->create(array_merge([
|
||||
'name' => 'Studio News',
|
||||
'slug' => 'studio-news',
|
||||
'description' => 'Studio News category',
|
||||
'position' => 0,
|
||||
'is_active' => true,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function studioNewsTag(array $attributes = []): NewsTag
|
||||
{
|
||||
return NewsTag::query()->create(array_merge([
|
||||
'name' => 'Studio',
|
||||
'slug' => 'studio',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('forbids newsroom studio pages for non moderators', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('studio.news.index'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('renders newsroom studio pages for moderators', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'modnews',
|
||||
'name' => 'Moderator News',
|
||||
]);
|
||||
$author = User::factory()->create([
|
||||
'username' => 'writernews',
|
||||
'name' => 'Writer News',
|
||||
]);
|
||||
$category = studioNewsCategory();
|
||||
$tag = studioNewsTag();
|
||||
$article = NewsArticle::query()->create([
|
||||
'title' => 'Moderated newsroom article',
|
||||
'slug' => 'moderated-newsroom-article',
|
||||
'excerpt' => 'Studio-managed newsroom article.',
|
||||
'content' => 'Studio body',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'type' => NewsArticle::TYPE_EDITORIAL,
|
||||
'status' => 'published',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
|
||||
'published_at' => Carbon::parse('2026-04-05 09:30:00'),
|
||||
]);
|
||||
$article->tags()->sync([$tag->id]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioNewsIndex')
|
||||
->where('title', 'Newsroom')
|
||||
->where('listing.items.0.title', 'Moderated newsroom article')
|
||||
->where('createUrl', route('studio.news.create')));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.create'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioNewsEditor')
|
||||
->where('title', 'Create article')
|
||||
->has('typeOptions')
|
||||
->has('statusOptions')
|
||||
->has('categoryOptions')
|
||||
->has('tagOptions')
|
||||
->where('defaultAuthor.id', $moderator->id));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.categories'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioNewsTaxonomies')
|
||||
->where('activeTab', 'categories')
|
||||
->has('categories.0')
|
||||
->has('tags.0'));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.preview', ['article' => $article->id]))
|
||||
->assertOk()
|
||||
->assertSee('Preview mode')
|
||||
->assertSee('Moderated newsroom article');
|
||||
});
|
||||
|
||||
it('filters newsroom listing by status type and category', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
]);
|
||||
$category = studioNewsCategory([
|
||||
'name' => 'Filtered Category',
|
||||
'slug' => 'filtered-category',
|
||||
]);
|
||||
$otherCategory = studioNewsCategory([
|
||||
'name' => 'Other Category',
|
||||
'slug' => 'other-category',
|
||||
]);
|
||||
$author = User::factory()->create();
|
||||
|
||||
NewsArticle::query()->create([
|
||||
'title' => 'Keep Me',
|
||||
'slug' => 'keep-me',
|
||||
'excerpt' => 'Should survive filtering.',
|
||||
'content' => 'Content',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'status' => 'draft',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
NewsArticle::query()->create([
|
||||
'title' => 'Drop Me By Type',
|
||||
'slug' => 'drop-me-type',
|
||||
'excerpt' => 'Wrong type.',
|
||||
'content' => 'Content',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'type' => NewsArticle::TYPE_EDITORIAL,
|
||||
'status' => 'draft',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
NewsArticle::query()->create([
|
||||
'title' => 'Drop Me By Category',
|
||||
'slug' => 'drop-me-category',
|
||||
'excerpt' => 'Wrong category.',
|
||||
'content' => 'Content',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $otherCategory->id,
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'status' => 'draft',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.news.index', [
|
||||
'status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'category_id' => $category->id,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioNewsIndex')
|
||||
->where('listing.filters.status', NewsArticle::EDITORIAL_STATUS_DRAFT)
|
||||
->where('listing.filters.type', NewsArticle::TYPE_ANNOUNCEMENT)
|
||||
->where('listing.filters.category_id', $category->id)
|
||||
->has('listing.items', 1)
|
||||
->where('listing.items.0.title', 'Keep Me'));
|
||||
});
|
||||
|
||||
it('stores a newsroom draft with taxonomy links', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'editornews',
|
||||
'name' => 'Editor News',
|
||||
]);
|
||||
$author = User::factory()->create();
|
||||
$category = studioNewsCategory([
|
||||
'name' => 'Launches',
|
||||
'slug' => 'launches',
|
||||
]);
|
||||
$tag = studioNewsTag([
|
||||
'name' => 'Update',
|
||||
'slug' => 'update',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($moderator)->post(route('studio.news.store'), [
|
||||
'title' => 'Stored newsroom draft',
|
||||
'slug' => 'stored-newsroom-draft',
|
||||
'excerpt' => 'Stored through the Studio newsroom form.',
|
||||
'content' => 'This article was created through the new Studio News flow.',
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'category_id' => $category->id,
|
||||
'author_id' => $author->id,
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
'published_at' => null,
|
||||
'tag_ids' => [$tag->id],
|
||||
'is_featured' => true,
|
||||
'is_pinned' => false,
|
||||
'meta_title' => 'Stored newsroom draft meta',
|
||||
'meta_description' => 'Stored newsroom draft description',
|
||||
]);
|
||||
|
||||
$article = NewsArticle::query()->where('slug', 'stored-newsroom-draft')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('studio.news.edit', ['article' => $article->id]));
|
||||
|
||||
$this->assertDatabaseHas('news_articles', [
|
||||
'id' => $article->id,
|
||||
'title' => 'Stored newsroom draft',
|
||||
'slug' => 'stored-newsroom-draft',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
expect($article->tags()->pluck('news_tags.id')->all())->toBe([$tag->id]);
|
||||
});
|
||||
Reference in New Issue
Block a user