feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View 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 (180364 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 (3651094 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');
});
});