(feat) Discord build share
This commit is contained in:
@@ -4,6 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||
import { publicOrigin } from '$lib/server/public-origin';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
|
||||
@@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
}));
|
||||
return {
|
||||
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
||||
publicOrigin: publicOrigin(url.origin),
|
||||
initialBuild,
|
||||
attributes,
|
||||
coreRanges,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
||||
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||
@@ -25,7 +26,10 @@
|
||||
let message = $state('');
|
||||
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
|
||||
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
|
||||
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private'));
|
||||
type BuildVisibility = 'private' | 'unlisted' | 'public';
|
||||
let persistedVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
|
||||
let savedBuildVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
|
||||
let savedBuildTitle = $state(untrack(() => draft.buildName));
|
||||
let handledBuildToken = '';
|
||||
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
|
||||
const coreKeys = ['firepower','toughness','engineering'] as const;
|
||||
@@ -378,7 +382,9 @@
|
||||
handledBuildToken = form.buildToken;
|
||||
persistedBuildId = form.savedBuild.id;
|
||||
persistedSlug = form.savedBuild.slug;
|
||||
persistedVisibility = form.savedBuild.visibility;
|
||||
persistedVisibility = form.savedBuild.visibility as BuildVisibility;
|
||||
savedBuildVisibility = form.savedBuild.visibility as BuildVisibility;
|
||||
savedBuildTitle = form.savedBuild.title;
|
||||
message = form.savedBuild.visibility === 'private'
|
||||
? 'Private build saved to your account.'
|
||||
: form.savedBuild.visibility === 'unlisted'
|
||||
@@ -419,15 +425,16 @@
|
||||
{ value:'private', label:'PRIVATE' },
|
||||
{ value:'unlisted', label:'UNLISTED' },
|
||||
{ value:'public', label:'PUBLIC' }
|
||||
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/>
|
||||
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
|
||||
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
|
||||
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
||||
</div>
|
||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
||||
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
|
||||
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
|
||||
<div class="loadout-workspace">
|
||||
<LoadoutMatrix
|
||||
|
||||
@@ -1,91 +1,15 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
import { publicOrigin } from '$lib/server/public-origin';
|
||||
import { loadSharedBuildProjection } from '$lib/server/shared-build';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const database = await getDatabase();
|
||||
export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
const viewer = await getSessionUser(cookies);
|
||||
const build = await (
|
||||
await database.prepare(`
|
||||
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
||||
user_profiles.public_slug,
|
||||
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
|
||||
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
|
||||
user_avatars.updated_at AS avatar_updated_at
|
||||
FROM builds
|
||||
JOIN users ON users.id=builds.owner_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
|
||||
WHERE builds.slug=?
|
||||
`)
|
||||
).get(params.slug);
|
||||
if (
|
||||
!build ||
|
||||
(build.visibility === 'private' && build.owner_id !== viewer?.id)
|
||||
)
|
||||
error(404, 'Build not found');
|
||||
let loadout: LoadoutDraft;
|
||||
try {
|
||||
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
|
||||
if (!parsed) error(404, 'Build data is unavailable');
|
||||
loadout = parsed;
|
||||
} catch {
|
||||
error(404, 'Build data is unavailable');
|
||||
}
|
||||
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
|
||||
await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,slot,tier,base_armor_min FROM gear',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||
(
|
||||
await database.prepare('SELECT id,name,quality FROM os_protocols')
|
||||
).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
|
||||
loadSharedBuildSummaryCatalog(database),
|
||||
]);
|
||||
const summary = summarizeLoadout(loadout, summaryCatalog);
|
||||
return {
|
||||
build: {
|
||||
id: String(build.id),
|
||||
title: String(build.title),
|
||||
slug: String(build.slug),
|
||||
visibility: String(build.visibility),
|
||||
author: String(build.author),
|
||||
ownerId: String(build.owner_id),
|
||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||
profileAvailable:
|
||||
Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||
authorAvatarUrl: avatarUrl(
|
||||
build.avatar_source,
|
||||
build.avatar_value,
|
||||
String(build.owner_id),
|
||||
build.avatar_updated_at,
|
||||
),
|
||||
owner: build.owner_id === viewer?.id,
|
||||
updatedAt: String(build.updated_at),
|
||||
},
|
||||
loadout,
|
||||
gear,
|
||||
weapons,
|
||||
chips,
|
||||
protocols,
|
||||
brands,
|
||||
summary,
|
||||
attributes: summaryCatalog.attributes,
|
||||
talents: summaryCatalog.talents,
|
||||
};
|
||||
const projection = await loadSharedBuildProjection(params.slug, {
|
||||
kind: 'viewer-page',
|
||||
viewerId: viewer?.id ?? null,
|
||||
});
|
||||
if (!projection) error(404, 'Build not found');
|
||||
return { ...projection, publicOrigin: publicOrigin(url.origin) };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { gearSlots } from '$lib/builder/loadout';
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
||||
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||
let { data } = $props();
|
||||
@@ -17,14 +18,37 @@
|
||||
.join(' · ');
|
||||
const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) =>
|
||||
`FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`;
|
||||
const description = $derived(`${data.build.title}, a Division Resurgence Build by ${data.build.author}. View its equipped weapons, gear, Skill Chips, and known Agent stats on SHD.build.`);
|
||||
const canonicalUrl = $derived(new URL(`/builds/${encodeURIComponent(data.build.slug)}`, data.publicOrigin).toString());
|
||||
const previewUrl = $derived(new URL(`${canonicalUrl}/preview.png`).toString());
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
|
||||
<svelte:head>
|
||||
<title>{data.build.title} — SHD.build</title>
|
||||
<meta name="description" content={description}/>
|
||||
<link rel="canonical" href={canonicalUrl}/>
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:site_name" content="SHD.build"/>
|
||||
<meta property="og:title" content={`${data.build.title} — SHD.build`}/>
|
||||
<meta property="og:description" content={description}/>
|
||||
<meta property="og:url" content={canonicalUrl}/>
|
||||
{#if data.build.visibility !== 'private'}
|
||||
<meta property="og:image" content={previewUrl}/>
|
||||
<meta property="og:image:width" content="1200"/>
|
||||
<meta property="og:image:height" content="630"/>
|
||||
<meta property="og:image:type" content="image/png"/>
|
||||
<meta property="og:image:alt" content={`Build preview for ${data.build.title}`}/>
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
<meta name="twitter:image" content={previewUrl}/>
|
||||
{/if}
|
||||
{#if data.build.visibility !== 'public'}<meta name="robots" content="noindex,nofollow"/>{/if}
|
||||
</svelte:head>
|
||||
<main class="app-page shared-build-page">
|
||||
<header class="shared-build-command">
|
||||
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
|
||||
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
||||
</header>
|
||||
<BuildShareControl title={data.build.title} slugOrUrl={canonicalUrl} visibility={data.build.visibility}/>
|
||||
<div class="shared-build-intelligence">
|
||||
<LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
|
||||
<PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import {
|
||||
renderBuildSocialCardPng,
|
||||
type BuildSocialCardFact,
|
||||
type BuildSocialCardInput,
|
||||
} from '$lib/server/build-social-card';
|
||||
import { publicOrigin } from '$lib/server/public-origin';
|
||||
import { loadSharedBuildProjection } from '$lib/server/shared-build';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||
const MAX_CACHE_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_ACTIVE_REQUESTS = 4;
|
||||
const MAX_QUEUED_REQUESTS = 32;
|
||||
type CachedImage = { body: Buffer; etag: string };
|
||||
const imageCache = new Map<string, CachedImage>();
|
||||
const pendingImages = new Map<string, Promise<CachedImage>>();
|
||||
const requestQueue: Array<(release: () => void) => void> = [];
|
||||
let cacheBytes = 0;
|
||||
let activeRequests = 0;
|
||||
|
||||
function unitValue(value: number, unit: string | null | undefined) {
|
||||
const rendered = number.format(value);
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit ?? ''}`;
|
||||
}
|
||||
|
||||
export function _socialCardInput(
|
||||
projection: NonNullable<
|
||||
Awaited<ReturnType<typeof loadSharedBuildProjection>>
|
||||
>,
|
||||
directUrl: string,
|
||||
): BuildSocialCardInput {
|
||||
const attribute = (id: string) =>
|
||||
projection.attributes.find((item) => String(item.id) === id);
|
||||
const statByName = (name: string) =>
|
||||
projection.summary.stats.find(
|
||||
(stat) => String(attribute(stat.id)?.name) === name,
|
||||
);
|
||||
const statValue = (name: string) => {
|
||||
const stat = statByName(name);
|
||||
return stat ? unitValue(Number(stat.value), attribute(stat.id)?.unit) : '0';
|
||||
};
|
||||
const weaponName = (index: number) => {
|
||||
const selected = projection.loadout.weapons[index];
|
||||
return (
|
||||
projection.weapons.find((item) => String(item.id) === selected?.itemId)
|
||||
?.name ?? 'Unassigned'
|
||||
);
|
||||
};
|
||||
const coreNames = new Set(['Firepower', 'Toughness', 'Engineering']);
|
||||
const supportingAttributes: BuildSocialCardFact[] = projection.summary.stats
|
||||
.filter((stat) => !coreNames.has(String(attribute(stat.id)?.name)))
|
||||
.slice(0, 3)
|
||||
.map((stat) => ({
|
||||
label: String(attribute(stat.id)?.name ?? 'Attribute'),
|
||||
value: unitValue(Number(stat.value), attribute(stat.id)?.unit),
|
||||
}));
|
||||
|
||||
return {
|
||||
title: projection.build.title,
|
||||
author: projection.build.author,
|
||||
primaryWeapon: String(weaponName(0)),
|
||||
secondaryWeapon: String(weaponName(1)),
|
||||
firepower: statValue('Firepower'),
|
||||
toughness: statValue('Toughness'),
|
||||
engineering: statValue('Engineering'),
|
||||
supportingAttributes,
|
||||
visibility: projection.build.visibility as 'public' | 'unlisted',
|
||||
directUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesEtag(request: Request, etag: string) {
|
||||
return request.headers
|
||||
.get('if-none-match')
|
||||
?.split(',')
|
||||
.some((value) => value.trim() === etag);
|
||||
}
|
||||
|
||||
function releaseRequest() {
|
||||
const next = requestQueue.shift();
|
||||
if (next) {
|
||||
next(releaseRequest);
|
||||
return;
|
||||
}
|
||||
activeRequests -= 1;
|
||||
}
|
||||
|
||||
function acquireRequest(): Promise<(() => void) | null> {
|
||||
if (activeRequests < MAX_ACTIVE_REQUESTS) {
|
||||
activeRequests += 1;
|
||||
return Promise.resolve(releaseRequest);
|
||||
}
|
||||
if (requestQueue.length >= MAX_QUEUED_REQUESTS) return Promise.resolve(null);
|
||||
return new Promise((resolve) => requestQueue.push(resolve));
|
||||
}
|
||||
|
||||
function storeImage(key: string, image: CachedImage) {
|
||||
imageCache.set(key, image);
|
||||
cacheBytes += image.body.byteLength;
|
||||
while (cacheBytes > MAX_CACHE_BYTES && imageCache.size > 1) {
|
||||
const oldestKey = imageCache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
const oldest = imageCache.get(oldestKey);
|
||||
imageCache.delete(oldestKey);
|
||||
cacheBytes -= oldest?.body.byteLength ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImage(input: BuildSocialCardInput, inputKey: string) {
|
||||
const cached = imageCache.get(inputKey);
|
||||
if (cached) {
|
||||
imageCache.delete(inputKey);
|
||||
imageCache.set(inputKey, cached);
|
||||
return cached;
|
||||
}
|
||||
const pending = pendingImages.get(inputKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = renderBuildSocialCardPng(input).then((body) => {
|
||||
const image = {
|
||||
body,
|
||||
etag: `"${createHash('sha256').update(body).digest('base64url')}"`,
|
||||
};
|
||||
storeImage(inputKey, image);
|
||||
return image;
|
||||
});
|
||||
pendingImages.set(inputKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
pendingImages.delete(inputKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, request, url }) => {
|
||||
const release = await acquireRequest();
|
||||
if (!release) error(503, 'Build preview capacity exceeded');
|
||||
try {
|
||||
const projection = await loadSharedBuildProjection(params.slug, {
|
||||
kind: 'public-preview',
|
||||
});
|
||||
if (!projection) error(404, 'Build preview not found');
|
||||
|
||||
const origin = publicOrigin(url.origin);
|
||||
const directUrl = new URL(
|
||||
`/builds/${encodeURIComponent(projection.build.slug)}`,
|
||||
origin,
|
||||
).toString();
|
||||
const input = _socialCardInput(projection, directUrl);
|
||||
const inputKey = createHash('sha256')
|
||||
.update(JSON.stringify({ version: 1, input }))
|
||||
.digest('base64url');
|
||||
const image = await loadImage(input, inputKey);
|
||||
const cacheControl = 'public, max-age=0, must-revalidate';
|
||||
if (matchesEtag(request, image.etag)) {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: { 'cache-control': cacheControl, etag: image.etag },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(image.body), {
|
||||
headers: {
|
||||
'cache-control': cacheControl,
|
||||
'content-length': String(image.body.byteLength),
|
||||
'content-type': 'image/png',
|
||||
'cross-origin-resource-policy': 'same-origin',
|
||||
etag: image.etag,
|
||||
'x-content-type-options': 'nosniff',
|
||||
...(projection.build.visibility === 'unlisted'
|
||||
? { 'x-robots-tag': 'noindex' }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadProjection: vi.fn(),
|
||||
render: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/shared-build', () => ({
|
||||
loadSharedBuildProjection: mocks.loadProjection,
|
||||
}));
|
||||
vi.mock('$lib/server/build-social-card', () => ({
|
||||
renderBuildSocialCardPng: mocks.render,
|
||||
}));
|
||||
|
||||
import { GET, _socialCardInput } from './+server';
|
||||
|
||||
const projection = {
|
||||
build: {
|
||||
title: 'Field Medic',
|
||||
slug: 'field-medic-12345678',
|
||||
author: 'Aewyn',
|
||||
visibility: 'unlisted',
|
||||
},
|
||||
loadout: {
|
||||
weapons: [{ itemId: 'weapon-1' }, { itemId: 'weapon-2' }],
|
||||
},
|
||||
weapons: [
|
||||
{ id: 'weapon-1', name: 'ACR' },
|
||||
{ id: 'weapon-2', name: 'Super 90' },
|
||||
],
|
||||
attributes: [
|
||||
{ id: 'fp', name: 'Firepower', unit: '' },
|
||||
{ id: 'tgh', name: 'Toughness', unit: '' },
|
||||
{ id: 'eng', name: 'Engineering', unit: '' },
|
||||
{ id: 'crit', name: 'Critical Hit Chance', unit: '%' },
|
||||
],
|
||||
summary: {
|
||||
stats: [
|
||||
{ id: 'fp', value: 1200 },
|
||||
{ id: 'tgh', value: 950 },
|
||||
{ id: 'eng', value: 610 },
|
||||
{ id: 'crit', value: 18.5 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('Build social preview endpoint', () => {
|
||||
beforeEach(() => {
|
||||
mocks.loadProjection.mockReset().mockResolvedValue(projection);
|
||||
mocks.render.mockReset().mockResolvedValue(Buffer.from([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('projects canonical Build facts into the card input', () => {
|
||||
expect(
|
||||
_socialCardInput(
|
||||
projection as never,
|
||||
'https://shd.build/builds/field-medic-12345678',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'Field Medic',
|
||||
author: 'Aewyn',
|
||||
primaryWeapon: 'ACR',
|
||||
secondaryWeapon: 'Super 90',
|
||||
firepower: '1,200',
|
||||
toughness: '950',
|
||||
engineering: '610',
|
||||
supportingAttributes: [{ label: 'Critical Hit Chance', value: '18.5%' }],
|
||||
visibility: 'unlisted',
|
||||
directUrl: 'https://shd.build/builds/field-medic-12345678',
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a cacheable PNG and honors its ETag', async () => {
|
||||
const first = await GET({
|
||||
params: { slug: 'field-medic-12345678' },
|
||||
request: new Request(
|
||||
'https://shd.build/builds/field-medic-12345678/preview.png',
|
||||
),
|
||||
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
|
||||
} as never);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.headers.get('content-type')).toBe('image/png');
|
||||
expect(first.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(first.headers.get('cache-control')).toBe(
|
||||
'public, max-age=0, must-revalidate',
|
||||
);
|
||||
expect(first.headers.get('x-robots-tag')).toBe('noindex');
|
||||
expect(mocks.loadProjection).toHaveBeenCalledWith('field-medic-12345678', {
|
||||
kind: 'public-preview',
|
||||
});
|
||||
const etag = first.headers.get('etag');
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const second = await GET({
|
||||
params: { slug: 'field-medic-12345678' },
|
||||
request: new Request(
|
||||
'https://shd.build/builds/field-medic-12345678/preview.png',
|
||||
{ headers: { 'if-none-match': String(etag) } },
|
||||
),
|
||||
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
|
||||
} as never);
|
||||
expect(second.status).toBe(304);
|
||||
expect(mocks.render).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns a generic 404 when the public projection is unavailable', async () => {
|
||||
mocks.loadProjection.mockResolvedValue(null);
|
||||
await expect(
|
||||
GET({
|
||||
params: { slug: 'private-build' },
|
||||
request: new Request(
|
||||
'https://shd.build/builds/private-build/preview.png',
|
||||
),
|
||||
url: new URL('https://shd.build/builds/private-build/preview.png'),
|
||||
} as never),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(mocks.render).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user