diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index aa1ca96e..1b3c2562 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -63,6 +63,8 @@ use Inertia\Inertia; class ProfileController extends Controller { + private const SSR_WORLD_HISTORY_TABS = ['achievements', 'worlds']; + private const PROFILE_TABS = [ 'posts', 'artworks', @@ -207,6 +209,26 @@ class ProfileController extends Controller ]); } + /** + * Read-only world history payload for profile tabs that are loaded client-side. + * This deliberately does not render the profile document or track a view. + */ + public function worldHistory(Request $request, string $username): JsonResponse + { + $normalized = UsernamePolicy::normalize($username); + $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); + + if (! $user) { + return response()->json(['error' => 'User not found'], 404); + } + + $isOwner = Auth::check() && Auth::id() === $user->id; + + return response()->json($isOwner + ? $this->worldProfileHistory->ownerPayloadForUser($user) + : $this->worldProfileHistory->publicPayloadForUser($user)); + } + public function legacyById(Request $request, int $id, ?string $username = null) { $user = User::query()->findOrFail($id); @@ -1292,9 +1314,11 @@ class ProfileController extends Controller ->all(); $achievementSummary = $this->achievements->summary((int) $user->id); $worldRewardSummary = $this->worldRewards->summaryForUser($user); - $worldHistory = $isOwner - ? $this->worldProfileHistory->ownerPayloadForUser($user) - : $this->worldProfileHistory->publicPayloadForUser($user); + $worldHistory = in_array($initialTab, self::SSR_WORLD_HISTORY_TABS, true) + ? ($isOwner + ? $this->worldProfileHistory->ownerPayloadForUser($user) + : $this->worldProfileHistory->publicPayloadForUser($user)) + : null; $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); $groupContributionHistory = $this->buildGroupContributionHistory($user); $journey = $this->creatorJourney->publicPayloadForUser($user); diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index 07edfbba..f504a9c4 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -17,6 +17,7 @@ import TabFollowers from '../../components/profile/tabs/TabFollowers' import GroupProfileSummary from '../../components/groups/GroupProfileSummary' import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks' import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites' +import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory' const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers'] @@ -98,6 +99,11 @@ export default function ProfileShow() { initialFavourites: favourites, enabled: activeTab === 'favourites', }) + const worldHistoryState = useLazyProfileWorldHistory({ + username: user?.username || user?.name || '', + initialWorldHistory: worldHistory, + enabled: activeTab === 'worlds' || activeTab === 'achievements', + }) const handleTabChange = useCallback((tab) => { if (!VALID_TABS.includes(tab)) return @@ -239,10 +245,10 @@ export default function ProfileShow() { /> )} {activeTab === 'achievements' && ( - + )} {activeTab === 'worlds' && ( - + )} {activeTab === 'collections' && ( + {loading ?

Loading worlds history…

: null} + {error ? ( +
+ Worlds history could not be loaded. + +
+ ) : null} ) -} \ No newline at end of file +} diff --git a/resources/js/lib/useLazyProfileWorldHistory.js b/resources/js/lib/useLazyProfileWorldHistory.js new file mode 100644 index 00000000..144b703c --- /dev/null +++ b/resources/js/lib/useLazyProfileWorldHistory.js @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export default function useLazyProfileWorldHistory({ username, initialWorldHistory, enabled }) { + const hasInitialValue = initialWorldHistory !== null && initialWorldHistory !== undefined + const [data, setData] = useState(() => hasInitialValue ? initialWorldHistory : null) + const [loaded, setLoaded] = useState(hasInitialValue) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const requestKeyRef = useRef(username) + const requestedRef = useRef(hasInitialValue) + const abortRef = useRef(null) + + useEffect(() => { + if (requestKeyRef.current === username) return undefined + + abortRef.current?.abort() + abortRef.current = null + requestKeyRef.current = username + requestedRef.current = false + setData(null) + setLoaded(false) + setLoading(false) + setError(false) + return undefined + }, [username]) + + useEffect(() => { + if (!hasInitialValue || requestKeyRef.current !== username) return + + abortRef.current?.abort() + abortRef.current = null + requestedRef.current = true + setData(initialWorldHistory) + setLoaded(true) + setLoading(false) + setError(false) + }, [hasInitialValue, initialWorldHistory, username]) + + const load = useCallback(async () => { + if (!enabled || hasInitialValue || requestKeyRef.current !== username || requestedRef.current || !username) return + + requestedRef.current = true + const controller = new AbortController() + const requestKey = requestKeyRef.current + abortRef.current = controller + setLoading(true) + setError(false) + + try { + const response = await fetch(`/api/profile/${encodeURIComponent(username)}/world-history`, { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + signal: controller.signal, + }) + if (!response.ok) throw new Error('World history could not be loaded.') + const payload = await response.json() + if (requestKey !== requestKeyRef.current || controller.signal.aborted) return + setData(payload) + setLoaded(true) + } catch (caught) { + if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return + requestedRef.current = false + setError(true) + } finally { + if (requestKey === requestKeyRef.current) { + abortRef.current = null + setLoading(false) + } + } + }, [enabled, hasInitialValue, username]) + + useEffect(() => { + if (enabled) load() + }, [enabled, load]) + + useEffect(() => () => abortRef.current?.abort(), []) + + return { data, loaded, loading, error, retry: load } +} diff --git a/resources/js/lib/useLazyProfileWorldHistory.test.jsx b/resources/js/lib/useLazyProfileWorldHistory.test.jsx new file mode 100644 index 00000000..a682f095 --- /dev/null +++ b/resources/js/lib/useLazyProfileWorldHistory.test.jsx @@ -0,0 +1,81 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import useLazyProfileWorldHistory from './useLazyProfileWorldHistory' + +function Harness({ username = 'alice', initial = null, enabled = false }) { + const state = useLazyProfileWorldHistory({ + username, + initialWorldHistory: initial, + enabled, + }) + + return {JSON.stringify(state)} +} + +describe('useLazyProfileWorldHistory', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('does not request on Posts until a consuming tab is active', () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + render() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('requests once on first consuming-tab activation and reuses the result', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ summary: { available: true }, entries: [{ id: 'world-1' }] }), + }) + const view = render() + + view.rerender() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + view.rerender() + view.rerender() + expect(fetchMock).toHaveBeenCalledTimes(1) + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true)) + }) + + it('uses direct Worlds/Achievements SSR data without a lazy request', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + const initial = { summary: { available: true }, entries: [{ id: 'world-1' }] } + const view = render() + view.rerender() + view.rerender() + + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toEqual(initial)) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('treats an empty response as loaded', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) }) + const view = render() + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true)) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('resets on profile navigation and ignores the stale response', async () => { + let resolveA + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveA = resolve })) + const view = render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + view.rerender() + resolveA({ ok: true, json: async () => ({ summary: { available: true }, entries: [{ id: 'alice' }] }) }) + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toBe(null)) + }) + + it('does not loop after an error and permits an explicit retry', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) }) + const view = render() + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).error).toBe(true)) + expect(fetchMock).toHaveBeenCalledTimes(1) + // The hook's returned retry is intentionally exercised through a small rerender activation. + view.rerender() + view.rerender() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + }) +}) diff --git a/routes/api.php b/routes/api.php index c588bdef..1b7c257a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -911,6 +911,7 @@ Route::middleware(['web', 'throttle:60,1']) Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites'); Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats'); Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks'); + Route::get('world-history', [\App\Http\Controllers\User\ProfileController::class, 'worldHistory'])->name('world-history'); }); // ── Link Preview (auth, throttled) ───────────────────────────────────────────── diff --git a/tests/Feature/Profile/ProfileWorldHistoryLazyTest.php b/tests/Feature/Profile/ProfileWorldHistoryLazyTest.php new file mode 100644 index 00000000..224cf020 --- /dev/null +++ b/tests/Feature/Profile/ProfileWorldHistoryLazyTest.php @@ -0,0 +1,73 @@ +create(['username' => 'lazyworlds']); + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->get('/@' . $user->username) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('initialTab', 'posts') + ->where('worldHistory', null)); + + $historyQueries = collect(DB::getQueryLog())->pluck('query')->filter(function (string $query): bool { + $query = strtolower($query); + + return str_contains($query, 'world_submissions') + || str_contains($query, 'group_challenge_outcomes') + || str_contains($query, 'world_relations'); + }); + + expect($historyQueries)->toBeEmpty(); +}); + +it('keeps world history in direct Worlds and Achievements SSR payloads', function (string $tab): void { + $user = User::factory()->create(['username' => 'ssr' . $tab]); + + $this->get('/@' . $user->username . '/' . $tab) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('initialTab', $tab) + ->has('worldHistory.summary') + ->has('worldHistory.entries')); +})->with(['worlds', 'achievements']); + +it('returns the exact public service payload without tracking a profile view', function (): void { + $user = User::factory()->create(['username' => 'lazyworldapi']); + $expected = app(WorldProfileHistoryService::class)->publicPayloadForUser($user); + $before = (int) ($user->profile_views_count ?? 0); + + $this->getJson('/api/profile/' . $user->username . '/world-history') + ->assertOk() + ->assertJson($expected); + + expect((int) $user->fresh()->profile_views_count)->toBe($before); +}); + +it('returns owner context only to the profile owner', function (): void { + $user = User::factory()->create(['username' => 'ownerworldapi']); + $other = User::factory()->create(['username' => 'otherworldapi']); + + $this->actingAs($user) + ->getJson('/api/profile/' . $user->username . '/world-history') + ->assertOk() + ->assertJsonPath('owner_context.pending_submissions', 0); + + $this->actingAs($other) + ->getJson('/api/profile/' . $user->username . '/world-history') + ->assertOk() + ->assertJsonPath('owner_context', null); +}); + +it('returns not found for an unknown world history profile', function (): void { + $this->getJson('/api/profile/missing-world-history/world-history')->assertNotFound(); +}); diff --git a/tests/Feature/Profile/WorldProfileHistoryTest.php b/tests/Feature/Profile/WorldProfileHistoryTest.php index ef2764c2..cd4040b6 100644 --- a/tests/Feature/Profile/WorldProfileHistoryTest.php +++ b/tests/Feature/Profile/WorldProfileHistoryTest.php @@ -95,7 +95,7 @@ it('exposes normalized world history on public profile pages', function (): void 'awarded_at' => now()->subHour(), ]); - $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + $this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Profile/ProfileShow') @@ -161,7 +161,7 @@ it('filters stale public world rewards while preserving owner-only context count 'status' => WorldSubmission::STATUS_PENDING, ]); - $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + $this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Profile/ProfileShow') @@ -170,7 +170,7 @@ it('filters stale public world rewards while preserving owner-only context count ->where('worldHistory.owner_context', null)); $this->actingAs($creator) - ->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + ->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Profile/ProfileShow') @@ -223,4 +223,4 @@ it('supports the canonical worlds profile tab route', function (): void { ->where('profileTabUrls.worlds', url('/@' . strtolower((string) $creator->username) . '/worlds')) ->where('worldHistory.summary.available', true) ->where('worldHistory.entries.0.primary_recognition.key', 'featured')); -}); \ No newline at end of file +});