$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'); }); });