storing analytics data

This commit is contained in:
2026-02-27 09:46:51 +01:00
parent 15b7b77d20
commit f0cca76eb3
57 changed files with 3478 additions and 466 deletions

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
// ── ActivityEvent::record() factory helper ────────────────────────────────────
it('creates a db row via record()', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
ActivityEvent::record(
actorId: $user->id,
type: ActivityEvent::TYPE_FAVORITE,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
meta: ['source' => 'test'],
);
$this->assertDatabaseHas('activity_events', [
'actor_id' => $user->id,
'type' => 'favorite',
'target_type' => 'artwork',
'target_id' => $artwork->id,
]);
});
it('stores all five event types without error', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$events = [
[ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_COMMENT, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_FAVORITE, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_AWARD, ActivityEvent::TARGET_ARTWORK, $artwork->id],
[ActivityEvent::TYPE_FOLLOW, ActivityEvent::TARGET_USER, $user->id],
];
foreach ($events as [$type, $targetType, $targetId]) {
ActivityEvent::record($user->id, $type, $targetType, $targetId);
}
expect(ActivityEvent::where('actor_id', $user->id)->count())->toBe(5);
});
it('created_at is populated on the returned instance', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record(
$user->id,
ActivityEvent::TYPE_COMMENT,
ActivityEvent::TARGET_ARTWORK,
$artwork->id,
);
expect($event->created_at)->not->toBeNull();
});
it('actor relation resolves after record()', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
expect($event->actor->id)->toBe($user->id);
});
it('meta is null when empty array is passed', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
expect($event->meta)->toBeNull();
});
it('meta is stored when non-empty array is passed', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$event = ActivityEvent::record(
$user->id,
ActivityEvent::TYPE_AWARD,
ActivityEvent::TARGET_ARTWORK,
$artwork->id,
['medal' => 'gold'],
);
expect($event->meta)->toBe(['medal' => 'gold']);
});
// ── Community activity feed route ─────────────────────────────────────────────
it('global activity feed returns 200 for guests', function () {
$this->get('/community/activity')->assertStatus(200);
});
it('following tab returns 200 for users with no follows', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/community/activity?type=following')
->assertStatus(200);
});
it('following tab shows only events from followed users', function () {
$user = User::factory()->create();
$creator = User::factory()->create();
$other = User::factory()->create();
$artwork = Artwork::factory()->create();
// user_followers has no updated_at column
DB::table('user_followers')->insert([
'user_id' => $creator->id,
'follower_id' => $user->id,
'created_at' => now(),
]);
// Event from followed creator
ActivityEvent::record($creator->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
// Event from non-followed user (should not appear)
ActivityEvent::record($other->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
$response = $this->actingAs($user)->get('/community/activity?type=following');
$response->assertStatus(200);
$events = $response->original->gatherData()['events'];
expect($events->total())->toBe(1);
expect($events->first()->actor_id)->toBe($creator->id);
});

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
// Use null Scout driver so no Meilisearch calls are made
config(['scout.driver' => 'null']);
});
it('redirects unauthenticated users to login', function () {
$this->get(route('discover.following'))
->assertRedirect(route('login'));
});
it('shows empty state with fallback data when user follows nobody', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('discover.following'));
$response->assertStatus(200);
$response->assertViewHas('empty', true);
$response->assertViewHas('fallback_trending');
$response->assertViewHas('fallback_creators');
$response->assertViewHas('section', 'following');
});
it('paginates artworks from followed creators', function () {
$user = User::factory()->create();
$creator = User::factory()->create();
// user_followers has no updated_at column
DB::table('user_followers')->insert([
'user_id' => $creator->id,
'follower_id' => $user->id,
'created_at' => now(),
]);
Artwork::factory()->count(3)->create([
'user_id' => $creator->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
$response = $this->actingAs($user)->get(route('discover.following'));
$response->assertStatus(200);
$response->assertViewHas('section', 'following');
$response->assertViewMissing('empty');
});
it('does not include artworks from non-followed creators in the feed', function () {
$user = User::factory()->create();
$creator = User::factory()->create();
$stranger = User::factory()->create();
DB::table('user_followers')->insert([
'user_id' => $creator->id,
'follower_id' => $user->id,
'created_at' => now(),
]);
// Only the stranger has an artwork — creator has none
Artwork::factory()->create([
'user_id' => $stranger->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
$response = $this->actingAs($user)->get(route('discover.following'));
$response->assertStatus(200);
/** @var \Illuminate\Pagination\LengthAwarePaginator $artworks */
$artworks = $response->original->gatherData()['artworks'];
expect($artworks->total())->toBe(0);
});
it('other discover routes return 200 without Meilisearch', function () {
// Trending and fresh routes fall through to DB fallback with null driver
$this->get(route('discover.trending'))->assertStatus(200);
$this->get(route('discover.fresh'))->assertStatus(200);
});

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Services\ArtworkService;
use App\Services\HomepageService;
use Illuminate\Pagination\LengthAwarePaginator;
beforeEach(function () {
// Use null Scout driver — Meilisearch calls return empty results gracefully
config(['scout.driver' => 'null']);
// ArtworkService is not final so it can be mocked
$artworksMock = Mockery::mock(ArtworkService::class);
$artworksMock->shouldReceive('getFeaturedArtworks')
->andReturn(new LengthAwarePaginator(collect(), 0, 1))
->byDefault();
app()->instance(ArtworkService::class, $artworksMock);
});
// ── Route integration ─────────────────────────────────────────────────────────
it('home page renders 200 for guests', function () {
$this->get('/')->assertStatus(200);
});
it('home page renders 200 for authenticated users', function () {
$this->actingAs(User::factory()->create())
->get('/')
->assertStatus(200);
});
// ── HomepageService section shape ─────────────────────────────────────────────
it('guest homepage has expected sections but no from_following', function () {
$sections = app(HomepageService::class)->all();
expect($sections)->toHaveKeys(['hero', 'trending', 'fresh', 'tags', 'creators', 'news']);
expect($sections)->not->toHaveKey('from_following');
expect($sections)->not->toHaveKey('by_tags');
expect($sections)->not->toHaveKey('by_categories');
});
it('authenticated homepage contains all personalised sections', function () {
$user = User::factory()->create();
$sections = app(HomepageService::class)->allForUser($user);
expect($sections)->toHaveKeys([
'hero',
'from_following',
'trending',
'by_tags',
'by_categories',
'tags',
'creators',
'news',
'preferences',
]);
});
it('preferences section exposes top_tags and top_categories arrays', function () {
$user = User::factory()->create();
$sections = app(HomepageService::class)->allForUser($user);
expect($sections['preferences'])->toHaveKeys(['top_tags', 'top_categories']);
expect($sections['preferences']['top_tags'])->toBeArray();
expect($sections['preferences']['top_categories'])->toBeArray();
});
it('guest and auth homepages have different key sets', function () {
$user = User::factory()->create();
$guest = array_keys(app(HomepageService::class)->all());
$auth = array_keys(app(HomepageService::class)->allForUser($user));
expect($guest)->not->toEqual($auth);
expect(in_array('from_following', $auth))->toBeTrue();
expect(in_array('from_following', $guest))->toBeFalse();
});

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkStatsService;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
// Disable Meilisearch and Redis during tests
config(['scout.driver' => 'null']);
});
// ── ArtworkViewController (POST /api/art/{id}/view) ──────────────────────────
it('returns 404 for a non-existent artwork on view', function () {
$this->postJson('/api/art/99999/view')->assertStatus(404);
});
it('returns 404 for a private artwork on view', function () {
$artwork = Artwork::factory()->create(['is_public' => false]);
$this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404);
});
it('returns 404 for an unapproved artwork on view', function () {
$artwork = Artwork::factory()->create(['is_approved' => false]);
$this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404);
});
it('records a view and returns ok=true on first call', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// Ensure a stats row exists with 0 views
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artwork->id,
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('logViewEvent')
->once()
->with($artwork->id, null); // null = guest (unauthenticated request)
$mock->shouldReceive('incrementViews')
->once()
->with($artwork->id, 1, true);
$response = $this->postJson("/api/art/{$artwork->id}/view");
$response->assertStatus(200)
->assertJsonPath('ok', true)
->assertJsonPath('counted', true);
});
it('skips DB increment and returns counted=false if artwork was already viewed this session', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// Mark as already viewed in the session
session()->put("art_viewed.{$artwork->id}", true);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementViews')->never();
$response = $this->postJson("/api/art/{$artwork->id}/view");
$response->assertStatus(200)
->assertJsonPath('ok', true)
->assertJsonPath('counted', false);
});
// ── ArtworkDownloadController (POST /api/art/{id}/download) ──────────────────
it('returns 404 for a non-existent artwork on download', function () {
$this->postJson('/api/art/99999/download')->assertStatus(404);
});
it('returns 404 for a private artwork on download', function () {
$artwork = Artwork::factory()->create(['is_public' => false]);
$this->postJson("/api/art/{$artwork->id}/download")->assertStatus(404);
});
it('records a download and returns ok=true with a url', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementDownloads')
->once()
->with($artwork->id, 1, true);
$response = $this->postJson("/api/art/{$artwork->id}/download");
$response->assertStatus(200)
->assertJsonPath('ok', true)
->assertJsonStructure(['ok', 'url']);
});
it('inserts a row in artwork_downloads on valid download', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// Stub the stats service so we don't need Redis
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementDownloads')->once();
$this->actingAs($user)->postJson("/api/art/{$artwork->id}/download");
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => $user->id,
]);
});
it('records download as guest (no user_id) when unauthenticated', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$mock = $this->mock(ArtworkStatsService::class);
$mock->shouldReceive('incrementDownloads')->once();
$this->postJson("/api/art/{$artwork->id}/download");
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => null,
]);
});
// ── Route names ───────────────────────────────────────────────────────────────
it('view endpoint route is named api.art.view', function () {
$artwork = Artwork::factory()->create([
'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(),
]);
expect(route('api.art.view', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/view");
});
it('download endpoint route is named api.art.download', function () {
$artwork = Artwork::factory()->create([
'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(),
]);
expect(route('api.art.download', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/download");
});

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
beforeEach(function () {
// Use null Scout driver so no Meilisearch calls are made
config(['scout.driver' => 'null']);
});
// ── 404 cases ─────────────────────────────────────────────────────────────────
it('returns 404 for a non-existent artwork id', function () {
$this->getJson('/api/art/99999/similar')
->assertStatus(404)
->assertJsonPath('error', 'Artwork not found');
});
it('returns 404 for a private artwork', function () {
$artwork = Artwork::factory()->create(['is_public' => false]);
$this->getJson("/api/art/{$artwork->id}/similar")
->assertStatus(404);
});
it('returns 404 for an unapproved artwork', function () {
$artwork = Artwork::factory()->create(['is_approved' => false]);
$this->getJson("/api/art/{$artwork->id}/similar")
->assertStatus(404);
});
it('returns 404 for an unpublished artwork', function () {
$artwork = Artwork::factory()->unpublished()->create();
$this->getJson("/api/art/{$artwork->id}/similar")
->assertStatus(404);
});
// ── Success cases ─────────────────────────────────────────────────────────────
it('returns a data array for a valid public artwork', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$response = $this->getJson("/api/art/{$artwork->id}/similar");
$response->assertStatus(200);
$response->assertJsonStructure(['data']);
expect($response->json('data'))->toBeArray();
});
it('the source artwork id is never present in results', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$ids = collect($this->getJson("/api/art/{$artwork->id}/similar")->json('data'))
->pluck('id')
->all();
expect($ids)->not->toContain($artwork->id);
});
it('result count does not exceed 12', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$count = count($this->getJson("/api/art/{$artwork->id}/similar")->json('data'));
// null Scout driver returns 0 results; max is 12
expect($count <= 12)->toBeTrue();
});
it('results do not include artworks by the same creator', function () {
$creatorA = User::factory()->create();
$creatorB = User::factory()->create();
$source = Artwork::factory()->create([
'user_id' => $creatorA->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
// A matching artwork from a different creator
Artwork::factory()->create([
'user_id' => $creatorB->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$response = $this->getJson("/api/art/{$source->id}/similar");
$response->assertStatus(200);
$items = $response->json('data');
// With null Scout driver the search returns 0 items; if items are present
// none should belong to the source artwork's creator.
foreach ($items as $item) {
expect($item)->toHaveKeys(['id', 'title', 'slug', 'thumb', 'url', 'author_id']);
expect($item['author_id'])->not->toBe($creatorA->id);
}
expect(true)->toBeTrue(); // always at least one assertion
});

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\TrendingService;
// RefreshDatabase is applied automatically to all Feature tests via Pest.php
it('returns zero when no artworks exist', function () {
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
expect(app(TrendingService::class)->recalculate('7d'))->toBe(0);
});
it('updates trending_score_24h for artworks published within 7 days', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHours(6),
]);
$updated = app(TrendingService::class)->recalculate('24h');
expect($updated)->toBe(1);
$artwork->refresh();
expect($artwork->trending_score_24h)->toBeFloat();
expect($artwork->last_trending_calculated_at)->not->toBeNull();
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
it('updates trending_score_7d for artworks published within 30 days', function () {
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
]);
$updated = app(TrendingService::class)->recalculate('7d');
expect($updated)->toBe(1);
$artwork->refresh();
expect($artwork->trending_score_7d)->toBeFloat();
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
it('skips artworks published outside the look-back window', function () {
Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(45), // outside 30-day window
]);
expect(app(TrendingService::class)->recalculate('7d'))->toBe(0);
});
it('skips private artworks', function () {
Artwork::factory()->create([
'is_public' => false,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
});
it('skips unapproved artworks', function () {
Artwork::factory()->create([
'is_public' => true,
'is_approved' => false,
'published_at' => now()->subDay(),
]);
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
});
it('score is always non-negative (GREATEST clamp)', function () {
// Artwork with no stats — time decay may be large, but score is clamped to ≥ 0
$artwork = Artwork::factory()->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(6),
]);
app(TrendingService::class)->recalculate('24h');
$artwork->refresh();
expect($artwork->trending_score_24h)->toBeGreaterThanOrEqualTo(0.0);
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
it('processes multiple artworks in a single run', function () {
Artwork::factory()->count(5)->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
expect(app(TrendingService::class)->recalculate('7d'))->toBe(5);
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
config(['scout.driver' => 'null']);
});
// ── Helper: ensure a stats row exists ────────────────────────────────────────
function seedStats(int $artworkId, array $overrides = []): void
{
DB::table('artwork_stats')->insertOrIgnore(array_merge([
'artwork_id' => $artworkId,
'views' => 0,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => 0,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
], $overrides));
}
// ── ArtworkStatsService ───────────────────────────────────────────────────────
it('incrementViews updates views, views_24h, and views_7d', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id);
app(ArtworkStatsService::class)->incrementViews($artwork->id, 3, defer: false);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views)->toBe(3);
expect((int) $row->views_24h)->toBe(3);
expect((int) $row->views_7d)->toBe(3);
});
it('incrementDownloads updates downloads, downloads_24h, and downloads_7d', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id);
app(ArtworkStatsService::class)->incrementDownloads($artwork->id, 2, defer: false);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->downloads)->toBe(2);
expect((int) $row->downloads_24h)->toBe(2);
expect((int) $row->downloads_7d)->toBe(2);
});
it('multiple view increments accumulate across all three columns', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id);
$svc = app(ArtworkStatsService::class);
$svc->incrementViews($artwork->id, 1, defer: false);
$svc->incrementViews($artwork->id, 1, defer: false);
$svc->incrementViews($artwork->id, 1, defer: false);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views)->toBe(3);
expect((int) $row->views_24h)->toBe(3);
expect((int) $row->views_7d)->toBe(3);
});
// ── ResetWindowedStatsCommand ─────────────────────────────────────────────────
it('reset-windowed-stats --period=24h zeros views_24h', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views_24h)->toBe(0);
// 7d column is NOT touched by a 24h reset
expect((int) $row->views_7d)->toBe(200);
});
it('reset-windowed-stats --period=7d zeros views_7d', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->views_7d)->toBe(0);
// 24h column is NOT touched by a 7d reset
expect((int) $row->views_24h)->toBe(50);
});
it('reset-windowed-stats recomputes downloads_24h from artwork_downloads log', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
seedStats($artwork->id, ['downloads_24h' => 99]); // stale value
// Insert 3 downloads within the last 24 hours
$ip = inet_pton('127.0.0.1');
DB::table('artwork_downloads')->insert([
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(1)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(6)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(12)],
]);
// Insert 2 old downloads outside the 24h window
DB::table('artwork_downloads')->insert([
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(2)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)],
]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
// Should equal exactly the 3 recent downloads, not the stale 99
expect((int) $row->downloads_24h)->toBe(3);
});
it('reset-windowed-stats recomputes downloads_7d including all downloads in 7-day window', function () {
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10)]);
seedStats($artwork->id, ['downloads_7d' => 0]);
$ip = inet_pton('127.0.0.1');
DB::table('artwork_downloads')->insert([
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(1)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)],
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(8)], // outside 7d
]);
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d'])
->assertExitCode(0);
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
expect((int) $row->downloads_7d)->toBe(2);
});
it('reset-windowed-stats returns failure for invalid period', function () {
$this->artisan('skinbase:reset-windowed-stats', ['--period' => 'bad'])
->assertExitCode(1);
});