feat: ship creator journey v2 and profile updates
This commit is contained in:
279
tests/Feature/Profile/CreatorJourneyTest.php
Normal file
279
tests/Feature/Profile/CreatorJourneyTest.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupRelease;
|
||||
use App\Models\GroupReleaseContributor;
|
||||
use App\Models\User;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedCreatorJourneyFixture(): User
|
||||
{
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'journeymaker',
|
||||
'is_active' => true,
|
||||
'created_at' => Carbon::parse('2020-01-02 09:00:00'),
|
||||
]);
|
||||
|
||||
$hiddenArtwork = Artwork::factory()->for($creator)->private()->create([
|
||||
'title' => 'Hidden Draft',
|
||||
'slug' => 'hidden-draft',
|
||||
'published_at' => Carbon::parse('2020-02-01 12:00:00'),
|
||||
]);
|
||||
|
||||
$firstArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Sky One',
|
||||
'slug' => 'sky-one',
|
||||
'published_at' => Carbon::parse('2021-03-10 10:00:00'),
|
||||
]);
|
||||
|
||||
$breakthroughArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Neon Archive',
|
||||
'slug' => 'neon-archive',
|
||||
'published_at' => Carbon::parse('2024-05-10 09:00:00'),
|
||||
]);
|
||||
|
||||
$lateYearArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Terminal Bloom',
|
||||
'slug' => 'terminal-bloom',
|
||||
'published_at' => Carbon::parse('2024-11-20 19:00:00'),
|
||||
]);
|
||||
|
||||
$latestArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Afterglow Atlas',
|
||||
'slug' => 'afterglow-atlas',
|
||||
'published_at' => Carbon::parse('2025-02-14 18:30:00'),
|
||||
]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
[
|
||||
'artwork_id' => $hiddenArtwork->id,
|
||||
'views' => 5000,
|
||||
'downloads' => 900,
|
||||
'favorites' => 400,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 35,
|
||||
'shares_count' => 12,
|
||||
'downloads_1h' => 80,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $firstArtwork->id,
|
||||
'views' => 120,
|
||||
'downloads' => 18,
|
||||
'favorites' => 9,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 2,
|
||||
'shares_count' => 0,
|
||||
'downloads_1h' => 2,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'views' => 1800,
|
||||
'downloads' => 220,
|
||||
'favorites' => 110,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 26,
|
||||
'shares_count' => 9,
|
||||
'downloads_1h' => 24,
|
||||
'heat_score_updated_at' => Carbon::parse('2024-05-10 11:00:00'),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'views' => 640,
|
||||
'downloads' => 90,
|
||||
'favorites' => 34,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 7,
|
||||
'shares_count' => 2,
|
||||
'downloads_1h' => 5,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
[
|
||||
'artwork_id' => $latestArtwork->id,
|
||||
'views' => 450,
|
||||
'downloads' => 48,
|
||||
'favorites' => 18,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'comments_count' => 4,
|
||||
'shares_count' => 1,
|
||||
'downloads_1h' => 3,
|
||||
'heat_score_updated_at' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $firstArtwork->id,
|
||||
'featured_at' => Carbon::parse('2022-06-01 13:00:00'),
|
||||
'priority' => 100,
|
||||
'label' => 'Feature',
|
||||
'is_active' => true,
|
||||
'created_by' => $creator->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('artwork_metric_snapshots_hourly')->insert([
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-05-10 10:00:00'),
|
||||
'views_count' => 1200,
|
||||
'downloads_count' => 40,
|
||||
'favourites_count' => 60,
|
||||
'comments_count' => 12,
|
||||
'shares_count' => 3,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-05-10 11:00:00'),
|
||||
'views_count' => 1320,
|
||||
'downloads_count' => 64,
|
||||
'favourites_count' => 68,
|
||||
'comments_count' => 13,
|
||||
'shares_count' => 4,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-11-20 19:00:00'),
|
||||
'views_count' => 300,
|
||||
'downloads_count' => 10,
|
||||
'favourites_count' => 12,
|
||||
'comments_count' => 1,
|
||||
'shares_count' => 0,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-11-20 20:00:00'),
|
||||
'views_count' => 360,
|
||||
'downloads_count' => 14,
|
||||
'favourites_count' => 15,
|
||||
'comments_count' => 2,
|
||||
'shares_count' => 0,
|
||||
'created_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$groupOwner = User::factory()->create(['is_active' => true]);
|
||||
$group = Group::factory()->for($groupOwner, 'owner')->create([
|
||||
'name' => 'Nova Collective',
|
||||
'slug' => 'nova-collective',
|
||||
'visibility' => Group::VISIBILITY_PUBLIC,
|
||||
'status' => Group::LIFECYCLE_ACTIVE,
|
||||
]);
|
||||
|
||||
$release = GroupRelease::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'First Spectrum Pack',
|
||||
'slug' => 'first-spectrum-pack',
|
||||
'summary' => 'A first collaborative release.',
|
||||
'status' => GroupRelease::STATUS_RELEASED,
|
||||
'current_stage' => GroupRelease::STAGE_RELEASED,
|
||||
'visibility' => GroupRelease::VISIBILITY_PUBLIC,
|
||||
'released_at' => Carbon::parse('2023-08-15 16:00:00'),
|
||||
'published_at' => Carbon::parse('2023-08-15 16:00:00'),
|
||||
'created_by_user_id' => $groupOwner->id,
|
||||
]);
|
||||
|
||||
GroupReleaseContributor::query()->create([
|
||||
'group_release_id' => $release->id,
|
||||
'user_id' => $creator->id,
|
||||
'role_label' => 'Illustrator',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
return $creator;
|
||||
}
|
||||
|
||||
it('rebuilds persisted creator milestones from public source data', function () {
|
||||
$creator = seedCreatorJourneyFixture();
|
||||
|
||||
$this->artisan('skinbase:rebuild-creator-journey', ['user_id' => $creator->id])
|
||||
->assertExitCode(0);
|
||||
|
||||
$storedTypes = DB::table('creator_milestones')
|
||||
->where('user_id', $creator->id)
|
||||
->orderBy('type')
|
||||
->pluck('type')
|
||||
->all();
|
||||
|
||||
expect($storedTypes)->toContain(
|
||||
'first_upload',
|
||||
'first_featured_artwork',
|
||||
'first_group_release',
|
||||
'biggest_download_spike',
|
||||
'best_performing_work',
|
||||
'most_productive_year',
|
||||
'yearly_recap',
|
||||
);
|
||||
|
||||
$payload = app(CreatorJourneyService::class)->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload['summary']['available'])->toBeTrue()
|
||||
->and($payload['summary']['member_since_year'])->toBe(2020)
|
||||
->and($payload['highlights'][0]['type'])->toBe('best_performing_work')
|
||||
->and(collect($payload['timeline'])->pluck('headline')->all())->toContain('Sky One', 'First Spectrum Pack')
|
||||
->and(collect($payload['timeline'])->pluck('headline')->all())->not->toContain('Hidden Draft')
|
||||
->and($payload['yearly_recaps'][0]['metrics']['year'])->toBe(2025);
|
||||
});
|
||||
|
||||
it('returns the public creator journey api payload without leaking private content', function () {
|
||||
$creator = seedCreatorJourneyFixture();
|
||||
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$response = $this->getJson(route('api.profile.journey', ['username' => $creator->username]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.summary.available', true);
|
||||
|
||||
// v2: comeback milestones appear in timeline (fixture has a 3+ year gap → legendary comeback)
|
||||
$highlightHeadlines = collect($response->json('data.highlights'))->pluck('headline')->filter()->all();
|
||||
$headlines = collect($response->json('data.timeline'))->pluck('headline')->filter()->all();
|
||||
$timelineTypes = collect($response->json('data.timeline'))->pluck('type')->filter()->values()->all();
|
||||
|
||||
expect($highlightHeadlines)->toContain('Neon Archive')
|
||||
->and($headlines)->toContain('Sky One')
|
||||
->and($headlines)->not->toContain('Hidden Draft')
|
||||
->and($timelineTypes)->toContain('biggest_download_spike');
|
||||
});
|
||||
|
||||
it('hydrates the public profile page with creator journey props', function () {
|
||||
$creator = seedCreatorJourneyFixture();
|
||||
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Profile/ProfileShow')
|
||||
->where('journey.summary.available', true)
|
||||
->where('journey.summary.member_since_year', 2020)
|
||||
->where('journey.highlights', fn ($highlights) => collect($highlights)->pluck('headline')->contains('Neon Archive'))
|
||||
->where('journey.timeline', fn ($timeline) => collect($timeline)->pluck('type')->contains('biggest_download_spike'))
|
||||
->where('journey.timeline', fn ($timeline) => collect($timeline)->pluck('headline')->contains('First Spectrum Pack'))
|
||||
->where('journeyApiUrl', route('api.profile.journey', ['username' => strtolower((string) $creator->username)]))
|
||||
);
|
||||
});
|
||||
530
tests/Feature/Profile/CreatorJourneyV2Test.php
Normal file
530
tests/Feature/Profile/CreatorJourneyV2Test.php
Normal file
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\CreatorMilestoneType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\CreatorEra;
|
||||
use App\Models\User;
|
||||
use App\Services\Profile\CreatorComebackService;
|
||||
use App\Services\Profile\CreatorEraService;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use App\Services\Profile\CreatorStreakService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a fake artwork row (stdClass) matching the shape returned by
|
||||
* CreatorJourneyService::publicArtworkRows().
|
||||
*/
|
||||
function makeArtworkRow(int $id, string $publishedAt, array $overrides = []): object
|
||||
{
|
||||
return (object) array_merge([
|
||||
'id' => $id,
|
||||
'title' => "Artwork #{$id}",
|
||||
'slug' => "artwork-{$id}",
|
||||
'published_at' => $publishedAt,
|
||||
'thumbnail_url' => null,
|
||||
'art_url' => null,
|
||||
'url' => "/art/{$id}/artwork-{$id}",
|
||||
'downloads' => 0,
|
||||
'views' => 0,
|
||||
'favorites' => 0,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the $makeMilestoneRow closure expected by all v2 services.
|
||||
* Returns a plain array so tests can inspect it easily.
|
||||
*/
|
||||
function makeMilestoneRowFn(): callable
|
||||
{
|
||||
return function (
|
||||
int $userId,
|
||||
CreatorMilestoneType $type,
|
||||
\Carbon\CarbonInterface $occurredAt,
|
||||
array $payload,
|
||||
?int $relatedArtworkId,
|
||||
\Carbon\CarbonInterface $computedAt,
|
||||
): array {
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'type' => $type->value,
|
||||
'occurred_at' => $occurredAt->toDateTimeString(),
|
||||
'occurred_year' => (int) $occurredAt->format('Y'),
|
||||
'related_artwork_id' => $relatedArtworkId,
|
||||
'is_public' => true,
|
||||
'priority' => $type->priority(),
|
||||
'payload_json' => json_encode($payload),
|
||||
'computed_at' => $computedAt->toDateTimeString(),
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorComebackService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorComebackService', function () {
|
||||
it('returns empty when fewer than 2 artworks', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$rows = $svc->calculateComebacks(
|
||||
collect([makeArtworkRow(1, '2022-01-01')]),
|
||||
1,
|
||||
now(),
|
||||
makeMilestoneRowFn(),
|
||||
);
|
||||
expect($rows)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('detects a minor comeback (180–364 day gap)', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2022-01-01'),
|
||||
makeArtworkRow(2, '2022-08-01'), // ~212 days later
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackMinor->value);
|
||||
});
|
||||
|
||||
it('detects a major comeback (365–1094 day gap)', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2020-01-01'),
|
||||
makeArtworkRow(2, '2021-06-01'), // ~517 days
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackMajor->value);
|
||||
});
|
||||
|
||||
it('detects a legendary comeback (1095+ day gap)', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2018-01-01'),
|
||||
makeArtworkRow(2, '2021-08-15'), // ~1326 days
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackLegendary->value);
|
||||
});
|
||||
|
||||
it('does not fire a comeback for a gap shorter than 180 days', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-01'),
|
||||
makeArtworkRow(2, '2023-05-01'), // ~120 days
|
||||
makeArtworkRow(3, '2023-07-01'), // ~61 days
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('fires one milestone per comeback gap even if multiple thresholds match', function () {
|
||||
$svc = app(CreatorComebackService::class);
|
||||
// 3+ year gap → only legendary, not also major or minor
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2017-03-01'),
|
||||
makeArtworkRow(2, '2021-03-01'), // exactly 1461 days (4 years)
|
||||
]);
|
||||
|
||||
$rows = $svc->calculateComebacks($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['type'])->toBe(CreatorMilestoneType::ComebackLegendary->value);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorStreakService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorStreakService', function () {
|
||||
it('returns zero streaks for empty collection', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$stats = $svc->computeStreakStats(collect());
|
||||
|
||||
expect($stats['current_monthly_streak'])->toBe(0)
|
||||
->and($stats['best_monthly_streak'])->toBe(0)
|
||||
->and($stats['current_year_streak'])->toBe(0)
|
||||
->and($stats['best_year_streak'])->toBe(0);
|
||||
});
|
||||
|
||||
it('computes a 3-month consecutive upload streak', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-15'),
|
||||
makeArtworkRow(2, '2023-02-10'),
|
||||
makeArtworkRow(3, '2023-03-20'),
|
||||
]);
|
||||
|
||||
$stats = $svc->computeStreakStats($artworks);
|
||||
|
||||
expect($stats['best_monthly_streak'])->toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('does not inflate streak across non-consecutive months', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
// Jan, Mar (skipped Feb) → streak of 1 each
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-15'),
|
||||
makeArtworkRow(2, '2023-03-10'),
|
||||
]);
|
||||
|
||||
$stats = $svc->computeStreakStats($artworks);
|
||||
|
||||
expect($stats['best_monthly_streak'])->toBe(1);
|
||||
});
|
||||
|
||||
it('returns upload_streak_3 milestone when best streak is 3+ months', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2023-01-15'),
|
||||
makeArtworkRow(2, '2023-02-10'),
|
||||
makeArtworkRow(3, '2023-03-20'),
|
||||
]);
|
||||
|
||||
$milestones = $svc->calculateStreakMilestones($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
$types = array_column($milestones, 'type');
|
||||
expect($types)->toContain(CreatorMilestoneType::UploadStreak3->value);
|
||||
});
|
||||
|
||||
it('only inserts the best streak milestone, not lesser tiers', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
// Build 12 consecutive months
|
||||
$artworks = collect(range(1, 12))->map(
|
||||
fn (int $i): object => makeArtworkRow($i, Carbon::create(2022, $i, 10)->toDateTimeString())
|
||||
);
|
||||
|
||||
$milestones = $svc->calculateStreakMilestones($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
$types = array_column($milestones, 'type');
|
||||
expect($types)->toContain(CreatorMilestoneType::UploadStreak12->value)
|
||||
->and($types)->not->toContain(CreatorMilestoneType::UploadStreak3->value)
|
||||
->and($types)->not->toContain(CreatorMilestoneType::UploadStreak6->value);
|
||||
});
|
||||
|
||||
it('computes a 3-year active streak', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2020-06-01'),
|
||||
makeArtworkRow(2, '2021-03-15'),
|
||||
makeArtworkRow(3, '2022-11-20'),
|
||||
]);
|
||||
|
||||
$stats = $svc->computeStreakStats($artworks);
|
||||
|
||||
expect($stats['best_year_streak'])->toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('returns active_year_streak_3 milestone when best year streak is 3+', function () {
|
||||
$svc = app(CreatorStreakService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow(1, '2020-06-01'),
|
||||
makeArtworkRow(2, '2021-03-15'),
|
||||
makeArtworkRow(3, '2022-11-20'),
|
||||
]);
|
||||
|
||||
$milestones = $svc->calculateStreakMilestones($artworks, 99, now(), makeMilestoneRowFn());
|
||||
|
||||
$types = array_column($milestones, 'type');
|
||||
expect($types)->toContain(CreatorMilestoneType::ActiveYearStreak3->value);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorEraService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorEraService', function () {
|
||||
it('creates an early_years era for a creator with uploads', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::parse('2020-01-01')]);
|
||||
$artwork = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2020-06-01')]);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$artworks = collect([makeArtworkRow($artwork->id, '2020-06-01')]);
|
||||
$svc->rebuildForUser($creator, $artworks);
|
||||
|
||||
$eras = CreatorEra::where('user_id', $creator->id)->get();
|
||||
expect($eras->pluck('era_type')->all())->toContain('early_years');
|
||||
});
|
||||
|
||||
it('creates a breakthrough era when a featured artwork exists', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::parse('2019-01-01')]);
|
||||
$a1 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2019-03-01')]);
|
||||
$a2 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2021-07-01')]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $a1->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
['artwork_id' => $a2->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $a1->id,
|
||||
'featured_at' => Carbon::parse('2021-01-15'),
|
||||
'priority' => 100,
|
||||
'label' => 'Feature',
|
||||
'is_active' => true,
|
||||
'created_by' => $creator->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow($a1->id, '2019-03-01'),
|
||||
makeArtworkRow($a2->id, '2021-07-01'),
|
||||
]);
|
||||
$svc->rebuildForUser($creator, $artworks);
|
||||
|
||||
$eraTypes = CreatorEra::where('user_id', $creator->id)->pluck('era_type')->all();
|
||||
expect($eraTypes)->toContain('breakthrough');
|
||||
});
|
||||
|
||||
it('creates a comeback era after a 180+ day gap', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::parse('2018-01-01')]);
|
||||
$a1 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2018-06-01')]);
|
||||
$a2 = Artwork::factory()->for($creator)->create(['published_at' => Carbon::parse('2019-06-01')]); // 365 days
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $a1->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
['artwork_id' => $a2->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null],
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$artworks = collect([
|
||||
makeArtworkRow($a1->id, '2018-06-01'),
|
||||
makeArtworkRow($a2->id, '2019-06-01'),
|
||||
]);
|
||||
$svc->rebuildForUser($creator, $artworks);
|
||||
|
||||
$eraTypes = CreatorEra::where('user_id', $creator->id)->pluck('era_type')->all();
|
||||
expect($eraTypes)->toContain('comeback');
|
||||
});
|
||||
|
||||
it('marks exactly one era as is_current', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::now()->subYears(2)]);
|
||||
$artwork = Artwork::factory()->for($creator)->create(['published_at' => Carbon::now()->subMonths(6)]);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$svc->rebuildForUser($creator, collect([makeArtworkRow($artwork->id, Carbon::now()->subMonths(6)->toDateTimeString())]));
|
||||
|
||||
$currentCount = CreatorEra::where('user_id', $creator->id)->where('is_current', true)->count();
|
||||
expect($currentCount)->toBe(1);
|
||||
});
|
||||
|
||||
it('publicErasForUser returns formatted eras in ascending order', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create(['is_active' => true, 'created_at' => Carbon::now()->subYears(2)]);
|
||||
$artwork = Artwork::factory()->for($creator)->create(['published_at' => Carbon::now()->subMonths(6)]);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
|
||||
$svc = app(CreatorEraService::class);
|
||||
$svc->rebuildForUser($creator, collect([makeArtworkRow($artwork->id, Carbon::now()->subMonths(6)->toDateTimeString())]));
|
||||
|
||||
$eras = $svc->publicErasForUser($creator->id);
|
||||
|
||||
expect($eras)->not->toBeEmpty();
|
||||
expect($eras[0])->toHaveKeys(['type', 'title', 'starts_at', 'is_current']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreatorJourneyService — v2 payload shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CreatorJourneyService v2 payload', function () {
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* Seed a creator with a simple 3-artwork history (no long gaps, no features).
|
||||
*/
|
||||
function seedSimpleCreator(): User
|
||||
{
|
||||
$creator = User::factory()->create([
|
||||
'is_active' => true,
|
||||
'created_at' => Carbon::parse('2021-01-01'),
|
||||
]);
|
||||
|
||||
$artworks = [
|
||||
['published_at' => Carbon::parse('2021-02-01'), 'title' => 'Alpha'],
|
||||
['published_at' => Carbon::parse('2021-03-01'), 'title' => 'Beta'],
|
||||
['published_at' => Carbon::parse('2021-04-01'), 'title' => 'Gamma'],
|
||||
];
|
||||
|
||||
foreach ($artworks as $data) {
|
||||
$art = Artwork::factory()->for($creator)->create($data);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $art->id, 'views' => 10, 'downloads' => 5, 'favorites' => 2,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $creator;
|
||||
}
|
||||
|
||||
it('includes eras key in the public payload after rebuild', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('eras');
|
||||
expect($payload['eras'])->toBeArray();
|
||||
});
|
||||
|
||||
it('includes streaks key with expected sub-keys in the public payload', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('streaks');
|
||||
expect($payload['streaks'])->toHaveKeys([
|
||||
'current_monthly_upload_streak',
|
||||
'best_monthly_upload_streak',
|
||||
'current_active_year_streak',
|
||||
'best_active_year_streak',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes evolution key in the public payload', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('evolution');
|
||||
expect($payload['evolution'])->toBeArray();
|
||||
});
|
||||
|
||||
it('includes shareable_recaps key in the public payload', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$payload = $svc->publicPayloadForUser($creator);
|
||||
|
||||
expect($payload)->toHaveKey('shareable_recaps');
|
||||
expect($payload['shareable_recaps'])->toBeArray();
|
||||
});
|
||||
|
||||
it('rebuilds eras in the creator_eras table when rebuildForUser is called', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
$svc = app(CreatorJourneyService::class);
|
||||
$svc->rebuildForUser($creator);
|
||||
|
||||
$eraCount = CreatorEra::where('user_id', $creator->id)->count();
|
||||
expect($eraCount)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes comeback milestones in public milestones when a large gap is present', function () {
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
|
||||
$creator = User::factory()->create([
|
||||
'is_active' => true,
|
||||
'created_at' => Carbon::parse('2018-01-01'),
|
||||
]);
|
||||
|
||||
$dates = [
|
||||
['published_at' => Carbon::parse('2018-06-01'), 'title' => 'Early Work'],
|
||||
['published_at' => Carbon::parse('2019-12-01'), 'title' => 'Return Work'], // ~548 days
|
||||
];
|
||||
|
||||
foreach ($dates as $data) {
|
||||
$art = Artwork::factory()->for($creator)->create($data);
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $art->id, 'views' => 10, 'downloads' => 5, 'favorites' => 2,
|
||||
'rating_avg' => 0, 'rating_count' => 0, 'comments_count' => 0,
|
||||
'shares_count' => 0, 'downloads_1h' => 0, 'heat_score_updated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$types = DB::table('creator_milestones')
|
||||
->where('user_id', $creator->id)
|
||||
->pluck('type')
|
||||
->all();
|
||||
|
||||
expect($types)->toContain(CreatorMilestoneType::ComebackMajor->value);
|
||||
});
|
||||
|
||||
it('v2 payload is returned via the public API endpoint', function () {
|
||||
$creator = seedSimpleCreator();
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$response = $this->getJson(route('api.profile.journey', ['username' => $creator->username]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json('data');
|
||||
|
||||
expect($data)->toHaveKey('eras')
|
||||
->and($data)->toHaveKey('streaks')
|
||||
->and($data)->toHaveKey('evolution');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user