create(['is_active' => true]); } function makeArtworkFor(User $user): Artwork { return Artwork::factory()->create([ 'user_id' => $user->id, 'is_public' => true, 'is_approved'=> true, ]); } function statsRow(int $userId): object { return DB::table('user_statistics')->where('user_id', $userId)->first(); } // ─── 1. Schema ─────────────────────────────────────────────────────────────── test('user_statistics v2 schema has all expected columns', function () { $columns = DB::getSchemaBuilder()->getColumnListing('user_statistics'); $expected = [ 'user_id', 'uploads_count', 'downloads_received_count', 'artwork_views_received_count', 'awards_received_count', 'favorites_received_count', 'comments_received_count', 'reactions_received_count', 'profile_views_count', 'followers_count', 'following_count', 'last_upload_at', 'last_active_at', 'created_at', 'updated_at', ]; foreach ($expected as $col) { expect(in_array($col, $columns, true))->toBeTrue("Column '{$col}' is missing from user_statistics"); } // Old column names must NOT exist foreach (['uploads', 'downloads', 'pageviews', 'awards', 'profile_views'] as $old) { expect(in_array($old, $columns, true))->toBeFalse("Old column '{$old}' still present in user_statistics"); } }); // ─── 2. UserStatsService – ensureRow ───────────────────────────────────────── test('ensureRow creates a stats row if none exists', function () { $user = makeCreator(); DB::table('user_statistics')->where('user_id', $user->id)->delete(); app(UserStatsService::class)->ensureRow($user->id); expect(DB::table('user_statistics')->where('user_id', $user->id)->exists())->toBeTrue(); }); test('ensureRow does not throw if row already exists', function () { $user = makeCreator(); app(UserStatsService::class)->ensureRow($user->id); app(UserStatsService::class)->ensureRow($user->id); // second call should not fail expect(DB::table('user_statistics')->where('user_id', $user->id)->count())->toBe(1); }); // ─── 3. UserStatsService – increment / decrement ──────────────────────────── test('incrementUploads increments uploads_count atomically', function () { Queue::fake(); // prevent Meilisearch reindex job $user = makeCreator(); $svc = app(UserStatsService::class); $svc->incrementUploads($user->id); $svc->incrementUploads($user->id, 4); expect((int) statsRow($user->id)->uploads_count)->toBe(5); }); test('decrementUploads does not go below zero', function () { Queue::fake(); $user = makeCreator(); $svc = app(UserStatsService::class); // Ensure row exists with default 0, then try to decrement $svc->ensureRow($user->id); $svc->decrementUploads($user->id, 10); expect((int) statsRow($user->id)->uploads_count)->toBe(0); }); test('incrementFavoritesReceived increments the counter', function () { Queue::fake(); $user = makeCreator(); $svc = app(UserStatsService::class); $svc->incrementFavoritesReceived($user->id); $svc->incrementFavoritesReceived($user->id); expect((int) statsRow($user->id)->favorites_received_count)->toBe(2); }); test('decrementFavoritesReceived does not go below zero', function () { Queue::fake(); $user = makeCreator(); $svc = app(UserStatsService::class); // Ensure row exists with default 0, then try to decrement $svc->ensureRow($user->id); $svc->decrementFavoritesReceived($user->id); expect((int) statsRow($user->id)->favorites_received_count)->toBe(0); }); test('incrementCommentsReceived and decrementCommentsReceived work symmetrically', function () { Queue::fake(); $user = makeCreator(); $svc = app(UserStatsService::class); $svc->incrementCommentsReceived($user->id); $svc->incrementCommentsReceived($user->id); $svc->decrementCommentsReceived($user->id); expect((int) statsRow($user->id)->comments_received_count)->toBe(1); }); test('incrementReactionsReceived and decrementReactionsReceived work symmetrically', function () { Queue::fake(); $user = makeCreator(); $svc = app(UserStatsService::class); $svc->incrementReactionsReceived($user->id, 3); $svc->decrementReactionsReceived($user->id, 2); expect((int) statsRow($user->id)->reactions_received_count)->toBe(1); }); test('incrementAwardsReceived and decrementAwardsReceived work symmetrically', function () { Queue::fake(); $user = makeCreator(); $svc = app(UserStatsService::class); $svc->incrementAwardsReceived($user->id); $svc->decrementAwardsReceived($user->id); expect((int) statsRow($user->id)->awards_received_count)->toBe(0); }); test('incrementProfileViews increments profile_views_count', function () { Queue::fake(); $user = makeCreator(); app(UserStatsService::class)->incrementProfileViews($user->id, 5); expect((int) statsRow($user->id)->profile_views_count)->toBe(5); }); // ─── 4. Timestamps ─────────────────────────────────────────────────────────── test('setLastUploadAt writes the timestamp', function () { Queue::fake(); $user = makeCreator(); $ts = now()->subHours(3); app(UserStatsService::class)->setLastUploadAt($user->id, $ts); $row = statsRow($user->id); expect($row->last_upload_at)->not->toBeNull(); }); test('setLastActiveAt writes the timestamp', function () { Queue::fake(); $user = makeCreator(); app(UserStatsService::class)->setLastActiveAt($user->id); expect(statsRow($user->id)->last_active_at)->not->toBeNull(); }); // ─── 5. Observer wiring – Artwork created ──────────────────────────────────── test('creating an artwork increments uploads_count for its owner', function () { Queue::fake(); $creator = makeCreator(); DB::table('user_statistics')->where('user_id', $creator->id)->delete(); makeArtworkFor($creator); expect((int) statsRow($creator->id)->uploads_count)->toBe(1); }); test('soft-deleting an artwork decrements uploads_count', function () { Queue::fake(); $creator = makeCreator(); $artwork = makeArtworkFor($creator); $before = (int) statsRow($creator->id)->uploads_count; $artwork->delete(); expect((int) statsRow($creator->id)->uploads_count)->toBe(max(0, $before - 1)); }); // ─── 6. Observer wiring – Favourites ───────────────────────────────────────── test('adding a favourite increments creator favorites_received_count', function () { Queue::fake(); $creator = makeCreator(); $liker = makeCreator(); $artwork = makeArtworkFor($creator); DB::table('user_statistics')->where('user_id', $creator->id) ->update(['favorites_received_count' => 0]); ArtworkFavourite::create(['user_id' => $liker->id, 'artwork_id' => $artwork->id]); expect((int) statsRow($creator->id)->favorites_received_count)->toBe(1); }); test('removing a favourite decrements creator favorites_received_count', function () { Queue::fake(); $creator = makeCreator(); $liker = makeCreator(); $artwork = makeArtworkFor($creator); $fav = ArtworkFavourite::create(['user_id' => $liker->id, 'artwork_id' => $artwork->id]); $after = (int) statsRow($creator->id)->favorites_received_count; $fav->delete(); expect((int) statsRow($creator->id)->favorites_received_count)->toBe(max(0, $after - 1)); }); // ─── 7. Observer wiring – Comments ─────────────────────────────────────────── test('adding a comment increments creator comments_received_count', function () { Queue::fake(); $creator = makeCreator(); $commenter = makeCreator(); $artwork = makeArtworkFor($creator); DB::table('user_statistics')->where('user_id', $creator->id) ->update(['comments_received_count' => 0]); ArtworkComment::create([ 'artwork_id' => $artwork->id, 'user_id' => $commenter->id, 'content' => 'Nice work!', 'is_approved'=> true, ]); expect((int) statsRow($creator->id)->comments_received_count)->toBe(1); }); test('soft-deleting a comment decrements creator comments_received_count', function () { Queue::fake(); $creator = makeCreator(); $commenter = makeCreator(); $artwork = makeArtworkFor($creator); $comment = ArtworkComment::create([ 'artwork_id' => $artwork->id, 'user_id' => $commenter->id, 'content' => 'Hi', 'is_approved'=> true, ]); $before = (int) statsRow($creator->id)->comments_received_count; $comment->delete(); expect((int) statsRow($creator->id)->comments_received_count)->toBe(max(0, $before - 1)); }); // ─── 8. Observer wiring – Reactions ────────────────────────────────────────── test('adding a reaction increments creator reactions_received_count', function () { Queue::fake(); $creator = makeCreator(); $reactor = makeCreator(); $artwork = makeArtworkFor($creator); DB::table('user_statistics')->where('user_id', $creator->id) ->update(['reactions_received_count' => 0]); ArtworkReaction::create([ 'artwork_id' => $artwork->id, 'user_id' => $reactor->id, 'reaction' => 'heart', ]); expect((int) statsRow($creator->id)->reactions_received_count)->toBe(1); }); test('removing a reaction decrements creator reactions_received_count', function () { Queue::fake(); $creator = makeCreator(); $reactor = makeCreator(); $artwork = makeArtworkFor($creator); $reaction = ArtworkReaction::create([ 'artwork_id' => $artwork->id, 'user_id' => $reactor->id, 'reaction' => 'thumbs_up', ]); $before = (int) statsRow($creator->id)->reactions_received_count; $reaction->delete(); expect((int) statsRow($creator->id)->reactions_received_count)->toBe(max(0, $before - 1)); }); // ─── 9. Observer wiring – Awards ──────────────────────────────────────────── test('giving an award increments creator awards_received_count', function () { Queue::fake(); $creator = makeCreator(); $awarder = makeCreator(); $artwork = makeArtworkFor($creator); DB::table('user_statistics')->where('user_id', $creator->id) ->update(['awards_received_count' => 0]); $svc = app(ArtworkAwardService::class); $svc->award($artwork, $awarder, 'gold'); expect((int) statsRow($creator->id)->awards_received_count)->toBe(1); }); test('removing an award decrements creator awards_received_count', function () { Queue::fake(); $creator = makeCreator(); $awarder = makeCreator(); $artwork = makeArtworkFor($creator); $svc = app(ArtworkAwardService::class); $svc->award($artwork, $awarder, 'gold'); $before = (int) statsRow($creator->id)->awards_received_count; $svc->removeAward($artwork, $awarder); expect((int) statsRow($creator->id)->awards_received_count)->toBe(max(0, $before - 1)); }); // ─── 10. Recompute – single user ───────────────────────────────────────────── test('recomputeUser rebuilds counters from source tables', function () { Queue::fake(); $creator = makeCreator(); $fanA = makeCreator(); $fanB = makeCreator(); $art1 = makeArtworkFor($creator); $art2 = makeArtworkFor($creator); // Add 2 favourites ArtworkFavourite::create(['user_id' => $fanA->id, 'artwork_id' => $art1->id]); ArtworkFavourite::create(['user_id' => $fanB->id, 'artwork_id' => $art2->id]); // Add 1 comment ArtworkComment::create([ 'artwork_id' => $art1->id, 'user_id' => $fanA->id, 'content' => 'Nice', 'is_approved'=> true, ]); // Corrupt the stored counters to simulate drift DB::table('user_statistics')->where('user_id', $creator->id)->update([ 'uploads_count' => 99, 'favorites_received_count'=> 99, 'comments_received_count' => 99, ]); // Recompute should restore correct values $svc = app(UserStatsService::class); $svc->recomputeUser($creator->id); $row = statsRow($creator->id); expect((int) $row->uploads_count)->toBe(2) ->and((int) $row->favorites_received_count)->toBe(2) ->and((int) $row->comments_received_count)->toBe(1); }); test('recomputeUser dry-run does not write to database', function () { Queue::fake(); $creator = makeCreator(); makeArtworkFor($creator); // Corrupt the counter DB::table('user_statistics')->where('user_id', $creator->id) ->update(['uploads_count' => 99]); $svc = app(UserStatsService::class); $result = $svc->recomputeUser($creator->id, dryRun: true); // Returned value should be correct expect($result['uploads_count'])->toBe(1); // Nothing should have been written expect((int) statsRow($creator->id)->uploads_count)->toBe(99); }); // ─── 11. Recompute command ──────────────────────────────────────────────────── test('recompute command dry-run does not write changes', function () { Queue::fake(); $creator = makeCreator(); makeArtworkFor($creator); DB::table('user_statistics')->where('user_id', $creator->id) ->update(['uploads_count' => 99]); $this->artisan('skinbase:recompute-user-stats', [ 'user_id' => $creator->id, '--dry-run'=> true, ])->assertSuccessful(); expect((int) statsRow($creator->id)->uploads_count)->toBe(99); }); test('recompute command live applies correct values', function () { Queue::fake(); $creator = makeCreator(); makeArtworkFor($creator); makeArtworkFor($creator); DB::table('user_statistics')->where('user_id', $creator->id) ->update(['uploads_count' => 0]); $this->artisan('skinbase:recompute-user-stats', [ 'user_id' => $creator->id, ])->assertSuccessful(); expect((int) statsRow($creator->id)->uploads_count)->toBe(2); }); test('recompute command --all processes all users', function () { Queue::fake(); $userA = makeCreator(); $userB = makeCreator(); makeArtworkFor($userA); DB::table('user_statistics') ->whereIn('user_id', [$userA->id, $userB->id]) ->update(['uploads_count' => 0]); $this->artisan('skinbase:recompute-user-stats', ['--all' => true]) ->assertSuccessful(); expect((int) statsRow($userA->id)->uploads_count)->toBe(1) ->and((int) statsRow($userB->id)->uploads_count)->toBe(0); }); // ─── 12. Meilisearch – toSearchableArray ───────────────────────────────────── test('User toSearchableArray contains v2 stat fields', function () { Queue::fake(); $user = makeCreator(); // Ensure stats row exists before updating app(UserStatsService::class)->ensureRow($user->id); DB::table('user_statistics')->where('user_id', $user->id)->update([ 'uploads_count' => 10, 'downloads_received_count' => 20, 'artwork_views_received_count' => 30, 'awards_received_count' => 4, 'favorites_received_count' => 5, 'comments_received_count' => 6, 'reactions_received_count' => 7, 'followers_count' => 100, 'following_count' => 50, ]); $user->load('statistics'); $arr = $user->toSearchableArray(); expect($arr)->toHaveKey('uploads_count', 10) ->and($arr)->toHaveKey('downloads_received_count', 20) ->and($arr)->toHaveKey('artwork_views_received_count', 30) ->and($arr)->toHaveKey('awards_received_count', 4) ->and($arr)->toHaveKey('favorites_received_count', 5) ->and($arr)->toHaveKey('comments_received_count', 6) ->and($arr)->toHaveKey('reactions_received_count', 7) ->and($arr)->toHaveKey('followers_count', 100) ->and($arr)->toHaveKey('following_count', 50); // Old key must not be present expect(array_key_exists('uploads', $arr))->toBeFalse(); });