Files
SkinbaseNova/tests/Feature/Profile/CreatorJourneyV2Test.php

531 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
});
});