optimizations
This commit is contained in:
134
tests/Unit/CollectionServiceTest.php
Normal file
134
tests/Unit/CollectionServiceTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\SmartCollectionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('generates unique slugs per owner while allowing reuse across different owners', function (): void {
|
||||
$firstUser = User::factory()->create();
|
||||
$secondUser = User::factory()->create();
|
||||
$service = app(CollectionService::class);
|
||||
|
||||
Collection::factory()->for($firstUser)->create([
|
||||
'title' => 'Portal Vault',
|
||||
'slug' => 'portal-vault',
|
||||
]);
|
||||
|
||||
expect($service->makeUniqueSlugForUser($firstUser, 'Portal Vault'))->toBe('portal-vault-2');
|
||||
expect($service->makeUniqueSlugForUser($secondUser, 'Portal Vault'))->toBe('portal-vault');
|
||||
});
|
||||
|
||||
it('falls back to the first attached artwork when an explicit cover is removed', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create();
|
||||
$firstArtwork = Artwork::factory()->for($user)->create();
|
||||
$secondArtwork = Artwork::factory()->for($user)->create();
|
||||
$service = app(CollectionService::class);
|
||||
|
||||
$service->attachArtworks($collection, $user, [$firstArtwork->id, $secondArtwork->id]);
|
||||
$collection->refresh();
|
||||
|
||||
$service->updateCollection($collection->loadMissing('user'), [
|
||||
'title' => $collection->title,
|
||||
'slug' => $collection->slug,
|
||||
'description' => $collection->description,
|
||||
'visibility' => $collection->visibility,
|
||||
'sort_mode' => $collection->sort_mode,
|
||||
'cover_artwork_id' => $secondArtwork->id,
|
||||
]);
|
||||
|
||||
$collection->refresh();
|
||||
expect($collection->resolvedCoverArtwork()?->id)->toBe($secondArtwork->id);
|
||||
|
||||
$service->removeArtwork($collection->loadMissing('user'), $secondArtwork);
|
||||
$collection->refresh();
|
||||
|
||||
expect($collection->cover_artwork_id)->toBeNull();
|
||||
expect($collection->resolvedCoverArtwork()?->id)->toBe($firstArtwork->id);
|
||||
});
|
||||
|
||||
it('keeps artworks_count in sync while attaching and removing artworks', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create(['artworks_count' => 0]);
|
||||
$artworkA = Artwork::factory()->for($user)->create();
|
||||
$artworkB = Artwork::factory()->for($user)->create();
|
||||
$service = app(CollectionService::class);
|
||||
|
||||
$service->attachArtworks($collection, $user, [$artworkA->id, $artworkB->id]);
|
||||
$collection->refresh();
|
||||
|
||||
expect($collection->artworks_count)->toBe(2);
|
||||
|
||||
$service->removeArtwork($collection->loadMissing('user'), $artworkA);
|
||||
$collection->refresh();
|
||||
|
||||
expect($collection->artworks_count)->toBe(1);
|
||||
expect($collection->artworks()->pluck('artworks.id')->all())->toBe([$artworkB->id]);
|
||||
});
|
||||
|
||||
it('builds a human readable smart summary for medium rules', function (): void {
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
$summary = $service->smartSummary([
|
||||
'match' => 'all',
|
||||
'sort' => 'newest',
|
||||
'rules' => [
|
||||
[
|
||||
'field' => 'medium',
|
||||
'operator' => 'equals',
|
||||
'value' => 'wallpapers',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($summary)->toBe('Includes artworks in medium wallpapers.');
|
||||
});
|
||||
|
||||
it('builds a human readable smart summary for style and color rules', function (): void {
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
$summary = $service->smartSummary([
|
||||
'match' => 'any',
|
||||
'sort' => 'newest',
|
||||
'rules' => [
|
||||
[
|
||||
'field' => 'style',
|
||||
'operator' => 'equals',
|
||||
'value' => 'digital painting',
|
||||
],
|
||||
[
|
||||
'field' => 'color',
|
||||
'operator' => 'equals',
|
||||
'value' => 'blue tones',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($summary)->toBe('Includes artworks matching style digital painting or using color palette blue tones.');
|
||||
});
|
||||
|
||||
it('builds a human readable smart summary for mature rules', function (): void {
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
$summary = $service->smartSummary([
|
||||
'match' => 'all',
|
||||
'sort' => 'newest',
|
||||
'rules' => [
|
||||
[
|
||||
'field' => 'is_mature',
|
||||
'operator' => 'equals',
|
||||
'value' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($summary)->toBe('Includes artworks marked as mature artworks.');
|
||||
});
|
||||
43
tests/Unit/CollectionWorkflowServiceTest.php
Normal file
43
tests/Unit/CollectionWorkflowServiceTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionWorkflowService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('rejects invalid workflow transitions', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'workflow_state' => Collection::WORKFLOW_DRAFT,
|
||||
]);
|
||||
|
||||
$service = app(CollectionWorkflowService::class);
|
||||
|
||||
expect(fn () => $service->update($collection, [
|
||||
'workflow_state' => Collection::WORKFLOW_ARCHIVED,
|
||||
], $user))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
it('allows approved collections to become programmed', function (): void {
|
||||
$user = User::factory()->create(['role' => 'admin']);
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'workflow_state' => Collection::WORKFLOW_APPROVED,
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
]);
|
||||
|
||||
$service = app(CollectionWorkflowService::class);
|
||||
$updated = $service->update($collection, [
|
||||
'workflow_state' => Collection::WORKFLOW_PROGRAMMED,
|
||||
'program_key' => 'frontpage-hero',
|
||||
], $user);
|
||||
|
||||
expect($updated->workflow_state)->toBe(Collection::WORKFLOW_PROGRAMMED);
|
||||
expect($updated->program_key)->toBe('frontpage-hero');
|
||||
});
|
||||
165
tests/Unit/Services/CollectionHealthServiceTest.php
Normal file
165
tests/Unit/Services/CollectionHealthServiceTest.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionHealthService;
|
||||
use App\Services\CollectionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('flags public collections as stale when freshness falls to zero', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = Collection::factory()->make([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Stale Health Candidate',
|
||||
'slug' => 'stale-health-candidate',
|
||||
'mode' => Collection::MODE_SMART,
|
||||
'artworks_count' => 6,
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'moderation_status' => Collection::MODERATION_ACTIVE,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'summary' => 'A focused summary for stale health coverage.',
|
||||
'description' => 'A longer description that gives the stale collection enough metadata depth for the health service.',
|
||||
'likes_count' => 45,
|
||||
'followers_count' => 20,
|
||||
'saves_count' => 24,
|
||||
'comments_count' => 6,
|
||||
'shares_count' => 4,
|
||||
'views_count' => 1000,
|
||||
'last_activity_at' => now()->subDays(60),
|
||||
'updated_at' => now()->subDays(60),
|
||||
'published_at' => now()->subDays(60),
|
||||
'workflow_state' => Collection::WORKFLOW_APPROVED,
|
||||
]);
|
||||
|
||||
$collection->cover_artwork_id = 999;
|
||||
|
||||
$collection->setRelation('coverArtwork', Artwork::factory()->for($user)->make([
|
||||
'width' => 1400,
|
||||
'height' => 900,
|
||||
'published_at' => now()->subDays(61),
|
||||
]));
|
||||
|
||||
$flagsMethod = new ReflectionMethod(CollectionHealthService::class, 'flags');
|
||||
$flagsMethod->setAccessible(true);
|
||||
|
||||
$flags = $flagsMethod->invoke($service, $collection, 80.0, 0.0, 80.0, 80.0);
|
||||
|
||||
expect($flags)->toContain(Collection::HEALTH_STALE);
|
||||
});
|
||||
|
||||
it('flags collections with fewer than six artworks as low content', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = seededHealthyCollection($user, 'Thin Collection Candidate', 5);
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_flags_json'])->toContain(Collection::HEALTH_LOW_CONTENT);
|
||||
});
|
||||
|
||||
it('flags broken items when too many attached artworks are not publicly visible', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'title' => 'Broken Items Candidate',
|
||||
'slug' => 'broken-items-candidate',
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'moderation_status' => Collection::MODERATION_ACTIVE,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'summary' => 'A collection used to validate broken item detection.',
|
||||
'description' => 'A detailed curatorial description for broken item health coverage.',
|
||||
'likes_count' => 40,
|
||||
'followers_count' => 20,
|
||||
'saves_count' => 15,
|
||||
'views_count' => 900,
|
||||
'published_at' => now()->subDays(10),
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$visible = Artwork::factory()->for($user)->create(['width' => 1280, 'height' => 720]);
|
||||
$private = Artwork::factory()->for($user)->private()->create();
|
||||
$unapproved = Artwork::factory()->for($user)->unapproved()->create();
|
||||
$unpublished = Artwork::factory()->for($user)->unpublished()->create();
|
||||
app(CollectionService::class)->attachArtworks($collection, $user, [$visible->id, $private->id, $unapproved->id, $unpublished->id]);
|
||||
|
||||
$collection->forceFill([
|
||||
'cover_artwork_id' => $visible->id,
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
])->save();
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_flags_json'])->toContain(Collection::HEALTH_BROKEN_ITEMS)
|
||||
->and($payload['placement_eligibility'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('flags collections without an explicit cover as weak cover', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = seededHealthyCollection($user, 'Weak Cover Candidate');
|
||||
|
||||
$collection->forceFill(['cover_artwork_id' => null])->save();
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_flags_json'])->toContain(Collection::HEALTH_WEAK_COVER);
|
||||
});
|
||||
|
||||
it('keeps strong active collections healthy and placement eligible', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = seededHealthyCollection($user, 'Healthy Cover Candidate');
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_state'])->toBe(Collection::HEALTH_HEALTHY)
|
||||
->and($payload['health_flags_json'])->toBe([])
|
||||
->and($payload['placement_eligibility'])->toBeTrue();
|
||||
});
|
||||
|
||||
function seededHealthyCollection(User $user, string $title, int $artworksCount = 6): Collection
|
||||
{
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'title' => $title,
|
||||
'slug' => str($title)->slug()->value(),
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'moderation_status' => Collection::MODERATION_ACTIVE,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'summary' => 'A focused summary for health scoring coverage.',
|
||||
'description' => 'A longer description that provides enough metadata depth for health scoring and editorial readiness calculations.',
|
||||
'likes_count' => 45,
|
||||
'followers_count' => 18,
|
||||
'saves_count' => 24,
|
||||
'comments_count' => 8,
|
||||
'shares_count' => 5,
|
||||
'views_count' => 1400,
|
||||
'published_at' => now()->subDays(5),
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
'workflow_state' => Collection::WORKFLOW_APPROVED,
|
||||
]);
|
||||
|
||||
$artworkIds = Artwork::factory()->count($artworksCount)->for($user)->create([
|
||||
'width' => 1400,
|
||||
'height' => 900,
|
||||
])->pluck('id')->all();
|
||||
|
||||
app(CollectionService::class)->attachArtworks($collection, $user, $artworkIds);
|
||||
|
||||
$collection->forceFill([
|
||||
'cover_artwork_id' => $artworkIds[0] ?? null,
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
])->save();
|
||||
|
||||
return $collection->fresh(['coverArtwork']);
|
||||
}
|
||||
Reference in New Issue
Block a user