Prefetch artwork prev/next navigation off the critical path.
Move neighbor lookup into a dedicated service and let the viewer load adjacent artworks after first paint instead of blocking the detail request.
This commit is contained in:
@@ -5,11 +5,15 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkNavigationService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ArtworkNavigationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkNavigationService $navigation) {}
|
||||
|
||||
/**
|
||||
* GET /api/artworks/navigation/{id}
|
||||
*
|
||||
@@ -26,35 +30,11 @@ class ArtworkNavigationController extends Controller
|
||||
'prev_id' => null, 'next_id' => null,
|
||||
'prev_url' => null, 'next_url' => null,
|
||||
'prev_slug' => null, 'next_slug' => null,
|
||||
'prev_preview' => null, 'next_preview' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = Artwork::published()
|
||||
->select(['id', 'title', 'slug'])
|
||||
->where('user_id', $artwork->user_id);
|
||||
|
||||
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
|
||||
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
|
||||
|
||||
// Infinite loop: wrap around when reaching the first or last artwork
|
||||
if (! $prev) {
|
||||
$prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first();
|
||||
}
|
||||
if (! $next) {
|
||||
$next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first();
|
||||
}
|
||||
|
||||
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
|
||||
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
|
||||
|
||||
return response()->json([
|
||||
'prev_id' => $prev?->id,
|
||||
'next_id' => $next?->id,
|
||||
'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null,
|
||||
'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null,
|
||||
'prev_slug' => $prevSlug,
|
||||
'next_slug' => $nextSlug,
|
||||
]);
|
||||
return response()->json($this->navigation->navigationFor($artwork));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,8 +42,12 @@ class ArtworkNavigationController extends Controller
|
||||
*
|
||||
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation.
|
||||
*/
|
||||
public function pageData(int $id): JsonResponse
|
||||
public function pageData(Request $request, int $id): JsonResponse
|
||||
{
|
||||
if ($request->boolean('prefetch')) {
|
||||
return $this->prefetchData($id);
|
||||
}
|
||||
|
||||
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
|
||||
->published()
|
||||
->find($id);
|
||||
@@ -73,7 +57,30 @@ class ArtworkNavigationController extends Controller
|
||||
}
|
||||
|
||||
$resource = (new ArtworkResource($artwork))->toArray(request());
|
||||
$resource['navigation'] = $this->navigation->navigationFor($artwork);
|
||||
|
||||
return response()->json($resource);
|
||||
}
|
||||
|
||||
private function prefetchData(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()
|
||||
->published()
|
||||
->select(['id', 'title', 'slug', 'hash', 'file_path', 'file_name'])
|
||||
->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) $artwork->title,
|
||||
'slug' => (string) $artwork->slug,
|
||||
'thumbs' => [
|
||||
'md' => ThumbnailPresenter::present($artwork, 'md'),
|
||||
'lg' => ThumbnailPresenter::present($artwork, 'lg'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class ArtworkNavigationService
|
||||
{
|
||||
/**
|
||||
* Build the public previous/next navigation payload for an already loaded
|
||||
* current artwork. No current-artwork lookup is performed here.
|
||||
*/
|
||||
public function navigationFor(Artwork $artwork): array
|
||||
{
|
||||
$scope = Artwork::published()
|
||||
->select(['id', 'title', 'slug', 'hash', 'file_path', 'file_name'])
|
||||
->where('user_id', $artwork->user_id);
|
||||
|
||||
$prev = (clone $scope)->where('id', '<', $artwork->id)->orderByDesc('id')->first();
|
||||
$next = (clone $scope)->where('id', '>', $artwork->id)->orderBy('id')->first();
|
||||
|
||||
if (! $prev) {
|
||||
$prev = (clone $scope)->where('id', '!=', $artwork->id)->orderByDesc('id')->first();
|
||||
}
|
||||
if (! $next) {
|
||||
$next = (clone $scope)->where('id', '!=', $artwork->id)->orderBy('id')->first();
|
||||
}
|
||||
|
||||
$prevSlug = $this->canonicalSlug($prev);
|
||||
$nextSlug = $this->canonicalSlug($next);
|
||||
|
||||
return [
|
||||
'prev_id' => $prev?->id,
|
||||
'next_id' => $next?->id,
|
||||
'prev_url' => $prev ? url('/art/'.$prev->id.'/'.$prevSlug) : null,
|
||||
'next_url' => $next ? url('/art/'.$next->id.'/'.$nextSlug) : null,
|
||||
'prev_slug' => $prevSlug,
|
||||
'next_slug' => $nextSlug,
|
||||
'prev_preview' => $this->preview($prev),
|
||||
'next_preview' => $this->preview($next),
|
||||
];
|
||||
}
|
||||
|
||||
private function canonicalSlug(?Artwork $artwork): ?string
|
||||
{
|
||||
if (! $artwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::slug($artwork->slug ?: $artwork->title) ?: (string) $artwork->id;
|
||||
}
|
||||
|
||||
private function preview(?Artwork $artwork): ?array
|
||||
{
|
||||
if (! $artwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) $artwork->title,
|
||||
'slug' => (string) $artwork->slug,
|
||||
'thumbs' => [
|
||||
'md' => ThumbnailPresenter::present($artwork, 'md'),
|
||||
'lg' => ThumbnailPresenter::present($artwork, 'lg'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavContext } from '../../lib/useNavContext';
|
||||
|
||||
const preloadCache = new Set();
|
||||
const prefetchCache = new Set();
|
||||
|
||||
function scheduleIdleTask(callback, delay = 1200) {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -32,8 +33,8 @@ function preloadImage(src) {
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId);
|
||||
export default function ArtworkNavigator({ artworkId, navigation = null, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId, navigation);
|
||||
const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
|
||||
|
||||
// Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values)
|
||||
@@ -67,17 +68,42 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
|
||||
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
|
||||
if (ids.length === 0) return undefined;
|
||||
|
||||
const previews = new Map([
|
||||
[neighbors.prevId, neighbors.prevPreview],
|
||||
[neighbors.nextId, neighbors.nextPreview],
|
||||
]);
|
||||
|
||||
let cancelled = false;
|
||||
const cancelIdleTask = scheduleIdleTask(() => {
|
||||
ids.forEach((id) => {
|
||||
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
if (prefetchCache.has(id)) return;
|
||||
prefetchCache.add(id);
|
||||
const preview = previews.get(id);
|
||||
if (preview) {
|
||||
if (!cancelled) {
|
||||
const imgUrl = preview.thumbs?.lg?.url || preview.thumbs?.md?.url;
|
||||
if (imgUrl) preloadImage(imgUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fetch(`/api/artworks/${id}/page?prefetch=1`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
prefetchCache.delete(id);
|
||||
return null;
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
if (!data) {
|
||||
prefetchCache.delete(id);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
|
||||
if (imgUrl) preloadImage(imgUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => prefetchCache.delete(id));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +111,7 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
|
||||
cancelled = true;
|
||||
cancelIdleTask();
|
||||
};
|
||||
}, [neighbors.prevId, neighbors.nextId]);
|
||||
}, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]);
|
||||
|
||||
// Stable navigate — reads state via refs, never recreated
|
||||
const navigate = useCallback(async (targetId, targetUrl) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import ArtworkNavigator from './ArtworkNavigator'
|
||||
|
||||
describe('ArtworkNavigator preview prefetch', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
delete window.requestIdleCallback
|
||||
delete window.cancelIdleCallback
|
||||
})
|
||||
|
||||
it('seeds neighbor prefetch from navigation and keeps full navigation separate', async () => {
|
||||
let idleCallback
|
||||
let ready
|
||||
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
|
||||
window.cancelIdleCallback = vi.fn()
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (String(url).includes('/api/artworks/navigation/777')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
prev_id: 776,
|
||||
next_id: 778,
|
||||
prev_url: '/art/776/previous',
|
||||
next_url: '/art/778/next',
|
||||
prev_slug: 'previous',
|
||||
next_slug: 'next',
|
||||
prev_preview: { id: 776, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-776' }, lg: { url: '/lg-776' } } },
|
||||
next_preview: { id: 778, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-778' }, lg: { url: '/lg-778' } } },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({ ok: true, json: async () => ({ id: 778, title: 'Next', slug: 'next' }) })
|
||||
})
|
||||
|
||||
render(<ArtworkNavigator artworkId={777} onNavigate={vi.fn()} onReady={(next) => { if (next.hasNext) ready = next }} />)
|
||||
await waitFor(() => expect(ready?.hasNext).toBe(true))
|
||||
await act(async () => idleCallback())
|
||||
|
||||
expect(global.fetch.mock.calls.filter(([url]) => String(url).includes('?prefetch=1'))).toHaveLength(0)
|
||||
await act(async () => ready.navigateNext())
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/artworks/778/page', { headers: { Accept: 'application/json' } })
|
||||
})
|
||||
|
||||
it('uses embedded initial navigation without a standalone or prefetch request', async () => {
|
||||
let idleCallback
|
||||
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
|
||||
window.cancelIdleCallback = vi.fn()
|
||||
global.fetch = vi.fn()
|
||||
const navigation = {
|
||||
prev_id: 880,
|
||||
next_id: 882,
|
||||
prev_url: '/art/880/previous',
|
||||
next_url: '/art/882/next',
|
||||
prev_slug: 'previous',
|
||||
next_slug: 'next',
|
||||
prev_preview: { id: 880, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-880' }, lg: { url: '/lg-880' } } },
|
||||
next_preview: { id: 882, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-882' }, lg: { url: '/lg-882' } } },
|
||||
}
|
||||
|
||||
render(<ArtworkNavigator artworkId={881} navigation={navigation} onNavigate={vi.fn()} onReady={vi.fn()} />)
|
||||
await waitFor(() => expect(window.requestIdleCallback).toHaveBeenCalledTimes(1))
|
||||
await act(async () => idleCallback())
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to standalone navigation for malformed embedded data', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ prev_id: 889, next_id: 891, prev_url: '/art/889/p', next_url: '/art/891/n', prev_slug: 'p', next_slug: 'n' }),
|
||||
})
|
||||
|
||||
render(<ArtworkNavigator artworkId={890} navigation={{ prev_id: 889 }} onNavigate={vi.fn()} onReady={vi.fn()} />)
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/artworks/navigation/890', { headers: { Accept: 'application/json' } }))
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,22 @@ import { useCallback } from 'react';
|
||||
// Module-level cache for API calls
|
||||
const fallbackCache = new Map();
|
||||
|
||||
function normalizeNavigation(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const hasPrev = data.prev_id == null || (data.prev_url && data.prev_slug);
|
||||
const hasNext = data.next_id == null || (data.next_url && data.next_slug);
|
||||
if (!hasPrev || !hasNext) return null;
|
||||
|
||||
return {
|
||||
prevId: data.prev_id ?? null,
|
||||
nextId: data.next_id ?? null,
|
||||
prevUrl: data.prev_url ?? null,
|
||||
nextUrl: data.next_url ?? null,
|
||||
prevPreview: data.prev_preview ?? null,
|
||||
nextPreview: data.next_preview ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFallback(artworkId) {
|
||||
const key = String(artworkId);
|
||||
if (fallbackCache.has(key)) return fallbackCache.get(key);
|
||||
@@ -16,28 +32,25 @@ async function fetchFallback(artworkId) {
|
||||
const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
|
||||
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
|
||||
const data = await res.json();
|
||||
const result = {
|
||||
prevId: data.prev_id ?? null,
|
||||
nextId: data.next_id ?? null,
|
||||
prevUrl: data.prev_url ?? null,
|
||||
nextUrl: data.next_url ?? null,
|
||||
};
|
||||
const result = normalizeNavigation(data) || { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
|
||||
fallbackCache.set(key, result);
|
||||
return result;
|
||||
} catch {
|
||||
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
|
||||
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function useNavContext(currentArtworkId) {
|
||||
export function useNavContext(currentArtworkId, embeddedNavigation = null) {
|
||||
/**
|
||||
* Always resolve via API to guarantee same-user navigation.
|
||||
*/
|
||||
const getNeighbors = useCallback(async () => {
|
||||
const embedded = normalizeNavigation(embeddedNavigation);
|
||||
if (embedded) return embedded;
|
||||
return fetchFallback(currentArtworkId);
|
||||
}, [currentArtworkId]);
|
||||
}, [currentArtworkId, embeddedNavigation]);
|
||||
|
||||
return { getNeighbors };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns only the image contract for navigation prefetch', function (): void {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Prefetch Artwork',
|
||||
'slug' => 'prefetch-artwork',
|
||||
'hash' => 'aabbccddeeff00112233445566778899',
|
||||
'file_path' => 'uploads/prefetch.jpg',
|
||||
]);
|
||||
$queries = 0;
|
||||
|
||||
DB::listen(function () use (&$queries): void {
|
||||
$queries++;
|
||||
});
|
||||
|
||||
$response = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')
|
||||
->assertOk();
|
||||
|
||||
expect($queries)->toBe(1)
|
||||
->and($response->json())->toHaveKeys(['id', 'title', 'slug', 'thumbs'])
|
||||
->and($response->json('thumbs'))->toHaveKeys(['md', 'lg'])
|
||||
->and($response->json('thumbs.xl'))->toBeNull()
|
||||
->and($response->json('stats'))->toBeNull()
|
||||
->and($response->json('thumbs.lg.url'))->toBe(
|
||||
'https://cdn.skinbase.org/artworks/lg/aa/bb/aabbccddeeff00112233445566778899.webp'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prefetch missing or unpublished artwork', function (): void {
|
||||
$unpublished = Artwork::factory()->unpublished()->create();
|
||||
|
||||
$this->getJson('/api/artworks/'.$unpublished->id.'/page?prefetch=1')
|
||||
->assertNotFound();
|
||||
|
||||
$this->getJson('/api/artworks/999999/page?prefetch=1')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('keeps full page navigation data available separately from prefetch', function (): void {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'slug' => 'full-navigation-artwork',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/artworks/'.$artwork->id.'/page')
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'id', 'title', 'thumbs', 'user', 'categories', 'tags', 'stats', 'viewer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('measures the lightweight prefetch contract for guest and authenticated viewers', function (): void {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'slug' => 'prefetch-measurement-artwork',
|
||||
'hash' => 'aabbccddeeff00112233445566778899',
|
||||
]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
$guestPrefetch = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')->assertOk();
|
||||
$guestPrefetchQueries = count(DB::getQueryLog());
|
||||
|
||||
DB::flushQueryLog();
|
||||
$guestFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk();
|
||||
$guestFullQueries = count(DB::getQueryLog());
|
||||
|
||||
DB::flushQueryLog();
|
||||
$authPrefetch = $this->actingAs(User::factory()->create())
|
||||
->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')
|
||||
->assertOk();
|
||||
$authPrefetchQueries = count(DB::getQueryLog());
|
||||
DB::flushQueryLog();
|
||||
$authFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk();
|
||||
$authFullQueries = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
fwrite(STDERR, sprintf(
|
||||
"M18B3 prefetch bytes/queries: guest=%d/%d auth=%d/%d; full guest=%d/%d auth=%d/%d\n",
|
||||
strlen((string) $guestPrefetch->getContent()),
|
||||
$guestPrefetchQueries,
|
||||
strlen((string) $authPrefetch->getContent()),
|
||||
$authPrefetchQueries,
|
||||
strlen((string) $guestFull->getContent()),
|
||||
$guestFullQueries,
|
||||
strlen((string) $authFull->getContent()),
|
||||
$authFullQueries,
|
||||
));
|
||||
|
||||
expect($guestPrefetchQueries)->toBe(1)
|
||||
->and($authPrefetchQueries)->toBeLessThanOrEqual(2)
|
||||
->and($guestPrefetch->json())->toEqual($authPrefetch->json());
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function m18b6PublishedArtwork(User $user, string $slug): Artwork
|
||||
{
|
||||
return Artwork::factory()->for($user)->create([
|
||||
'slug' => $slug,
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
it('returns the correct previous and next artwork for a middle item', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$first = m18b6PublishedArtwork($user, 'first');
|
||||
$middle = m18b6PublishedArtwork($user, 'middle');
|
||||
$last = m18b6PublishedArtwork($user, 'last');
|
||||
|
||||
$this->getJson('/api/artworks/navigation/'.$middle->id)
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'prev_id' => $first->id,
|
||||
'next_id' => $last->id,
|
||||
'prev_url' => url('/art/'.$first->id.'/first'),
|
||||
'next_url' => url('/art/'.$last->id.'/last'),
|
||||
'prev_slug' => 'first',
|
||||
'next_slug' => 'last',
|
||||
])
|
||||
->assertJsonStructure([
|
||||
'prev_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']],
|
||||
'next_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('wraps the first item to the last item', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$first = m18b6PublishedArtwork($user, 'first');
|
||||
$last = m18b6PublishedArtwork($user, 'last');
|
||||
|
||||
$this->getJson('/api/artworks/navigation/'.$first->id)
|
||||
->assertOk()
|
||||
->assertJsonPath('prev_id', $last->id)
|
||||
->assertJsonPath('next_id', $last->id)
|
||||
->assertJsonPath('prev_preview.id', $last->id)
|
||||
->assertJsonPath('next_preview.id', $last->id);
|
||||
});
|
||||
|
||||
it('wraps the last item to the first item', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$first = m18b6PublishedArtwork($user, 'first');
|
||||
$last = m18b6PublishedArtwork($user, 'last');
|
||||
|
||||
$this->getJson('/api/artworks/navigation/'.$last->id)
|
||||
->assertOk()
|
||||
->assertJsonPath('prev_id', $first->id)
|
||||
->assertJsonPath('next_id', $first->id)
|
||||
->assertJsonPath('prev_preview.id', $first->id)
|
||||
->assertJsonPath('next_preview.id', $first->id);
|
||||
});
|
||||
|
||||
it('returns the empty contract for a missing or hidden artwork', function (): void {
|
||||
$empty = [
|
||||
'prev_id' => null,
|
||||
'next_id' => null,
|
||||
'prev_url' => null,
|
||||
'next_url' => null,
|
||||
'prev_slug' => null,
|
||||
'next_slug' => null,
|
||||
'prev_preview' => null,
|
||||
'next_preview' => null,
|
||||
];
|
||||
|
||||
$this->getJson('/api/artworks/navigation/999999')
|
||||
->assertOk()
|
||||
->assertExactJson($empty);
|
||||
|
||||
$private = Artwork::factory()->private()->create();
|
||||
$this->getJson('/api/artworks/navigation/'.$private->id)
|
||||
->assertOk()
|
||||
->assertExactJson($empty);
|
||||
|
||||
$this->actingAs(User::factory()->create())
|
||||
->getJson('/api/artworks/navigation/'.$private->id)
|
||||
->assertOk()
|
||||
->assertExactJson($empty);
|
||||
});
|
||||
Reference in New Issue
Block a user