desloppify: code health overhaul plus abuse hardening (140 green, strict 87.2)
Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean.
This commit is contained in:
78 files changed
+4908
-1430
No files matched your search
@@ -1,19 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const ALLOWED_ORIGIN = "https://fristy.net";
|
||||
|
||||
export function isAllowedOrigin(request: Request): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
return origin === null || origin === ALLOWED_ORIGIN;
|
||||
}
|
||||
|
||||
export function corsHeaders(): HeadersInit {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
|
||||
Vary: "Origin",
|
||||
};
|
||||
}
|
||||
|
||||
export function forbidden(): NextResponse {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { invalidIdResponse, jsonErrorWithCors, jsonWithCors, parseAppId } from "@/lib/api-route";
|
||||
|
||||
function contextWith(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
describe("parseAppId", () => {
|
||||
it("accepts numeric ids", async () => {
|
||||
await expect(parseAppId(contextWith("12345"))).resolves.toBe("12345");
|
||||
});
|
||||
|
||||
it("rejects non-numeric ids", async () => {
|
||||
await expect(parseAppId(contextWith("abc"))).resolves.toBeNull();
|
||||
await expect(parseAppId(contextWith(""))).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidIdResponse", () => {
|
||||
it("returns a 400 JSON response", async () => {
|
||||
const response = invalidIdResponse();
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({ error: "Invalid application id" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonErrorWithCors", () => {
|
||||
it("sets status, cache, and CORS headers", async () => {
|
||||
const response = jsonErrorWithCors({ error: "Nope" }, 404);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Cache-Control")).toContain("s-maxage=60");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
|
||||
await expect(response.json()).resolves.toEqual({ error: "Nope" });
|
||||
});
|
||||
});
|
||||
describe("jsonWithCors", () => {
|
||||
it("sets cache and CORS headers", async () => {
|
||||
const response = jsonWithCors({ ok: true }, 3600);
|
||||
expect(response.headers.get("Cache-Control")).toContain("s-maxage=3600");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
|
||||
await expect(response.json()).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("echoes a validated dev origin instead of the production URL", async () => {
|
||||
const response = jsonWithCors({ ok: true }, 3600, "http://localhost:3000");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("falls back to production for untrusted origins", async () => {
|
||||
const response = jsonWithCors({ ok: true }, 3600, "https://evil.example");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DEV_ORIGINS, SITE_URL } from "@/lib/site";
|
||||
|
||||
const ALLOWED_ORIGIN = SITE_URL;
|
||||
|
||||
function isDev(): boolean {
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
export function isAllowedOrigin(request: Request): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin === null) {
|
||||
return true;
|
||||
}
|
||||
if (origin === ALLOWED_ORIGIN) {
|
||||
return true;
|
||||
}
|
||||
if (isDev() && DEV_ORIGINS.has(origin)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveAllowedOrigin(origin: string | null): string {
|
||||
if (
|
||||
origin !== null &&
|
||||
(origin === ALLOWED_ORIGIN || (isDev() && DEV_ORIGINS.has(origin)))
|
||||
) {
|
||||
return origin;
|
||||
}
|
||||
return ALLOWED_ORIGIN;
|
||||
}
|
||||
|
||||
export function corsHeaders(origin?: string | null): Record<string, string> {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": resolveAllowedOrigin(origin ?? null),
|
||||
Vary: "Origin",
|
||||
};
|
||||
}
|
||||
|
||||
export function forbidden(origin?: string | null): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{
|
||||
status: 403,
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=0, stale-while-revalidate=86400",
|
||||
...corsHeaders(origin),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function parseAppId(context: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<string | null> {
|
||||
const { id } = await context.params;
|
||||
if (!/^\d{1,20}$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function invalidIdResponse(origin?: string | null): NextResponse {
|
||||
return jsonErrorWithCors({ error: "Invalid application id" }, 400, 0, origin);
|
||||
}
|
||||
|
||||
export function jsonErrorWithCors(
|
||||
data: unknown,
|
||||
status: number,
|
||||
sMaxage = 60,
|
||||
origin?: string | null,
|
||||
): NextResponse {
|
||||
return NextResponse.json(data, {
|
||||
status,
|
||||
headers: {
|
||||
"Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`,
|
||||
...corsHeaders(origin),
|
||||
},
|
||||
});
|
||||
}
|
||||
export function jsonWithCors(
|
||||
data: unknown,
|
||||
sMaxage = 3600,
|
||||
origin?: string | null,
|
||||
): NextResponse {
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`,
|
||||
...corsHeaders(origin),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function requestOrigin(request: Request): string | null {
|
||||
return request.headers.get("origin");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchJson } from "@/lib/client-fetch";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("fetchJson", () => {
|
||||
it("returns parsed JSON on success", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ a: 1 }) }) as Response),
|
||||
);
|
||||
await expect(fetchJson<{ a: number }>("https://example.com")).resolves.toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("returns null on HTTP error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
|
||||
);
|
||||
await expect(fetchJson("https://example.com")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on network failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
);
|
||||
await expect(fetchJson("https://example.com")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null> {
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
warnDev(`client fetch failed: ${url.split("?")[0]} -> ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
warnDev(`client fetch failed: ${url.split("?")[0]}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,635 +0,0 @@
|
||||
import { getDiscordAppInfo } from "@/lib/lanyard";
|
||||
|
||||
export interface GameExternalLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GameReviewSummary {
|
||||
label: string;
|
||||
total: number | null;
|
||||
positive: number | null;
|
||||
negative: number | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function steamdbRating(positive: number, total: number): number | null {
|
||||
if (
|
||||
!Number.isFinite(positive) ||
|
||||
!Number.isFinite(total) ||
|
||||
total <= 0 ||
|
||||
positive < 0 ||
|
||||
positive > total
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const score = positive / total;
|
||||
return score - (score - 0.5) * 2 ** -Math.log10(total + 1);
|
||||
}
|
||||
|
||||
export interface GamePricePoint {
|
||||
price: number;
|
||||
regular: number;
|
||||
currency: string;
|
||||
shop: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ItadReview {
|
||||
score: number | null;
|
||||
source: string;
|
||||
count: number | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GameProfile {
|
||||
applicationId: string;
|
||||
gameId: string;
|
||||
gameUrl: string;
|
||||
name: string;
|
||||
genres: string[];
|
||||
description: string | null;
|
||||
developers: string[];
|
||||
publishers: string[];
|
||||
releaseDate: string | null;
|
||||
platforms: string[];
|
||||
links: GameExternalLink[];
|
||||
coverUrl: string | null;
|
||||
artworkUrl: string | null;
|
||||
screenshots: string[];
|
||||
steamId: string | null;
|
||||
steamUrl: string | null;
|
||||
igdbUrl: string | null;
|
||||
steamReviews: GameReviewSummary | null;
|
||||
itad: {
|
||||
prices: GamePricePoint | null;
|
||||
historyLow: GamePricePoint | null;
|
||||
pageUrl: string | null;
|
||||
playerCount: number | null;
|
||||
reviews: ItadReview[];
|
||||
} | null;
|
||||
sources: {
|
||||
igdb: boolean;
|
||||
steam: boolean;
|
||||
itad: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface DetectableEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
third_party_skus?: Array<{ distributor: string; id: string }>;
|
||||
}
|
||||
|
||||
let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null;
|
||||
|
||||
async function getDetectables(): Promise<DetectableEntry[]> {
|
||||
if (detectablesCache && Date.now() < detectablesCache.expires) {
|
||||
return detectablesCache.data;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json");
|
||||
if (!response.ok) {
|
||||
console.error(`detectables failed: ${response.status}`);
|
||||
return detectablesCache?.data ?? [];
|
||||
}
|
||||
const data = (await response.json()) as DetectableEntry[];
|
||||
detectablesCache = { data, expires: Date.now() + 86_400_000 };
|
||||
return data;
|
||||
} catch {
|
||||
return detectablesCache?.data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const DISTRIBUTOR_PLATFORM: Record<string, string> = {
|
||||
xbox: "Xbox",
|
||||
xbox_title: "Xbox",
|
||||
xbox_game_pass: "Xbox",
|
||||
playstation: "PlayStation",
|
||||
nintendo: "Switch",
|
||||
};
|
||||
|
||||
function steamIdFromEntry(entry: DetectableEntry | undefined): string | null {
|
||||
const sku = entry?.third_party_skus?.find(
|
||||
(s) => s.distributor === "steam" && /^\d+$/.test(s.id),
|
||||
);
|
||||
return sku?.id ?? null;
|
||||
}
|
||||
|
||||
interface SteamAppDetails {
|
||||
success: boolean;
|
||||
data?: {
|
||||
name?: string;
|
||||
short_description?: string;
|
||||
header_image?: string;
|
||||
genres?: Array<{ description?: string }>;
|
||||
developers?: string[];
|
||||
publishers?: string[];
|
||||
platforms?: { windows?: boolean; mac?: boolean; linux?: boolean };
|
||||
release_date?: { date?: string };
|
||||
screenshots?: Array<{ path_full?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const STEAM_LANG: Record<string, string> = {
|
||||
tr: "turkish",
|
||||
en: "english",
|
||||
de: "german",
|
||||
};
|
||||
|
||||
async function getSteamDetails(
|
||||
steamId: string,
|
||||
lang = "en",
|
||||
): Promise<SteamAppDetails["data"] | null> {
|
||||
try {
|
||||
const steamLang = STEAM_LANG[lang] ?? "english";
|
||||
const response = await fetch(
|
||||
`https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=US`,
|
||||
{ next: { revalidate: 86_400 } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.error(`steam details failed: ${steamId} ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as Record<string, SteamAppDetails>;
|
||||
const entry = json[steamId];
|
||||
return entry?.success ? (entry.data ?? null) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SteamReviewQuery {
|
||||
review_score_desc?: string;
|
||||
total_positive?: number;
|
||||
total_negative?: number;
|
||||
total_reviews?: number;
|
||||
}
|
||||
|
||||
async function getSteamReviewSummary(steamId: string): Promise<GameReviewSummary | null> {
|
||||
try {
|
||||
const url =
|
||||
`https://store.steampowered.com/appreviews/${steamId}` +
|
||||
"?json=1&language=all&purchase_type=all&num_per_page=0";
|
||||
const response = await fetch(url, { next: { revalidate: 3600 } });
|
||||
if (!response.ok) {
|
||||
console.error(`steam reviews failed: ${steamId} ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as { query_summary?: SteamReviewQuery };
|
||||
const summary = json.query_summary;
|
||||
if (!summary || typeof summary.total_reviews !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: summary.review_score_desc ?? "",
|
||||
total: summary.total_reviews,
|
||||
positive: summary.total_positive ?? null,
|
||||
negative: summary.total_negative ?? null,
|
||||
url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface IgdbGame {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
genres?: Array<{ name?: string }>;
|
||||
summary?: string;
|
||||
storyline?: string;
|
||||
cover?: { url?: string };
|
||||
artworks?: Array<{ url?: string }>;
|
||||
screenshots?: Array<{ url?: string }>;
|
||||
websites?: Array<{ url?: string; category?: number }>;
|
||||
involved_companies?: Array<{
|
||||
developer?: boolean;
|
||||
publisher?: boolean;
|
||||
company?: { name?: string };
|
||||
}>;
|
||||
platforms?: Array<{ abbreviation?: string; name?: string }>;
|
||||
first_release_date?: number;
|
||||
}
|
||||
|
||||
let igdbTokenCache: { token: string; expires: number } | null = null;
|
||||
|
||||
let igdbKeyWarned = false;
|
||||
|
||||
async function getIgdbToken(): Promise<string | null> {
|
||||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
if (!igdbKeyWarned) {
|
||||
igdbKeyWarned = true;
|
||||
console.error("igdb keys missing: set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (igdbTokenCache && Date.now() < igdbTokenCache.expires) {
|
||||
return igdbTokenCache.token;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("https://id.twitch.tv/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "client_credentials",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`igdb token failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||
if (!json.access_token) {
|
||||
return null;
|
||||
}
|
||||
igdbTokenCache = {
|
||||
token: json.access_token,
|
||||
expires: Date.now() + (json.expires_in ?? 3600) * 1000 - 60_000,
|
||||
};
|
||||
return json.access_token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function igdbQuery<T>(token: string, endpoint: string, body: string): Promise<T | null> {
|
||||
try {
|
||||
const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Client-ID": process.env.TWITCH_CLIENT_ID ?? "",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body,
|
||||
next: { revalidate: 86_400 },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`igdb query failed: ${endpoint} ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const IGDB_GAME_FIELDS =
|
||||
"name,slug,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," +
|
||||
"websites.url,websites.category,involved_companies.developer," +
|
||||
"involved_companies.publisher,involved_companies.company.name," +
|
||||
"platforms.abbreviation,platforms.name,first_release_date;";
|
||||
|
||||
function igdbImage(url: string | undefined, size: string): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
const full = url.startsWith("//") ? `https:${url}` : url;
|
||||
return full.replace(/t_thumb|t_cover_small|t_logo_med/, size);
|
||||
}
|
||||
|
||||
async function getIgdbGame(name: string, steamId: string | null): Promise<IgdbGame | null> {
|
||||
const token = await getIgdbToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
if (steamId) {
|
||||
const external = await igdbQuery<Array<{ game?: number }>>(
|
||||
token,
|
||||
"external_games",
|
||||
`fields game; where uid = "${steamId}"; limit 1;`,
|
||||
);
|
||||
const gameId = external?.[0]?.game;
|
||||
if (gameId) {
|
||||
const games = await igdbQuery<IgdbGame[]>(
|
||||
token,
|
||||
"games",
|
||||
`fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`,
|
||||
);
|
||||
if (games?.[0]) {
|
||||
return games[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
const cleaned = name
|
||||
.replace(/[®™©]/g, "")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
const norm = (v: string): string =>
|
||||
v
|
||||
.toLowerCase()
|
||||
.replace(/[®™©]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
const escaped = cleaned.replace(/"/g, '\\"');
|
||||
const games = await igdbQuery<IgdbGame[]>(
|
||||
token,
|
||||
"games",
|
||||
`fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`,
|
||||
);
|
||||
if (!games || games.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const wanted = norm(name);
|
||||
return games.find((g) => g.name && norm(g.name) === wanted) ?? games[0] ?? null;
|
||||
}
|
||||
|
||||
const IGDB_WEBSITE_LABELS: Record<number, string> = {
|
||||
1: "Website",
|
||||
4: "Facebook",
|
||||
5: "X",
|
||||
6: "Twitch",
|
||||
8: "Instagram",
|
||||
9: "YouTube",
|
||||
13: "Steam",
|
||||
14: "Reddit",
|
||||
16: "Epic",
|
||||
17: "GOG",
|
||||
18: "Discord",
|
||||
};
|
||||
|
||||
interface ItadGameInfo {
|
||||
title?: string;
|
||||
assets?: { boxart?: string; banner600?: string };
|
||||
tags?: string[];
|
||||
releaseDate?: string | null;
|
||||
developers?: Array<{ name?: string }>;
|
||||
publishers?: Array<{ name?: string }>;
|
||||
reviews?: Array<{
|
||||
score?: number | null;
|
||||
source?: string;
|
||||
count?: number | null;
|
||||
url?: string;
|
||||
}>;
|
||||
players?: { recent?: number } | null;
|
||||
urls?: { game?: string };
|
||||
}
|
||||
|
||||
interface ItadOverviewPrice {
|
||||
current?: {
|
||||
shop?: { name?: string };
|
||||
price?: { amount?: number; currency?: string };
|
||||
regular?: { amount?: number };
|
||||
cut?: number;
|
||||
url?: string;
|
||||
} | null;
|
||||
lowest?: {
|
||||
shop?: { name?: string };
|
||||
price?: { amount?: number; currency?: string };
|
||||
regular?: { amount?: number };
|
||||
cut?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
let itadKeyWarned = false;
|
||||
|
||||
async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
|
||||
const key = process.env.ITAD_API_KEY;
|
||||
if (!key) {
|
||||
if (!itadKeyWarned) {
|
||||
itadKeyWarned = true;
|
||||
console.error("itad key missing: set ITAD_API_KEY");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
const response = await fetch(
|
||||
`https://api.isthereanydeal.com${path}${separator}key=${key}`,
|
||||
{
|
||||
...init,
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.error(`itad failed: ${path.split("?")[0]} ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getItadId(title: string): Promise<string | null> {
|
||||
const results = await itadFetch<Array<{ id?: string; title?: string }>>(
|
||||
`/games/search/v1?title=${encodeURIComponent(title)}&results=5`,
|
||||
);
|
||||
if (!results || results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const norm = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[®™©]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
const wanted = norm(title);
|
||||
return results.find((r) => r.title && norm(r.title) === wanted)?.id ?? null;
|
||||
}
|
||||
|
||||
function toIsoDate(unixSeconds: number | undefined): string | null {
|
||||
if (!unixSeconds) {
|
||||
return null;
|
||||
}
|
||||
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function getGameProfile(
|
||||
applicationId: string,
|
||||
country = "TR",
|
||||
lang = "en",
|
||||
): Promise<GameProfile | null> {
|
||||
if (!/^\d+$/.test(applicationId)) {
|
||||
return null;
|
||||
}
|
||||
const appInfo = await getDiscordAppInfo(applicationId);
|
||||
if (!appInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detectables = await getDetectables();
|
||||
const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId);
|
||||
const steamId = steamIdFromEntry(entry);
|
||||
|
||||
const [steam, steamReviews, igdb] = await Promise.all([
|
||||
steamId ? getSteamDetails(steamId, lang) : Promise.resolve(null),
|
||||
steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null),
|
||||
getIgdbGame(appInfo.name || entry?.name || "", steamId),
|
||||
]);
|
||||
|
||||
const itadId = process.env.ITAD_API_KEY
|
||||
? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name)
|
||||
: null;
|
||||
const [itadInfo, itadOverview] = itadId
|
||||
? await Promise.all([
|
||||
itadFetch<ItadGameInfo>(`/games/info/v2?id=${itadId}`),
|
||||
itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify([itadId]),
|
||||
}),
|
||||
])
|
||||
: [null, null];
|
||||
|
||||
const companies = igdb?.involved_companies ?? [];
|
||||
const devList: string[] =
|
||||
companies.length > 0
|
||||
? [
|
||||
...new Set(
|
||||
companies
|
||||
.filter((c) => c.developer)
|
||||
.map((c) => c.company?.name)
|
||||
.filter((n): n is string => !!n),
|
||||
),
|
||||
]
|
||||
: (steam?.developers ??
|
||||
(itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || []));
|
||||
const pubList: string[] =
|
||||
companies.length > 0
|
||||
? [
|
||||
...new Set(
|
||||
companies
|
||||
.filter((c) => c.publisher)
|
||||
.map((c) => c.company?.name)
|
||||
.filter((n): n is string => !!n),
|
||||
),
|
||||
]
|
||||
: (steam?.publishers ??
|
||||
(itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || []));
|
||||
|
||||
const igdbPlatforms = new Set<string>();
|
||||
for (const p of igdb?.platforms ?? []) {
|
||||
const raw = (p.name ?? "").trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (raw === "PC (Microsoft Windows)") {
|
||||
igdbPlatforms.add("Windows");
|
||||
} else if (raw === "Mac") {
|
||||
igdbPlatforms.add("MacOS");
|
||||
} else {
|
||||
igdbPlatforms.add(raw);
|
||||
}
|
||||
}
|
||||
const platforms = new Set<string>(igdbPlatforms);
|
||||
if (platforms.size === 0 && steam?.platforms) {
|
||||
if (steam.platforms.windows) {
|
||||
platforms.add("Windows");
|
||||
}
|
||||
if (steam.platforms.mac) {
|
||||
platforms.add("MacOS");
|
||||
}
|
||||
if (steam.platforms.linux) {
|
||||
platforms.add("Linux");
|
||||
}
|
||||
for (const sku of entry?.third_party_skus ?? []) {
|
||||
const mapped = DISTRIBUTOR_PLATFORM[sku.distributor];
|
||||
if (mapped) {
|
||||
platforms.add(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const links: GameExternalLink[] = [];
|
||||
for (const site of igdb?.websites ?? []) {
|
||||
if (!site.url) {
|
||||
continue;
|
||||
}
|
||||
const url = site.url.startsWith("http") ? site.url : `https://${site.url}`;
|
||||
let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null;
|
||||
if (!label) {
|
||||
try {
|
||||
label = new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!links.some((l) => l.url === url)) {
|
||||
links.push({ label, url });
|
||||
}
|
||||
}
|
||||
|
||||
const screenshots: string[] = [
|
||||
...(igdb?.screenshots ?? [])
|
||||
.map((s) => igdbImage(s.url, "t_screenshot_big"))
|
||||
.filter((u): u is string => !!u),
|
||||
...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u),
|
||||
].slice(0, 8);
|
||||
|
||||
const priceEntry = itadOverview?.prices?.[0];
|
||||
const toPoint = (p: ItadOverviewPrice["current"]): GamePricePoint | null => {
|
||||
if (!p?.price || typeof p.price.amount !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
price: p.price.amount,
|
||||
regular: p.regular?.amount ?? p.price.amount,
|
||||
currency: p.price.currency ?? "",
|
||||
shop: p.shop?.name ?? "",
|
||||
url: p.url ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
applicationId,
|
||||
gameId: appInfo.gameId,
|
||||
gameUrl: appInfo.gameUrl,
|
||||
name: igdb?.name ?? steam?.name ?? appInfo.name,
|
||||
genres: [
|
||||
...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n),
|
||||
...(igdb?.genres?.length ? [] : (itadInfo?.tags ?? []).slice(0, 5)),
|
||||
].slice(0, 6),
|
||||
description:
|
||||
lang === "en"
|
||||
? (igdb?.summary ?? steam?.short_description ?? null)
|
||||
: (steam?.short_description ?? igdb?.summary ?? null),
|
||||
developers: devList,
|
||||
publishers: pubList,
|
||||
releaseDate:
|
||||
toIsoDate(igdb?.first_release_date) ??
|
||||
itadInfo?.releaseDate ??
|
||||
steam?.release_date?.date ??
|
||||
null,
|
||||
platforms: [...platforms],
|
||||
links,
|
||||
coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"),
|
||||
artworkUrl:
|
||||
igdbImage(igdb?.artworks?.[0]?.url, "t_1080p") ??
|
||||
steam?.header_image ??
|
||||
itadInfo?.assets?.banner600 ??
|
||||
null,
|
||||
screenshots,
|
||||
steamId,
|
||||
steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null,
|
||||
igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null,
|
||||
steamReviews,
|
||||
itad:
|
||||
process.env.ITAD_API_KEY && (itadInfo || priceEntry)
|
||||
? {
|
||||
prices: toPoint(priceEntry?.current),
|
||||
historyLow: toPoint(priceEntry?.lowest),
|
||||
pageUrl: itadInfo?.urls?.game ?? null,
|
||||
playerCount: itadInfo?.players?.recent ?? null,
|
||||
reviews: (itadInfo?.reviews ?? [])
|
||||
.filter((r) => r.source && r.url)
|
||||
.map((r) => ({
|
||||
score: r.score ?? null,
|
||||
source: r.source as string,
|
||||
count: r.count ?? null,
|
||||
url: r.url as string,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
sources: {
|
||||
igdb: !!igdb,
|
||||
steam: !!steam,
|
||||
itad: !!itadInfo,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { _resetDetectablesCache, getDetectables, steamIdFromEntry } from "@/lib/game/detectables";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
_resetDetectablesCache();
|
||||
});
|
||||
|
||||
describe("steamIdFromEntry", () => {
|
||||
it("extracts numeric Steam SKUs", () => {
|
||||
expect(
|
||||
steamIdFromEntry({
|
||||
id: "game",
|
||||
name: "Game",
|
||||
third_party_skus: [{ distributor: "steam", id: "12345" }],
|
||||
}),
|
||||
).toBe("12345");
|
||||
});
|
||||
|
||||
it("rejects non-numeric or non-Steam SKUs", () => {
|
||||
expect(
|
||||
steamIdFromEntry({
|
||||
id: "game",
|
||||
name: "Game",
|
||||
third_party_skus: [{ distributor: "steam", id: "abc" }],
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
steamIdFromEntry({
|
||||
id: "game",
|
||||
name: "Game",
|
||||
third_party_skus: [{ distributor: "gog", id: "12345" }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing entries", () => {
|
||||
expect(steamIdFromEntry(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDetectables", () => {
|
||||
it("caches the snapshot within the TTL", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => [{ id: "1", name: "Game" }],
|
||||
}) as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const first = await getDetectables();
|
||||
const second = await getDetectables();
|
||||
expect(first).toHaveLength(1);
|
||||
expect(second).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to empty when fetch rejects", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
);
|
||||
await expect(getDetectables()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to empty on HTTP error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
|
||||
);
|
||||
await expect(getDetectables()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { warnDev } from "@/lib/log";
|
||||
import type { DetectableEntry } from "@/lib/game/game-profile-types";
|
||||
|
||||
const DETECTABLES_URL = "https://cdn.discordapp.com/detectables/games-v1.json";
|
||||
const DETECTABLES_TTL_MS = 86_400_000;
|
||||
|
||||
let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null;
|
||||
|
||||
export function _resetDetectablesCache(): void {
|
||||
detectablesCache = null;
|
||||
}
|
||||
|
||||
export async function getDetectables(): Promise<DetectableEntry[]> {
|
||||
if (detectablesCache && Date.now() < detectablesCache.expires) {
|
||||
return detectablesCache.data;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(DETECTABLES_URL);
|
||||
if (!response.ok) {
|
||||
warnDev(`detectables failed: ${response.status}`);
|
||||
return detectablesCache?.data ?? [];
|
||||
}
|
||||
const data = (await response.json()) as DetectableEntry[];
|
||||
detectablesCache = { data, expires: Date.now() + DETECTABLES_TTL_MS };
|
||||
return data;
|
||||
} catch (error) {
|
||||
warnDev(`detectables request failed: ${error instanceof Error ? error.message : error}`);
|
||||
return detectablesCache?.data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export function steamIdFromEntry(entry: DetectableEntry | undefined): string | null {
|
||||
const sku = entry?.third_party_skus?.find(
|
||||
(s) => s.distributor === "steam" && /^\d+$/.test(s.id),
|
||||
);
|
||||
return sku?.id ?? null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getDiscordAppInfo } from "@/lib/game/discord-app";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function okRpc(overrides = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ id: "123", name: "Game", ...overrides }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("getDiscordAppInfo", () => {
|
||||
it("returns null for non-numeric ids without fetching", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(getDiscordAppInfo("abc")).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a successful RPC response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => okRpc()),
|
||||
);
|
||||
const info = await getDiscordAppInfo("123");
|
||||
expect(info?.gameUrl).toBe("https://discord.com/games/123");
|
||||
});
|
||||
|
||||
it("returns null on HTTP error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 404 }) as Response),
|
||||
);
|
||||
await expect(getDiscordAppInfo("123")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("throws on network failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
);
|
||||
await expect(getDiscordAppInfo("123")).rejects.toThrow("discord rpc unreachable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
export function discordAppIconUrl(
|
||||
applicationId: string,
|
||||
iconHash: string | null,
|
||||
size = 128,
|
||||
): string | null {
|
||||
if (!iconHash) {
|
||||
return null;
|
||||
}
|
||||
const extension = iconHash.startsWith("a_") ? "gif" : "png";
|
||||
return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`;
|
||||
}
|
||||
|
||||
export function discordGameUrl(gameId: string): string {
|
||||
return `https://discord.com/games/${gameId}`;
|
||||
}
|
||||
|
||||
export function discordAppCoverUrl(
|
||||
applicationId: string,
|
||||
coverHash: string | null,
|
||||
size = 512,
|
||||
): string | null {
|
||||
if (!coverHash) {
|
||||
return null;
|
||||
}
|
||||
return `https://cdn.discordapp.com/app-icons/${applicationId}/${coverHash}.png?size=${size}`;
|
||||
}
|
||||
|
||||
export function steamdbAppUrl(steamId: string): string {
|
||||
return `https://steamdb.info/app/${steamId}/`;
|
||||
}
|
||||
|
||||
export interface DiscordAppInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
iconUrl: string | null;
|
||||
coverImage: string | null;
|
||||
coverUrl: string | null;
|
||||
gameId: string;
|
||||
gameUrl: string;
|
||||
}
|
||||
|
||||
interface DiscordRpcResponse {
|
||||
id?: string;
|
||||
name?: string;
|
||||
icon?: string | null;
|
||||
cover_image?: string | null;
|
||||
linked_games?: Array<{ id: string }> | null;
|
||||
}
|
||||
|
||||
export async function getDiscordAppInfo(applicationId: string): Promise<DiscordAppInfo | null> {
|
||||
if (!/^\d+$/.test(applicationId)) {
|
||||
return null;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`https://discord.com/api/v10/applications/${applicationId}/rpc`, {
|
||||
headers: { "User-Agent": "fristy.net (personal site)" },
|
||||
next: { revalidate: 86_400 },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`discord rpc unreachable: ${applicationId}`, { cause: error });
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw new Error(`discord rpc upstream failed: ${applicationId} -> ${response.status}`, {
|
||||
cause: new Error(`HTTP ${response.status}`),
|
||||
});
|
||||
}
|
||||
warnDev(`discord app failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
let json: DiscordRpcResponse;
|
||||
try {
|
||||
json = (await response.json()) as DiscordRpcResponse;
|
||||
} catch (error) {
|
||||
throw new Error(`discord rpc malformed response: ${applicationId}`, { cause: error });
|
||||
}
|
||||
const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId;
|
||||
return {
|
||||
id: json.id ?? applicationId,
|
||||
name: json.name ?? "",
|
||||
icon: json.icon ?? null,
|
||||
iconUrl: discordAppIconUrl(applicationId, json.icon ?? null),
|
||||
coverImage: json.cover_image ?? null,
|
||||
coverUrl: discordAppCoverUrl(applicationId, json.cover_image ?? null),
|
||||
gameId,
|
||||
gameUrl: discordGameUrl(gameId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GamePricePoint } from "@/lib/game/game-profile-types";
|
||||
import {
|
||||
describeProfile,
|
||||
discountPercent,
|
||||
formatCount,
|
||||
formatElapsed,
|
||||
formatPrice,
|
||||
formatReleaseDate,
|
||||
localizeScore,
|
||||
normalizeGameTitle,
|
||||
resolveGameLocale,
|
||||
selectGameView,
|
||||
type ModalDict,
|
||||
} from "@/lib/game/game-format";
|
||||
|
||||
const dict = {
|
||||
reviewScores: {
|
||||
overwhelminglyPositive: "Son Derece Olumlu",
|
||||
positive: "Olumlu",
|
||||
mixed: "Karışık",
|
||||
},
|
||||
} as unknown as ModalDict;
|
||||
|
||||
const point: GamePricePoint = {
|
||||
price: 100,
|
||||
regular: 200,
|
||||
currency: "USD",
|
||||
shop: "Steam",
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
describe("localizeScore", () => {
|
||||
it("maps known Steam labels", () => {
|
||||
expect(localizeScore("Overwhelmingly Positive", dict)).toBe("Son Derece Olumlu");
|
||||
});
|
||||
|
||||
it("falls back to the raw label", () => {
|
||||
expect(localizeScore("Unknown Label", dict)).toBe("Unknown Label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discountPercent", () => {
|
||||
it("computes the discount", () => {
|
||||
expect(discountPercent(point)).toBe(50);
|
||||
});
|
||||
|
||||
it("returns null without a real discount", () => {
|
||||
expect(discountPercent({ ...point, regular: 100 })).toBeNull();
|
||||
expect(discountPercent({ ...point, regular: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCount", () => {
|
||||
it("formats with unit", () => {
|
||||
expect(formatCount(3, "en-US", "reviews")).toBe("3 reviews");
|
||||
});
|
||||
|
||||
it("renders a dash for nullish values", () => {
|
||||
expect(formatCount(null, "en-US", "reviews")).toBe("—");
|
||||
expect(formatCount(undefined, "en-US", "reviews")).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPrice", () => {
|
||||
it("formats currency", () => {
|
||||
expect(formatPrice(point, "en-US")).toContain("100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatReleaseDate", () => {
|
||||
it("localizes ISO dates", () => {
|
||||
expect(formatReleaseDate("2024-01-15", "en-US")).toContain("2024");
|
||||
});
|
||||
|
||||
it("passes through non-ISO values", () => {
|
||||
expect(formatReleaseDate("soon", "en-US")).toBe("soon");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeGameTitle", () => {
|
||||
it("lowercases and strips marks", () => {
|
||||
expect(normalizeGameTitle("Elden Ring™")).toBe("elden ring");
|
||||
});
|
||||
|
||||
it("collapses separators", () => {
|
||||
expect(normalizeGameTitle("Half-Life 2: Episode One")).toBe("half life 2 episode one");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGameLocale", () => {
|
||||
it("resolves supported locales", () => {
|
||||
expect(resolveGameLocale("tr")).toEqual({
|
||||
lang: "tr",
|
||||
country: "TR",
|
||||
localeTag: "tr-TR",
|
||||
steamLang: "turkish",
|
||||
});
|
||||
expect(resolveGameLocale("de").country).toBe("DE");
|
||||
});
|
||||
|
||||
it("falls back to English", () => {
|
||||
expect(resolveGameLocale("fr").lang).toBe("en");
|
||||
expect(resolveGameLocale("").localeTag).toBe("en-US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectGameView", () => {
|
||||
it("falls back without a profile", () => {
|
||||
expect(selectGameView(null, "Name", "https://fallback")).toEqual({
|
||||
gameUrl: "https://fallback",
|
||||
name: "Name",
|
||||
bestPrice: null,
|
||||
historyLow: null,
|
||||
isFree: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("formatElapsed", () => {
|
||||
it("formats per-locale units from one table", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
|
||||
try {
|
||||
const start = new Date("2024-01-15T10:30:00Z").getTime();
|
||||
expect(formatElapsed(start, "tr")).toBe("1s 30dk");
|
||||
expect(formatElapsed(start, "de")).toBe("1 Std. 30 Min.");
|
||||
expect(formatElapsed(start, "en")).toBe("1h 30m");
|
||||
expect(formatElapsed(start, "fr")).toBe("1h 30m");
|
||||
expect(formatElapsed(Date.now(), "tr")).toBe("<1dk");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeProfile", () => {
|
||||
it("handles a null profile", () => {
|
||||
const summary = describeProfile(null);
|
||||
expect(summary).toEqual({
|
||||
dbRating: null,
|
||||
itadReviews: [],
|
||||
hasDetails: false,
|
||||
hasReviews: false,
|
||||
metascore: null,
|
||||
opencritic: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { GamePricePoint, GameProfile, ItadReview } from "@/lib/game/game-profile-types";
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
|
||||
export type ModalDict = Dictionary["discord"]["gameModal"];
|
||||
|
||||
export const STEAM_SCORE_KEYS: Partial<Record<string, keyof ModalDict["reviewScores"]>> = {
|
||||
"Overwhelmingly Positive": "overwhelminglyPositive",
|
||||
"Very Positive": "veryPositive",
|
||||
Positive: "positive",
|
||||
"Mostly Positive": "mostlyPositive",
|
||||
Mixed: "mixed",
|
||||
"Mostly Negative": "mostlyNegative",
|
||||
Negative: "negative",
|
||||
"Very Negative": "veryNegative",
|
||||
"Overwhelmingly Negative": "overwhelminglyNegative",
|
||||
};
|
||||
|
||||
export const LOCALE_TAGS: Record<Locale, string> = {
|
||||
tr: "tr-TR",
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
};
|
||||
|
||||
export const COUNTRIES: Record<Locale, string> = {
|
||||
tr: "TR",
|
||||
en: "US",
|
||||
de: "DE",
|
||||
};
|
||||
|
||||
export function localizeScore(label: string, dict: ModalDict): string {
|
||||
const key = STEAM_SCORE_KEYS[label];
|
||||
return key ? dict.reviewScores[key] : label;
|
||||
}
|
||||
|
||||
export function formatRatingPercent(value: number, localeTag: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(localeTag, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `%${(value * 100).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(point: GamePricePoint, localeTag: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(localeTag, {
|
||||
style: "currency",
|
||||
currency: point.currency,
|
||||
}).format(point.price);
|
||||
} catch {
|
||||
return `${point.price} ${point.currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReleaseDate(value: string, localeTag: string): string {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toLocaleDateString(localeTag, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatCount(value: number | null | undefined, localeTag: string, unit: string): string {
|
||||
if (value == null) {
|
||||
return "—";
|
||||
}
|
||||
return `${value.toLocaleString(localeTag)} ${unit}`;
|
||||
}
|
||||
|
||||
export function discountPercent(point: GamePricePoint): number | null {
|
||||
if (point.regular > point.price && point.regular > 0) {
|
||||
return Math.round((1 - point.price / point.regular) * 100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function steamdbRating(positive: number, total: number): number | null {
|
||||
if (
|
||||
!Number.isFinite(positive) ||
|
||||
!Number.isFinite(total) ||
|
||||
total <= 0 ||
|
||||
positive < 0 ||
|
||||
positive > total
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const score = positive / total;
|
||||
return score - (score - 0.5) * 2 ** -Math.log10(total + 1);
|
||||
}
|
||||
|
||||
export function toIsoDate(unixSeconds: number | undefined): string | null {
|
||||
if (!unixSeconds) {
|
||||
return null;
|
||||
}
|
||||
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export interface GameView {
|
||||
gameUrl: string;
|
||||
name: string;
|
||||
bestPrice: GamePricePoint | null;
|
||||
historyLow: GamePricePoint | null;
|
||||
isFree: boolean;
|
||||
}
|
||||
|
||||
export function selectGameView(
|
||||
profile: GameProfile | null,
|
||||
fallbackName: string,
|
||||
fallbackGameUrl: string,
|
||||
): GameView {
|
||||
const bestPrice = profile?.itad?.prices ?? null;
|
||||
const historyLow = profile?.itad?.historyLow ?? null;
|
||||
return {
|
||||
gameUrl: profile?.gameUrl ?? fallbackGameUrl,
|
||||
name: profile?.name || fallbackName,
|
||||
bestPrice,
|
||||
historyLow,
|
||||
isFree: !!bestPrice && bestPrice.price === 0,
|
||||
};
|
||||
}
|
||||
export interface ProfileSummary {
|
||||
dbRating: number | null;
|
||||
itadReviews: ItadReview[];
|
||||
hasDetails: boolean;
|
||||
hasReviews: boolean;
|
||||
metascore: ItadReview | null;
|
||||
opencritic: ItadReview | null;
|
||||
}
|
||||
|
||||
export function describeProfile(profile: GameProfile | null): ProfileSummary {
|
||||
const dbRating =
|
||||
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
|
||||
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
|
||||
: null;
|
||||
const itadReviews = profile?.itad?.reviews ?? [];
|
||||
const hasDetails = !!(
|
||||
profile &&
|
||||
(profile.developers.length > 0 ||
|
||||
profile.publishers.length > 0 ||
|
||||
profile.releaseDate ||
|
||||
profile.platforms.length > 0 ||
|
||||
profile.links.length > 0)
|
||||
);
|
||||
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
|
||||
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
|
||||
const hasReviews = !!(profile?.steamReviews || metascore || opencritic);
|
||||
return { dbRating, itadReviews, hasDetails, hasReviews, metascore, opencritic };
|
||||
}
|
||||
|
||||
const DEFAULT_ELAPSED_UNITS = { hour: "h", minute: "m", lessThanMinute: "<1m" };
|
||||
|
||||
const ELAPSED_UNITS: Partial<
|
||||
Record<string, { hour: string; minute: string; lessThanMinute: string }>
|
||||
> = {
|
||||
tr: { hour: "s", minute: "dk", lessThanMinute: "<1dk" },
|
||||
de: { hour: " Std.", minute: " Min.", lessThanMinute: "<1 Min." },
|
||||
en: DEFAULT_ELAPSED_UNITS,
|
||||
};
|
||||
|
||||
export function formatElapsed(start: number, lang: string): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const units = ELAPSED_UNITS[lang] ?? ELAPSED_UNITS.en ?? DEFAULT_ELAPSED_UNITS;
|
||||
if (hours > 0) {
|
||||
return `${hours}${units.hour} ${minutes}${units.minute}`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}${units.minute}`;
|
||||
}
|
||||
return units.lessThanMinute;
|
||||
}
|
||||
export function normalizeGameTitle(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[®™©]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export interface GameLocale {
|
||||
lang: Locale;
|
||||
country: string;
|
||||
localeTag: string;
|
||||
steamLang: string;
|
||||
}
|
||||
|
||||
const STEAM_LANG: Record<Locale, string> = {
|
||||
tr: "turkish",
|
||||
en: "english",
|
||||
de: "german",
|
||||
};
|
||||
|
||||
export function resolveGameLocale(lang: string): GameLocale {
|
||||
const resolved = lang === "tr" || lang === "en" || lang === "de" ? lang : "en";
|
||||
return {
|
||||
lang: resolved,
|
||||
country: COUNTRIES[resolved],
|
||||
localeTag: LOCALE_TAGS[resolved],
|
||||
steamLang: STEAM_LANG[resolved],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getGameProfile } from "@/lib/game/game-profile";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => unknown) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => handler(url, init)),
|
||||
);
|
||||
}
|
||||
|
||||
function ok(data: unknown) {
|
||||
return { ok: true, json: async () => data };
|
||||
}
|
||||
|
||||
function baseEnv() {
|
||||
vi.stubGlobal("process", { ...process, env: {} });
|
||||
}
|
||||
|
||||
describe("getGameProfile", () => {
|
||||
it("rejects non-numeric ids without fetching", async () => {
|
||||
baseEnv();
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(getGameProfile("abc")).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assembles a profile from Steam when providers are absent", async () => {
|
||||
baseEnv();
|
||||
mockFetch((url) => {
|
||||
if (url.includes("discord.com")) {
|
||||
return ok({ id: "1", name: "Game" });
|
||||
}
|
||||
if (url.includes("detectables")) {
|
||||
return ok([
|
||||
{
|
||||
id: "1",
|
||||
name: "Game",
|
||||
third_party_skus: [{ distributor: "steam", id: "100" }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (url.includes("appdetails")) {
|
||||
return ok({
|
||||
"100": {
|
||||
success: true,
|
||||
data: {
|
||||
name: "Game",
|
||||
developers: ["Dev"],
|
||||
publishers: ["Pub"],
|
||||
platforms: { windows: true, mac: false, linux: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("appreviews")) {
|
||||
return ok({
|
||||
query_summary: {
|
||||
review_score_desc: "Positive",
|
||||
total_positive: 8,
|
||||
total_negative: 2,
|
||||
total_reviews: 10,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
const profile = await getGameProfile("1", "en", "US");
|
||||
expect(profile?.name).toBe("Game");
|
||||
expect(profile?.developers).toEqual(["Dev"]);
|
||||
expect(profile?.platforms).toContain("Windows");
|
||||
expect(profile?.sources).toEqual({ igdb: false, steam: true, itad: false });
|
||||
expect(profile?.itad).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when Discord RPC is missing", async () => {
|
||||
baseEnv();
|
||||
mockFetch((url) => {
|
||||
if (url.includes("discord.com")) {
|
||||
return { ok: false, status: 404 };
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
await expect(getGameProfile("1")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
export interface GameExternalLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GameReviewSummary {
|
||||
label: string;
|
||||
total: number | null;
|
||||
positive: number | null;
|
||||
negative: number | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GamePricePoint {
|
||||
price: number;
|
||||
regular: number;
|
||||
currency: string;
|
||||
shop: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ItadReview {
|
||||
score: number | null;
|
||||
source: string;
|
||||
count: number | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GameProfile {
|
||||
applicationId: string;
|
||||
gameId: string;
|
||||
gameUrl: string;
|
||||
name: string;
|
||||
genres: string[];
|
||||
description: string | null;
|
||||
developers: string[];
|
||||
publishers: string[];
|
||||
releaseDate: string | null;
|
||||
platforms: string[];
|
||||
links: GameExternalLink[];
|
||||
coverUrl: string | null;
|
||||
artworkUrl: string | null;
|
||||
screenshots: string[];
|
||||
steamId: string | null;
|
||||
steamUrl: string | null;
|
||||
igdbUrl: string | null;
|
||||
steamReviews: GameReviewSummary | null;
|
||||
itad: {
|
||||
prices: GamePricePoint | null;
|
||||
historyLow: GamePricePoint | null;
|
||||
pageUrl: string | null;
|
||||
playerCount: number | null;
|
||||
reviews: ItadReview[];
|
||||
} | null;
|
||||
sources: {
|
||||
igdb: boolean;
|
||||
steam: boolean;
|
||||
itad: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetectableEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
third_party_skus?: Array<{ distributor: string; id: string }>;
|
||||
}
|
||||
|
||||
export interface SteamAppDetails {
|
||||
success: boolean;
|
||||
data?: {
|
||||
name?: string;
|
||||
short_description?: string;
|
||||
header_image?: string;
|
||||
genres?: Array<{ description?: string }>;
|
||||
developers?: string[];
|
||||
publishers?: string[];
|
||||
platforms?: { windows?: boolean; mac?: boolean; linux?: boolean };
|
||||
release_date?: { date?: string };
|
||||
screenshots?: Array<{ path_full?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamReviewQuery {
|
||||
review_score_desc?: string;
|
||||
total_positive?: number;
|
||||
total_negative?: number;
|
||||
total_reviews?: number;
|
||||
}
|
||||
|
||||
export interface IgdbGame {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
genres?: Array<{ name?: string }>;
|
||||
summary?: string;
|
||||
storyline?: string;
|
||||
cover?: { url?: string };
|
||||
artworks?: Array<{ url?: string }>;
|
||||
screenshots?: Array<{ url?: string }>;
|
||||
websites?: Array<{ url?: string; category?: number }>;
|
||||
involved_companies?: Array<{
|
||||
developer?: boolean;
|
||||
publisher?: boolean;
|
||||
company?: { name?: string };
|
||||
}>;
|
||||
platforms?: Array<{ abbreviation?: string; name?: string }>;
|
||||
first_release_date?: number;
|
||||
}
|
||||
|
||||
export interface ItadGameInfo {
|
||||
title?: string;
|
||||
assets?: { boxart?: string; banner600?: string };
|
||||
tags?: string[];
|
||||
releaseDate?: string | null;
|
||||
developers?: Array<{ name?: string }>;
|
||||
publishers?: Array<{ name?: string }>;
|
||||
reviews?: Array<{
|
||||
score?: number | null;
|
||||
source?: string;
|
||||
count?: number | null;
|
||||
url?: string;
|
||||
}>;
|
||||
players?: { recent?: number } | null;
|
||||
urls?: { game?: string };
|
||||
}
|
||||
|
||||
export interface ItadOverviewPrice {
|
||||
current?: {
|
||||
shop?: { name?: string };
|
||||
price?: { amount?: number; currency?: string };
|
||||
regular?: { amount?: number };
|
||||
cut?: number;
|
||||
url?: string;
|
||||
} | null;
|
||||
lowest?: {
|
||||
shop?: { name?: string };
|
||||
price?: { amount?: number; currency?: string };
|
||||
regular?: { amount?: number };
|
||||
cut?: number;
|
||||
url?: string;
|
||||
} | null;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLinks, mergePeople, mergePlatforms, selectScreenshots } from "@/lib/game/game-profile";
|
||||
import type { IgdbGame } from "@/lib/game/game-profile-types";
|
||||
|
||||
describe("mergePeople", () => {
|
||||
it("prefers IGDB companies when present", () => {
|
||||
const igdb = {
|
||||
involved_companies: [
|
||||
{ developer: true, company: { name: "Dev Studio" } },
|
||||
{ publisher: true, company: { name: "Pub Inc" } },
|
||||
],
|
||||
} as IgdbGame;
|
||||
const { developers, publishers } = mergePeople(igdb, null, null);
|
||||
expect(developers).toEqual(["Dev Studio"]);
|
||||
expect(publishers).toEqual(["Pub Inc"]);
|
||||
});
|
||||
|
||||
it("falls back to Steam and ITAD lists", () => {
|
||||
const steam = { developers: ["Valve"], publishers: ["Valve"] };
|
||||
const { developers, publishers } = mergePeople(null, steam, null);
|
||||
expect(developers).toEqual(["Valve"]);
|
||||
expect(publishers).toEqual(["Valve"]);
|
||||
});
|
||||
|
||||
it("returns empty lists without sources", () => {
|
||||
expect(mergePeople(null, null, null)).toEqual({ developers: [], publishers: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePlatforms", () => {
|
||||
it("normalizes IGDB platform names", () => {
|
||||
const igdb = {
|
||||
platforms: [{ name: "PC (Microsoft Windows)" }, { name: "Mac" }, { name: "Switch" }],
|
||||
} as IgdbGame;
|
||||
expect(mergePlatforms(igdb, null, undefined)).toEqual(["Windows", "MacOS", "Switch"]);
|
||||
});
|
||||
|
||||
it("merges IGDB, Steam, and SKU platforms without early exit", () => {
|
||||
const igdb = { platforms: [{ name: "Switch" }] } as IgdbGame;
|
||||
const steam = { platforms: { windows: true, mac: false, linux: false } };
|
||||
const entry = { id: "g", name: "G", third_party_skus: [{ distributor: "xbox", id: "1" }] };
|
||||
expect(mergePlatforms(igdb, steam, entry)).toEqual(["Switch", "Windows", "Xbox"]);
|
||||
});
|
||||
|
||||
it("still resolves SKUs when Steam data is missing", () => {
|
||||
const entry = {
|
||||
id: "g",
|
||||
name: "G",
|
||||
third_party_skus: [{ distributor: "playstation", id: "1" }],
|
||||
};
|
||||
expect(mergePlatforms(null, null, entry)).toEqual(["PlayStation"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLinks", () => {
|
||||
it("labels known categories and dedupes", () => {
|
||||
const igdb = {
|
||||
websites: [
|
||||
{ url: "https://store.steampowered.com/app/1", category: 13 },
|
||||
{ url: "https://store.steampowered.com/app/1", category: 13 },
|
||||
{ url: "example.com", category: 99 },
|
||||
],
|
||||
} as IgdbGame;
|
||||
const links = buildLinks(igdb);
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toEqual({
|
||||
label: "Steam",
|
||||
url: "https://store.steampowered.com/app/1",
|
||||
});
|
||||
expect(links[1].label).toBe("example.com");
|
||||
});
|
||||
|
||||
it("returns no links without websites", () => {
|
||||
expect(buildLinks(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectScreenshots", () => {
|
||||
it("prefers IGDB shots and caps at 8", () => {
|
||||
const igdb = {
|
||||
screenshots: Array.from({ length: 10 }, (_, i) => ({
|
||||
url: `//images.igdb.com/t_thumb/${i}.jpg`,
|
||||
})),
|
||||
} as IgdbGame;
|
||||
const shots = selectScreenshots(igdb, null);
|
||||
expect(shots).toHaveLength(8);
|
||||
expect(shots[0]).toContain("t_screenshot_big");
|
||||
});
|
||||
|
||||
it("falls back to Steam shots", () => {
|
||||
const steam = { screenshots: [{ path_full: "https://cdn/shot.jpg" }] };
|
||||
expect(selectScreenshots(null, steam)).toEqual(["https://cdn/shot.jpg"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { getDiscordAppInfo } from "@/lib/game/discord-app";
|
||||
import { getDetectables, steamIdFromEntry } from "@/lib/game/detectables";
|
||||
import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam";
|
||||
import { getIgdbGame, igdbImage } from "@/lib/game/igdb";
|
||||
import { getItadId, itadFetch } from "@/lib/game/itad";
|
||||
import { toIsoDate } from "@/lib/game/game-format";
|
||||
import type {
|
||||
GameExternalLink,
|
||||
GamePricePoint,
|
||||
GameProfile,
|
||||
IgdbGame,
|
||||
ItadGameInfo,
|
||||
ItadOverviewPrice,
|
||||
ItadReview,
|
||||
SteamAppDetails,
|
||||
} from "@/lib/game/game-profile-types";
|
||||
import type { DetectableEntry } from "@/lib/game/game-profile-types";
|
||||
|
||||
const DISTRIBUTOR_PLATFORM: Partial<Record<string, string>> = {
|
||||
xbox: "Xbox",
|
||||
xbox_title: "Xbox",
|
||||
xbox_game_pass: "Xbox",
|
||||
playstation: "PlayStation",
|
||||
nintendo: "Switch",
|
||||
};
|
||||
|
||||
const IGDB_WEBSITE_LABELS: Record<number, string> = {
|
||||
1: "Website",
|
||||
4: "Facebook",
|
||||
5: "X",
|
||||
6: "Twitch",
|
||||
8: "Instagram",
|
||||
9: "YouTube",
|
||||
13: "Steam",
|
||||
14: "Reddit",
|
||||
16: "Epic",
|
||||
17: "GOG",
|
||||
18: "Discord",
|
||||
};
|
||||
|
||||
type SteamData = SteamAppDetails["data"];
|
||||
|
||||
export function mergePeople(
|
||||
igdb: IgdbGame | null,
|
||||
steam: SteamData | null,
|
||||
itadInfo: ItadGameInfo | null,
|
||||
): { developers: string[]; publishers: string[] } {
|
||||
const companies = igdb?.involved_companies ?? [];
|
||||
if (companies.length === 0) {
|
||||
const steamDevs = steam?.developers?.length
|
||||
? steam.developers
|
||||
: itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || [];
|
||||
const steamPubs = steam?.publishers?.length
|
||||
? steam.publishers
|
||||
: itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || [];
|
||||
return { developers: steamDevs, publishers: steamPubs };
|
||||
}
|
||||
const names = (developer: boolean): string[] => [
|
||||
...new Set(
|
||||
companies
|
||||
.filter((c) => (developer ? c.developer : c.publisher))
|
||||
.map((c) => c.company?.name)
|
||||
.filter((n): n is string => !!n),
|
||||
),
|
||||
];
|
||||
return { developers: names(true), publishers: names(false) };
|
||||
}
|
||||
|
||||
export function mergePlatforms(
|
||||
igdb: IgdbGame | null,
|
||||
steam: SteamData | null,
|
||||
entry: DetectableEntry | undefined,
|
||||
): string[] {
|
||||
const platforms = new Set<string>();
|
||||
for (const p of igdb?.platforms ?? []) {
|
||||
const raw = (p.name ?? "").trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (raw === "PC (Microsoft Windows)") {
|
||||
platforms.add("Windows");
|
||||
} else if (raw === "Mac") {
|
||||
platforms.add("MacOS");
|
||||
} else {
|
||||
platforms.add(raw);
|
||||
}
|
||||
}
|
||||
if (steam?.platforms) {
|
||||
if (steam.platforms.windows) {
|
||||
platforms.add("Windows");
|
||||
}
|
||||
if (steam.platforms.mac) {
|
||||
platforms.add("MacOS");
|
||||
}
|
||||
if (steam.platforms.linux) {
|
||||
platforms.add("Linux");
|
||||
}
|
||||
}
|
||||
for (const sku of entry?.third_party_skus ?? []) {
|
||||
const mapped = DISTRIBUTOR_PLATFORM[sku.distributor];
|
||||
if (mapped) {
|
||||
platforms.add(mapped);
|
||||
}
|
||||
}
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
export function buildLinks(igdb: IgdbGame | null): GameExternalLink[] {
|
||||
const links: GameExternalLink[] = [];
|
||||
for (const site of igdb?.websites ?? []) {
|
||||
if (!site.url) {
|
||||
continue;
|
||||
}
|
||||
const url = site.url.startsWith("http") ? site.url : `https://${site.url}`;
|
||||
let parsed: URL | null = null;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
continue;
|
||||
}
|
||||
let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null;
|
||||
if (!label) {
|
||||
label = parsed.hostname.replace(/^www\./, "");
|
||||
}
|
||||
if (!links.some((l) => l.url === url)) {
|
||||
links.push({ label, url });
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
export function selectScreenshots(igdb: IgdbGame | null, steam: SteamData | null): string[] {
|
||||
return [
|
||||
...(igdb?.screenshots ?? [])
|
||||
.map((s) => igdbImage(s.url, "t_screenshot_big"))
|
||||
.filter((u): u is string => !!u),
|
||||
...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u),
|
||||
].slice(0, 8);
|
||||
}
|
||||
|
||||
export function buildGenres(igdb: IgdbGame | null, itadInfo: ItadGameInfo | null): string[] {
|
||||
return [
|
||||
...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n),
|
||||
...(igdb?.genres?.some((g) => !!g.name) ? [] : (itadInfo?.tags ?? []).slice(0, 5)),
|
||||
].slice(0, 6);
|
||||
}
|
||||
|
||||
export function selectDescription(
|
||||
lang: string,
|
||||
igdb: IgdbGame | null,
|
||||
steam: SteamData | null,
|
||||
): string | null {
|
||||
return lang === "en"
|
||||
? (igdb?.summary ?? steam?.short_description ?? null)
|
||||
: (steam?.short_description ?? igdb?.summary ?? null);
|
||||
}
|
||||
|
||||
export function selectArtwork(
|
||||
igdb: IgdbGame | null,
|
||||
steam: SteamData | null,
|
||||
itadInfo: ItadGameInfo | null,
|
||||
): string | null {
|
||||
return (
|
||||
igdbImage(igdb?.cover?.url, "t_cover_big") ??
|
||||
steam?.header_image ??
|
||||
itadInfo?.assets?.banner600 ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function toPricePoint(p: ItadOverviewPrice["current"]): GamePricePoint | null {
|
||||
if (!p?.price || typeof p.price.amount !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
price: p.price.amount,
|
||||
regular: p.regular?.amount ?? p.price.amount,
|
||||
currency: p.price.currency ?? "",
|
||||
shop: p.shop?.name ?? "",
|
||||
url: p.url?.startsWith("https://") ? p.url : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function mapItadBlock(
|
||||
itadInfo: ItadGameInfo | null,
|
||||
priceEntry: { current?: ItadOverviewPrice["current"]; lowest?: ItadOverviewPrice["current"] } | undefined,
|
||||
): NonNullable<GameProfile["itad"]> | null {
|
||||
if (!process.env.ITAD_API_KEY || (!itadInfo && !priceEntry)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
prices: toPricePoint(priceEntry?.current),
|
||||
historyLow: toPricePoint(priceEntry?.lowest),
|
||||
pageUrl: itadInfo?.urls?.game ?? null,
|
||||
playerCount: itadInfo?.players?.recent ?? null,
|
||||
reviews: (itadInfo?.reviews ?? [])
|
||||
.filter(
|
||||
(r): r is ItadReview & { source: string; url: string } =>
|
||||
!!r.source && !!r.url,
|
||||
)
|
||||
.map((r) => ({
|
||||
score: r.score ?? null,
|
||||
source: r.source,
|
||||
count: r.count ?? null,
|
||||
url: r.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGameProfile(
|
||||
applicationId: string,
|
||||
lang = "en",
|
||||
country = "TR",
|
||||
): Promise<GameProfile | null> {
|
||||
if (!/^\d+$/.test(applicationId)) {
|
||||
return null;
|
||||
}
|
||||
const appInfo = await getDiscordAppInfo(applicationId);
|
||||
if (!appInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detectables = await getDetectables();
|
||||
const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId);
|
||||
const steamId = steamIdFromEntry(entry);
|
||||
|
||||
const [steam, steamReviews, igdb] = await Promise.all([
|
||||
steamId ? getSteamDetails(steamId, lang, country) : Promise.resolve(null),
|
||||
steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null),
|
||||
getIgdbGame(appInfo.name || entry?.name || "", steamId),
|
||||
]);
|
||||
|
||||
const itadId = process.env.ITAD_API_KEY
|
||||
? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name)
|
||||
: null;
|
||||
const [itadInfo, itadOverview] = itadId
|
||||
? await Promise.all([
|
||||
itadFetch<ItadGameInfo>(`/games/info/v2?id=${itadId}`),
|
||||
itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify([itadId]),
|
||||
}),
|
||||
])
|
||||
: [null, null];
|
||||
|
||||
const { developers, publishers } = mergePeople(igdb, steam, itadInfo);
|
||||
const platforms = mergePlatforms(igdb, steam, entry);
|
||||
const links = buildLinks(igdb);
|
||||
const screenshots = selectScreenshots(igdb, steam);
|
||||
|
||||
const priceEntry = itadOverview?.prices?.[0];
|
||||
|
||||
return {
|
||||
applicationId,
|
||||
gameId: appInfo.gameId,
|
||||
gameUrl: appInfo.gameUrl,
|
||||
name: igdb?.name ?? steam?.name ?? appInfo.name,
|
||||
genres: buildGenres(igdb, itadInfo),
|
||||
description: selectDescription(lang, igdb, steam),
|
||||
developers,
|
||||
publishers,
|
||||
releaseDate:
|
||||
toIsoDate(igdb?.first_release_date) ??
|
||||
itadInfo?.releaseDate ??
|
||||
steam?.release_date?.date ??
|
||||
null,
|
||||
platforms,
|
||||
links,
|
||||
coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"),
|
||||
artworkUrl: selectArtwork(igdb, steam, itadInfo),
|
||||
screenshots,
|
||||
steamId,
|
||||
steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null,
|
||||
igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null,
|
||||
steamReviews,
|
||||
itad: mapItadBlock(itadInfo, priceEntry),
|
||||
sources: {
|
||||
igdb: !!igdb,
|
||||
steam: !!steam,
|
||||
itad: !!(itadInfo || priceEntry),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { _resetIgdbState, getIgdbGame, igdbImage } from "@/lib/game/igdb";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
_resetIgdbState();
|
||||
});
|
||||
|
||||
function tokenResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ access_token: "tok", expires_in: 3600 }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("igdbImage", () => {
|
||||
it("upgrades thumbnail sizes", () => {
|
||||
expect(igdbImage("//images.igdb.com/t_thumb/1.jpg", "t_cover_big")).toBe(
|
||||
"https://images.igdb.com/t_cover_big/1.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null without a url", () => {
|
||||
expect(igdbImage(undefined, "t_cover_big")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIgdbGame", () => {
|
||||
it("returns null without credentials and warns once", async () => {
|
||||
vi.stubGlobal("process", { ...process, env: {} });
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
|
||||
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when the token fetch rejects", async () => {
|
||||
vi.stubGlobal("process", {
|
||||
...process,
|
||||
env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" },
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
);
|
||||
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("escapes backslashes and quotes in search queries", async () => {
|
||||
vi.stubGlobal("process", {
|
||||
...process,
|
||||
env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" },
|
||||
});
|
||||
const bodies: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (typeof init?.body === "string" && init.body.includes("external_games")) {
|
||||
return { ok: true, json: async () => [] } as Response;
|
||||
}
|
||||
if (typeof init?.body === "string") {
|
||||
bodies.push(init.body);
|
||||
return { ok: true, json: async () => [] } as Response;
|
||||
}
|
||||
return tokenResponse();
|
||||
}),
|
||||
);
|
||||
await expect(getIgdbGame('a\\b"c', null)).resolves.toBeNull();
|
||||
expect(bodies.some((b) => b.includes('a\\\\b\\"c'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { IgdbGame } from "@/lib/game/game-profile-types";
|
||||
import { normalizeGameTitle } from "@/lib/game/game-format";
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
const IGDB_GAME_FIELDS =
|
||||
"name,slug,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," +
|
||||
"websites.url,websites.category,involved_companies.developer," +
|
||||
"involved_companies.publisher,involved_companies.company.name," +
|
||||
"platforms.abbreviation,platforms.name,first_release_date;";
|
||||
|
||||
const TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token";
|
||||
const TOKEN_FALLBACK_EXPIRY_S = 3600;
|
||||
const MS_PER_SECOND = 1000;
|
||||
const TOKEN_EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
let igdbTokenCache: { token: string; expires: number } | null = null;
|
||||
|
||||
let igdbKeyWarned = false;
|
||||
|
||||
export function _resetIgdbState(): void {
|
||||
igdbTokenCache = null;
|
||||
igdbKeyWarned = false;
|
||||
}
|
||||
|
||||
async function getIgdbToken(): Promise<string | null> {
|
||||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
if (!igdbKeyWarned) {
|
||||
igdbKeyWarned = true;
|
||||
warnDev("igdb not configured");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (igdbTokenCache && Date.now() < igdbTokenCache.expires) {
|
||||
return igdbTokenCache.token;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(TWITCH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "client_credentials",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
warnDev(`igdb token failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||
if (!json.access_token) {
|
||||
return null;
|
||||
}
|
||||
igdbTokenCache = {
|
||||
token: json.access_token,
|
||||
expires:
|
||||
Date.now() +
|
||||
(json.expires_in ?? TOKEN_FALLBACK_EXPIRY_S) * MS_PER_SECOND -
|
||||
TOKEN_EXPIRY_SKEW_MS,
|
||||
};
|
||||
return json.access_token;
|
||||
} catch (error) {
|
||||
warnDev(`igdb token request failed: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function igdbQuery<T>(token: string, endpoint: string, body: string): Promise<T | null> {
|
||||
try {
|
||||
const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Client-ID": process.env.TWITCH_CLIENT_ID ?? "",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body,
|
||||
next: { revalidate: 86_400 },
|
||||
});
|
||||
if (!response.ok) {
|
||||
warnDev(`igdb query failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
warnDev(`igdb query request failed: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function igdbImage(url: string | undefined, size: string): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
const full = url.startsWith("//") ? `https:${url}` : url;
|
||||
return full.replace(/t_thumb|t_cover_small|t_logo_med/, size);
|
||||
}
|
||||
|
||||
export async function getIgdbGame(name: string, steamId: string | null): Promise<IgdbGame | null> {
|
||||
const token = await getIgdbToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
if (steamId) {
|
||||
const external = await igdbQuery<Array<{ game?: number }>>(
|
||||
token,
|
||||
"external_games",
|
||||
`fields game; where uid = "${steamId}"; limit 1;`,
|
||||
);
|
||||
const gameId = external?.[0]?.game;
|
||||
if (gameId) {
|
||||
const games = await igdbQuery<IgdbGame[]>(
|
||||
token,
|
||||
"games",
|
||||
`fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`,
|
||||
);
|
||||
if (games?.[0]) {
|
||||
return games[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
const cleaned = name
|
||||
.replace(/[®™©]/g, "")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
const escaped = cleaned.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const games = await igdbQuery<IgdbGame[]>(
|
||||
token,
|
||||
"games",
|
||||
`fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`,
|
||||
);
|
||||
if (!games || games.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const wanted = normalizeGameTitle(name);
|
||||
return games.find((g) => g.name && normalizeGameTitle(g.name) === wanted) ?? games[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { _resetItadWarning, getItadId, itadFetch } from "@/lib/game/itad";
|
||||
import { toIsoDate } from "@/lib/game/game-format";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
_resetItadWarning();
|
||||
});
|
||||
|
||||
describe("toIsoDate", () => {
|
||||
it("converts unix seconds to ISO dates", () => {
|
||||
expect(toIsoDate(1705276800)).toBe("2024-01-15");
|
||||
});
|
||||
|
||||
it("returns null for missing values", () => {
|
||||
expect(toIsoDate(undefined)).toBeNull();
|
||||
expect(toIsoDate(0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("itadFetch", () => {
|
||||
it("returns null without an API key and without fetching", async () => {
|
||||
vi.stubGlobal("process", { ...process, env: {} });
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns data on success and null on HTTP error", async () => {
|
||||
vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ id: "1" }) }) as Response),
|
||||
);
|
||||
await expect(itadFetch("/games/info/v2")).resolves.toEqual({ id: "1" });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
);
|
||||
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 429 }) as Response),
|
||||
);
|
||||
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getItadId", () => {
|
||||
it("matches normalized titles", async () => {
|
||||
vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => [{ id: "7", title: "Elden Ring" }],
|
||||
}) as Response,
|
||||
),
|
||||
);
|
||||
await expect(getItadId("ELDEN RING")).resolves.toBe("7");
|
||||
await expect(getItadId("Unknown")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { normalizeGameTitle } from "@/lib/game/game-format";
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
let itadKeyWarned = false;
|
||||
|
||||
export function _resetItadWarning(): void {
|
||||
itadKeyWarned = false;
|
||||
}
|
||||
|
||||
export async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
|
||||
const key = process.env.ITAD_API_KEY;
|
||||
if (!key) {
|
||||
if (!itadKeyWarned) {
|
||||
itadKeyWarned = true;
|
||||
warnDev("itad not configured");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
const response = await fetch(
|
||||
`https://api.isthereanydeal.com${path}${separator}key=${key}`,
|
||||
{
|
||||
...init,
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
warnDev(`itad failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
warnDev(`itad request failed: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getItadId(title: string): Promise<string | null> {
|
||||
const results = await itadFetch<Array<{ id?: string; title?: string }>>(
|
||||
`/games/search/v1?title=${encodeURIComponent(title)}&results=5`,
|
||||
);
|
||||
if (!results || results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const wanted = normalizeGameTitle(title);
|
||||
return results.find((r) => r.title && normalizeGameTitle(r.title) === wanted)?.id ?? null;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityImageUrl,
|
||||
discordAvatarUrl,
|
||||
formatSpotifyList,
|
||||
getLanyard,
|
||||
spotifyTrackUrl,
|
||||
type LanyardActivity,
|
||||
} from "@/lib/game/lanyard";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function activity(overrides: Partial<LanyardActivity> = {}): LanyardActivity {
|
||||
return { id: "1", name: "Game", type: 0, ...overrides };
|
||||
}
|
||||
|
||||
describe("activityImageUrl", () => {
|
||||
it("resolves external media keys", () => {
|
||||
expect(activityImageUrl(activity({ assets: { large_image: "mp:abc" } }), "large")).toBe(
|
||||
"https://media.discordapp.net/external/abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Spotify keys", () => {
|
||||
expect(activityImageUrl(activity({ assets: { large_image: "spotify:img" } }), "large")).toBe(
|
||||
"https://i.scdn.co/image/img",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves app assets with application id", () => {
|
||||
expect(
|
||||
activityImageUrl(
|
||||
activity({ application_id: "9", assets: { large_image: "art" } }),
|
||||
"large",
|
||||
),
|
||||
).toBe("https://cdn.discordapp.com/app-assets/9/art.png");
|
||||
});
|
||||
|
||||
it("returns null without a key", () => {
|
||||
expect(activityImageUrl(activity(), "large")).toBeNull();
|
||||
});
|
||||
|
||||
it("picks small vs large art", () => {
|
||||
const withBoth = activity({
|
||||
assets: { large_image: "mp:big", small_image: "mp:small" },
|
||||
});
|
||||
expect(activityImageUrl(withBoth, "small")).toContain("small");
|
||||
expect(activityImageUrl(withBoth, "large")).toContain("big");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSpotifyList", () => {
|
||||
it("normalizes separators", () => {
|
||||
expect(formatSpotifyList("a;b, ,c")).toBe("a,b,c");
|
||||
expect(formatSpotifyList("a b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("returns empty for nullish input", () => {
|
||||
expect(formatSpotifyList(null)).toBe("");
|
||||
expect(formatSpotifyList(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotifyTrackUrl", () => {
|
||||
it("builds the track URL", () => {
|
||||
expect(spotifyTrackUrl("xyz")).toBe("https://open.spotify.com/track/xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discordAvatarUrl", () => {
|
||||
it("falls back to the default avatar", () => {
|
||||
expect(discordAvatarUrl("1", null)).toContain("embed/avatars/0.png");
|
||||
});
|
||||
|
||||
it("picks gif for animated hashes", () => {
|
||||
expect(discordAvatarUrl("1", "a_hash")).toContain(".gif");
|
||||
expect(discordAvatarUrl("1", "hash")).toContain(".png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLanyard", () => {
|
||||
it("returns data on success and null otherwise", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: {} }) })),
|
||||
);
|
||||
await expect(getLanyard()).resolves.toEqual({});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ success: false }) })),
|
||||
);
|
||||
await expect(getLanyard()).resolves.toBeNull();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
|
||||
);
|
||||
await expect(getLanyard()).resolves.toBeNull();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
);
|
||||
await expect(getLanyard()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
export const DISCORD_ID = "1014549805608992901";
|
||||
|
||||
const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
|
||||
export const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
|
||||
|
||||
export const LANYARD_WS = "wss://api.lanyard.rest/socket";
|
||||
|
||||
@@ -31,8 +33,13 @@ export interface LanyardActivity {
|
||||
assets?: LanyardActivityAssets | null;
|
||||
}
|
||||
|
||||
export function activityImageUrl(activity: LanyardActivity, small: boolean): string | null {
|
||||
const key = small ? activity.assets?.small_image : activity.assets?.large_image;
|
||||
export type ActivityImageSize = "small" | "large";
|
||||
|
||||
export function activityImageUrl(
|
||||
activity: LanyardActivity,
|
||||
size: ActivityImageSize = "large",
|
||||
): string | null {
|
||||
const key = size === "small" ? activity.assets?.small_image : activity.assets?.large_image;
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
@@ -75,75 +82,6 @@ export function spotifyTrackUrl(trackId: string): string {
|
||||
return `https://open.spotify.com/track/${trackId}`;
|
||||
}
|
||||
|
||||
export function discordAppIconUrl(
|
||||
applicationId: string,
|
||||
iconHash: string | null,
|
||||
size = 128,
|
||||
): string | null {
|
||||
if (!iconHash) {
|
||||
return null;
|
||||
}
|
||||
const extension = iconHash.startsWith("a_") ? "gif" : "png";
|
||||
return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`;
|
||||
}
|
||||
|
||||
export function discordGameUrl(gameId: string): string {
|
||||
return `https://discord.com/games/${gameId}`;
|
||||
}
|
||||
|
||||
export interface DiscordAppInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
iconUrl: string | null;
|
||||
coverImage: string | null;
|
||||
coverUrl: string | null;
|
||||
gameId: string;
|
||||
gameUrl: string;
|
||||
}
|
||||
|
||||
interface DiscordRpcResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
cover_image?: string | null;
|
||||
linked_games?: Array<{ id: string }> | null;
|
||||
}
|
||||
|
||||
export async function getDiscordAppInfo(applicationId: string): Promise<DiscordAppInfo | null> {
|
||||
if (!/^\d+$/.test(applicationId)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://discord.com/api/v10/applications/${applicationId}/rpc`,
|
||||
{
|
||||
headers: { "User-Agent": "fristy.net (personal site)" },
|
||||
next: { revalidate: 86_400 },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as DiscordRpcResponse;
|
||||
const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId;
|
||||
return {
|
||||
id: json.id ?? applicationId,
|
||||
name: json.name ?? "",
|
||||
icon: json.icon ?? null,
|
||||
iconUrl: discordAppIconUrl(applicationId, json.icon ?? null),
|
||||
coverImage: json.cover_image ?? null,
|
||||
coverUrl: json.cover_image
|
||||
? `https://cdn.discordapp.com/app-icons/${applicationId}/${json.cover_image}.png?size=512`
|
||||
: null,
|
||||
gameId,
|
||||
gameUrl: discordGameUrl(gameId),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LanyardData {
|
||||
discord_user: {
|
||||
id: string;
|
||||
@@ -172,19 +110,17 @@ export async function getLanyard(): Promise<LanyardData | null> {
|
||||
try {
|
||||
const response = await fetch(LANYARD_API, { next: { revalidate: 10 } });
|
||||
if (!response.ok) {
|
||||
warnDev(`lanyard failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as LanyardResponse;
|
||||
return json.success ? json.data : null;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
warnDev(`lanyard request failed: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function lanyardUrl(): string {
|
||||
return LANYARD_API;
|
||||
}
|
||||
|
||||
export function discordAvatarUrl(userId: string, avatarHash: string | null, size = 128): string {
|
||||
if (!avatarHash) {
|
||||
return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam";
|
||||
import { steamdbRating } from "@/lib/game/game-format";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("steamdbRating", () => {
|
||||
it("returns null for invalid input", () => {
|
||||
expect(steamdbRating(5, 0)).toBeNull();
|
||||
expect(steamdbRating(-1, 10)).toBeNull();
|
||||
expect(steamdbRating(11, 10)).toBeNull();
|
||||
expect(steamdbRating(Number.NaN, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("pulls low-count ratios toward 50%", () => {
|
||||
const rating = steamdbRating(9, 10);
|
||||
expect(rating).not.toBeNull();
|
||||
expect(rating as number).toBeLessThan(0.9);
|
||||
expect(rating as number).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it("converges to the raw ratio with many reviews", () => {
|
||||
const rating = steamdbRating(900000, 1000000);
|
||||
expect(rating).not.toBeNull();
|
||||
expect(Math.abs((rating as number) - 0.9)).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it("rates unanimous positives below 100%", () => {
|
||||
const rating = steamdbRating(10, 10);
|
||||
expect(rating).not.toBeNull();
|
||||
expect(rating as number).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSteamDetails", () => {
|
||||
it("returns entry data on success", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ "100": { success: true, data: { name: "Game" } } }),
|
||||
}) as Response,
|
||||
),
|
||||
);
|
||||
await expect(getSteamDetails("100")).resolves.toEqual({ name: "Game" });
|
||||
});
|
||||
|
||||
it("returns null when fetch rejects", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("down");
|
||||
}),
|
||||
);
|
||||
await expect(getSteamDetails("100")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on failed entries and HTTP errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ "100": { success: false } }) })),
|
||||
);
|
||||
await expect(getSteamDetails("100")).resolves.toBeNull();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
|
||||
);
|
||||
await expect(getSteamDetails("100")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSteamReviewSummary", () => {
|
||||
it("maps the query summary", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query_summary: {
|
||||
review_score_desc: "Positive",
|
||||
total_positive: 9,
|
||||
total_negative: 1,
|
||||
total_reviews: 10,
|
||||
},
|
||||
}),
|
||||
}) as Response,
|
||||
),
|
||||
);
|
||||
const summary = await getSteamReviewSummary("100");
|
||||
expect(summary?.total).toBe(10);
|
||||
expect(summary?.url).toContain("100");
|
||||
});
|
||||
|
||||
it("returns null without a numeric total", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ query_summary: {} }) })),
|
||||
);
|
||||
await expect(getSteamReviewSummary("100")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
GameReviewSummary,
|
||||
SteamAppDetails,
|
||||
SteamReviewQuery,
|
||||
} from "@/lib/game/game-profile-types";
|
||||
import { resolveGameLocale } from "@/lib/game/game-format";
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
export async function getSteamDetails(
|
||||
steamId: string,
|
||||
lang = "en",
|
||||
country = "US",
|
||||
): Promise<SteamAppDetails["data"] | null> {
|
||||
try {
|
||||
const { steamLang } = resolveGameLocale(lang);
|
||||
const response = await fetch(
|
||||
`https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=${country}`,
|
||||
{ next: { revalidate: 86_400 } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
warnDev(`steam details failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as Record<string, SteamAppDetails>;
|
||||
const entry = json[steamId];
|
||||
return entry?.success ? (entry.data ?? null) : null;
|
||||
} catch (error) {
|
||||
warnDev(`steam details request failed: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function getSteamReviewSummary(steamId: string): Promise<GameReviewSummary | null> {
|
||||
try {
|
||||
const url =
|
||||
`https://store.steampowered.com/appreviews/${steamId}` +
|
||||
"?json=1&language=all&purchase_type=all&num_per_page=0";
|
||||
const response = await fetch(url, { next: { revalidate: 3600 } });
|
||||
if (!response.ok) {
|
||||
warnDev(`steam reviews failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as { query_summary?: SteamReviewQuery };
|
||||
const summary = json.query_summary;
|
||||
if (!summary || typeof summary.total_reviews !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: summary.review_score_desc ?? "",
|
||||
total: summary.total_reviews,
|
||||
positive: summary.total_positive ?? null,
|
||||
negative: summary.total_negative ?? null,
|
||||
url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`,
|
||||
};
|
||||
} catch (error) {
|
||||
warnDev(`steam reviews request failed: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRepos } from "@/lib/gitea";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function apiRepo(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: 0,
|
||||
name: "repo",
|
||||
full_name: "fristy/repo",
|
||||
description: null,
|
||||
language: null,
|
||||
html_url: "https://git.fristy.net/fristy/repo",
|
||||
website: "",
|
||||
stars_count: 0,
|
||||
forks_count: 0,
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getRepos", () => {
|
||||
it("maps, filters, and sorts repos", async () => {
|
||||
const payload = [
|
||||
apiRepo({ id: 2, name: "old", full_name: "fristy/old", language: "TypeScript" }),
|
||||
apiRepo({ id: 1, name: ".profile", updated_at: "2025-01-01T00:00:00Z" }),
|
||||
apiRepo({
|
||||
id: 3,
|
||||
name: "new",
|
||||
full_name: "fristy/new",
|
||||
description: "fresh",
|
||||
language: "Go",
|
||||
website: "https://example.com",
|
||||
stars_count: 5,
|
||||
forks_count: 2,
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => payload })),
|
||||
);
|
||||
const repos = await getRepos();
|
||||
expect(repos.map((r) => r.name)).toEqual(["new", "old"]);
|
||||
expect(repos[0]?.website).toBe("https://example.com");
|
||||
expect(repos[1]?.website).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty on HTTP error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 500 })),
|
||||
);
|
||||
await expect(getRepos()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
+5
-3
@@ -1,3 +1,5 @@
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
const GITEA_API = "https://git.fristy.net/api/v1";
|
||||
const GITEA_USER = "fristy";
|
||||
|
||||
@@ -36,7 +38,7 @@ export async function getRepos(): Promise<GiteaRepo[]> {
|
||||
{ next: { revalidate: 3600 } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.error(`cannot fetch gitea repos: ${response.status}`);
|
||||
warnDev(`cannot fetch gitea repos: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
const data = (await response.json()) as GiteaApiRepo[];
|
||||
@@ -55,8 +57,8 @@ export async function getRepos(): Promise<GiteaRepo[]> {
|
||||
updatedAt: repo.updated_at,
|
||||
}))
|
||||
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
|
||||
} catch (error) {
|
||||
console.error("cannot fetch gitea repos:", error);
|
||||
} catch {
|
||||
warnDev("cannot fetch gitea repos");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,12 @@
|
||||
"loadError": "Spieldaten konnten nicht geladen werden.",
|
||||
"retry": "Erneut versuchen",
|
||||
"partialFail": "Einige Daten konnten nicht geladen werden.",
|
||||
"noGameData": "Für diese App wurden keine Spieldaten gefunden.",
|
||||
"screenshots": "Screenshots",
|
||||
"reviewsUnit": "Bewertungen",
|
||||
"scrollBack": "Zurück",
|
||||
"scrollForward": "Weiter",
|
||||
"totalReviews": "Bewertungen insgesamt",
|
||||
"totalReviews": "Steam",
|
||||
"steamdbRating": "SteamDB-Wertung",
|
||||
"reviewScores": {
|
||||
"overwhelminglyPositive": "Äußerst positiv",
|
||||
|
||||
@@ -49,11 +49,12 @@
|
||||
"loadError": "Couldn't load game info.",
|
||||
"retry": "Retry",
|
||||
"partialFail": "Some data failed to load.",
|
||||
"noGameData": "No game data found for this app.",
|
||||
"screenshots": "Screenshots",
|
||||
"reviewsUnit": "reviews",
|
||||
"scrollBack": "Back",
|
||||
"scrollForward": "Forward",
|
||||
"totalReviews": "Total Reviews",
|
||||
"totalReviews": "Steam",
|
||||
"steamdbRating": "SteamDB Rating",
|
||||
"reviewScores": {
|
||||
"overwhelminglyPositive": "Overwhelmingly Positive",
|
||||
|
||||
@@ -49,11 +49,12 @@
|
||||
"loadError": "Oyun bilgileri yüklenemedi.",
|
||||
"retry": "Tekrar Dene",
|
||||
"partialFail": "Bazı veriler yüklenemedi.",
|
||||
"noGameData": "Bu uygulama için oyun verisi bulunamadı.",
|
||||
"screenshots": "Görseller",
|
||||
"reviewsUnit": "değerlendirme",
|
||||
"scrollBack": "Geri",
|
||||
"scrollForward": "İleri",
|
||||
"totalReviews": "Toplam Değerlendirme",
|
||||
"totalReviews": "Steam",
|
||||
"steamdbRating": "SteamDB Puanı",
|
||||
"reviewScores": {
|
||||
"overwhelminglyPositive": "Son Derece Olumlu",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDictionary } from "@/lib/i18n/get-dictionary";
|
||||
import { DEFAULT_LOCALE, matchLocale, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
describe("getDictionary", () => {
|
||||
it("returns the requested locale", () => {
|
||||
expect(getDictionary("de").discord.gameProfile).toBe("Spielprofil");
|
||||
});
|
||||
|
||||
it("falls back to Turkish for unknown locales", () => {
|
||||
expect(getDictionary(matchLocale("fr"))).toEqual(getDictionary("tr"));
|
||||
});
|
||||
|
||||
it("keeps all locales structurally in sync", () => {
|
||||
const shape = (locale: Locale): string => JSON.stringify(getDictionary(locale));
|
||||
expect(Object.keys(JSON.parse(shape("en"))).sort()).toEqual(
|
||||
Object.keys(JSON.parse(shape("tr"))).sort(),
|
||||
);
|
||||
expect(Object.keys(JSON.parse(shape("de"))).sort()).toEqual(
|
||||
Object.keys(JSON.parse(shape("tr"))).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to Turkish", () => {
|
||||
expect(DEFAULT_LOCALE).toBe("tr");
|
||||
});
|
||||
});
|
||||
@@ -8,5 +8,5 @@ const dictionaries = { tr, en, de } as const;
|
||||
export type Dictionary = typeof tr;
|
||||
|
||||
export function getDictionary(locale: Locale): Dictionary {
|
||||
return dictionaries[locale] ?? dictionaries.tr;
|
||||
return dictionaries[locale];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(),
|
||||
}));
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { getLocale } from "@/lib/i18n/get-locale";
|
||||
|
||||
describe("getLocale", () => {
|
||||
it("returns the cookie locale when valid", async () => {
|
||||
vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "de" }) } as never);
|
||||
await expect(getLocale()).resolves.toBe("de");
|
||||
});
|
||||
|
||||
it("falls back to default without a cookie", async () => {
|
||||
vi.mocked(cookies).mockResolvedValue({ get: () => undefined } as never);
|
||||
await expect(getLocale()).resolves.toBe("tr");
|
||||
});
|
||||
|
||||
it("falls back on unknown values", async () => {
|
||||
vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "fr" }) } as never);
|
||||
await expect(getLocale()).resolves.toBe("tr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLocale, matchLocale } from "@/lib/i18n/locales";
|
||||
|
||||
describe("isLocale", () => {
|
||||
it("accepts supported locales", () => {
|
||||
expect(isLocale("tr")).toBe(true);
|
||||
expect(isLocale("en")).toBe(true);
|
||||
expect(isLocale("de")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects anything else", () => {
|
||||
expect(isLocale("fr")).toBe(false);
|
||||
expect(isLocale("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchLocale", () => {
|
||||
it("defaults without a header", () => {
|
||||
expect(matchLocale(null)).toBe("tr");
|
||||
expect(matchLocale("")).toBe("tr");
|
||||
});
|
||||
|
||||
it("matches primary ranges", () => {
|
||||
expect(matchLocale("en-US,en;q=0.9")).toBe("en");
|
||||
expect(matchLocale("de-DE,de;q=0.9,en;q=0.8")).toBe("de");
|
||||
});
|
||||
|
||||
it("respects q-values", () => {
|
||||
expect(matchLocale("en;q=0.5,tr;q=0.9")).toBe("tr");
|
||||
});
|
||||
|
||||
it("falls back on unknown or wildcard ranges", () => {
|
||||
expect(matchLocale("fr-FR,fr;q=0.9")).toBe("tr");
|
||||
expect(matchLocale("*")).toBe("tr");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { LOCALE_COOKIE, isLocale, type Locale } from "./locales";
|
||||
|
||||
export async function setLocaleCookie(locale: Locale): Promise<void> {
|
||||
if (!isLocale(locale)) {
|
||||
return;
|
||||
}
|
||||
const store = await cookies();
|
||||
store.set(LOCALE_COOKIE, locale, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: "lax",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { warnDev } from "@/lib/log";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("warnDev", () => {
|
||||
it("warns outside production", () => {
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "test" } });
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnDev("hello");
|
||||
expect(warn).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("stays silent in production", () => {
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "production" } });
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnDev("hello");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function warnDev(message: string): void {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return;
|
||||
}
|
||||
console.warn(message);
|
||||
}
|
||||
@@ -7,3 +7,7 @@ export const DISCORD_SERVER_URL = "https://discord.gg/fristy";
|
||||
export const SPOTIFY_URL = "https://open.spotify.com/user/gayiw5j9i0rtvyra6gfzhpfwf";
|
||||
export const STEAM_URL = "https://steamcommunity.com/id/fristylikeaillusion";
|
||||
export const APACHE_LICENSE_URL = "https://www.apache.org/licenses/LICENSE-2.0";
|
||||
export const DEV_ORIGINS: ReadonlySet<string> = new Set([
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]);
|
||||
Reference in new issue
Block a user