diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..bcb20970 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm test *)" + ] + } +} diff --git a/.github/workflows/block-main-prs.yml b/.github/workflows/block-main-prs.yml new file mode 100644 index 00000000..60540332 --- /dev/null +++ b/.github/workflows/block-main-prs.yml @@ -0,0 +1,46 @@ +name: Block PRs targeting main + +on: + pull_request_target: + types: [opened, reopened] + branches: [main] + +permissions: + pull-requests: write + +jobs: + block: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login != 'davedumto' + steps: + - name: Comment and close PR + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const message = [ + `Hi @${pr.user.login} — thanks for the contribution!`, + ``, + `This repo uses \`dev\` as the integration branch. PRs targeting \`main\` are not accepted.`, + ``, + `Please re-open this PR against \`dev\` instead. From your branch:`, + ``, + `1. Click **Edit** on this PR title area`, + `2. Change the base branch from \`main\` to \`dev\``, + ``, + `Or open a fresh PR from the GitHub UI with \`dev\` selected as the base.`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: message, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed', + }); diff --git a/.gitignore b/.gitignore index 33c5b38f..4321f30f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ package-lock.json dev bun.* bun.lock +fix.md +issue.md +pr.md diff --git a/.prettierignore b/.prettierignore index 16cd053d..6578ea5d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -104,3 +104,6 @@ pnpm-lock.yaml # Generated files *.min.js *.min.css + +# TypeScript cache +*.tsbuildinfo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..5480842b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kiroAgent.configureMCP": "Disabled" +} \ No newline at end of file diff --git a/app/[username]/watch/page.tsx b/app/[username]/watch/page.tsx index d5e3bd72..e25f58ff 100644 --- a/app/[username]/watch/page.tsx +++ b/app/[username]/watch/page.tsx @@ -1,11 +1,12 @@ "use client"; -import { use, useEffect, useState, useRef } from "react"; -import { notFound, useSearchParams } from "next/navigation"; +import { use, useCallback, useEffect, useState, useRef } from "react"; +import { notFound } from "next/navigation"; import ViewStream from "@/components/stream/view-stream"; import { ViewStreamSkeleton } from "@/components/skeletons/ViewStreamSkeleton"; +import AccessGate from "@/components/stream/AccessGate"; import { toast } from "sonner"; -import PrivateStreamGate from "@/components/stream/private-stream-gate"; +import { useStellarWallet } from "@/contexts/stellar-wallet-context"; interface PageProps { params: Promise<{ username: string }>; @@ -24,26 +25,27 @@ interface UserData { follower_count: number; is_following: boolean; stellar_address: string | null; + is_password_protected: boolean; + stream_access_type: "public" | "password" | "subscription" | null; + subscription_price_usdc: number | null; latency_mode: string | null; - stream_privacy: string | null; -} - -interface AccessInfo { - allowed: boolean; - reason: string | null; } const WatchPage = ({ params }: PageProps) => { const { username } = use(params); - const searchParams = useSearchParams(); - const shareKey = searchParams?.get("key") ?? null; + const { publicKey, privyWallet } = useStellarWallet(); + const viewerPublicKey = publicKey || privyWallet?.wallet || null; const [userData, setUserData] = useState(null); - const [access, setAccess] = useState(null); const [loading, setLoading] = useState(true); const [notFound404, setNotFound404] = useState(false); const [isFollowing, setIsFollowing] = useState(false); const [followLoading, setFollowLoading] = useState(false); const [loggedInUsername, setLoggedInUsername] = useState(null); + const [accessGranted, setAccessGranted] = useState(false); + + const handleAccessGranted = useCallback(() => { + setAccessGranted(true); + }, []); // Viewer tracking: one unique ID per page visit const viewerSessionId = useRef(null); @@ -64,15 +66,10 @@ const WatchPage = ({ params }: PageProps) => { setLoading(true); } - const qs = new URLSearchParams(); - if (loggedInUsername) { - qs.set("viewer_username", loggedInUsername); - } - if (shareKey) { - qs.set("key", shareKey); - } - qs.set("t", String(Date.now())); - const response = await fetch(`/api/users/${username}?${qs.toString()}`); + const viewerParam = loggedInUsername + ? `?viewer_username=${encodeURIComponent(loggedInUsername)}&t=${Date.now()}` + : `?t=${Date.now()}`; + const response = await fetch(`/api/users/${username}${viewerParam}`); if (response.status === 404) { setNotFound404(true); @@ -88,7 +85,6 @@ const WatchPage = ({ params }: PageProps) => { const data = await response.json(); setUserData(data.user); - setAccess(data.access ?? { allowed: true, reason: null }); setIsFollowing(!!data.user.is_following); } catch (error) { console.error("Failed to fetch user data:", error); @@ -106,7 +102,7 @@ const WatchPage = ({ params }: PageProps) => { fetchUserData(); const interval = setInterval(fetchUserData, 5000); return () => clearInterval(interval); - }, [username, loggedInUsername, shareKey]); + }, [username, loggedInUsername]); // Register viewer once when stream goes live; guard against re-registration on every poll useEffect(() => { @@ -220,14 +216,26 @@ const WatchPage = ({ params }: PageProps) => { const isOwner = loggedInUsername?.toLowerCase() === username.toLowerCase(); - // Private stream + viewer not authorized: show gate instead of player - if (access && !access.allowed && !isOwner) { + // Show access gate if stream is password protected and viewer isn't the owner + const needsPassword = + userData.is_password_protected && + userData.stream_access_type !== "subscription" && + !isOwner && + !accessGranted; + const needsSubscription = + userData.stream_access_type === "subscription" && + !isOwner && + !accessGranted; + + if ((needsPassword || needsSubscription) && userData.mux_playback_id) { return ( - ); } @@ -247,8 +255,6 @@ const WatchPage = ({ params }: PageProps) => { playbackId: userData.mux_playback_id, latencyMode: userData.latency_mode || "low", isLive: userData.is_live, - privacy: userData.stream_privacy || "public", - shareKey: shareKey, }; return ( diff --git a/app/api/admin/feature-flags/route.ts b/app/api/admin/feature-flags/route.ts new file mode 100644 index 00000000..c0c77c0b --- /dev/null +++ b/app/api/admin/feature-flags/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { isAdmin } from "@/lib/admin-auth"; + +/** + * Admin-only CRUD for feature flags. + * + * GET /api/admin/feature-flags – list all flags + * POST /api/admin/feature-flags – create a flag + * PATCH /api/admin/feature-flags – update a flag (body: { key, ...fields }) + * DELETE /api/admin/feature-flags?key=xxx – delete a flag + */ + +async function guardAdmin(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return { ok: false as const, response: session.response }; + if (!isAdmin(session.userId)) { + return { + ok: false as const, + response: NextResponse.json({ error: "Forbidden" }, { status: 403 }), + }; + } + return { ok: true as const }; +} + +export async function GET(req: NextRequest) { + const guard = await guardAdmin(req); + if (!guard.ok) return guard.response; + + const { rows } = await sql`SELECT * FROM feature_flags ORDER BY key`; + return NextResponse.json({ flags: rows }); +} + +export async function POST(req: NextRequest) { + const guard = await guardAdmin(req); + if (!guard.ok) return guard.response; + + const { key, description, enabled = false, rollout_percentage = 0, allowed_user_ids = [] } = await req.json(); + if (!key) return NextResponse.json({ error: "key is required" }, { status: 400 }); + + const { rows } = await sql` + INSERT INTO feature_flags (key, description, enabled, rollout_percentage, allowed_user_ids) + VALUES (${key}, ${description ?? null}, ${enabled}, ${rollout_percentage}, ${allowed_user_ids}) + ON CONFLICT (key) DO NOTHING + RETURNING * + `; + if (!rows.length) return NextResponse.json({ error: "Flag already exists" }, { status: 409 }); + return NextResponse.json({ flag: rows[0] }, { status: 201 }); +} + +export async function PATCH(req: NextRequest) { + const guard = await guardAdmin(req); + if (!guard.ok) return guard.response; + + const { key, enabled, rollout_percentage, allowed_user_ids, description } = await req.json(); + if (!key) return NextResponse.json({ error: "key is required" }, { status: 400 }); + + const { rows } = await sql` + UPDATE feature_flags SET + enabled = COALESCE(${enabled ?? null}, enabled), + rollout_percentage = COALESCE(${rollout_percentage ?? null}, rollout_percentage), + allowed_user_ids = COALESCE(${allowed_user_ids ?? null}, allowed_user_ids), + description = COALESCE(${description ?? null}, description), + updated_at = CURRENT_TIMESTAMP + WHERE key = ${key} + RETURNING * + `; + if (!rows.length) return NextResponse.json({ error: "Flag not found" }, { status: 404 }); + return NextResponse.json({ flag: rows[0] }); +} + +export async function DELETE(req: NextRequest) { + const guard = await guardAdmin(req); + if (!guard.ok) return guard.response; + + const key = new URL(req.url).searchParams.get("key"); + if (!key) return NextResponse.json({ error: "key is required" }, { status: 400 }); + + await sql`DELETE FROM feature_flags WHERE key = ${key}`; + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 7ea760f2..78a4aa5d 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -3,6 +3,7 @@ import { PrivyClient } from "@privy-io/server-auth"; import { sql } from "@vercel/postgres"; import { createRateLimiter } from "@/lib/rate-limit"; import { getRandomProfileIcon } from "@/lib/profile-icons"; +import { createSession } from "@/lib/sessions/user-sessions"; // 10 Privy session exchanges per IP per 60 s const isRateLimited = createRateLimiter(60_000, 10); @@ -131,8 +132,10 @@ export async function POST(req: NextRequest) { // We store the privy_id (opaque, server-verified) — never the raw JWT const isProduction = process.env.NODE_ENV === "production"; const cookieMaxAge = 24 * 60 * 60; // 24 h in seconds + // The raw token for a Privy session is the privy_id itself + const rawToken = privyUserId; const cookieValue = [ - `privy_session=${privyUserId}`, + `privy_session=${rawToken}`, `Path=/`, `Max-Age=${cookieMaxAge}`, `HttpOnly`, @@ -157,6 +160,21 @@ export async function POST(req: NextRequest) { }); res.headers.set("Set-Cookie", cookieValue); + + // Record the session in user_sessions (fire-and-forget — don't block the response) + createSession({ + userId: dbUser.id, + rawToken, + ipAddress: + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + null, + userAgent: req.headers.get("user-agent") ?? null, + ttlSeconds: cookieMaxAge, + }).catch((err) => + console.error("[session] Failed to record user_session row:", err) + ); + return res; } catch (err) { console.error("[session] DB error:", err); diff --git a/app/api/auth/wallet-session/route.ts b/app/api/auth/wallet-session/route.ts index bd526cc2..a4d7f782 100644 --- a/app/api/auth/wallet-session/route.ts +++ b/app/api/auth/wallet-session/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; import { signToken } from "@/lib/auth/sign-token"; import { createRateLimiter } from "@/lib/rate-limit"; +import { createSession } from "@/lib/sessions/user-sessions"; const isRateLimited = createRateLimiter(60_000, 20); // 20 requests/min per IP @@ -89,6 +90,20 @@ export async function POST(req: NextRequest) { const res = NextResponse.json({ ok: true }); res.headers.set("Set-Cookie", cookieValue); + + // Record the session in user_sessions (fire-and-forget) + // getIp() may return "unknown" — normalise to null so the INET cast doesn't fail + const rawIp = getIp(req); + createSession({ + userId: u.id, + rawToken: token, + ipAddress: rawIp === "unknown" ? null : rawIp, + userAgent: req.headers.get("user-agent") ?? null, + ttlSeconds: COOKIE_MAX_AGE, + }).catch((err) => + console.error("[wallet-session] Failed to record user_session row:", err) + ); + return res; } catch (err) { console.error("[wallet-session] DB error:", err); diff --git a/app/api/feature-flags/route.ts b/app/api/feature-flags/route.ts new file mode 100644 index 00000000..32babe55 --- /dev/null +++ b/app/api/feature-flags/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +/** + * GET /api/feature-flags?keys=clips,gifts + * Returns flag states for the authenticated user. + * Pass ?keys= as a comma-separated list to filter; omit for all flags. + * + * Resolution order (first match wins): + * 1. User is in allowed_user_ids → enabled + * 2. Flag is globally enabled AND user hash falls within rollout_percentage → enabled + * 3. Otherwise → disabled + */ +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { searchParams } = new URL(req.url); + const keysParam = searchParams.get("keys"); + const keys = keysParam ? keysParam.split(",").map(k => k.trim()).filter(Boolean) : []; + + try { + const { rows } = keys.length + ? await sql` + SELECT key, enabled, rollout_percentage, allowed_user_ids + FROM feature_flags + WHERE key = ANY(${keys as any}) + ` + : await sql`SELECT key, enabled, rollout_percentage, allowed_user_ids FROM feature_flags`; + + const userId = session.userId; + // Simple deterministic hash: sum of char codes mod 100 → 0-99 + const userHash = userId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0) % 100; + + const result: Record = {}; + for (const row of rows) { + const inAllowlist = Array.isArray(row.allowed_user_ids) && row.allowed_user_ids.includes(userId); + const inRollout = row.enabled && userHash < row.rollout_percentage; + result[row.key] = inAllowlist || inRollout; + } + + return NextResponse.json({ flags: result }); + } catch (err) { + console.error("[feature-flags] GET error:", err); + return NextResponse.json({ error: "Failed to fetch feature flags" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/2fa/_lib/totp.ts b/app/api/routes-f/2fa/_lib/totp.ts new file mode 100644 index 00000000..3b0db514 --- /dev/null +++ b/app/api/routes-f/2fa/_lib/totp.ts @@ -0,0 +1,130 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + randomBytes, +} from "crypto"; + +// ── Base32 (RFC 4648, no padding needed for otpauth) ────────────────────────── + +const B32_ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Encode(buf: Buffer): string { + let bits = 0; + let value = 0; + let output = ""; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += B32_ALPHA[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) output += B32_ALPHA[(value << (5 - bits)) & 31]; + return output; +} + +export function base32Decode(input: string): Buffer { + const clean = input.toUpperCase().replace(/=+$/, ""); + let bits = 0; + let value = 0; + const output: number[] = []; + for (const ch of clean) { + const idx = B32_ALPHA.indexOf(ch); + if (idx === -1) throw new Error("Invalid base32 character: " + ch); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(output); +} + +// ── TOTP (RFC 6238 / RFC 4226) ──────────────────────────────────────────────── + +function hotpCode(keyBuf: Buffer, counter: bigint): string { + const msg = Buffer.alloc(8); + msg.writeBigUInt64BE(counter); + const hmac = createHmac("sha1", keyBuf).update(msg).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + (hmac[offset + 1] << 16) | + (hmac[offset + 2] << 8) | + hmac[offset + 3]; + return String(code % 1_000_000).padStart(6, "0"); +} + +export function generateTotpCode(secret: string, windowOffset = 0): string { + const counter = BigInt(Math.floor(Date.now() / 1000 / 30)) + BigInt(windowOffset); + return hotpCode(base32Decode(secret), counter); +} + +export function verifyTotpToken(secret: string, token: string): boolean { + for (const w of [-1, 0, 1]) { + if (generateTotpCode(secret, w) === token) return true; + } + return false; +} + +export function generateTotpSecret(): string { + return base32Encode(randomBytes(20)); +} + +export function buildOtpauthUri(secret: string, account: string, issuer = "StreamFi"): string { + const label = encodeURIComponent(`${issuer}:${account}`); + return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; +} + +// ── AES-256-GCM secret encryption ──────────────────────────────────────────── + +function resolveKey(): Buffer { + const raw = + process.env.TWO_FA_ENCRYPTION_KEY ?? + process.env.STELLAR_ENCRYPTION_KEY ?? + process.env.SESSION_SECRET; + if (!raw) throw new Error("Missing encryption key env var"); + if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, "hex"); + // SHA-256 of passphrase → 32-byte key + const { createHash } = require("crypto") as typeof import("crypto"); + return createHash("sha256").update(raw).digest(); +} + +export interface EncryptedSecret { + ciphertext: string; + iv: string; + tag: string; +} + +export function encryptSecret(plaintext: string): EncryptedSecret { + const key = resolveKey(); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + ciphertext: ciphertext.toString("base64"), + iv: iv.toString("base64"), + tag: (cipher as ReturnType & { getAuthTag(): Buffer }).getAuthTag().toString("base64"), + }; +} + +export function decryptSecret(enc: EncryptedSecret): string { + const key = resolveKey(); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(enc.iv, "base64")); + (decipher as ReturnType & { setAuthTag(t: Buffer): void }).setAuthTag(Buffer.from(enc.tag, "base64")); + return Buffer.concat([ + decipher.update(Buffer.from(enc.ciphertext, "base64")), + decipher.final(), + ]).toString("utf8"); +} + +// ── Backup codes ────────────────────────────────────────────────────────────── + +export function generateBackupCodes(count = 5): string[] { + return Array.from({ length: count }, () => + randomBytes(5).toString("hex").toUpperCase().match(/.{1,5}/g)!.join("-") + ); +} diff --git a/app/api/routes-f/2fa/disable/route.ts b/app/api/routes-f/2fa/disable/route.ts new file mode 100644 index 00000000..03cdefbc --- /dev/null +++ b/app/api/routes-f/2fa/disable/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { sql } from "@vercel/postgres"; +import { createHash } from "crypto"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { decryptSecret, verifyTotpToken } from "../_lib/totp"; + +const disableSchema = z.object({ + token: z + .string() + .length(6) + .regex(/^\d{6}$/) + .optional(), + backupCode: z.string().min(1).optional(), +}).refine((d) => d.token !== undefined || d.backupCode !== undefined, { + message: "Provide either a TOTP token or a backup code", +}); + +export async function POST(request: NextRequest) { + const session = await verifySession(request); + if (!session.ok) return session.response; + + const body = await validateBody(request, disableSchema); + if (body instanceof NextResponse) return body; + + try { + const { rows } = await sql` + SELECT totp_secret_ciphertext, totp_secret_iv, totp_secret_tag, + totp_enabled, backup_code_hashes + FROM user_two_factor + WHERE user_id = ${session.userId} + LIMIT 1 + `; + + if (!rows[0]?.totp_enabled) { + return NextResponse.json( + { error: "2FA is not currently enabled." }, + { status: 400 } + ); + } + + const row = rows[0]; + let authorized = false; + + if (body.data.token) { + const secret = decryptSecret({ + ciphertext: row.totp_secret_ciphertext, + iv: row.totp_secret_iv, + tag: row.totp_secret_tag, + }); + authorized = verifyTotpToken(secret, body.data.token); + } else if (body.data.backupCode) { + const stored: string[] = JSON.parse(row.backup_code_hashes ?? "[]"); + const hash = createHash("sha256") + .update(body.data.backupCode.toUpperCase()) + .digest("hex"); + const idx = stored.indexOf(hash); + if (idx !== -1) { + authorized = true; + stored.splice(idx, 1); + await sql` + UPDATE user_two_factor + SET backup_code_hashes = ${JSON.stringify(stored)}, updated_at = NOW() + WHERE user_id = ${session.userId} + `; + } + } + + if (!authorized) { + return NextResponse.json({ error: "Invalid token or backup code" }, { status: 401 }); + } + + await sql` + UPDATE user_two_factor + SET totp_enabled = false, + totp_secret_ciphertext = NULL, + totp_secret_iv = NULL, + totp_secret_tag = NULL, + backup_code_hashes = NULL, + updated_at = NOW() + WHERE user_id = ${session.userId} + `; + + return NextResponse.json({ disabled: true }); + } catch (error) { + console.error("[routes-f 2fa/disable POST]", error); + return NextResponse.json({ error: "Failed to disable 2FA" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/2fa/setup/route.ts b/app/api/routes-f/2fa/setup/route.ts new file mode 100644 index 00000000..0a9fea7a --- /dev/null +++ b/app/api/routes-f/2fa/setup/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + generateTotpSecret, + buildOtpauthUri, + encryptSecret, +} from "../_lib/totp"; + +export async function POST(request: NextRequest) { + const session = await verifySession(request); + if (!session.ok) return session.response; + + try { + const { rows } = await sql` + SELECT totp_enabled FROM user_two_factor WHERE user_id = ${session.userId} LIMIT 1 + `; + + if (rows[0]?.totp_enabled) { + return NextResponse.json( + { error: "2FA is already enabled. Disable it before setting up again." }, + { status: 409 } + ); + } + + const secret = generateTotpSecret(); + const enc = encryptSecret(secret); + const otpauthUri = buildOtpauthUri(secret, session.userId); + + await sql` + INSERT INTO user_two_factor (user_id, totp_secret_ciphertext, totp_secret_iv, totp_secret_tag, totp_enabled, updated_at) + VALUES ( + ${session.userId}, + ${enc.ciphertext}, + ${enc.iv}, + ${enc.tag}, + false, + NOW() + ) + ON CONFLICT (user_id) DO UPDATE SET + totp_secret_ciphertext = EXCLUDED.totp_secret_ciphertext, + totp_secret_iv = EXCLUDED.totp_secret_iv, + totp_secret_tag = EXCLUDED.totp_secret_tag, + totp_enabled = false, + updated_at = NOW() + `; + + return NextResponse.json({ otpauthUri }, { status: 200 }); + } catch (error) { + console.error("[routes-f 2fa/setup POST]", error); + return NextResponse.json({ error: "Failed to initiate 2FA setup" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/2fa/status/route.ts b/app/api/routes-f/2fa/status/route.ts new file mode 100644 index 00000000..a78eec6b --- /dev/null +++ b/app/api/routes-f/2fa/status/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +export async function GET(request: NextRequest) { + const session = await verifySession(request); + if (!session.ok) return session.response; + + try { + const { rows } = await sql` + SELECT totp_enabled, updated_at + FROM user_two_factor + WHERE user_id = ${session.userId} + LIMIT 1 + `; + + const row = rows[0]; + + return NextResponse.json({ + enabled: row?.totp_enabled ?? false, + configuredAt: row?.updated_at ?? null, + }); + } catch (error) { + console.error("[routes-f 2fa/status GET]", error); + return NextResponse.json({ error: "Failed to fetch 2FA status" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/2fa/verify/route.ts b/app/api/routes-f/2fa/verify/route.ts new file mode 100644 index 00000000..77fa19dd --- /dev/null +++ b/app/api/routes-f/2fa/verify/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { + decryptSecret, + verifyTotpToken, + generateBackupCodes, + encryptSecret, +} from "../_lib/totp"; +import { createHash } from "crypto"; + +const verifySchema = z.object({ + token: z.string().length(6, "Token must be exactly 6 digits").regex(/^\d{6}$/), +}); + +export async function POST(request: NextRequest) { + const session = await verifySession(request); + if (!session.ok) return session.response; + + const body = await validateBody(request, verifySchema); + if (body instanceof NextResponse) return body; + + try { + const { rows } = await sql` + SELECT totp_secret_ciphertext, totp_secret_iv, totp_secret_tag, totp_enabled + FROM user_two_factor + WHERE user_id = ${session.userId} + LIMIT 1 + `; + + if (!rows[0]) { + return NextResponse.json( + { error: "2FA setup not initiated. Call /api/routes-f/2fa/setup first." }, + { status: 400 } + ); + } + + if (rows[0].totp_enabled) { + return NextResponse.json( + { error: "2FA is already verified and active." }, + { status: 409 } + ); + } + + const secret = decryptSecret({ + ciphertext: rows[0].totp_secret_ciphertext, + iv: rows[0].totp_secret_iv, + tag: rows[0].totp_secret_tag, + }); + + if (!verifyTotpToken(secret, body.data.token)) { + return NextResponse.json({ error: "Invalid TOTP token" }, { status: 400 }); + } + + const codes = generateBackupCodes(5); + const hashedCodes = codes.map((c) => + createHash("sha256").update(c).digest("hex") + ); + + await sql` + UPDATE user_two_factor + SET totp_enabled = true, + backup_code_hashes = ${JSON.stringify(hashedCodes)}, + updated_at = NOW() + WHERE user_id = ${session.userId} + `; + + return NextResponse.json( + { + enabled: true, + backupCodes: codes, + message: "2FA enabled. Store these backup codes securely — they will not be shown again.", + }, + { status: 200 } + ); + } catch (error) { + console.error("[routes-f 2fa/verify POST]", error); + return NextResponse.json({ error: "Failed to verify 2FA token" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/__tests__/binary-to-text.test.ts b/app/api/routes-f/__tests__/binary-to-text.test.ts new file mode 100644 index 00000000..c57a67d3 --- /dev/null +++ b/app/api/routes-f/__tests__/binary-to-text.test.ts @@ -0,0 +1,103 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST, toBinary, fromBinary } from "../binary-to-text/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/binary-to-text", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/binary-to-text", () => { + // --- unit helpers --- + describe("toBinary", () => { + it("encodes ASCII correctly", () => { + expect(toBinary("A", 8)).toBe("01000001"); + expect(toBinary("Hi", 8)).toBe("01001000 01101001"); + }); + + it("round-trips ASCII with fromBinary", () => { + const bin = toBinary("Hello, World!", 8); + expect(fromBinary(bin, 8)).toBe("Hello, World!"); + }); + + it("round-trips emoji (multibyte UTF-8)", () => { + const bin = toBinary("😊", 8); + expect(fromBinary(bin, 8)).toBe("😊"); + }); + + it("round-trips mixed ASCII + emoji", () => { + const input = "hi 🌍"; + expect(fromBinary(toBinary(input, 8), 8)).toBe(input); + }); + }); + + describe("fromBinary", () => { + it("rejects non-binary characters", () => { + expect(() => fromBinary("01000001 0100GG01", 8)).toThrow(); + }); + + it("rejects tokens with wrong bit length", () => { + expect(() => fromBinary("0100000", 8)).toThrow(); // 7 bits + }); + }); + + // --- POST handler --- + it("to_binary returns correct result for ASCII", async () => { + const res = await POST(makeReq({ input: "A", mode: "to_binary" })); + expect(res.status).toBe(200); + const { result } = await res.json(); + expect(result).toBe("01000001"); + }); + + it("from_binary decodes back to original ASCII", async () => { + const res = await POST(makeReq({ input: "01000001", mode: "from_binary" })); + expect(res.status).toBe(200); + const { result } = await res.json(); + expect(result).toBe("A"); + }); + + it("round-trips emoji via POST", async () => { + const encRes = await POST(makeReq({ input: "😊", mode: "to_binary" })); + const { result: bin } = await encRes.json(); + + const decRes = await POST(makeReq({ input: bin, mode: "from_binary" })); + expect(decRes.status).toBe(200); + const { result } = await decRes.json(); + expect(result).toBe("😊"); + }); + + it("returns 400 for malformed binary on decode", async () => { + const res = await POST(makeReq({ input: "0100GG01", mode: "from_binary" })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + it("returns 400 for wrong bit-length token", async () => { + const res = await POST(makeReq({ input: "0100000", mode: "from_binary" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing mode", async () => { + const res = await POST(makeReq({ input: "hello" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing input", async () => { + const res = await POST(makeReq({ mode: "to_binary" })); + expect(res.status).toBe(400); + }); + + it("handles empty string to_binary", async () => { + const res = await POST(makeReq({ input: "", mode: "to_binary" })); + expect(res.status).toBe(200); + const { result } = await res.json(); + expect(result).toBe(""); + }); +}); diff --git a/app/api/routes-f/__tests__/cancel-subscription.test.ts b/app/api/routes-f/__tests__/cancel-subscription.test.ts new file mode 100644 index 00000000..963fe78a --- /dev/null +++ b/app/api/routes-f/__tests__/cancel-subscription.test.ts @@ -0,0 +1,116 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, GET } from "../subscriptions/cancel/route"; +import { GET as GET_MAIN, subscriptions } from "../subscriptions/route"; + +function makePostReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/subscriptions/cancel", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeGetReq(subscriptionId: string) { + return new NextRequest( + `http://localhost/api/routes-f/subscriptions/cancel?subscription_id=${subscriptionId}` + ); +} + +function makeMainGetReq(subscriptionId: string) { + return new NextRequest( + `http://localhost/api/routes-f/subscriptions?subscription_id=${subscriptionId}` + ); +} + +describe("Cancel Subscription API", () => { + beforeEach(() => { + subscriptions.clear(); + }); + + it("should successfully cancel an active subscription, keeping expires_at intact", async () => { + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + subscriptions.set("sub-123", { + subscription_id: "sub-123", + subscriber_id: "subscriber-456", + creator_id: "creator-789", + tier_id: "premium", + payment_tx_hash: "hash-xyz", + asset: "USDC", + started_at: new Date().toISOString(), + expires_at: expiresAt, + status: "active", + }); + + const req = makePostReq({ + subscription_id: "sub-123", + subscriber_id: "subscriber-456", + }); + const res = await POST(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("cancelled"); + expect(data.expires_at).toBe(expiresAt); + + // Verify GET cancel endpoint returns current state + const getRes = await GET(makeGetReq("sub-123")); + expect(getRes.status).toBe(200); + const getData = await getRes.json(); + expect(getData.status).toBe("cancelled"); + expect(getData.expires_at).toBe(expiresAt); + + // Verify GET main endpoint also returns current state + const getMainRes = await GET_MAIN(makeMainGetReq("sub-123")); + expect(getMainRes.status).toBe(200); + const getMainData = await getMainRes.json(); + expect(getMainData.status).toBe("cancelled"); + }); + + it("should return 403 Forbidden if the requester is not the subscriber", async () => { + subscriptions.set("sub-123", { + subscription_id: "sub-123", + subscriber_id: "subscriber-456", + creator_id: "creator-789", + tier_id: "premium", + payment_tx_hash: "hash-xyz", + asset: "USDC", + started_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + status: "active", + }); + + const req = makePostReq({ + subscription_id: "sub-123", + subscriber_id: "subscriber-wrong", + }); + const res = await POST(req); + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.error).toBe("Forbidden"); + }); + + it("should handle double-cancel correctly, returning cancelled state", async () => { + subscriptions.set("sub-123", { + subscription_id: "sub-123", + subscriber_id: "subscriber-456", + creator_id: "creator-789", + tier_id: "premium", + payment_tx_hash: "hash-xyz", + asset: "USDC", + started_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + status: "cancelled", + }); + + const req = makePostReq({ + subscription_id: "sub-123", + subscriber_id: "subscriber-456", + }); + const res = await POST(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("cancelled"); + }); +}); diff --git a/app/api/routes-f/__tests__/case-convert.test.ts b/app/api/routes-f/__tests__/case-convert.test.ts new file mode 100644 index 00000000..ba320690 --- /dev/null +++ b/app/api/routes-f/__tests__/case-convert.test.ts @@ -0,0 +1,214 @@ +/** + * @jest-environment jsdom + */ + +import { POST } from '../case-convert/route'; +import { NextRequest } from 'next/server'; + +// Mock the data module +jest.mock('../case-convert/data', () => ({ + convertCase: jest.fn(), +})); + +const { convertCase } = require('../case-convert/data'); + +describe('/api/routes-f/case-convert', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('POST', () => { + it('should return all case formats when no target specified', async () => { + const mockConversions = { + camelCase: 'helloWorld', + snake_case: 'hello_world', + 'kebab-case': 'hello-world', + PascalCase: 'HelloWorld', + CONSTANT_CASE: 'HELLO_WORLD', + 'Title Case': 'Hello World', + 'Sentence case': 'Hello world' + }; + + convertCase.mockReturnValue(mockConversions); + + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: JSON.stringify({ + text: 'Hello World' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockConversions); + expect(convertCase).toHaveBeenCalledWith('Hello World', undefined); + }); + + it('should return specific case format when target specified', async () => { + const mockConversion = { + result: 'helloWorld' + }; + + convertCase.mockReturnValue(mockConversion); + + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: JSON.stringify({ + text: 'Hello World', + target: 'camelCase' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockConversion); + expect(convertCase).toHaveBeenCalledWith('Hello World', 'camelCase'); + }); + + it('should handle mixed case inputs', async () => { + const mockConversions = { + camelCase: 'helloWorldTest', + snake_case: 'hello_world_test', + 'kebab-case': 'hello-world-test', + PascalCase: 'HelloWorldTest', + CONSTANT_CASE: 'HELLO_WORLD_TEST', + 'Title Case': 'Hello World Test', + 'Sentence case': 'Hello world test' + }; + + convertCase.mockReturnValue(mockConversions); + + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: JSON.stringify({ + text: 'HelloWorld_test-case' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockConversions); + expect(convertCase).toHaveBeenCalledWith('HelloWorld_test-case', undefined); + }); + + it('should preserve numbers in identifiers', async () => { + const mockConversions = { + camelCase: 'test123Value', + snake_case: 'test_123_value', + 'kebab-case': 'test-123-value', + PascalCase: 'Test123Value', + CONSTANT_CASE: 'TEST_123_VALUE', + 'Title Case': 'Test 123 Value', + 'Sentence case': 'Test 123 value' + }; + + convertCase.mockReturnValue(mockConversions); + + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: JSON.stringify({ + text: 'Test123Value' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockConversions); + expect(convertCase).toHaveBeenCalledWith('Test123Value', undefined); + }); + + it('should return 400 for missing request body', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: JSON.stringify({}), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('Invalid request body'); + }); + + it('should return 400 for invalid target case', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: JSON.stringify({ + text: 'Hello World', + target: 'invalidCase' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('Invalid target case'); + }); + + it('should return 400 for invalid JSON', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: 'invalid json', + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('Invalid JSON'); + }); + + it('should handle empty string input', async () => { + const mockConversions = {}; + + convertCase.mockReturnValue(mockConversions); + + const request = new NextRequest('http://localhost:3000/api/routes-f/case-convert', { + method: 'POST', + body: JSON.stringify({ + text: '' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockConversions); + expect(convertCase).toHaveBeenCalledWith('', undefined); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/char-stats.test.ts b/app/api/routes-f/__tests__/char-stats.test.ts new file mode 100644 index 00000000..8798e1b4 --- /dev/null +++ b/app/api/routes-f/__tests__/char-stats.test.ts @@ -0,0 +1,99 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../char-stats/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/char-stats", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/char-stats", () => { + it("counts ASCII text correctly", async () => { + const res = await POST(makeReq({ text: "Hello, World! 123" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.total).toBe(17); + expect(data.letters).toBe(10); + expect(data.digits).toBe(3); + expect(data.punctuation).toBeGreaterThanOrEqual(1); + expect(data.whitespace).toBe(2); + expect(data.emoji).toBe(0); + expect(data.by_script.latin).toBe(10); + }); + + it("counts mixed scripts", async () => { + const res = await POST(makeReq({ text: "Hello Привет" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.by_script.latin).toBe(5); + expect(data.by_script.cyrillic).toBe(6); + expect(data.letters).toBe(11); + expect(data.whitespace).toBe(1); + }); + + it("counts CJK characters", async () => { + const res = await POST(makeReq({ text: "你好世界" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.letters).toBe(4); + expect(data.by_script.cjk).toBe(4); + }); + + it("counts emoji", async () => { + const res = await POST(makeReq({ text: "Hi 👋🏽 there 😊" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.emoji).toBeGreaterThanOrEqual(2); + }); + + it("handles ZWJ emoji sequences", async () => { + // Family emoji: man+woman+girl+boy via ZWJ + const family = "👨‍👩‍👧‍👦"; + const res = await POST(makeReq({ text: family })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.emoji).toBe(1); + }); + + it("handles empty string", async () => { + const res = await POST(makeReq({ text: "" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.total).toBe(0); + expect(data.letters).toBe(0); + expect(data.emoji).toBe(0); + }); + + it("counts digits and symbols", async () => { + const res = await POST(makeReq({ text: "42 + 58 = 100" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.digits).toBe(7); + expect(data.whitespace).toBe(4); + }); + + it("rejects non-string text", async () => { + const res = await POST(makeReq({ text: 42 })); + expect(res.status).toBe(400); + }); + + it("rejects missing text field", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/char-stats", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "bad", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/chat-emote-mode.test.ts b/app/api/routes-f/__tests__/chat-emote-mode.test.ts new file mode 100644 index 00000000..91272e7d --- /dev/null +++ b/app/api/routes-f/__tests__/chat-emote-mode.test.ts @@ -0,0 +1,83 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../chat-emote-mode/check/route"; + +function makeCheckReq(message: string) { + return new NextRequest("http://localhost/api/routes-f/chat-emote-mode/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message }), + }); +} + +describe("/api/routes-f/chat-emote-mode/check", () => { + it("accepts all-emoji message", async () => { + const req = makeCheckReq("😀 🎉 👍"); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.is_emote_only).toBe(true); + expect(data.would_be_blocked).toBe(false); + }); + + it("blocks plain text", async () => { + const req = makeCheckReq("hello world"); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.is_emote_only).toBe(false); + expect(data.would_be_blocked).toBe(true); + }); + + it("blocks mixed emoji and text", async () => { + const req = makeCheckReq("hello 😀 world"); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.is_emote_only).toBe(false); + expect(data.would_be_blocked).toBe(true); + }); + + it("accepts emoji with whitespace", async () => { + const req = makeCheckReq(" 😀 🎉 👍 "); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.is_emote_only).toBe(true); + expect(data.would_be_blocked).toBe(false); + }); + + it("rejects empty message", async () => { + const req = makeCheckReq(" "); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.is_emote_only).toBe(false); + }); + + it("rejects missing message field", async () => { + const req = new NextRequest("http://localhost/api/routes-f/chat-emote-mode/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("handles unicode emoji correctly", async () => { + const req = makeCheckReq("❤️ 🚀 🌟"); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.is_emote_only).toBe(true); + }); +}); diff --git a/app/api/routes-f/__tests__/cookie-parse.test.ts b/app/api/routes-f/__tests__/cookie-parse.test.ts new file mode 100644 index 00000000..6961e284 --- /dev/null +++ b/app/api/routes-f/__tests__/cookie-parse.test.ts @@ -0,0 +1,139 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../cookie-parse/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/cookie-parse", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/cookie-parse", () => { + describe("parse mode", () => { + it("parses a simple cookie", async () => { + const res = await POST(makeReq({ mode: "parse", input: "session=abc123" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.session.value).toBe("abc123"); + expect(data.session.secure).toBe(false); + expect(data.session.http_only).toBe(false); + }); + + it("parses a cookie with all attributes", async () => { + const input = + "token=xyz; Expires=Wed, 01 Jan 2025 00:00:00 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Strict"; + const res = await POST(makeReq({ mode: "parse", input })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.token.value).toBe("xyz"); + expect(data.token.expires).toBe("Wed, 01 Jan 2025 00:00:00 GMT"); + expect(data.token.max_age).toBe(3600); + expect(data.token.domain).toBe("example.com"); + expect(data.token.path).toBe("/"); + expect(data.token.secure).toBe(true); + expect(data.token.http_only).toBe(true); + expect(data.token.same_site).toBe("Strict"); + }); + + it("handles URL-encoded values", async () => { + const res = await POST(makeReq({ mode: "parse", input: "user=hello%20world" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.user.value).toBe("hello world"); + }); + + it("rejects invalid SameSite value", async () => { + const res = await POST( + makeReq({ mode: "parse", input: "x=1; SameSite=Invalid" }) + ); + expect(res.status).toBe(400); + }); + }); + + describe("build mode", () => { + it("builds a simple Set-Cookie header", async () => { + const res = await POST( + makeReq({ mode: "build", input: { name: "session", value: "abc" } }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.header).toBe("session=abc"); + }); + + it("builds a full Set-Cookie header", async () => { + const res = await POST( + makeReq({ + mode: "build", + input: { + name: "token", + value: "xyz", + path: "/", + secure: true, + http_only: true, + same_site: "Lax", + }, + }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.header).toContain("token=xyz"); + expect(data.header).toContain("Path=/"); + expect(data.header).toContain("Secure"); + expect(data.header).toContain("HttpOnly"); + expect(data.header).toContain("SameSite=Lax"); + }); + + it("round-trips build -> parse", async () => { + const buildRes = await POST( + makeReq({ + mode: "build", + input: { + name: "auth", + value: "tok123", + path: "/app", + secure: true, + http_only: true, + same_site: "None", + }, + }) + ); + expect(buildRes.status).toBe(200); + const { header } = await buildRes.json(); + + const parseRes = await POST(makeReq({ mode: "parse", input: header })); + expect(parseRes.status).toBe(200); + const data = await parseRes.json(); + expect(data.auth.value).toBe("tok123"); + expect(data.auth.path).toBe("/app"); + expect(data.auth.secure).toBe(true); + expect(data.auth.http_only).toBe(true); + expect(data.auth.same_site).toBe("None"); + }); + + it("rejects invalid same_site in build mode", async () => { + const res = await POST( + makeReq({ mode: "build", input: { name: "x", value: "1", same_site: "Bad" } }) + ); + expect(res.status).toBe(400); + }); + }); + + it("rejects unknown mode", async () => { + const res = await POST(makeReq({ mode: "delete", input: "x=1" })); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/cookie-parse", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "bad", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/creator-min-tip.test.ts b/app/api/routes-f/__tests__/creator-min-tip.test.ts new file mode 100644 index 00000000..e0cde7cd --- /dev/null +++ b/app/api/routes-f/__tests__/creator-min-tip.test.ts @@ -0,0 +1,201 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET, PUT } from "../creator-min-tip/route"; +import { POST as CHECK } from "../creator-min-tip/check/route"; +import { minTipStore } from "../creator-min-tip/store"; + +function getReq(creatorId: string) { + return new NextRequest( + `http://localhost/api/routes-f/creator-min-tip?creator_id=${creatorId}` + ); +} + +function putReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routes-f/creator-min-tip", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +function checkReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routes-f/creator-min-tip/check", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +describe("/api/routes-f/creator-min-tip", () => { + beforeEach(() => { + minTipStore.clear(); + }); + + describe("GET — retrieve minimum tips", () => { + it("returns defaults (0, 0) for unknown creator", async () => { + const res = await GET(getReq("creator-new")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.min_xlm).toBe(0); + expect(data.min_usdc).toBe(0); + }); + + it("returns configured values after PUT", async () => { + await PUT( + putReq({ creator_id: "c1", min_xlm: 5, min_usdc: 2 }) + ); + + const res = await GET(getReq("c1")); + const data = await res.json(); + expect(data.min_xlm).toBe(5); + expect(data.min_usdc).toBe(2); + }); + + it("rejects missing creator_id", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/creator-min-tip" + ); + const res = await GET(req); + expect(res.status).toBe(400); + }); + }); + + describe("PUT — set minimum tips", () => { + it("sets both min_xlm and min_usdc", async () => { + const res = await PUT( + putReq({ creator_id: "c1", min_xlm: 10, min_usdc: 5 }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.min_xlm).toBe(10); + expect(data.min_usdc).toBe(5); + }); + + it("allows setting only min_xlm", async () => { + const res = await PUT(putReq({ creator_id: "c1", min_xlm: 3 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.min_xlm).toBe(3); + expect(data.min_usdc).toBe(0); + }); + + it("allows setting only min_usdc", async () => { + const res = await PUT(putReq({ creator_id: "c1", min_usdc: 1 })); + const data = await res.json(); + expect(data.min_xlm).toBe(0); + expect(data.min_usdc).toBe(1); + }); + + it("rejects negative min_xlm", async () => { + const res = await PUT( + putReq({ creator_id: "c1", min_xlm: -1 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects negative min_usdc", async () => { + const res = await PUT( + putReq({ creator_id: "c1", min_usdc: -5 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing creator_id", async () => { + const res = await PUT(putReq({ min_xlm: 5 })); + expect(res.status).toBe(400); + }); + }); + + describe("POST /check — verify tip allowed", () => { + it("allows a tip above the minimum", async () => { + await PUT(putReq({ creator_id: "c1", min_xlm: 5 })); + + const res = await CHECK( + checkReq({ creator_id: "c1", asset: "XLM", amount: 10 }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.allowed).toBe(true); + expect(data.reason).toBeUndefined(); + }); + + it("rejects a tip below the minimum with reason", async () => { + await PUT(putReq({ creator_id: "c1", min_xlm: 5 })); + + const res = await CHECK( + checkReq({ creator_id: "c1", asset: "XLM", amount: 2 }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.allowed).toBe(false); + expect(data.reason).toMatch(/Minimum XLM/); + }); + + it("allows exact minimum amount", async () => { + await PUT(putReq({ creator_id: "c1", min_usdc: 10 })); + + const res = await CHECK( + checkReq({ creator_id: "c1", asset: "USDC", amount: 10 }) + ); + const data = await res.json(); + expect(data.allowed).toBe(true); + }); + + it("rejects USDC tip below minimum", async () => { + await PUT(putReq({ creator_id: "c1", min_usdc: 10 })); + + const res = await CHECK( + checkReq({ creator_id: "c1", asset: "USDC", amount: 5 }) + ); + const data = await res.json(); + expect(data.allowed).toBe(false); + expect(data.reason).toMatch(/Minimum USDC/); + }); + + it("rejects unsupported asset", async () => { + const res = await CHECK( + checkReq({ creator_id: "c1", asset: "BTC", amount: 1 }) + ); + const data = await res.json(); + expect(data.allowed).toBe(false); + expect(data.reason).toMatch(/Unsupported asset/); + }); + + it("allows any amount when no minimum is configured", async () => { + const res = await CHECK( + checkReq({ creator_id: "c-new", asset: "XLM", amount: 0.001 }) + ); + const data = await res.json(); + expect(data.allowed).toBe(true); + }); + + it("rejects missing creator_id", async () => { + const res = await CHECK( + checkReq({ asset: "XLM", amount: 5 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing asset", async () => { + const res = await CHECK( + checkReq({ creator_id: "c1", amount: 5 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing amount", async () => { + const res = await CHECK( + checkReq({ creator_id: "c1", asset: "XLM" }) + ); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/deep-merge.test.ts b/app/api/routes-f/__tests__/deep-merge.test.ts new file mode 100644 index 00000000..9123987d --- /dev/null +++ b/app/api/routes-f/__tests__/deep-merge.test.ts @@ -0,0 +1,144 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST, deepMerge } from "../deep-merge/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/deep-merge", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/deep-merge", () => { + describe("deepMerge helper", () => { + it("merges two flat objects", () => { + const result = deepMerge([{ a: 1 }, { b: 2 }], "replace"); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("overwrites primitive values", () => { + const result = deepMerge([{ a: 1 }, { a: 2 }], "replace"); + expect(result).toEqual({ a: 2 }); + }); + + it("deep merges nested objects", () => { + const result = deepMerge( + [{ user: { name: "Alice", age: 30 } }, { user: { age: 31, city: "NYC" } }], + "replace" + ); + expect(result).toEqual({ user: { name: "Alice", age: 31, city: "NYC" } }); + }); + + it("replace strategy replaces arrays", () => { + const result = deepMerge([{ arr: [1, 2] }, { arr: [3, 4] }], "replace"); + expect(result).toEqual({ arr: [3, 4] }); + }); + + it("concat strategy concatenates arrays", () => { + const result = deepMerge([{ arr: [1, 2] }, { arr: [3, 4] }], "concat"); + expect(result).toEqual({ arr: [1, 2, 3, 4] }); + }); + + it("union strategy deduplicates arrays", () => { + const result = deepMerge([{ arr: [1, 2, 2] }, { arr: [2, 3] }], "union"); + expect(result).toEqual({ arr: [1, 2, 3] }); + }); + + it("handles three objects", () => { + const result = deepMerge([{ a: 1 }, { b: 2 }, { c: 3 }], "replace"); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("deeply nested merge", () => { + const result = deepMerge( + [ + { config: { db: { host: "localhost" } } }, + { config: { db: { port: 5432 }, cache: true } }, + ], + "replace" + ); + expect(result).toEqual({ + config: { db: { host: "localhost", port: 5432 }, cache: true }, + }); + }); + }); + + describe("POST handler", () => { + it("merges two objects with default strategy", async () => { + const res = await POST(makeReq({ objects: [{ a: 1 }, { b: 2 }] })); + expect(res.status).toBe(200); + const { merged } = await res.json(); + expect(merged).toEqual({ a: 1, b: 2 }); + }); + + it("uses replace strategy by default for arrays", async () => { + const res = await POST(makeReq({ objects: [{ arr: [1] }, { arr: [2] }] })); + const { merged } = await res.json(); + expect(merged.arr).toEqual([2]); + }); + + it("concat strategy works", async () => { + const res = await POST( + makeReq({ objects: [{ arr: [1] }, { arr: [2] }], array_strategy: "concat" }) + ); + const { merged } = await res.json(); + expect(merged.arr).toEqual([1, 2]); + }); + + it("union strategy works", async () => { + const res = await POST( + makeReq({ objects: [{ arr: [1, 2] }, { arr: [2, 3] }], array_strategy: "union" }) + ); + const { merged } = await res.json(); + expect(merged.arr).toEqual([1, 2, 3]); + }); + + it("deep nested merge via POST", async () => { + const res = await POST( + makeReq({ + objects: [ + { user: { name: "Bob", settings: { theme: "dark" } } }, + { user: { settings: { lang: "en" } } }, + ], + }) + ); + const { merged } = await res.json(); + expect(merged.user.settings).toEqual({ theme: "dark", lang: "en" }); + }); + + it("returns 400 for empty objects array", async () => { + const res = await POST(makeReq({ objects: [] })); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing objects", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid array_strategy", async () => { + const res = await POST( + makeReq({ objects: [{ a: 1 }], array_strategy: "invalid" }) + ); + expect(res.status).toBe(400); + }); + + it("handles single object", async () => { + const res = await POST(makeReq({ objects: [{ a: 1, b: 2 }] })); + const { merged } = await res.json(); + expect(merged).toEqual({ a: 1, b: 2 }); + }); + + it("rejects body exceeding 2MB", async () => { + const large = { objects: [{ data: "x".repeat(3 * 1024 * 1024) }] }; + const res = await POST(makeReq(large)); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("exceeds"); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/duration.test.ts b/app/api/routes-f/__tests__/duration.test.ts new file mode 100644 index 00000000..ecfc22e3 --- /dev/null +++ b/app/api/routes-f/__tests__/duration.test.ts @@ -0,0 +1,73 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../duration/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/duration", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/duration", () => { + it("parses a time-only ISO 8601 duration", async () => { + const res = await POST(makeReq({ mode: "parse", text: "PT1H30M5S" })); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.components).toEqual({ + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 1, + minutes: 30, + seconds: 5, + }); + expect(data.total_seconds).toBe(5405); + }); + + it("round-trips a combined date and time duration", async () => { + const parseRes = await POST( + makeReq({ mode: "parse", text: "P1Y2M3W4DT5H6M7S" }) + ); + expect(parseRes.status).toBe(200); + const parsed = await parseRes.json(); + + expect(parsed.components).toEqual({ + years: 1, + months: 2, + weeks: 3, + days: 4, + hours: 5, + minutes: 6, + seconds: 7, + }); + + const formatRes = await POST( + makeReq({ mode: "format", components: parsed.components }) + ); + expect(formatRes.status).toBe(200); + const formatted = await formatRes.json(); + + expect(formatted.text).toBe("P1Y2M3W4DT5H6M7S"); + expect(formatted.total_seconds).toBe(1 * 31536000 + 2 * 2592000 + 3 * 604800 + 4 * 86400 + 5 * 3600 + 6 * 60 + 7); + + const roundTripRes = await POST(makeReq({ mode: "parse", text: formatted.text })); + expect(roundTripRes.status).toBe(200); + const roundTrip = await roundTripRes.json(); + expect(roundTrip.components).toEqual(parsed.components); + }); + + it("formats zero duration as PT0S", async () => { + const res = await POST(makeReq({ mode: "format", components: {} })); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.text).toBe("PT0S"); + expect(data.total_seconds).toBe(0); + }); +}); diff --git a/app/api/routes-f/__tests__/followed-categories.test.ts b/app/api/routes-f/__tests__/followed-categories.test.ts new file mode 100644 index 00000000..7cd4a9c4 --- /dev/null +++ b/app/api/routes-f/__tests__/followed-categories.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../categories/followed/route"; + +function makeReq(query: string) { + return new NextRequest( + `http://localhost/api/routes-f/categories/followed?${query}` + ); +} + +describe("Followed Categories Feed API", () => { + it("should fail if viewer_id is missing", async () => { + const req = makeReq(""); + const res = await GET(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("viewer_id is required"); + }); + + it("should return empty list if viewer follows no categories", async () => { + const req = makeReq("viewer_id=viewer-no-follows"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.streams).toEqual([]); + }); + + it("should return streams in followed categories, sorted by viewer_count descending", async () => { + // viewer-1 follows Gaming, Music, Talk Shows + const req = makeReq("viewer_id=viewer-1"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(Array.isArray(data.streams)).toBe(true); + expect(data.streams.length).toBe(4); + + // Verify categories are correct + const categories = data.streams.map((s: any) => s.category); + expect(categories).toContain("Gaming"); + expect(categories).toContain("Music"); + expect(categories).toContain("Talk Shows"); + expect(categories).not.toContain("Crypto"); + expect(categories).not.toContain("Coding"); + expect(categories).not.toContain("Sports"); + + // Verify ordering is descending by viewer_count + // Expected order of followed categories: + // 1. creator-gaming-1 (Gaming) - 1500 viewers + // 2. creator-talk-1 (Talk Shows) - 1200 viewers + // 3. creator-gaming-2 (Gaming) - 800 viewers + // 4. creator-music-1 (Music) - 450 viewers + expect(data.streams[0].creator).toBe("creator-gaming-1"); + expect(data.streams[1].creator).toBe("creator-talk-1"); + expect(data.streams[2].creator).toBe("creator-gaming-2"); + expect(data.streams[3].creator).toBe("creator-music-1"); + + for (let i = 0; i < data.streams.length - 1; i++) { + expect(data.streams[i].viewer_count).toBeGreaterThanOrEqual( + data.streams[i + 1].viewer_count + ); + } + }); + + it("should return correct streams for another viewer with correct ranking", async () => { + // viewer-3 follows Crypto, Coding + // Streams: + // creator-crypto-1 (Crypto) - 3100 viewers + // creator-coding-1 (Coding) - 950 viewers + const req = makeReq("viewer_id=viewer-3"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.streams.length).toBe(2); + expect(data.streams[0].creator).toBe("creator-crypto-1"); + expect(data.streams[1].creator).toBe("creator-coding-1"); + }); +}); diff --git a/app/api/routes-f/__tests__/gcd-lcm.test.ts b/app/api/routes-f/__tests__/gcd-lcm.test.ts new file mode 100644 index 00000000..7b532d42 --- /dev/null +++ b/app/api/routes-f/__tests__/gcd-lcm.test.ts @@ -0,0 +1,109 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../gcd-lcm/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/gcd-lcm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/gcd-lcm", () => { + it("computes gcd and lcm of a known pair (12, 18)", async () => { + const res = await POST(makeReq({ numbers: [12, 18] })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.gcd).toBe(6); + expect(data.lcm).toBe(36); + expect(data.n_count).toBe(2); + }); + + it("computes gcd only when operation=gcd", async () => { + const res = await POST(makeReq({ numbers: [12, 18], operation: "gcd" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.gcd).toBe(6); + expect(data.lcm).toBeUndefined(); + }); + + it("computes lcm only when operation=lcm", async () => { + const res = await POST(makeReq({ numbers: [12, 18], operation: "lcm" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.lcm).toBe(36); + expect(data.gcd).toBeUndefined(); + }); + + it("handles multiple numbers", async () => { + const res = await POST(makeReq({ numbers: [4, 6, 8] })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.gcd).toBe(2); + expect(data.lcm).toBe(24); + expect(data.n_count).toBe(3); + }); + + it("handles edge case with 1", async () => { + const res = await POST(makeReq({ numbers: [1, 7] })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.gcd).toBe(1); + expect(data.lcm).toBe(7); + }); + + it("handles prime numbers", async () => { + const res = await POST(makeReq({ numbers: [7, 11] })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.gcd).toBe(1); + expect(data.lcm).toBe(77); + }); + + it("handles single number", async () => { + const res = await POST(makeReq({ numbers: [15] })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.gcd).toBe(15); + expect(data.lcm).toBe(15); + }); + + it("rejects non-positive integers", async () => { + const res = await POST(makeReq({ numbers: [0, 5] })); + expect(res.status).toBe(400); + }); + + it("rejects floats", async () => { + const res = await POST(makeReq({ numbers: [1.5, 3] })); + expect(res.status).toBe(400); + }); + + it("rejects arrays exceeding 100 numbers", async () => { + const numbers = Array.from({ length: 101 }, (_, i) => i + 1); + const res = await POST(makeReq({ numbers })); + expect(res.status).toBe(400); + }); + + it("rejects empty array", async () => { + const res = await POST(makeReq({ numbers: [] })); + expect(res.status).toBe(400); + }); + + it("rejects invalid operation", async () => { + const res = await POST(makeReq({ numbers: [4, 6], operation: "max" })); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/gcd-lcm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/html-escape.test.ts b/app/api/routes-f/__tests__/html-escape.test.ts new file mode 100644 index 00000000..32f58ed0 --- /dev/null +++ b/app/api/routes-f/__tests__/html-escape.test.ts @@ -0,0 +1,239 @@ +// @ts-nocheck +/** + * @jest-environment jsdom + */ + +import { POST } from "../html-escape/route"; +import { NextRequest } from "next/server"; + +// Mock the data module +jest.mock("../html-escape/data", () => ({ + escapeHtml: jest.fn(), + unescapeHtml: jest.fn(), +})); + +const { escapeHtml, unescapeHtml } = require("../html-escape/data"); + +describe("/api/routes-f/html-escape", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("POST", () => { + it("should escape HTML in escape mode", async () => { + escapeHtml.mockReturnValue( + "<div>Hello & "world"'</div>" + ); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({ + input: '
Hello & "world"
', + mode: "escape", + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.output).toBe( + "<div>Hello & "world"'</div>" + ); + expect(escapeHtml).toHaveBeenCalledWith('
Hello & "world"
'); + }); + + it("should unescape HTML in unescape mode", async () => { + unescapeHtml.mockReturnValue('
Hello & "world"
'); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({ + input: "<div>Hello & "world"'</div>", + mode: "unescape", + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.output).toBe('
Hello & "world"
'); + expect(unescapeHtml).toHaveBeenCalledWith( + "<div>Hello & "world"'</div>" + ); + }); + + it("should handle numeric entities in unescape mode", async () => { + unescapeHtml.mockReturnValue("A"); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({ + input: "A", + mode: "unescape", + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.output).toBe("A"); + expect(unescapeHtml).toHaveBeenCalledWith("A"); + }); + + it("should handle hexadecimal entities in unescape mode", async () => { + unescapeHtml.mockReturnValue("A"); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({ + input: "A", + mode: "unescape", + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.output).toBe("A"); + expect(unescapeHtml).toHaveBeenCalledWith("A"); + }); + + it("should handle named entities in unescape mode", async () => { + unescapeHtml.mockReturnValue("<"); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({ + input: "<", + mode: "unescape", + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.output).toBe("<"); + expect(unescapeHtml).toHaveBeenCalledWith("<"); + }); + + it("should return 400 for missing request body", async () => { + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({}), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid request body"); + }); + + it("should return 400 for invalid mode", async () => { + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({ + input: "test", + mode: "invalid", + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid mode"); + }); + + it("should return 400 for invalid JSON", async () => { + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: "invalid json", + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid JSON"); + }); + + it("should return 413 for input too large", async () => { + // Create a string larger than 1MB + const largeInput = "a".repeat(1024 * 1024 + 1); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/html-escape", + { + method: "POST", + body: JSON.stringify({ + input: largeInput, + mode: "escape", + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(413); + expect(data.error).toContain("Input too large"); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/http-status.test.ts b/app/api/routes-f/__tests__/http-status.test.ts new file mode 100644 index 00000000..b8275f8d --- /dev/null +++ b/app/api/routes-f/__tests__/http-status.test.ts @@ -0,0 +1,122 @@ +// @ts-nocheck +/** + * @jest-environment jsdom + */ + +import { GET } from "../http-status/route"; +import { NextRequest } from "next/server"; + +// Mock the data module +jest.mock("../http-status/data", () => ({ + getStatusByCode: jest.fn(), + getStatusesByCategory: jest.fn(), + findNearestStatus: jest.fn(), +})); + +const { + getStatusByCode, + getStatusesByCategory, + findNearestStatus, +} = require("../http-status/data"); + +describe("/api/routes-f/http-status", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET with code parameter", () => { + it("should return status details for valid code", async () => { + const mockStatus = { + code: 404, + name: "Not Found", + description: "The server can not find the requested resource", + category: "4xx", + rfc: "RFC 7231", + }; + + getStatusByCode.mockReturnValue(mockStatus); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/http-status?code=404" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockStatus); + expect(getStatusByCode).toHaveBeenCalledWith(404); + }); + + it("should return 404 for unknown status code with suggestion", async () => { + getStatusByCode.mockReturnValue(undefined); + + const nearestStatus = { + code: 404, + name: "Not Found", + description: "The server can not find the requested resource", + category: "4xx", + }; + + findNearestStatus.mockReturnValue(nearestStatus); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/http-status?code=403" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toContain("HTTP status code 403 not found"); + expect(data.suggestion).toContain("Did you mean 404"); + expect(findNearestStatus).toHaveBeenCalledWith(403); + }); + + it("should return 400 for invalid code format", async () => { + const request = new NextRequest( + "http://localhost:3000/api/routes-f/http-status?code=invalid" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid status code format"); + }); + }); + + describe("GET without code parameter", () => { + it("should return all statuses grouped by category", async () => { + const mockGroupedStatuses = { + "2xx": [ + { + code: 200, + name: "OK", + description: "The request succeeded", + category: "2xx", + rfc: "RFC 7231", + }, + ], + "4xx": [ + { + code: 404, + name: "Not Found", + description: "The server can not find the requested resource", + category: "4xx", + rfc: "RFC 7231", + }, + ], + }; + + getStatusesByCategory.mockReturnValue(mockGroupedStatuses); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/http-status" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockGroupedStatuses); + expect(getStatusesByCategory).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/loan-amortization.test.ts b/app/api/routes-f/__tests__/loan-amortization.test.ts new file mode 100644 index 00000000..50318f38 --- /dev/null +++ b/app/api/routes-f/__tests__/loan-amortization.test.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../loan-amortization/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/loan-amortization", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/loan-amortization", () => { + it("computes basic loan schedule", async () => { + const res = await POST( + makeReq({ principal: 100000, annual_rate: 5, years: 30 }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.monthly_payment).toBeCloseTo(536.82, 0); + // 360 months theoretical; rounding to cents can add 1 extra month + expect(d.payoff_months).toBeGreaterThanOrEqual(360); + expect(d.payoff_months).toBeLessThanOrEqual(362); + expect(Array.isArray(d.schedule)).toBe(true); + expect(d.schedule.length).toBe(d.payoff_months); + expect(d.schedule[0].month).toBe(1); + expect(d.schedule[d.payoff_months - 1].balance).toBe(0); + }); + + it("accelerates payoff with extra monthly payment", async () => { + const baseRes = await POST( + makeReq({ principal: 100000, annual_rate: 5, years: 30 }) + ); + const base = await baseRes.json(); + + const extraRes = await POST( + makeReq({ + principal: 100000, + annual_rate: 5, + years: 30, + extra_monthly_payment: 200, + }) + ); + const extra = await extraRes.json(); + + expect(extra.payoff_months).toBeLessThan(base.payoff_months); + expect(extra.total_interest).toBeLessThan(base.total_interest); + }); + + it("handles zero interest rate", async () => { + const res = await POST( + makeReq({ principal: 12000, annual_rate: 0, years: 1 }) + ); + const d = await res.json(); + expect(d.monthly_payment).toBe(1000); + expect(d.total_interest).toBe(0); + }); + + it("schedule first row has correct structure", async () => { + const res = await POST( + makeReq({ principal: 10000, annual_rate: 6, years: 1 }) + ); + const { schedule } = await res.json(); + const row = schedule[0]; + expect(typeof row.month).toBe("number"); + expect(typeof row.payment).toBe("number"); + expect(typeof row.principal).toBe("number"); + expect(typeof row.interest).toBe("number"); + expect(typeof row.balance).toBe("number"); + }); + + it("rejects negative principal", async () => { + const res = await POST( + makeReq({ principal: -1000, annual_rate: 5, years: 10 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects years > 50", async () => { + const res = await POST( + makeReq({ principal: 10000, annual_rate: 5, years: 51 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects negative rate", async () => { + const res = await POST( + makeReq({ principal: 10000, annual_rate: -1, years: 10 }) + ); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/mortgage.test.ts b/app/api/routes-f/__tests__/mortgage.test.ts new file mode 100644 index 00000000..05243528 --- /dev/null +++ b/app/api/routes-f/__tests__/mortgage.test.ts @@ -0,0 +1,167 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../mortgage/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/mortgage", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/mortgage", () => { + // --- Typical 30-year mortgage --- + it("computes typical 30-year mortgage", async () => { + const res = await POST( + makeReq({ + home_price: 300000, + down_payment: 60000, + annual_rate: 6.5, + years: 30, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.loan_amount).toBe(240000); + expect(d.monthly_principal_interest).toBeCloseTo(1517.09, 0); + expect(d.monthly_taxes).toBe(0); + expect(d.monthly_insurance).toBe(0); + expect(d.monthly_hoa).toBe(0); + expect(d.monthly_total).toBeCloseTo(1517.09, 0); + expect(d.total_interest).toBeGreaterThan(0); + expect(d.total_paid).toBeGreaterThan(d.loan_amount); + expect(d.ltv_ratio).toBe(80); + expect(typeof d.payoff_date).toBe("string"); + }); + + // --- No extras (bare minimum) --- + it("computes mortgage with no extras", async () => { + const res = await POST( + makeReq({ + home_price: 200000, + down_payment: 40000, + annual_rate: 5, + years: 15, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.loan_amount).toBe(160000); + expect(d.monthly_taxes).toBe(0); + expect(d.monthly_insurance).toBe(0); + expect(d.monthly_hoa).toBe(0); + expect(d.ltv_ratio).toBe(80); + }); + + // --- With all fees --- + it("computes mortgage with all fees", async () => { + const res = await POST( + makeReq({ + home_price: 400000, + down_payment: 80000, + annual_rate: 7, + years: 30, + property_tax_annual: 4800, + insurance_annual: 1200, + hoa_monthly: 300, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.monthly_taxes).toBe(400); + expect(d.monthly_insurance).toBe(100); + expect(d.monthly_hoa).toBe(300); + expect(d.monthly_total).toBeGreaterThan(d.monthly_principal_interest); + }); + + // --- Zero interest rate --- + it("handles zero interest rate", async () => { + const res = await POST( + makeReq({ + home_price: 120000, + down_payment: 0, + annual_rate: 0, + years: 10, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.monthly_principal_interest).toBe(1000); + expect(d.total_interest).toBe(0); + }); + + // --- Validation: years > 50 --- + it("rejects years > 50", async () => { + const res = await POST( + makeReq({ + home_price: 300000, + down_payment: 60000, + annual_rate: 6, + years: 51, + }) + ); + expect(res.status).toBe(400); + }); + + // --- Validation: negative home price --- + it("rejects negative home_price", async () => { + const res = await POST( + makeReq({ + home_price: -100000, + down_payment: 0, + annual_rate: 5, + years: 30, + }) + ); + expect(res.status).toBe(400); + }); + + // --- Validation: down payment >= home price --- + it("rejects down_payment >= home_price", async () => { + const res = await POST( + makeReq({ + home_price: 200000, + down_payment: 200000, + annual_rate: 5, + years: 30, + }) + ); + expect(res.status).toBe(400); + }); + + // --- Invalid JSON --- + it("rejects invalid JSON body", async () => { + const req = new NextRequest("http://localhost/api/routes-f/mortgage", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + // --- Cent precision check --- + it("returns cent precision (2 decimal places)", async () => { + const res = await POST( + makeReq({ + home_price: 333333, + down_payment: 33333, + annual_rate: 4.375, + years: 30, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + const decimals = (n: number) => { + const parts = String(n).split("."); + return parts.length > 1 ? parts[1].length : 0; + }; + expect(decimals(d.monthly_principal_interest)).toBeLessThanOrEqual(2); + expect(decimals(d.loan_amount)).toBeLessThanOrEqual(2); + expect(decimals(d.total_interest)).toBeLessThanOrEqual(2); + }); +}); diff --git a/app/api/routes-f/__tests__/nanoid.test.ts b/app/api/routes-f/__tests__/nanoid.test.ts new file mode 100644 index 00000000..3d74c0eb --- /dev/null +++ b/app/api/routes-f/__tests__/nanoid.test.ts @@ -0,0 +1,106 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../nanoid/route"; +import { generateId } from "../nanoid/_lib/helpers"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/nanoid", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("generateId", () => { + it("returns a string of the requested size", () => { + expect(generateId(21, "abc123").length).toBe(21); + expect(generateId(10, "abc").length).toBe(10); + expect(generateId(1, "ab").length).toBe(1); + }); + + it("only uses characters from the given alphabet", () => { + const alphabet = "abc"; + const id = generateId(100, alphabet); + for (const ch of id) { + expect(alphabet).toContain(ch); + } + }); + + it("generates unique IDs across many calls", () => { + const ids = new Set(Array.from({ length: 1000 }, () => generateId(21, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"))); + expect(ids.size).toBe(1000); + }); +}); + +describe("POST /api/routes-f/nanoid", () => { + it("returns 1 ID of length 21 with defaults", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(200); + const { ids } = await res.json(); + expect(ids).toHaveLength(1); + expect(ids[0]).toHaveLength(21); + }); + + it("respects custom count", async () => { + const res = await POST(makeReq({ count: 5 })); + expect(res.status).toBe(200); + const { ids } = await res.json(); + expect(ids).toHaveLength(5); + }); + + it("respects custom size", async () => { + const res = await POST(makeReq({ size: 10 })); + expect(res.status).toBe(200); + const { ids } = await res.json(); + expect(ids[0]).toHaveLength(10); + }); + + it("respects custom alphabet", async () => { + const alphabet = "01"; + const res = await POST(makeReq({ size: 32, alphabet })); + expect(res.status).toBe(200); + const { ids } = await res.json(); + for (const ch of ids[0]) { + expect(alphabet).toContain(ch); + } + }); + + it("generates unique IDs across 100 requests", async () => { + const res = await POST(makeReq({ count: 100 })); + expect(res.status).toBe(200); + const { ids } = await res.json(); + expect(new Set(ids).size).toBe(100); + }); + + it("returns 400 when count exceeds 100", async () => { + const res = await POST(makeReq({ count: 101 })); + expect(res.status).toBe(400); + }); + + it("returns 400 when count is not a positive integer", async () => { + const res = await POST(makeReq({ count: 0 })); + expect(res.status).toBe(400); + }); + + it("returns 400 when size is not a positive integer", async () => { + const res = await POST(makeReq({ size: -1 })); + expect(res.status).toBe(400); + }); + + it("returns 400 when alphabet has fewer than 2 characters", async () => { + const res = await POST(makeReq({ alphabet: "a" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/nanoid", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/nth-prime.test.ts b/app/api/routes-f/__tests__/nth-prime.test.ts new file mode 100644 index 00000000..f0b77e38 --- /dev/null +++ b/app/api/routes-f/__tests__/nth-prime.test.ts @@ -0,0 +1,30 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../nth-prime/route"; + +test("/api/routes-f/nth-prime returns the tenth prime", async () => { + const req = new NextRequest("http://localhost/api/routes-f/nth-prime?n=10"); + const res = await GET(req); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ n: 10, prime: 29 }); +}); + +test("/api/routes-f/nth-prime returns the 100000th prime", async () => { + const req = new NextRequest("http://localhost/api/routes-f/nth-prime?n=100000"); + const res = await GET(req); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ n: 100000, prime: 1299709 }); +}); + +test("/api/routes-f/nth-prime rejects out-of-range values", async () => { + const req = new NextRequest("http://localhost/api/routes-f/nth-prime?n=0"); + const res = await GET(req); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/integer between 1 and 100000/i); +}); diff --git a/app/api/routes-f/__tests__/pace.test.ts b/app/api/routes-f/__tests__/pace.test.ts new file mode 100644 index 00000000..b6ae1747 --- /dev/null +++ b/app/api/routes-f/__tests__/pace.test.ts @@ -0,0 +1,120 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../pace/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/pace", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/pace", () => { + describe("mode: pace (distance + time → pace)", () => { + it("computes pace from 10km in 50:00", async () => { + const res = await POST( + makeReq({ mode: "pace", distance: 10, time: "00:50:00" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.pace).toBe("5:00 per km"); + }); + + it("includes race splits", async () => { + const res = await POST( + makeReq({ mode: "pace", distance: 10, time: "01:00:00" }) + ); + const d = await res.json(); + expect(d.race_splits["5K"]).toBeDefined(); + expect(d.race_splits["Marathon"]).toBeDefined(); + }); + }); + + describe("mode: time (distance + pace → time)", () => { + it("computes time for 5km at 6:00/km", async () => { + const res = await POST( + makeReq({ mode: "time", distance: 5, pace: "6:00" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.time).toBe("00:30:00"); + }); + + it("computes marathon time at 4:30/km pace", async () => { + const res = await POST( + makeReq({ mode: "time", distance: 42.195, pace: "4:30" }) + ); + const d = await res.json(); + // 42.195 * 270s ≈ 11392.65s ≈ 3h 9m 52s + expect(d.time).toMatch(/^03:/); + }); + }); + + describe("mode: distance (time + pace → distance)", () => { + it("computes distance for 1h at 5:00/km", async () => { + const res = await POST( + makeReq({ mode: "distance", time: "01:00:00", pace: "5:00" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.distance).toBeCloseTo(12, 0); + }); + }); + + describe("mile unit support", () => { + it("computes pace in miles", async () => { + const res = await POST( + makeReq({ mode: "pace", distance: 6.2, time: "00:50:00", unit: "mi" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.pace).toContain("per mi"); + }); + }); + + describe("validation", () => { + it("rejects invalid mode", async () => { + const res = await POST( + makeReq({ mode: "speed", distance: 10, time: "00:50:00" }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid unit", async () => { + const res = await POST( + makeReq({ + mode: "pace", + distance: 10, + time: "00:50:00", + unit: "meters", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid time format", async () => { + const res = await POST( + makeReq({ mode: "pace", distance: 10, time: "not-a-time" }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid pace format", async () => { + const res = await POST( + makeReq({ mode: "time", distance: 10, pace: "fast" }) + ); + expect(res.status).toBe(400); + }); + + it("rejects zero distance", async () => { + const res = await POST( + makeReq({ mode: "pace", distance: 0, time: "00:30:00" }) + ); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/percentile.test.ts b/app/api/routes-f/__tests__/percentile.test.ts new file mode 100644 index 00000000..525cafff --- /dev/null +++ b/app/api/routes-f/__tests__/percentile.test.ts @@ -0,0 +1,74 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../percentile/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/percentile", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/percentile", () => { + it("computes p50 (median) from known dataset", async () => { + const res = await POST( + makeReq({ data: [1, 2, 3, 4, 5], percentiles: [50] }) + ); + expect(res.status).toBe(200); + const { results } = await res.json(); + expect(results[0].percentile).toBe(50); + expect(results[0].value).toBe(3); + }); + + it("computes p0 and p100 (min and max)", async () => { + const res = await POST( + makeReq({ data: [10, 20, 30, 40, 50], percentiles: [0, 100] }) + ); + const { results } = await res.json(); + expect(results[0].value).toBe(10); + expect(results[1].value).toBe(50); + }); + + it("uses linear interpolation for p25 and p75", async () => { + const res = await POST( + makeReq({ data: [1, 2, 3, 4], percentiles: [25, 75] }) + ); + const { results } = await res.json(); + expect(results[0].value).toBeCloseTo(1.75, 5); + expect(results[1].value).toBeCloseTo(3.25, 5); + }); + + it("returns multiple percentiles in input order", async () => { + const res = await POST( + makeReq({ data: [1, 2, 3], percentiles: [90, 10, 50] }) + ); + const { results } = await res.json(); + expect(results.map((r: { percentile: number }) => r.percentile)).toEqual([ + 90, 10, 50, + ]); + }); + + it("rejects empty data", async () => { + const res = await POST(makeReq({ data: [], percentiles: [50] })); + expect(res.status).toBe(400); + }); + + it("rejects empty percentiles array", async () => { + const res = await POST(makeReq({ data: [1, 2, 3], percentiles: [] })); + expect(res.status).toBe(400); + }); + + it("rejects percentile out of range", async () => { + const res = await POST(makeReq({ data: [1, 2, 3], percentiles: [101] })); + expect(res.status).toBe(400); + }); + + it("rejects non-numeric data values", async () => { + const res = await POST(makeReq({ data: [1, "two", 3], percentiles: [50] })); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/quadratic.test.ts b/app/api/routes-f/__tests__/quadratic.test.ts new file mode 100644 index 00000000..2b8ab539 --- /dev/null +++ b/app/api/routes-f/__tests__/quadratic.test.ts @@ -0,0 +1,83 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../quadratic/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/quadratic", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/quadratic", () => { + it("solves equation with two real distinct roots: x^2 - 5x + 6 = 0", async () => { + const res = await POST(makeReq({ a: 1, b: -5, c: 6 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.has_complex_roots).toBe(false); + expect(data.discriminant).toBe(1); + const reals = data.roots.map((r: { real: number }) => r.real).sort(); + expect(reals[0]).toBeCloseTo(2); + expect(reals[1]).toBeCloseTo(3); + }); + + it("solves equation with repeated root: x^2 - 2x + 1 = 0", async () => { + const res = await POST(makeReq({ a: 1, b: -2, c: 1 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.has_complex_roots).toBe(false); + expect(data.discriminant).toBe(0); + expect(data.roots[0].real).toBeCloseTo(1); + expect(data.roots[1].real).toBeCloseTo(1); + }); + + it("solves equation with complex roots: x^2 + 1 = 0", async () => { + const res = await POST(makeReq({ a: 1, b: 0, c: 1 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.has_complex_roots).toBe(true); + expect(data.discriminant).toBe(-4); + expect(data.roots[0].real).toBeCloseTo(0); + expect(data.roots[0].imaginary).toBeCloseTo(1); + expect(data.roots[1].imaginary).toBeCloseTo(-1); + }); + + it("returns correct vertex: x^2 - 4x + 3", async () => { + const res = await POST(makeReq({ a: 1, b: -4, c: 3 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.vertex.x).toBeCloseTo(2); + expect(data.vertex.y).toBeCloseTo(-1); + expect(data.axis_of_symmetry).toBeCloseTo(2); + }); + + it("rejects a == 0 with 400", async () => { + const res = await POST(makeReq({ a: 0, b: 2, c: 1 })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/linear/i); + }); + + it("rejects non-finite values", async () => { + const res = await POST(makeReq({ a: Infinity, b: 1, c: 1 })); + expect(res.status).toBe(400); + }); + + it("rejects missing coefficients", async () => { + const res = await POST(makeReq({ a: 1, b: 2 })); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/quadratic", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "bad", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/query-parse.test.ts b/app/api/routes-f/__tests__/query-parse.test.ts new file mode 100644 index 00000000..c10b58b4 --- /dev/null +++ b/app/api/routes-f/__tests__/query-parse.test.ts @@ -0,0 +1,169 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../query-parse/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/query-parse", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/query-parse", () => { + // --- Parse: basic query string --- + it("parses basic query string", async () => { + const res = await POST( + makeReq({ mode: "parse", input: "foo=bar&baz=qux" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result.foo).toBe("bar"); + expect(d.result.baz).toBe("qux"); + }); + + // --- Parse: leading ? is stripped --- + it("strips leading ? in parse mode", async () => { + const res = await POST(makeReq({ mode: "parse", input: "?foo=bar" })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result.foo).toBe("bar"); + }); + + // --- Parse: repeated keys become arrays --- + it("parses repeated keys as arrays", async () => { + const res = await POST(makeReq({ mode: "parse", input: "a=1&a=2&a=3" })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result.a).toEqual(["1", "2", "3"]); + }); + + // --- Parse: nested objects via bracket notation --- + it("parses nested objects via bracket notation", async () => { + const res = await POST( + makeReq({ mode: "parse", input: "user[name]=john&user[age]=30" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result.user).toEqual({ name: "john", age: "30" }); + }); + + // --- Build: basic object to query string --- + it("builds basic query string from object", async () => { + const res = await POST( + makeReq({ mode: "build", input: { foo: "bar", baz: "qux" } }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result).toContain("foo=bar"); + expect(d.result).toContain("baz=qux"); + }); + + // --- Build: array with repeat format (default) --- + it("builds array with repeat format", async () => { + const res = await POST( + makeReq({ + mode: "build", + input: { a: [1, 2, 3] }, + options: { array_format: "repeat" }, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result).toBe("a=1&a=2&a=3"); + }); + + // --- Build: array with bracket format --- + it("builds array with bracket format", async () => { + const res = await POST( + makeReq({ + mode: "build", + input: { a: [1, 2] }, + options: { array_format: "bracket" }, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result).toContain("a[]=1"); + expect(d.result).toContain("a[]=2"); + }); + + // --- Build: array with comma format --- + it("builds array with comma format", async () => { + const res = await POST( + makeReq({ + mode: "build", + input: { colors: ["red", "green", "blue"] }, + options: { array_format: "comma" }, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result).toBe("colors=red,green,blue"); + }); + + // --- Build: nested object --- + it("builds nested object with bracket notation", async () => { + const res = await POST( + makeReq({ + mode: "build", + input: { user: { name: "john", age: "30" } }, + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.result).toContain("user[name]=john"); + expect(d.result).toContain("user[age]=30"); + }); + + // --- Parse + Build round-trip --- + it("round-trips parse then build", async () => { + const original = "x=1&y=2&z=3"; + const parseRes = await POST(makeReq({ mode: "parse", input: original })); + const parsed = await parseRes.json(); + + const buildRes = await POST( + makeReq({ mode: "build", input: parsed.result }) + ); + const built = await buildRes.json(); + + // Parse the built string to compare semantically + const reparseRes = await POST( + makeReq({ mode: "parse", input: built.result }) + ); + const reparsed = await reparseRes.json(); + expect(reparsed.result).toEqual(parsed.result); + }); + + // --- Invalid mode --- + it("rejects invalid mode", async () => { + const res = await POST(makeReq({ mode: "invalid", input: "foo=bar" })); + expect(res.status).toBe(400); + }); + + // --- Parse: input not a string --- + it("rejects non-string input in parse mode", async () => { + const res = await POST(makeReq({ mode: "parse", input: { foo: "bar" } })); + expect(res.status).toBe(400); + }); + + // --- Build: input not an object --- + it("rejects non-object input in build mode", async () => { + const res = await POST(makeReq({ mode: "build", input: "foo=bar" })); + expect(res.status).toBe(400); + }); + + // --- Invalid JSON body --- + it("rejects invalid JSON body", async () => { + const req = new NextRequest("http://localhost/api/routes-f/query-parse", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/quote.test.ts b/app/api/routes-f/__tests__/quote.test.ts new file mode 100644 index 00000000..10313abd --- /dev/null +++ b/app/api/routes-f/__tests__/quote.test.ts @@ -0,0 +1,217 @@ +// @ts-nocheck +/** + * @jest-environment jsdom + */ + +import { GET } from "../quote/route"; +import { NextRequest } from "next/server"; + +// Mock the data module +jest.mock("../quote/data", () => ({ + getQuoteById: jest.fn(), + getRandomQuote: jest.fn(), + getDeterministicQuote: jest.fn(), + getCategories: jest.fn(), + quotes: [ + { + id: 1, + text: "The best way to predict the future is to invent it.", + author: "Alan Kay", + category: "technology", + year: 1971, + }, + ], +})); + +const { + getQuoteById, + getRandomQuote, + getDeterministicQuote, + getCategories, + quotes, +} = require("../quote/data"); + +describe("/api/routes-f/quote", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /quote/[id]", () => { + it("should return quote by valid ID", async () => { + const mockQuote = { + id: 1, + text: "The best way to predict the future is to invent it.", + author: "Alan Kay", + category: "technology", + year: 1971, + }; + + getQuoteById.mockReturnValue(mockQuote); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/1" + ); + const response = await GET(request, { params: { id: "1" } }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockQuote); + expect(getQuoteById).toHaveBeenCalledWith(1); + }); + + it("should return 404 for non-existent quote ID", async () => { + getQuoteById.mockReturnValue(undefined); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/999" + ); + const response = await GET(request, { params: { id: "999" } }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toContain("Quote with ID 999 not found"); + }); + + it("should return 400 for invalid quote ID format", async () => { + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/invalid" + ); + const response = await GET(request, { params: { id: "invalid" } }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid quote ID format"); + }); + }); + + describe("GET /quote/today", () => { + it("should return deterministic quote for given date", async () => { + const mockQuote = { + id: 1, + text: "The best way to predict the future is to invent it.", + author: "Alan Kay", + category: "technology", + year: 1971, + }; + + getDeterministicQuote.mockReturnValue(mockQuote); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/today?date=2024-01-01" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockQuote); + expect(getDeterministicQuote).toHaveBeenCalledWith("2024-01-01"); + }); + + it("should return deterministic quote for today when no date provided", async () => { + const mockQuote = { + id: 1, + text: "The best way to predict the future is to invent it.", + author: "Alan Kay", + category: "technology", + year: 1971, + }; + + getDeterministicQuote.mockReturnValue(mockQuote); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/today" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockQuote); + expect(getDeterministicQuote).toHaveBeenCalled(); + }); + + it("should return 400 for invalid date format", async () => { + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/today?date=invalid" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid date format"); + }); + }); + + describe("GET /quote/random", () => { + it("should return random quote", async () => { + const mockQuote = { + id: 1, + text: "The best way to predict the future is to invent it.", + author: "Alan Kay", + category: "technology", + year: 1971, + }; + + getRandomQuote.mockReturnValue(mockQuote); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/random" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockQuote); + expect(getRandomQuote).toHaveBeenCalledWith(undefined); + }); + + it("should return random quote from specific category", async () => { + const mockQuote = { + id: 1, + text: "The best way to predict the future is to invent it.", + author: "Alan Kay", + category: "technology", + year: 1971, + }; + + getRandomQuote.mockReturnValue(mockQuote); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/random?category=technology" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockQuote); + expect(getRandomQuote).toHaveBeenCalledWith("technology"); + }); + + it("should return 400 for invalid category", async () => { + getCategories.mockReturnValue(["technology", "inspiration"]); + + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote/random?category=invalid" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Category 'invalid' not found"); + expect(data.availableCategories).toEqual(["technology", "inspiration"]); + }); + }); + + describe("GET /quote (list all)", () => { + it("should return all quotes", async () => { + const request = new NextRequest( + "http://localhost:3000/api/routes-f/quote" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.quotes).toEqual(quotes); + expect(data.total).toBe(1); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/recent-tips.test.ts b/app/api/routes-f/__tests__/recent-tips.test.ts new file mode 100644 index 00000000..8dc9e92c --- /dev/null +++ b/app/api/routes-f/__tests__/recent-tips.test.ts @@ -0,0 +1,71 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../tips/recent/route"; + +function makeReq(query: string) { + return new NextRequest(`http://localhost/api/routes-f/tips/recent?${query}`); +} + +describe("Recent Tips Feed API", () => { + it("should fail if creator_id is missing", async () => { + const req = makeReq(""); + const res = await GET(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("creator_id is required"); + }); + + it("should return newest tips first with default limit of 20", async () => { + const req = makeReq("creator_id=creator-123"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(Array.isArray(data.tips)).toBe(true); + expect(data.tips.length).toBe(20); + + // Verify ordering is newest first + for (let i = 0; i < data.tips.length - 1; i++) { + const currentTs = new Date(data.tips[i].ts).getTime(); + const nextTs = new Date(data.tips[i + 1].ts).getTime(); + expect(currentTs).toBeGreaterThan(nextTs); + } + + expect(data.next_cursor).toBe("20"); + }); + + it("should respect limit parameter", async () => { + const req = makeReq("creator_id=creator-123&limit=5"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.tips.length).toBe(5); + expect(data.next_cursor).toBe("5"); + }); + + it("should correctly paginate using cursor", async () => { + // Page 1 + const req1 = makeReq("creator_id=creator-123&limit=15"); + const res1 = await GET(req1); + const data1 = await res1.json(); + expect(data1.tips.length).toBe(15); + const cursor = data1.next_cursor; + expect(cursor).toBe("15"); + + // Page 2 + const req2 = makeReq(`creator_id=creator-123&limit=15&cursor=${cursor}`); + const res2 = await GET(req2); + const data2 = await res2.json(); + expect(data2.tips.length).toBe(15); + expect(data2.next_cursor).toBeNull(); + + // Verify all returned tips are distinct + const allHashes = new Set([ + ...data1.tips.map((t: any) => t.tx_hash), + ...data2.tips.map((t: any) => t.tx_hash), + ]); + expect(allHashes.size).toBe(30); + }); +}); diff --git a/app/api/routes-f/__tests__/relative-date.test.ts b/app/api/routes-f/__tests__/relative-date.test.ts new file mode 100644 index 00000000..0a2fc5c8 --- /dev/null +++ b/app/api/routes-f/__tests__/relative-date.test.ts @@ -0,0 +1,93 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../relative-date/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/relative-date", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/relative-date", () => { + const mockNow = "2026-05-28T12:00:00.000Z"; + + it("parses tomorrow relative to now", async () => { + const res = await POST(makeReq({ text: "tomorrow", now: mockNow })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.resolved).toBe("2026-05-29T12:00:00.000Z"); + expect(data.matched).toBe("tomorrow"); + }); + + it("parses yesterday relative to now", async () => { + const res = await POST(makeReq({ text: "yesterday", now: mockNow })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.resolved).toBe("2026-05-27T12:00:00.000Z"); + expect(data.matched).toBe("yesterday"); + }); + + it("parses next monday relative to now (which is a Thursday)", async () => { + // 2026-05-28 is a Thursday. + // Next Monday should be 2026-06-01. + const res = await POST(makeReq({ text: "next monday", now: mockNow })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.resolved).toBe("2026-06-01T12:00:00.000Z"); + expect(data.matched).toBe("next monday"); + }); + + it("parses in 3 days relative to now", async () => { + const res = await POST(makeReq({ text: "in 3 days", now: mockNow })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.resolved).toBe("2026-05-31T12:00:00.000Z"); + expect(data.matched).toBe("in 3 days"); + }); + + it("parses 2 weeks ago relative to now", async () => { + const res = await POST(makeReq({ text: "2 weeks ago", now: mockNow })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.resolved).toBe("2026-05-14T12:00:00.000Z"); + expect(data.matched).toBe("2 weeks ago"); + }); + + it("rejects unparseable input with 400", async () => { + const res = await POST(makeReq({ text: "random string", now: mockNow })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Unable to parse relative date"); + }); + + it("rejects invalid now ISO string with 400", async () => { + const res = await POST(makeReq({ text: "tomorrow", now: "invalid-date" })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("now is not a valid date string"); + }); + + it("rejects non-string text with 400", async () => { + const res = await POST(makeReq({ text: 123 })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("text must be a string"); + }); + + it("uses the current system time if now is not provided", async () => { + const res = await POST(makeReq({ text: "tomorrow" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.resolved).toBeDefined(); + expect(data.matched).toBe("tomorrow"); + + // The resolved date should be roughly 24 hours from now + const resolvedTime = new Date(data.resolved).getTime(); + const systemTomorrow = Date.now() + 24 * 60 * 60 * 1000; + expect(Math.abs(resolvedTime - systemTomorrow)).toBeLessThan(10000); // 10s tolerance + }); +}); diff --git a/app/api/routes-f/__tests__/retry-after.test.ts b/app/api/routes-f/__tests__/retry-after.test.ts new file mode 100644 index 00000000..ca529376 --- /dev/null +++ b/app/api/routes-f/__tests__/retry-after.test.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ +import { NextRequest } from 'next/server'; +import { POST } from '../retry-after/route'; + +function makeReq(body: unknown) { + return new NextRequest('http://localhost/api/routes-f/retry-after', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('/api/routes-f/retry-after', () => { + it('parses integer seconds and returns the correct retry_at', async () => { + const res = await POST( + makeReq({ header: '120', now: '2026-05-27T12:00:00.000Z' }) + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toEqual({ + delay_seconds: 120, + retry_at: '2026-05-27T12:02:00.000Z', + }); + }); + + it('parses an HTTP-date and returns the future delay', async () => { + const res = await POST( + makeReq({ + header: 'Fri, 28 May 2026 12:00:00 GMT', + now: '2026-05-27T12:00:00.000Z', + }) + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toEqual({ + delay_seconds: 86400, + retry_at: '2026-05-28T12:00:00.000Z', + }); + }); + + it('returns zero delay for a past HTTP-date', async () => { + const res = await POST( + makeReq({ + header: 'Wed, 27 May 2026 11:00:00 GMT', + now: '2026-05-27T12:00:00.000Z', + }) + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toEqual({ + delay_seconds: 0, + retry_at: '2026-05-27T11:00:00.000Z', + }); + }); + + it('rejects malformed retry-after headers', async () => { + const res = await POST(makeReq({ header: 'not-a-date', now: '2026-05-27T12:00:00.000Z' })); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/robots-txt.test.ts b/app/api/routes-f/__tests__/robots-txt.test.ts new file mode 100644 index 00000000..49cf3f3c --- /dev/null +++ b/app/api/routes-f/__tests__/robots-txt.test.ts @@ -0,0 +1,50 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../robots-txt/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/robots-txt", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/robots-txt", () => { + it("generates robots.txt for multiple agents", async () => { + const res = await POST( + makeReq({ + rules: [ + { user_agent: "*", allow: ["/"], disallow: ["/private"] }, + { user_agent: "Googlebot", disallow: ["/no-google"] }, + ], + }) + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.robots_txt).toBe( + "User-agent: *\nAllow: /\nDisallow: /private\n\nUser-agent: Googlebot\nDisallow: /no-google\n" + ); + }); + + it("includes a sitemap line when provided", async () => { + const res = await POST( + makeReq({ + rules: [{ user_agent: "*", disallow: ["/drafts"] }], + sitemap: "https://example.com/sitemap.xml", + }) + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.robots_txt).toContain("Sitemap: https://example.com/sitemap.xml"); + }); + + it("rejects requests without at least one rule", async () => { + const res = await POST(makeReq({ rules: [] })); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/routesFResponse.test.ts b/app/api/routes-f/__tests__/routesFResponse.test.ts new file mode 100644 index 00000000..cc5a5fb8 --- /dev/null +++ b/app/api/routes-f/__tests__/routesFResponse.test.ts @@ -0,0 +1,26 @@ +import { routesFSuccess, routesFError } from "../../routesF/response"; +import { ROUTES_F_API_VERSION } from "../../routesF/version" + +describe("Routes-F response wrapper", () => { + it("includes apiVersion in success response", async () => { + const res = routesFSuccess({ test: true }); + const body = await res.json(); + + expect(body).toEqual({ + apiVersion: ROUTES_F_API_VERSION, + success: true, + data: { test: true }, + }); + }); + + it("includes apiVersion in error response", async () => { + const res = routesFError("Error", 400); + const body = await res.json(); + + expect(body).toEqual({ + apiVersion: ROUTES_F_API_VERSION, + success: false, + error: "Error", + }); + }); +}); \ No newline at end of file diff --git a/app/api/routes-f/__tests__/scientific-notation.test.ts b/app/api/routes-f/__tests__/scientific-notation.test.ts new file mode 100644 index 00000000..2b3bbdd3 --- /dev/null +++ b/app/api/routes-f/__tests__/scientific-notation.test.ts @@ -0,0 +1,55 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../scientific-notation/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/scientific-notation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/scientific-notation", () => { + it("formats large numbers in scientific notation", async () => { + const res = await POST(makeReq({ mode: "format", value: 1230000, sig_figs: 3 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.result).toBe("1.23e6"); + }); + + it("parses scientific notation back to a number", async () => { + const res = await POST(makeReq({ mode: "parse", value: "1.23e6" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.result).toBe(1230000); + }); + + it("formats small magnitudes", async () => { + const res = await POST(makeReq({ mode: "format", value: 0.0000012, sig_figs: 2 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.result).toBe("1.2e-6"); + }); + + it("formats and parses negative engineering notation", async () => { + const formatRes = await POST( + makeReq({ mode: "format", value: -4560, sig_figs: 3, style: "engineering" }) + ); + expect(formatRes.status).toBe(200); + const formatted = await formatRes.json(); + expect(formatted.result).toBe("-4.56 k"); + + const parseRes = await POST(makeReq({ mode: "parse", value: "-4.56 k", style: "engineering" })); + expect(parseRes.status).toBe(200); + const parsed = await parseRes.json(); + expect(parsed.result).toBeCloseTo(-4560); + }); + + it("rejects invalid modes", async () => { + const res = await POST(makeReq({ mode: "convert", value: 42 })); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/sitemap-xml.test.ts b/app/api/routes-f/__tests__/sitemap-xml.test.ts new file mode 100644 index 00000000..75e68594 --- /dev/null +++ b/app/api/routes-f/__tests__/sitemap-xml.test.ts @@ -0,0 +1,130 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../sitemap-xml/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/sitemap-xml", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/sitemap-xml", () => { + it("generates a valid sitemap with a single required loc", async () => { + const res = await POST(makeReq({ urls: [{ loc: "https://example.com/" }] })); + expect(res.status).toBe(200); + const { sitemap } = await res.json(); + expect(sitemap).toContain(''); + expect(sitemap).toContain("https://example.com/"); + expect(sitemap).toContain(""); + }); + + it("includes optional fields when provided", async () => { + const res = await POST( + makeReq({ + urls: [ + { + loc: "https://example.com/page", + lastmod: "2024-01-15", + changefreq: "weekly", + priority: 0.8, + }, + ], + }) + ); + expect(res.status).toBe(200); + const { sitemap } = await res.json(); + expect(sitemap).toContain("2024-01-15"); + expect(sitemap).toContain("weekly"); + expect(sitemap).toContain("0.8"); + }); + + it("omits optional fields when not provided", async () => { + const res = await POST(makeReq({ urls: [{ loc: "https://example.com/" }] })); + expect(res.status).toBe(200); + const { sitemap } = await res.json(); + expect(sitemap).not.toContain(""); + expect(sitemap).not.toContain(""); + expect(sitemap).not.toContain(""); + }); + + it("handles multiple URL entries", async () => { + const res = await POST( + makeReq({ + urls: [ + { loc: "https://example.com/" }, + { loc: "https://example.com/about", priority: 0.5 }, + { loc: "https://example.com/blog", changefreq: "daily" }, + ], + }) + ); + expect(res.status).toBe(200); + const { sitemap } = await res.json(); + expect((sitemap.match(//g) ?? []).length).toBe(3); + }); + + it("escapes XML special characters in loc", async () => { + const res = await POST( + makeReq({ urls: [{ loc: "https://example.com/path?a=1&b=2" }] }) + ); + expect(res.status).toBe(200); + const { sitemap } = await res.json(); + expect(sitemap).toContain("&"); + expect(sitemap).not.toContain("&b="); + }); + + it("rejects invalid loc (not a URL)", async () => { + const res = await POST(makeReq({ urls: [{ loc: "not-a-url" }] })); + expect(res.status).toBe(400); + }); + + it("rejects priority outside [0, 1]", async () => { + const res = await POST( + makeReq({ urls: [{ loc: "https://example.com/", priority: 1.5 }] }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid changefreq", async () => { + const res = await POST( + makeReq({ urls: [{ loc: "https://example.com/", changefreq: "sometimes" }] }) + ); + expect(res.status).toBe(400); + }); + + it("rejects empty urls array", async () => { + const res = await POST(makeReq({ urls: [] })); + expect(res.status).toBe(400); + }); + + it("rejects missing urls field", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON body", async () => { + const req = new NextRequest("http://localhost/api/routes-f/sitemap-xml", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("accepts priority of exactly 0 and 1", async () => { + const res = await POST( + makeReq({ + urls: [ + { loc: "https://example.com/low", priority: 0 }, + { loc: "https://example.com/high", priority: 1 }, + ], + }) + ); + expect(res.status).toBe(200); + }); +}); diff --git a/app/api/routes-f/__tests__/stream-category-switch.test.ts b/app/api/routes-f/__tests__/stream-category-switch.test.ts new file mode 100644 index 00000000..704569fe --- /dev/null +++ b/app/api/routes-f/__tests__/stream-category-switch.test.ts @@ -0,0 +1,115 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, GET } from "../stream/category-switch/route"; +import { categoryTimelines } from "../stream/category-switch/store"; + +function postReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routes-f/stream/category-switch", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +function getReq(streamId: string) { + return new NextRequest( + `http://localhost/api/routes-f/stream/category-switch?stream_id=${streamId}` + ); +} + +describe("/api/routes-f/stream/category-switch", () => { + beforeEach(() => { + categoryTimelines.clear(); + }); + + describe("POST — switch category", () => { + it("switches to a valid category and returns previous + new", async () => { + const res = await POST( + postReq({ stream_id: "s1", category: "gaming" }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.previous_category).toBe("none"); + expect(data.new_category).toBe("gaming"); + expect(data.switched_at).toBeDefined(); + }); + + it("tracks successive category switches", async () => { + await POST(postReq({ stream_id: "s1", category: "gaming" })); + const res = await POST( + postReq({ stream_id: "s1", category: "music" }) + ); + const data = await res.json(); + expect(data.previous_category).toBe("gaming"); + expect(data.new_category).toBe("music"); + }); + + it("rejects an unknown category", async () => { + const res = await POST( + postReq({ stream_id: "s1", category: "underwater-basket-weaving" }) + ); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/Unknown category/); + }); + + it("rejects missing stream_id", async () => { + const res = await POST(postReq({ category: "gaming" })); + expect(res.status).toBe(400); + }); + + it("rejects missing category", async () => { + const res = await POST(postReq({ stream_id: "s1" })); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON body", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/stream/category-switch", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + } + ); + const res = await POST(req); + expect(res.status).toBe(400); + }); + }); + + describe("GET — category timeline", () => { + it("returns empty timeline for a fresh stream", async () => { + const res = await GET(getReq("s1")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.timeline).toEqual([]); + }); + + it("returns full timeline after multiple switches", async () => { + await POST(postReq({ stream_id: "s1", category: "gaming" })); + await POST(postReq({ stream_id: "s1", category: "music" })); + await POST(postReq({ stream_id: "s1", category: "irl" })); + + const res = await GET(getReq("s1")); + const data = await res.json(); + expect(data.stream_id).toBe("s1"); + expect(data.timeline).toHaveLength(3); + expect(data.timeline[0].category).toBe("gaming"); + expect(data.timeline[1].category).toBe("music"); + expect(data.timeline[2].category).toBe("irl"); + }); + + it("rejects missing stream_id", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/stream/category-switch" + ); + const res = await GET(req); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/stream-chat-restriction.test.ts b/app/api/routes-f/__tests__/stream-chat-restriction.test.ts new file mode 100644 index 00000000..78a30125 --- /dev/null +++ b/app/api/routes-f/__tests__/stream-chat-restriction.test.ts @@ -0,0 +1,321 @@ +/** + * @jest-environment jsdom + */ + +import { POST, DELETE, GET } from '../stream/chat/route'; +import { NextRequest } from 'next/server'; + +import { chatRestrictionStore } from '../stream/chat/utils'; + +describe('/api/routes-f/stream/chat', () => { + beforeEach(() => { + // Clear the in-memory store before each test + chatRestrictionStore.clear(); + }); + + describe('POST', () => { + it('should enable chat restriction with default 10 minutes', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.enabled).toBe(true); + expect(data.min_follow_minutes).toBe(10); + }); + + it('should enable chat restriction with custom threshold', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + min_follow_minutes: 30 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.enabled).toBe(true); + expect(data.min_follow_minutes).toBe(30); + }); + + it('should validate min_follow_minutes is at least 1', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + min_follow_minutes: 0 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('min_follow_minutes must be at least 1 minute'); + }); + + it('should validate min_follow_minutes is an integer', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + min_follow_minutes: 10.5 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('min_follow_minutes must be an integer'); + }); + + it('should validate min_follow_minutes maximum (1 week)', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + min_follow_minutes: 10081 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('min_follow_minutes must be at most 10080 minutes (1 week)'); + }); + + it('should accept max valid value (10080 minutes)', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + min_follow_minutes: 10080 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.min_follow_minutes).toBe(10080); + }); + + it('should return 400 for missing stream_id', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + min_follow_minutes: 10 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it('should return 400 for invalid JSON', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: 'invalid json', + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + }); + + describe('DELETE', () => { + it('should disable chat restriction', async () => { + // First enable restriction + const postRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + min_follow_minutes: 10 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(postRequest); + + // Then disable it + const deleteRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat?stream_id=stream-123', { + method: 'DELETE' + }); + const response = await DELETE(deleteRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.enabled).toBe(false); + }); + + it('should return 400 for missing stream_id', async () => { + const deleteRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'DELETE' + }); + const response = await DELETE(deleteRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('stream_id is required'); + }); + }); + + describe('GET', () => { + it('should return current restriction state when enabled', async () => { + // Enable restriction + const postRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + min_follow_minutes: 15 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(postRequest); + + // Get state + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat?stream_id=stream-123'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.enabled).toBe(true); + expect(data.min_follow_minutes).toBe(15); + }); + + it('should return disabled state when no restriction exists', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat?stream_id=stream-456'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.enabled).toBe(false); + expect(data.min_follow_minutes).toBeUndefined(); + }); + + it('should return 400 for missing stream_id', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('stream_id is required'); + }); + + it('should handle toggle lifecycle (enable -> get -> disable -> get)', async () => { + const streamId = 'stream-123'; + + // Enable + const postRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: streamId, + min_follow_minutes: 20 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + let response = await POST(postRequest); + expect(response.status).toBe(200); + + // Get - should be enabled + let getRequest = new NextRequest(`http://localhost:3000/api/routes-f/stream/chat?stream_id=${streamId}`); + response = await GET(getRequest); + let data = await response.json(); + expect(data.enabled).toBe(true); + expect(data.min_follow_minutes).toBe(20); + + // Disable + const deleteRequest = new NextRequest(`http://localhost:3000/api/routes-f/stream/chat?stream_id=${streamId}`, { + method: 'DELETE' + }); + response = await DELETE(deleteRequest); + expect(response.status).toBe(200); + + // Get - should be disabled + getRequest = new NextRequest(`http://localhost:3000/api/routes-f/stream/chat?stream_id=${streamId}`); + response = await GET(getRequest); + data = await response.json(); + expect(data.enabled).toBe(false); + }); + + it('should update threshold when re-enabling with different value', async () => { + const streamId = 'stream-123'; + + // Enable with 10 minutes + const postRequest1 = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: streamId, + min_follow_minutes: 10 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(postRequest1); + + // Re-enable with 30 minutes + const postRequest2 = new NextRequest('http://localhost:3000/api/routes-f/stream/chat', { + method: 'POST', + body: JSON.stringify({ + stream_id: streamId, + min_follow_minutes: 30 + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(postRequest2); + + // Get state + const getRequest = new NextRequest(`http://localhost:3000/api/routes-f/stream/chat?stream_id=${streamId}`); + const response = await GET(getRequest); + const data = await response.json(); + + expect(data.enabled).toBe(true); + expect(data.min_follow_minutes).toBe(30); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/stream-chat-timeout.test.ts b/app/api/routes-f/__tests__/stream-chat-timeout.test.ts new file mode 100644 index 00000000..54b70143 --- /dev/null +++ b/app/api/routes-f/__tests__/stream-chat-timeout.test.ts @@ -0,0 +1,196 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, GET, DELETE } from "../stream/chat-timeout/route"; +import { timeoutStore } from "../stream/chat-timeout/store"; + +function postReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routes-f/stream/chat-timeout", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +function getReq(streamId: string) { + return new NextRequest( + `http://localhost/api/routes-f/stream/chat-timeout?stream_id=${streamId}` + ); +} + +function deleteReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routes-f/stream/chat-timeout", + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +describe("/api/routes-f/stream/chat-timeout", () => { + beforeEach(() => { + timeoutStore.clear(); + }); + + describe("POST — apply timeout", () => { + it("applies a timeout and returns expires_at", async () => { + const res = await POST( + postReq({ + stream_id: "s1", + user_id: "u1", + seconds: 300, + reason: "spamming", + }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.expires_at).toBeDefined(); + const expiresAt = new Date(data.expires_at).getTime(); + expect(expiresAt).toBeGreaterThan(Date.now()); + }); + + it("allows optional reason to be omitted", async () => { + const res = await POST( + postReq({ stream_id: "s1", user_id: "u1", seconds: 60 }) + ); + expect(res.status).toBe(200); + }); + + it("rejects seconds below 1", async () => { + const res = await POST( + postReq({ stream_id: "s1", user_id: "u1", seconds: 0 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects seconds above 86400", async () => { + const res = await POST( + postReq({ stream_id: "s1", user_id: "u1", seconds: 86401 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects non-integer seconds", async () => { + const res = await POST( + postReq({ stream_id: "s1", user_id: "u1", seconds: 1.5 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing stream_id", async () => { + const res = await POST( + postReq({ user_id: "u1", seconds: 60 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing user_id", async () => { + const res = await POST( + postReq({ stream_id: "s1", seconds: 60 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing seconds", async () => { + const res = await POST( + postReq({ stream_id: "s1", user_id: "u1" }) + ); + expect(res.status).toBe(400); + }); + }); + + describe("GET — list active timeouts", () => { + it("returns empty list when no timeouts", async () => { + const res = await GET(getReq("s1")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.timeouts).toEqual([]); + }); + + it("returns active timeouts with seconds_remaining", async () => { + await POST( + postReq({ + stream_id: "s1", + user_id: "u1", + seconds: 600, + reason: "spam", + }) + ); + await POST( + postReq({ stream_id: "s1", user_id: "u2", seconds: 300 }) + ); + + const res = await GET(getReq("s1")); + const data = await res.json(); + expect(data.stream_id).toBe("s1"); + expect(data.timeouts).toHaveLength(2); + expect(data.timeouts[0].seconds_remaining).toBeGreaterThan(0); + }); + + it("filters out expired timeouts automatically", async () => { + // Manually insert an already-expired entry + timeoutStore.set("s1:u-expired", { + stream_id: "s1", + user_id: "u-expired", + expires_at: new Date(Date.now() - 1000).toISOString(), + }); + + const res = await GET(getReq("s1")); + const data = await res.json(); + expect(data.timeouts).toHaveLength(0); + }); + + it("rejects missing stream_id", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/stream/chat-timeout" + ); + const res = await GET(req); + expect(res.status).toBe(400); + }); + }); + + describe("DELETE — lift timeout", () => { + it("lifts an active timeout", async () => { + await POST( + postReq({ stream_id: "s1", user_id: "u1", seconds: 300 }) + ); + + const res = await DELETE( + deleteReq({ stream_id: "s1", user_id: "u1" }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.lifted).toBe(true); + + // Verify it's gone + const getRes = await GET(getReq("s1")); + const getData = await getRes.json(); + expect(getData.timeouts).toHaveLength(0); + }); + + it("returns lifted=false when no timeout exists", async () => { + const res = await DELETE( + deleteReq({ stream_id: "s1", user_id: "u-none" }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.lifted).toBe(false); + }); + + it("rejects missing stream_id", async () => { + const res = await DELETE(deleteReq({ user_id: "u1" })); + expect(res.status).toBe(400); + }); + + it("rejects missing user_id", async () => { + const res = await DELETE(deleteReq({ stream_id: "s1" })); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/stream-intermission.test.ts b/app/api/routes-f/__tests__/stream-intermission.test.ts new file mode 100644 index 00000000..d695ed5e --- /dev/null +++ b/app/api/routes-f/__tests__/stream-intermission.test.ts @@ -0,0 +1,274 @@ +/** + * @jest-environment jsdom + */ + +import { POST, DELETE, GET } from '../stream/intermission/route'; +import { NextRequest } from 'next/server'; + +import { intermissionStore } from '../stream/intermission/utils'; + +describe('/api/routes-f/stream/intermission', () => { + beforeEach(() => { + // Clear the in-memory store before each test + intermissionStore.clear(); + }); + + describe('POST', () => { + it('should create an intermission with message', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + message: 'Be right back!' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.active).toBe(true); + }); + + it('should create an intermission with ends_at timer', async () => { + const endsAt = new Date(Date.now() + 300000).toISOString(); // 5 minutes from now + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + message: 'Short break', + ends_at: endsAt + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.active).toBe(true); + }); + + it('should return 400 for invalid ends_at format', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + message: 'Break', + ends_at: 'invalid-date' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('ends_at must be a valid ISO date'); + }); + + it('should return 400 for missing stream_id', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + message: 'Break' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it('should return 400 for missing message', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it('should return 400 for invalid JSON', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: 'invalid json', + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + }); + + describe('DELETE', () => { + it('should clear an active intermission', async () => { + // First create an intermission + const postRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + message: 'Break' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(postRequest); + + // Then delete it + const deleteRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission?stream_id=stream-123', { + method: 'DELETE' + }); + const response = await DELETE(deleteRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.active).toBe(false); + }); + + it('should return 400 for missing stream_id', async () => { + const deleteRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'DELETE' + }); + const response = await DELETE(deleteRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('stream_id is required'); + }); + }); + + describe('GET', () => { + it('should return active intermission state', async () => { + // Create an intermission + const postRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + message: 'Be right back!' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(postRequest); + + // Get the state + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission?stream_id=stream-123'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.active).toBe(true); + expect(data.message).toBe('Be right back!'); + expect(data.ends_at).toBeUndefined(); + expect(data.seconds_remaining).toBeUndefined(); + }); + + it('should return intermission with countdown when ends_at is set', async () => { + const endsAt = new Date(Date.now() + 120000).toISOString(); // 2 minutes from now + const postRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + message: 'Short break', + ends_at: endsAt + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(postRequest); + + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission?stream_id=stream-123'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.active).toBe(true); + expect(data.message).toBe('Short break'); + expect(data.ends_at).toBe(endsAt); + expect(data.seconds_remaining).toBeGreaterThan(0); + expect(data.seconds_remaining).toBeLessThanOrEqual(120); + }); + + it('should return inactive state when no intermission exists', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission?stream_id=stream-456'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.active).toBe(false); + expect(data.message).toBeUndefined(); + expect(data.ends_at).toBeUndefined(); + expect(data.seconds_remaining).toBeUndefined(); + }); + + it('should return 400 for missing stream_id', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('stream_id is required'); + }); + + it('should handle intermission lifecycle (create -> get -> delete -> get)', async () => { + const streamId = 'stream-123'; + + // Create + const postRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/intermission', { + method: 'POST', + body: JSON.stringify({ + stream_id: streamId, + message: 'Lifecycle test' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + let response = await POST(postRequest); + expect(response.status).toBe(200); + + // Get - should be active + let getRequest = new NextRequest(`http://localhost:3000/api/routes-f/stream/intermission?stream_id=${streamId}`); + response = await GET(getRequest); + let data = await response.json(); + expect(data.active).toBe(true); + + // Delete + const deleteRequest = new NextRequest(`http://localhost:3000/api/routes-f/stream/intermission?stream_id=${streamId}`, { + method: 'DELETE' + }); + response = await DELETE(deleteRequest); + expect(response.status).toBe(200); + + // Get - should be inactive + getRequest = new NextRequest(`http://localhost:3000/api/routes-f/stream/intermission?stream_id=${streamId}`); + response = await GET(getRequest); + data = await response.json(); + expect(data.active).toBe(false); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/stream-pinned-message.test.ts b/app/api/routes-f/__tests__/stream-pinned-message.test.ts new file mode 100644 index 00000000..d96b66db --- /dev/null +++ b/app/api/routes-f/__tests__/stream-pinned-message.test.ts @@ -0,0 +1,232 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, DELETE, GET } from "../stream/pinned-message/route"; +import { pinnedMessages } from "../stream/pinned-message/store"; + +function postReq(body: unknown) { + return new NextRequest( + "http://localhost/api/routes-f/stream/pinned-message", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +function getReq(streamId: string) { + return new NextRequest( + `http://localhost/api/routes-f/stream/pinned-message?stream_id=${streamId}` + ); +} + +function deleteReq(streamId: string) { + return new NextRequest( + `http://localhost/api/routes-f/stream/pinned-message?stream_id=${streamId}`, + { method: "DELETE" } + ); +} + +describe("/api/routes-f/stream/pinned-message", () => { + beforeEach(() => { + pinnedMessages.clear(); + }); + + describe("POST — pin a message", () => { + it("pins a message and returns pinned_at", async () => { + const res = await POST( + postReq({ + stream_id: "s1", + message_id: "msg-1", + message_text: "Hello everyone!", + pinned_by: "mod-1", + }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.pinned_at).toBeDefined(); + expect(data.expires_at).toBeUndefined(); + }); + + it("returns expires_at when provided", async () => { + const expiresAt = new Date(Date.now() + 60000).toISOString(); + const res = await POST( + postReq({ + stream_id: "s1", + message_id: "msg-1", + message_text: "Limited pin", + pinned_by: "mod-1", + expires_at: expiresAt, + }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.expires_at).toBe(expiresAt); + }); + + it("replaces the previous pin (only most recent is active)", async () => { + await POST( + postReq({ + stream_id: "s1", + message_id: "msg-1", + message_text: "First pin", + pinned_by: "mod-1", + }) + ); + await POST( + postReq({ + stream_id: "s1", + message_id: "msg-2", + message_text: "Second pin", + pinned_by: "mod-2", + }) + ); + + const res = await GET(getReq("s1")); + const data = await res.json(); + expect(data.pin.message_id).toBe("msg-2"); + expect(data.pin.message_text).toBe("Second pin"); + }); + + it("rejects missing stream_id", async () => { + const res = await POST( + postReq({ + message_id: "m1", + message_text: "t", + pinned_by: "u1", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing message_id", async () => { + const res = await POST( + postReq({ + stream_id: "s1", + message_text: "t", + pinned_by: "u1", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing message_text", async () => { + const res = await POST( + postReq({ + stream_id: "s1", + message_id: "m1", + pinned_by: "u1", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing pinned_by", async () => { + const res = await POST( + postReq({ + stream_id: "s1", + message_id: "m1", + message_text: "t", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid expires_at", async () => { + const res = await POST( + postReq({ + stream_id: "s1", + message_id: "m1", + message_text: "t", + pinned_by: "u1", + expires_at: "not-a-date", + }) + ); + expect(res.status).toBe(400); + }); + }); + + describe("DELETE — unpin", () => { + it("unpins the current message", async () => { + await POST( + postReq({ + stream_id: "s1", + message_id: "msg-1", + message_text: "Pin", + pinned_by: "mod-1", + }) + ); + + const delRes = await DELETE(deleteReq("s1")); + expect(delRes.status).toBe(200); + expect((await delRes.json()).unpinned).toBe(true); + + const getRes = await GET(getReq("s1")); + const data = await getRes.json(); + expect(data.pin).toBeNull(); + }); + + it("rejects missing stream_id", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/stream/pinned-message", + { method: "DELETE" } + ); + const res = await DELETE(req); + expect(res.status).toBe(400); + }); + }); + + describe("GET — get current pin", () => { + it("returns null when no pin exists", async () => { + const res = await GET(getReq("s1")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.pin).toBeNull(); + }); + + it("returns the current pin", async () => { + await POST( + postReq({ + stream_id: "s1", + message_id: "msg-1", + message_text: "Pinned!", + pinned_by: "mod-1", + }) + ); + + const res = await GET(getReq("s1")); + const data = await res.json(); + expect(data.pin.message_id).toBe("msg-1"); + expect(data.pin.message_text).toBe("Pinned!"); + expect(data.pin.pinned_by).toBe("mod-1"); + expect(data.pin.pinned_at).toBeDefined(); + }); + + it("auto-clears an expired pin", async () => { + const pastDate = new Date(Date.now() - 1000).toISOString(); + await POST( + postReq({ + stream_id: "s1", + message_id: "msg-1", + message_text: "Expired", + pinned_by: "mod-1", + expires_at: pastDate, + }) + ); + + const res = await GET(getReq("s1")); + const data = await res.json(); + expect(data.pin).toBeNull(); + }); + + it("rejects missing stream_id", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/stream/pinned-message" + ); + const res = await GET(req); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/stream-tags.test.ts b/app/api/routes-f/__tests__/stream-tags.test.ts new file mode 100644 index 00000000..6556e499 --- /dev/null +++ b/app/api/routes-f/__tests__/stream-tags.test.ts @@ -0,0 +1,248 @@ +/** + * @jest-environment jsdom + */ + +import { POST, GET } from '../stream/tags/route'; +import { NextRequest } from 'next/server'; + +import { streamTagsStore } from '../stream/tags/utils'; + +describe('/api/routes-f/stream/tags', () => { + beforeEach(() => { + // Clear the in-memory store before each test + streamTagsStore.clear(); + }); + + describe('POST', () => { + it('should add tags to a stream', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['gaming', 'fps'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.tags).toEqual(['gaming', 'fps']); + }); + + it('should normalize tags to lowercase and hyphenated', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['GAMING', 'First Person Shooter', ' RPG '] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.tags).toEqual(['gaming', 'first-person-shooter', 'rpg']); + }); + + it('should remove tags from a stream', async () => { + // First add tags + const addRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['gaming', 'fps', 'rpg'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(addRequest); + + // Then remove one + const removeRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + remove: ['fps'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(removeRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.tags).toEqual(['gaming', 'rpg']); + }); + + it('should deduplicate tags', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['gaming', 'GAMING', 'gaming'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.tags).toEqual(['gaming']); + }); + + it('should cap tags at 10', async () => { + // First add 10 tags + const addRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7', 'tag8', 'tag9', 'tag10'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(addRequest); + + // Try to add one more + const overflowRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['tag11'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(overflowRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Maximum 10 tags allowed'); + }); + + it('should handle add and remove in same request', async () => { + // First add tags + const addRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['gaming', 'fps', 'rpg'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(addRequest); + + // Add and remove in same request + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['action'], + remove: ['rpg'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.tags).toEqual(['gaming', 'fps', 'action']); + }); + + it('should return 400 for missing stream_id', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + add: ['gaming'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + }); + + it('should return 400 for invalid JSON', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: 'invalid json', + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + }); + + describe('GET', () => { + it('should return current tags for a stream', async () => { + // First add tags + const addRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags', { + method: 'POST', + body: JSON.stringify({ + stream_id: 'stream-123', + add: ['gaming', 'fps'] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await POST(addRequest); + + // Then get them + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags?stream_id=stream-123'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.tags).toEqual(['gaming', 'fps']); + }); + + it('should return empty array for stream with no tags', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags?stream_id=stream-456'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.tags).toEqual([]); + }); + + it('should return 400 for missing stream_id', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/tags'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('stream_id is required'); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/stream-title.test.ts b/app/api/routes-f/__tests__/stream-title.test.ts new file mode 100644 index 00000000..9582889b --- /dev/null +++ b/app/api/routes-f/__tests__/stream-title.test.ts @@ -0,0 +1,208 @@ +/** + * @jest-environment jsdom + */ + +import { PATCH, GET } from '../stream/title/route'; +import { NextRequest } from 'next/server'; + +import { titleHistoryStore } from '../stream/title/utils'; + +describe('/api/routes-f/stream/title', () => { + beforeEach(() => { + // Clear the in-memory store before each test + titleHistoryStore.clear(); + }); + + describe('PATCH', () => { + it('should update stream title and return timestamp', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + stream_id: 'stream-123', + title: 'My Awesome Stream' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.title).toBe('My Awesome Stream'); + expect(data.updated_at).toBeDefined(); + expect(typeof data.updated_at).toBe('string'); + }); + + it('should validate title length (min 1 char)', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + stream_id: 'stream-123', + title: '' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Title must be at least 1 character'); + }); + + it('should validate title length (max 100 chars)', async () => { + const longTitle = 'a'.repeat(101); + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + stream_id: 'stream-123', + title: longTitle + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Title must be at most 100 characters'); + }); + + it('should accept title exactly at 100 characters', async () => { + const title = 'a'.repeat(100); + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + stream_id: 'stream-123', + title: title + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.title).toBe(title); + }); + + it('should return 400 for missing stream_id', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + title: 'My Stream' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await PATCH(request); + + expect(response.status).toBe(400); + }); + + it('should return 400 for invalid JSON', async () => { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: 'invalid json', + headers: { + 'Content-Type': 'application/json' + } + }); + + const response = await PATCH(request); + + expect(response.status).toBe(400); + }); + }); + + describe('GET', () => { + it('should return last 10 title changes for a stream', async () => { + // Add 12 title changes + for (let i = 1; i <= 12; i++) { + const request = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + stream_id: 'stream-123', + title: `Title ${i}` + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await PATCH(request); + } + + // Get history + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/title?stream_id=stream-123'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.history).toHaveLength(10); + expect(data.history[0].title).toBe('Title 12'); // Most recent first + expect(data.history[9].title).toBe('Title 3'); // Oldest of the 10 + }); + + it('should return empty array for stream with no history', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/title?stream_id=stream-456'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.history).toEqual([]); + }); + + it('should maintain chronological order (newest first)', async () => { + const request1 = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + stream_id: 'stream-123', + title: 'First Title' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await PATCH(request1); + + const request2 = new NextRequest('http://localhost:3000/api/routes-f/stream/title', { + method: 'PATCH', + body: JSON.stringify({ + stream_id: 'stream-123', + title: 'Second Title' + }), + headers: { + 'Content-Type': 'application/json' + } + }); + await PATCH(request2); + + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/title?stream_id=stream-123'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.history[0].title).toBe('Second Title'); + expect(data.history[1].title).toBe('First Title'); + }); + + it('should return 400 for missing stream_id', async () => { + const getRequest = new NextRequest('http://localhost:3000/api/routes-f/stream/title'); + const response = await GET(getRequest); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('stream_id is required'); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/subscriber-roster.test.ts b/app/api/routes-f/__tests__/subscriber-roster.test.ts new file mode 100644 index 00000000..991cd3f0 --- /dev/null +++ b/app/api/routes-f/__tests__/subscriber-roster.test.ts @@ -0,0 +1,56 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../subscriber-roster/route"; + +function makeReq(creatorId: string) { + const req = new NextRequest("http://localhost/api/routes-f/subscriber-roster", { + method: "GET", + }); + req.nextUrl.searchParams.set("creator_id", creatorId); + return req; +} + +describe("/api/routes-f/subscriber-roster", () => { + it("returns subscriber list with tier breakdown", async () => { + const req = makeReq("creator123"); + const res = await GET(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toHaveProperty("subscribers"); + expect(data).toHaveProperty("by_tier"); + expect(data).toHaveProperty("monthly_recurring_revenue_usdc"); + expect(Array.isArray(data.subscribers)).toBe(true); + }); + + it("computes MRR correctly", async () => { + const req = makeReq("creator123"); + const res = await GET(req); + const data = await res.json(); + + expect(typeof data.monthly_recurring_revenue_usdc).toBe("number"); + expect(data.monthly_recurring_revenue_usdc).toBeGreaterThanOrEqual(0); + }); + + it("sorts subscribers by started_at descending", async () => { + const req = makeReq("creator123"); + const res = await GET(req); + const data = await res.json(); + + if (data.subscribers.length > 1) { + for (let i = 0; i < data.subscribers.length - 1; i++) { + const current = new Date(data.subscribers[i].started_at); + const next = new Date(data.subscribers[i + 1].started_at); + expect(current.getTime()).toBeGreaterThanOrEqual(next.getTime()); + } + } + }); + + it("rejects missing creator_id", async () => { + const req = new NextRequest("http://localhost/api/routes-f/subscriber-roster"); + const res = await GET(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/subscription-badges.test.ts b/app/api/routes-f/__tests__/subscription-badges.test.ts new file mode 100644 index 00000000..9b827355 --- /dev/null +++ b/app/api/routes-f/__tests__/subscription-badges.test.ts @@ -0,0 +1,144 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../subscriptions/badges/route"; +import { subscriptions } from "../subscriptions/route"; + +function makeReq(creatorId: string, subscriberId: string) { + return new NextRequest( + `http://localhost/api/routes-f/subscriptions/badges?creator_id=${creatorId}&subscriber_id=${subscriberId}` + ); +} + +describe("Subscription Badges by Tier API", () => { + beforeEach(() => { + subscriptions.clear(); + }); + + it("should return has_sub false if no subscription exists", async () => { + const req = makeReq("creator-1", "user-1"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.has_sub).toBe(false); + expect(data.months_subscribed).toBe(0); + expect(data.tier_id).toBeUndefined(); + }); + + it("should return active subscription details (single tier)", async () => { + const creatorId = "creator-1"; + const subscriberId = "user-1"; + + const now = Date.now(); + // 30 days active sub + subscriptions.set("sub-1", { + subscription_id: "sub-1", + subscriber_id: subscriberId, + creator_id: creatorId, + tier_id: "premium", + payment_tx_hash: "hash-1", + asset: "USDC", + started_at: new Date(now - 15 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(now + 15 * 24 * 60 * 60 * 1000).toISOString(), + }); + + const req = makeReq(creatorId, subscriberId); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.has_sub).toBe(true); + expect(data.tier_id).toBe("premium"); + expect(data.badge_url).toBe("/api/routes-f/subscriptions/badges/svg"); + expect(data.months_subscribed).toBe(1); // 30 days total + }); + + it("should stack months_subscribed across non-overlapping historical subscriptions", async () => { + const creatorId = "creator-1"; + const subscriberId = "user-1"; + const now = Date.now(); + + // Sub 1: Active premium sub (30 days, from now-15d to now+15d) + subscriptions.set("sub-1", { + subscription_id: "sub-1", + subscriber_id: subscriberId, + creator_id: creatorId, + tier_id: "premium", + payment_tx_hash: "hash-1", + asset: "USDC", + started_at: new Date(now - 15 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(now + 15 * 24 * 60 * 60 * 1000).toISOString(), + }); + + // Sub 2: Past basic sub (30 days, from now-75d to now-45d) + subscriptions.set("sub-2", { + subscription_id: "sub-2", + subscriber_id: subscriberId, + creator_id: creatorId, + tier_id: "basic", + payment_tx_hash: "hash-2", + asset: "XLM", + started_at: new Date(now - 75 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(now - 45 * 24 * 60 * 60 * 1000).toISOString(), + }); + + // Sub 3: Even older basic sub (60 days, from now-150d to now-90d) + subscriptions.set("sub-3", { + subscription_id: "sub-3", + subscriber_id: subscriberId, + creator_id: creatorId, + tier_id: "basic", + payment_tx_hash: "hash-3", + asset: "XLM", + started_at: new Date(now - 150 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(now - 90 * 24 * 60 * 60 * 1000).toISOString(), + }); + + const req = makeReq(creatorId, subscriberId); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.has_sub).toBe(true); + expect(data.tier_id).toBe("premium"); // Returns current active tier + expect(data.months_subscribed).toBe(4); // 30 + 30 + 60 = 120 days -> 4 months + }); + + it("should not double count overlapping subscription days", async () => { + const creatorId = "creator-1"; + const subscriberId = "user-1"; + const now = Date.now(); + + // Sub 1: 30 days + subscriptions.set("sub-1", { + subscription_id: "sub-1", + subscriber_id: subscriberId, + creator_id: creatorId, + tier_id: "premium", + payment_tx_hash: "hash-1", + asset: "USDC", + started_at: new Date(now - 15 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(now + 15 * 24 * 60 * 60 * 1000).toISOString(), + }); + + // Sub 2: Overlapping (starts 5 days into sub-1, runs for 30 days) + subscriptions.set("sub-2", { + subscription_id: "sub-2", + subscriber_id: subscriberId, + creator_id: creatorId, + tier_id: "premium", + payment_tx_hash: "hash-2", + asset: "USDC", + started_at: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(now + 20 * 24 * 60 * 60 * 1000).toISOString(), + }); + + const req = makeReq(creatorId, subscriberId); + const res = await GET(req); + const data = await res.json(); + + // Total interval: now-15 to now+20 (35 days total) -> Math.floor(35/30) = 1 month + expect(data.months_subscribed).toBe(1); + }); +}); diff --git a/app/api/routes-f/__tests__/subscription-tiers.test.ts b/app/api/routes-f/__tests__/subscription-tiers.test.ts new file mode 100644 index 00000000..23502dd8 --- /dev/null +++ b/app/api/routes-f/__tests__/subscription-tiers.test.ts @@ -0,0 +1,117 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET, POST } from "../subscription-tiers/route"; + +function makeReq(body?: unknown) { + return new NextRequest("http://localhost/api/routes-f/subscription-tiers", { + method: body ? "POST" : "GET", + headers: { "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +function makeAuthReq(body: unknown) { + const req = new NextRequest("http://localhost/api/routes-f/subscription-tiers", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "session=mock-token", + }, + body: JSON.stringify(body), + }); + return req; +} + +describe("/api/routes-f/subscription-tiers", () => { + describe("POST", () => { + it("creates a tier with valid fields", async () => { + const req = makeAuthReq({ + name: "Basic", + price_usdc: 5, + duration_days: 30, + perks: ["badge"], + }); + const res = await POST(req); + expect(res.status).toBe(201); + + const data = await res.json(); + expect(data).toHaveProperty("id"); + expect(data.name).toBe("Basic"); + expect(data.price_usdc).toBe(5); + }); + + it("rejects tier with missing name", async () => { + const req = makeAuthReq({ + price_usdc: 5, + duration_days: 30, + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects tier with zero price", async () => { + const req = makeAuthReq({ + name: "Free", + price_usdc: 0, + duration_days: 30, + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects tier with negative duration", async () => { + const req = makeAuthReq({ + name: "Bad", + price_usdc: 5, + duration_days: -1, + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("caps at 5 tiers per creator", async () => { + for (let i = 0; i < 5; i++) { + const req = makeAuthReq({ + name: `Tier ${i}`, + price_usdc: i + 1, + duration_days: 30, + }); + const res = await POST(req); + expect(res.status).toBe(201); + } + + const sixthReq = makeAuthReq({ + name: "Tier 6", + price_usdc: 6, + duration_days: 30, + }); + const sixthRes = await POST(sixthReq); + expect(sixthRes.status).toBe(400); + + const data = await sixthRes.json(); + expect(data.error).toContain("Cannot exceed 5"); + }); + }); + + describe("GET", () => { + it("lists tiers for creator", async () => { + const req = makeReq(); + req.nextUrl.searchParams.set("creator_id", "user123"); + + const res = await GET(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toHaveProperty("tiers"); + expect(Array.isArray(data.tiers)).toBe(true); + }); + + it("rejects missing creator_id", async () => { + const req = makeReq(); + const res = await GET(req); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/template-interpolation.test.ts b/app/api/routes-f/__tests__/template-interpolation.test.ts new file mode 100644 index 00000000..efd941dc --- /dev/null +++ b/app/api/routes-f/__tests__/template-interpolation.test.ts @@ -0,0 +1,78 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../template-interpolation/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/template-interpolation", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/template-interpolation", () => { + it("interpolates nested values using dot paths", async () => { + const res = await POST( + makeReq({ + template: "Hello {{user.name}}, your city is {{user.location.city}}.", + values: { user: { name: "Alice", location: { city: "Seattle" } } }, + }) + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + output: "Hello Alice, your city is Seattle.", + missing_keys: [], + }); + }); + + it("replaces missing values with empty strings when on_missing is empty", async () => { + const res = await POST( + makeReq({ + template: "Hi {{user.name}}, {{user.age}} years old.", + values: { user: { name: "Bob" } }, + on_missing: "empty", + }) + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + output: "Hi Bob, years old.", + missing_keys: ["user.age"], + }); + }); + + it("keeps placeholders when on_missing is keep", async () => { + const res = await POST( + makeReq({ + template: "Hello {{user.name}} and {{user.nickname}}.", + values: { user: { name: "Sam" } }, + on_missing: "keep", + }) + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + output: "Hello Sam and {{user.nickname}}.", + missing_keys: ["user.nickname"], + }); + }); + + it("returns error when on_missing is error and values are missing", async () => { + const res = await POST( + makeReq({ + template: "{{a}} {{b}}", + values: { a: "1" }, + on_missing: "error", + }) + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Missing values for template placeholders.", + missing_keys: ["b"], + }); + }); +}); diff --git a/app/api/routes-f/__tests__/text-fingerprint.test.ts b/app/api/routes-f/__tests__/text-fingerprint.test.ts new file mode 100644 index 00000000..aa87b8f7 --- /dev/null +++ b/app/api/routes-f/__tests__/text-fingerprint.test.ts @@ -0,0 +1,119 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../text-fingerprint/route"; +import { fingerprint, normalizeText } from "../text-fingerprint/_lib/helpers"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/text-fingerprint", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("normalizeText", () => { + it("lowercases text", () => { + expect(normalizeText("Hello World")).toBe("hello world"); + }); + + it("strips punctuation and collapses resulting whitespace", () => { + // comma and ! become spaces → collapse → "hello world" + expect(normalizeText("hello, world!")).toBe("hello world"); + // apostrophe → space, period → space, collapse → "it s a test" + expect(normalizeText("it's a test.")).toBe("it s a test"); + }); + + it("collapses whitespace and trims", () => { + expect(normalizeText(" foo bar ")).toBe("foo bar"); + }); +}); + +describe("fingerprint", () => { + it("returns a sha256 hex fingerprint and normalized text", () => { + const result = fingerprint("Hello World"); + expect(result.fingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(result.normalized).toBe("hello world"); + }); + + it("same fingerprint for texts differing only in word order", () => { + const a = fingerprint("foo bar baz"); + const b = fingerprint("baz foo bar"); + expect(a.fingerprint).toBe(b.fingerprint); + }); + + it("same fingerprint for texts differing only in case", () => { + const a = fingerprint("Hello World"); + const b = fingerprint("hello world"); + expect(a.fingerprint).toBe(b.fingerprint); + }); + + it("same fingerprint for texts differing only in punctuation and order", () => { + const a = fingerprint("The quick, brown fox!"); + const b = fingerprint("fox brown quick the"); + expect(a.fingerprint).toBe(b.fingerprint); + }); + + it("different fingerprint for genuinely different content", () => { + const a = fingerprint("hello world"); + const b = fingerprint("goodbye world"); + expect(a.fingerprint).not.toBe(b.fingerprint); + }); + + it("is idempotent for same input", () => { + const a = fingerprint("test text"); + const b = fingerprint("test text"); + expect(a.fingerprint).toBe(b.fingerprint); + }); + + it("handles empty string", () => { + const result = fingerprint(""); + expect(result.fingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(result.normalized).toBe(""); + }); +}); + +describe("POST /api/routes-f/text-fingerprint", () => { + it("returns fingerprint and normalized for valid text", async () => { + const res = await POST(makeReq({ text: "Hello World" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toHaveProperty("fingerprint"); + expect(data).toHaveProperty("normalized"); + expect(data.fingerprint).toMatch(/^[0-9a-f]{64}$/); + }); + + it("returns same fingerprint for order-swapped text", async () => { + const res1 = await POST(makeReq({ text: "apple banana cherry" })); + const res2 = await POST(makeReq({ text: "cherry apple banana" })); + const d1 = await res1.json(); + const d2 = await res2.json(); + expect(d1.fingerprint).toBe(d2.fingerprint); + }); + + it("returns 400 for missing text field", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + }); + + it("returns 400 when text is not a string", async () => { + const res = await POST(makeReq({ text: 123 })); + expect(res.status).toBe(400); + }); + + it("returns 400 for non-object body", async () => { + const res = await POST(makeReq("just a string")); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/text-fingerprint", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/__tests__/text-similarity.test.ts b/app/api/routes-f/__tests__/text-similarity.test.ts new file mode 100644 index 00000000..8dee4473 --- /dev/null +++ b/app/api/routes-f/__tests__/text-similarity.test.ts @@ -0,0 +1,52 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../text-similarity/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/text-similarity", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/text-similarity", () => { + it("returns 1 for identical text on both Jaccard and cosine", async () => { + const res = await POST(makeReq({ a: "The quick brown fox", b: "The quick brown fox" })); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.jaccard).toBe(1); + expect(data.cosine).toBe(1); + }); + + it("returns 0 for completely disjoint text", async () => { + const res = await POST(makeReq({ a: "apple orange", b: "cat dog" })); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.jaccard).toBe(0); + expect(data.cosine).toBe(0); + }); + + it("computes partial overlap using both algorithms", async () => { + const res = await POST( + makeReq({ a: "quick brown fox", b: "brown fox jumps", algorithm: "both" }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.jaccard).toBe(0.5); + expect(data.cosine).toBeCloseTo(0.6667, 3); + }); + + it("supports single-algorithm selection", async () => { + const res = await POST(makeReq({ a: "a b c", b: "a b", algorithm: "jaccard" })); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data).toEqual({ jaccard: 0.6666666666666666 }); + }); +}); diff --git a/app/api/routes-f/__tests__/tips-anonymous.test.ts b/app/api/routes-f/__tests__/tips-anonymous.test.ts new file mode 100644 index 00000000..4e5bd704 --- /dev/null +++ b/app/api/routes-f/__tests__/tips-anonymous.test.ts @@ -0,0 +1,78 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../tips/route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/tips", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/tips", () => { + describe("POST", () => { + it("toggles anonymous flag on a tip", async () => { + const req = makeReq({ + tip_id: "tip123", + anonymous: true, + }); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toHaveProperty("updated"); + expect(data.updated).toBe(true); + expect(data.tip).toHaveProperty("anonymous"); + }); + + it("accepts boolean toggle to false", async () => { + const req = makeReq({ + tip_id: "tip456", + anonymous: false, + }); + const res = await POST(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.updated).toBe(true); + expect(data.tip.anonymous).toBe(false); + }); + + it("rejects missing tip_id", async () => { + const req = makeReq({ + anonymous: true, + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects missing anonymous field", async () => { + const req = makeReq({ + tip_id: "tip789", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects non-boolean anonymous", async () => { + const req = makeReq({ + tip_id: "tip789", + anonymous: "yes", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 404 for non-existent tip", async () => { + const req = makeReq({ + tip_id: "nonexistent", + anonymous: true, + }); + const res = await POST(req); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/app/api/routes-f/__tests__/triangle.test.ts b/app/api/routes-f/__tests__/triangle.test.ts new file mode 100644 index 00000000..c5402f99 --- /dev/null +++ b/app/api/routes-f/__tests__/triangle.test.ts @@ -0,0 +1,146 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../triangle/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/triangle", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/triangle", () => { + // --- Equilateral triangle --- + it("calculates equilateral triangle from sides", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [5, 5, 5] })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.is_valid_triangle).toBe(true); + expect(d.type).toBe("equilateral"); + expect(d.angle_type).toBe("acute"); + expect(d.angles_deg[0]).toBeCloseTo(60, 1); + expect(d.angles_deg[1]).toBeCloseTo(60, 1); + expect(d.angles_deg[2]).toBeCloseTo(60, 1); + expect(d.perimeter).toBe(15); + expect(d.area).toBeCloseTo(10.8253, 2); + }); + + // --- Isosceles triangle --- + it("calculates isosceles triangle from sides", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [5, 5, 8] })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.type).toBe("isosceles"); + }); + + // --- Scalene triangle --- + it("calculates scalene triangle from sides", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [3, 4, 6] })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.type).toBe("scalene"); + }); + + // --- Right triangle (3-4-5) --- + it("detects right triangle (3-4-5)", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [3, 4, 5] })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.is_valid_triangle).toBe(true); + expect(d.angle_type).toBe("right"); + expect(d.type).toBe("scalene"); + expect(d.area).toBe(6); + expect(d.perimeter).toBe(12); + }); + + // --- Obtuse triangle --- + it("detects obtuse triangle", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [2, 3, 4] })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.angle_type).toBe("obtuse"); + }); + + // --- Vertices mode --- + it("calculates triangle from vertices", async () => { + const res = await POST( + makeReq({ + mode: "vertices", + vertices: [ + [0, 0], + [4, 0], + [0, 3], + ], + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.is_valid_triangle).toBe(true); + expect(d.area).toBe(6); + expect(d.angle_type).toBe("right"); + expect(d.centroid).toBeDefined(); + expect(d.centroid.x).toBeCloseTo(1.3333, 2); + expect(d.centroid.y).toBeCloseTo(1, 2); + }); + + // --- Invalid triangle (sides don't satisfy triangle inequality) --- + it("rejects invalid triangle inequality", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [1, 2, 10] })); + expect(res.status).toBe(400); + }); + + // --- Degenerate triangle (collinear points) --- + it("rejects degenerate triangle (collinear)", async () => { + const res = await POST( + makeReq({ + mode: "vertices", + vertices: [ + [0, 0], + [1, 1], + [2, 2], + ], + }) + ); + expect(res.status).toBe(400); + }); + + // --- Invalid mode --- + it("rejects invalid mode", async () => { + const res = await POST(makeReq({ mode: "invalid", sides: [3, 4, 5] })); + expect(res.status).toBe(400); + }); + + // --- Missing sides --- + it("rejects missing sides in sides mode", async () => { + const res = await POST(makeReq({ mode: "sides" })); + expect(res.status).toBe(400); + }); + + // --- Negative side --- + it("rejects negative sides", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [-1, 3, 4] })); + expect(res.status).toBe(400); + }); + + // --- Invalid JSON --- + it("rejects invalid JSON body", async () => { + const req = new NextRequest("http://localhost/api/routes-f/triangle", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + // --- Circumradius is returned --- + it("returns circumradius", async () => { + const res = await POST(makeReq({ mode: "sides", sides: [3, 4, 5] })); + const d = await res.json(); + expect(d.circumradius).toBe(2.5); + }); +}); diff --git a/app/api/routes-f/__tests__/url-parse.test.ts b/app/api/routes-f/__tests__/url-parse.test.ts new file mode 100644 index 00000000..6365e3a3 --- /dev/null +++ b/app/api/routes-f/__tests__/url-parse.test.ts @@ -0,0 +1,112 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../url-parse/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/url-parse", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/url-parse", () => { + // --- Full URL with auth and query --- + it("parses full URL with auth, query, and hash", async () => { + const res = await POST( + makeReq({ + url: "https://user:pass@example.com:8080/path/to/page?foo=bar&baz=qux#section", + }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.protocol).toBe("https:"); + expect(d.host).toBe("example.com:8080"); + expect(d.hostname).toBe("example.com"); + expect(d.port).toBe("8080"); + expect(d.pathname).toBe("/path/to/page"); + expect(d.search).toBe("?foo=bar&baz=qux"); + expect(d.hash).toBe("#section"); + expect(d.username).toBe("user"); + expect(d.password).toBe("pass"); + expect(d.query.foo).toBe("bar"); + expect(d.query.baz).toBe("qux"); + expect(d.path_segments).toEqual(["path", "to", "page"]); + expect(d.origin).toBe("https://example.com:8080"); + }); + + // --- Repeated query keys become arrays --- + it("handles repeated query keys as arrays", async () => { + const res = await POST( + makeReq({ url: "https://example.com?tag=a&tag=b&tag=c" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.query.tag).toEqual(["a", "b", "c"]); + }); + + // --- Varied protocols --- + it("parses ftp URL", async () => { + const res = await POST( + makeReq({ url: "ftp://files.example.com/pub/readme.txt" }) + ); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.protocol).toBe("ftp:"); + expect(d.hostname).toBe("files.example.com"); + expect(d.path_segments).toEqual(["pub", "readme.txt"]); + }); + + // --- Simple URL with no port/auth --- + it("parses simple URL with default port", async () => { + const res = await POST(makeReq({ url: "https://example.com/about" })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.port).toBe(""); + expect(d.pathname).toBe("/about"); + expect(d.hash).toBe(""); + expect(d.search).toBe(""); + }); + + // --- Invalid URL --- + it("rejects invalid URL", async () => { + const res = await POST(makeReq({ url: "not-a-url" })); + expect(res.status).toBe(400); + }); + + // --- Missing url field --- + it("rejects missing url field", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + }); + + // --- URL too long --- + it("rejects URL exceeding 4KB", async () => { + const longUrl = "https://example.com/" + "a".repeat(5000); + const res = await POST(makeReq({ url: longUrl })); + expect(res.status).toBe(400); + }); + + // --- Invalid JSON body --- + it("rejects invalid JSON body", async () => { + const req = new NextRequest("http://localhost/api/routes-f/url-parse", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + // --- URL with empty path --- + it("handles URL with empty path", async () => { + const res = await POST(makeReq({ url: "https://example.com" })); + expect(res.status).toBe(200); + const d = await res.json(); + expect(d.pathname).toBe("/"); + expect(d.path_segments).toEqual([]); + }); +}); diff --git a/app/api/routes-f/__tests__/xml-to-json.test.ts b/app/api/routes-f/__tests__/xml-to-json.test.ts new file mode 100644 index 00000000..c743451c --- /dev/null +++ b/app/api/routes-f/__tests__/xml-to-json.test.ts @@ -0,0 +1,83 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST } from "../xml-to-json/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/xml-to-json", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/xml-to-json", () => { + it("converts simple XML element", async () => { + const res = await POST(makeReq({ xml: "Alice" })); + expect(res.status).toBe(200); + const { json, root_element } = await res.json(); + expect(root_element).toBe("root"); + expect(json.root.name["#text"]).toBe("Alice"); + }); + + it("handles attributes with default @ prefix", async () => { + const res = await POST(makeReq({ xml: '' })); + const { json } = await res.json(); + expect(json.root["@id"]).toBe("42"); + }); + + it("respects custom attribute_prefix", async () => { + const res = await POST( + makeReq({ xml: '', attribute_prefix: "_" }) + ); + const { json } = await res.json(); + expect(json.root["_id"]).toBe("1"); + }); + + it("respects custom text_key", async () => { + const res = await POST( + makeReq({ xml: "hello", text_key: "$" }) + ); + const { json } = await res.json(); + expect(json.root["$"]).toBe("hello"); + }); + + it("handles nested elements", async () => { + const xml = "RustSteve"; + const res = await POST(makeReq({ xml })); + const { json } = await res.json(); + expect(json.book.title["#text"]).toBe("Rust"); + expect(json.book.author["#text"]).toBe("Steve"); + }); + + it("handles CDATA sections", async () => { + const xml = "raw html]]>"; + const res = await POST(makeReq({ xml })); + const { json } = await res.json(); + expect(json.root["#text"]).toContain("raw html"); + }); + + it("returns 400 for malformed XML", async () => { + const res = await POST(makeReq({ xml: "" })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + it("returns 400 for mismatched closing tag", async () => { + const res = await POST(makeReq({ xml: "" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for empty xml", async () => { + const res = await POST(makeReq({ xml: "" })); + expect(res.status).toBe(400); + }); + + it("returns 400 when xml is missing", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/_lib/duration.ts b/app/api/routes-f/_lib/duration.ts new file mode 100644 index 00000000..caf847a6 --- /dev/null +++ b/app/api/routes-f/_lib/duration.ts @@ -0,0 +1,103 @@ +export type DurationComponents = { + years?: number; + months?: number; + weeks?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; +}; + +const DURATION_PATTERN = /^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/; + +export function parseDuration(text: string): DurationComponents { + if (typeof text !== "string") { + throw new Error("Duration text must be a string."); + } + + const match = DURATION_PATTERN.exec(text); + if (!match) { + throw new Error("Invalid ISO 8601 duration format."); + } + + const [ + , + years = "0", + months = "0", + weeks = "0", + days = "0", + hours = "0", + minutes = "0", + seconds = "0", + ] = match; + + return { + years: Number(years), + months: Number(months), + weeks: Number(weeks), + days: Number(days), + hours: Number(hours), + minutes: Number(minutes), + seconds: Number(seconds), + }; +} + +export function formatDuration(components: DurationComponents): string { + const normalized = normalizeComponents(components); + const { years, months, weeks, days, hours, minutes, seconds } = normalized; + + if (!years && !months && !weeks && !days && !hours && !minutes && !seconds) { + return "PT0S"; + } + + let text = "P"; + + if (years) text += `${years}Y`; + if (months) text += `${months}M`; + if (weeks) text += `${weeks}W`; + if (days) text += `${days}D`; + + if (hours || minutes || seconds) { + text += "T"; + if (hours) text += `${hours}H`; + if (minutes) text += `${minutes}M`; + if (seconds) text += `${seconds}S`; + } + + return text; +} + +export function durationToSeconds(components: DurationComponents): number { + const { years, months, weeks, days, hours, minutes, seconds } = normalizeComponents(components); + + return ( + years * 31536000 + + months * 2592000 + + weeks * 604800 + + days * 86400 + + hours * 3600 + + minutes * 60 + + seconds + ); +} + +export function normalizeComponents(components: DurationComponents): Required { + const normalized = { + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + ...components, + }; + + for (const [key, value] of Object.entries(normalized)) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error("Duration components must be non-negative finite numbers."); + } + } + + return normalized; +} diff --git a/app/api/routes-f/_lib/prime.ts b/app/api/routes-f/_lib/prime.ts new file mode 100644 index 00000000..3749d193 --- /dev/null +++ b/app/api/routes-f/_lib/prime.ts @@ -0,0 +1,52 @@ +function getSieveLimit(n: number): number { + if (n < 6) { + return 15; + } + + return Math.ceil(n * (Math.log(n) + Math.log(Math.log(n)))) + 10; +} + +function sieveNthPrime(n: number, limit: number): number | null { + const sieve = new Uint8Array(limit + 1); + sieve.fill(1); + sieve[0] = 0; + sieve[1] = 0; + + let count = 0; + + for (let i = 2; i <= limit; i += 1) { + if (!sieve[i]) { + continue; + } + + count += 1; + if (count === n) { + return i; + } + + const step = i * i; + if (step <= limit) { + for (let j = step; j <= limit; j += i) { + sieve[j] = 0; + } + } + } + + return null; +} + +export function findNthPrime(n: number): number { + if (!Number.isInteger(n) || n < 1 || n > 100000) { + throw new Error("n must be an integer between 1 and 100000."); + } + + let limit = getSieveLimit(n); + let prime = sieveNthPrime(n, limit); + + while (prime === null) { + limit = Math.ceil(limit * 1.2) + 10; + prime = sieveNthPrime(n, limit); + } + + return prime; +} diff --git a/app/api/routes-f/_lib/templateInterpolation.ts b/app/api/routes-f/_lib/templateInterpolation.ts new file mode 100644 index 00000000..25b334d8 --- /dev/null +++ b/app/api/routes-f/_lib/templateInterpolation.ts @@ -0,0 +1,58 @@ +export type MissingMode = "empty" | "keep" | "error"; + +export type InterpolationResult = { + output: string; + missing_keys: string[]; +}; + +function resolveValue(values: unknown, path: string): unknown { + const segments = path.split(".").filter(Boolean); + let current = values; + + for (const segment of segments) { + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + + if (Array.isArray(current)) { + current = (current as any)[segment]; + } else { + current = (current as Record)[segment]; + } + } + + return current; +} + +export function interpolateTemplate( + template: string, + values: unknown, + onMissing: MissingMode = "empty" +): InterpolationResult { + const missingKeys = new Set(); + + const output = template.replace(/{{\s*([^}]+?)\s*}}/g, (match, path) => { + const key = String(path).trim(); + const resolved = resolveValue(values, key); + + if (resolved === undefined || resolved === null) { + missingKeys.add(key); + return onMissing === "keep" ? match : ""; + } + + if (typeof resolved === "string") { + return resolved; + } + + if (typeof resolved === "number" || typeof resolved === "boolean") { + return String(resolved); + } + + return JSON.stringify(resolved); + }); + + return { + output, + missing_keys: Array.from(missingKeys), + }; +} diff --git a/app/api/routes-f/_lib/textSimilarity.ts b/app/api/routes-f/_lib/textSimilarity.ts new file mode 100644 index 00000000..8bb9de8e --- /dev/null +++ b/app/api/routes-f/_lib/textSimilarity.ts @@ -0,0 +1,75 @@ +export type SimilarityAlgorithm = "jaccard" | "cosine" | "both"; + +export function tokenize(text: string): string[] { + return Array.from(text.toLowerCase().match(/[a-z0-9]+/g) ?? []); +} + +export function jaccardSimilarity(a: string, b: string): number { + const aTokens = new Set(tokenize(a)); + const bTokens = new Set(tokenize(b)); + + if (aTokens.size === 0 && bTokens.size === 0) { + return 1; + } + + const intersectionSize = Array.from(aTokens).reduce((count, token) => { + return bTokens.has(token) ? count + 1 : count; + }, 0); + + const unionSize = new Set([...aTokens, ...bTokens]).size; + return unionSize === 0 ? 0 : intersectionSize / unionSize; +} + +export function cosineSimilarity(a: string, b: string): number { + const aTokens = tokenize(a); + const bTokens = tokenize(b); + + if (aTokens.length === 0 && bTokens.length === 0) { + return 1; + } + + const aFreq = new Map(); + const bFreq = new Map(); + + for (const token of aTokens) { + aFreq.set(token, (aFreq.get(token) ?? 0) + 1); + } + for (const token of bTokens) { + bFreq.set(token, (bFreq.get(token) ?? 0) + 1); + } + + let dotProduct = 0; + let aSum = 0; + let bSum = 0; + + for (const [token, aCount] of aFreq.entries()) { + aSum += aCount * aCount; + const bCount = bFreq.get(token) ?? 0; + dotProduct += aCount * bCount; + } + + for (const bCount of bFreq.values()) { + bSum += bCount * bCount; + } + + const denominator = Math.sqrt(aSum) * Math.sqrt(bSum); + return denominator === 0 ? 0 : dotProduct / denominator; +} + +export function computeSimilarity( + a: string, + b: string, + algorithm: SimilarityAlgorithm = "both" +): { jaccard?: number; cosine?: number } { + const result: { jaccard?: number; cosine?: number } = {}; + + if (algorithm === "jaccard" || algorithm === "both") { + result.jaccard = jaccardSimilarity(a, b); + } + + if (algorithm === "cosine" || algorithm === "both") { + result.cosine = cosineSimilarity(a, b); + } + + return result; +} diff --git a/app/api/routes-f/address-generator/__tests__/route.test.ts b/app/api/routes-f/address-generator/__tests__/route.test.ts new file mode 100644 index 00000000..ade985a0 --- /dev/null +++ b/app/api/routes-f/address-generator/__tests__/route.test.ts @@ -0,0 +1,34 @@ +import { generateAddresses } from "../route"; + +describe("generateAddresses", () => { + it("returns the requested count", () => { + expect(generateAddresses(5, "US", 42)).toHaveLength(5); + expect(generateAddresses(1, "NG", 7)).toHaveLength(1); + }); + + it("is deterministic for a given seed", () => { + expect(generateAddresses(3, "US", 42)).toEqual( + generateAddresses(3, "US", 42), + ); + }); + + it("produces different output for different seeds", () => { + expect(generateAddresses(3, "US", 42)).not.toEqual( + generateAddresses(3, "US", 43), + ); + }); + + it("uses the correct postal format per country", () => { + expect(generateAddresses(10, "US", 1).every((a) => /^\d{5}$/.test(a.postal_code))).toBe(true); + expect(generateAddresses(10, "NG", 1).every((a) => /^\d{6}$/.test(a.postal_code))).toBe(true); + expect( + generateAddresses(10, "UK", 1).every((a) => + /^[A-Z]{2}\d \d[A-Z]{2}$/.test(a.postal_code), + ), + ).toBe(true); + }); + + it("tags addresses with the country name", () => { + expect(generateAddresses(1, "US", 1)[0].country).toBe("United States"); + }); +}); diff --git a/app/api/routes-f/address-generator/route.ts b/app/api/routes-f/address-generator/route.ts new file mode 100644 index 00000000..ef3cd7df --- /dev/null +++ b/app/api/routes-f/address-generator/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +export type CountryCode = "US" | "UK" | "NG"; + +export interface SyntheticAddress { + street: string; + city: string; + region: string; + postal_code: string; + country: string; +} + +// Pools and postal formats bundled inside this folder (no external data deps). +interface CountryPool { + country: string; + streets: string[]; + cities: { city: string; region: string }[]; + postal: (rng: () => number) => string; +} + +const digits = (rng: () => number, n: number): string => + Array.from({ length: n }, () => Math.floor(rng() * 10)).join(""); + +const letter = (rng: () => number): string => + String.fromCharCode(65 + Math.floor(rng() * 26)); + +const POOLS: Record = { + US: { + country: "United States", + streets: ["Main St", "Oak Ave", "Maple Dr", "Cedar Ln", "Pine Rd", "Elm St"], + cities: [ + { city: "Springfield", region: "IL" }, + { city: "Austin", region: "TX" }, + { city: "Denver", region: "CO" }, + { city: "Portland", region: "OR" }, + ], + postal: (rng) => digits(rng, 5), + }, + UK: { + country: "United Kingdom", + streets: ["High St", "Station Rd", "Church Ln", "Victoria Rd", "Kings Way"], + cities: [ + { city: "London", region: "England" }, + { city: "Manchester", region: "England" }, + { city: "Glasgow", region: "Scotland" }, + { city: "Cardiff", region: "Wales" }, + ], + // e.g. "AB1 2CD" + postal: (rng) => + `${letter(rng)}${letter(rng)}${digits(rng, 1)} ${digits(rng, 1)}${letter(rng)}${letter(rng)}`, + }, + NG: { + country: "Nigeria", + streets: ["Awolowo Rd", "Adeola Odeku St", "Broad St", "Ahmadu Bello Way"], + cities: [ + { city: "Lagos", region: "Lagos" }, + { city: "Abuja", region: "FCT" }, + { city: "Kano", region: "Kano" }, + { city: "Enugu", region: "Enugu" }, + ], + postal: (rng) => digits(rng, 6), + }, +}; + +/** Deterministic PRNG (mulberry32). */ +function mulberry32(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const pick = (arr: T[], rng: () => number): T => + arr[Math.floor(rng() * arr.length)]; + +/** Generate `count` deterministic synthetic addresses for `country` from `seed`. */ +export function generateAddresses( + count: number, + country: CountryCode, + seed: number, +): SyntheticAddress[] { + const rng = mulberry32(seed); + const pool = POOLS[country]; + return Array.from({ length: count }, () => { + const number = 1 + Math.floor(rng() * 9998); + const { city, region } = pick(pool.cities, rng); + return { + street: `${number} ${pick(pool.streets, rng)}`, + city, + region, + postal_code: pool.postal(rng), + country: pool.country, + }; + }); +} + +const schema = z.object({ + count: z.coerce.number().int().min(1).max(100).optional().default(5), + country: z.enum(["US", "UK", "NG"]).optional().default("US"), + seed: z.coerce.number().int().optional().default(0), +}); + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const result = validateQuery(searchParams, schema); + if (result instanceof NextResponse) return result; + const { count, country, seed } = result.data; + return NextResponse.json({ addresses: generateAddresses(count, country, seed) }); +} diff --git a/app/api/routes-f/age-units/__tests__/route.test.ts b/app/api/routes-f/age-units/__tests__/route.test.ts new file mode 100644 index 00000000..7372a280 --- /dev/null +++ b/app/api/routes-f/age-units/__tests__/route.test.ts @@ -0,0 +1,60 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { POST } from "../route"; + +function makeRequest(body: object) { + return new Request("http://localhost/api/routes-f/age-units", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f age-units", () => { + it("computes exact birthday values", async () => { + const res = await POST( + makeRequest({ + birthdate: "2000-05-28T00:00:00.000Z", + on_date: "2026-05-28T00:00:00.000Z", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.years).toBe(26); + expect(json.total_months).toBe(312); + }); + + it("handles leap-year births before the leap-day anniversary is reached", async () => { + const res = await POST( + makeRequest({ + birthdate: "2000-02-29T00:00:00.000Z", + on_date: "2021-02-28T00:00:00.000Z", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.years).toBe(20); + expect(json.total_months).toBe(251); + }); + + it("rejects future birthdates", async () => { + const res = await POST( + makeRequest({ + birthdate: "2030-01-01T00:00:00.000Z", + on_date: "2026-01-01T00:00:00.000Z", + }) + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/age-units/route.ts b/app/api/routes-f/age-units/route.ts new file mode 100644 index 00000000..d3cb8b6c --- /dev/null +++ b/app/api/routes-f/age-units/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const schema = z.object({ + birthdate: z.string().datetime({ offset: true }).or(z.string().date()), + on_date: z + .string() + .datetime({ offset: true }) + .or(z.string().date()) + .optional(), +}); + +function parseDate(value: string): Date { + return new Date(value); +} + +function isValidDate(value: Date): boolean { + return !Number.isNaN(value.getTime()); +} + +function getCompletedYears(birthdate: Date, onDate: Date): number { + let years = onDate.getUTCFullYear() - birthdate.getUTCFullYear(); + const birthMonth = birthdate.getUTCMonth(); + const currentMonth = onDate.getUTCMonth(); + + if ( + currentMonth < birthMonth || + (currentMonth === birthMonth && + onDate.getUTCDate() < birthdate.getUTCDate()) + ) { + years -= 1; + } + + return years; +} + +function getCompletedMonths(birthdate: Date, onDate: Date): number { + let months = + (onDate.getUTCFullYear() - birthdate.getUTCFullYear()) * 12 + + (onDate.getUTCMonth() - birthdate.getUTCMonth()); + + if (onDate.getUTCDate() < birthdate.getUTCDate()) { + months -= 1; + } + + return months; +} + +export async function POST(req: NextRequest) { + let rawBody: unknown; + + try { + rawBody = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = schema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { + error: "Validation failed", + issues: parsed.error.issues.map(issue => ({ + field: issue.path.join(".") || "body", + message: issue.message, + })), + }, + { status: 400 } + ); + } + + const birthdate = parseDate(parsed.data.birthdate); + const onDate = parseDate(parsed.data.on_date ?? new Date().toISOString()); + + if (!isValidDate(birthdate) || !isValidDate(onDate)) { + return NextResponse.json({ error: "Invalid date input" }, { status: 400 }); + } + + if (birthdate > onDate) { + return NextResponse.json( + { error: "birthdate cannot be in the future" }, + { status: 400 } + ); + } + + const diffMs = onDate.getTime() - birthdate.getTime(); + const totalHours = Math.floor(diffMs / (1000 * 60 * 60)); + const totalDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const totalWeeks = Math.floor(totalDays / 7); + + return NextResponse.json({ + years: getCompletedYears(birthdate, onDate), + total_months: getCompletedMonths(birthdate, onDate), + total_weeks: totalWeeks, + total_days: totalDays, + total_hours: totalHours, + }); +} diff --git a/app/api/routes-f/base32/__tests__/route.test.ts b/app/api/routes-f/base32/__tests__/route.test.ts new file mode 100644 index 00000000..6b542edc --- /dev/null +++ b/app/api/routes-f/base32/__tests__/route.test.ts @@ -0,0 +1,33 @@ +import { base32Encode, base32Decode } from "../route"; + +describe("base32Encode", () => { + it("matches RFC 4648 test vectors", () => { + expect(base32Encode("")).toBe(""); + expect(base32Encode("f")).toBe("MY======"); + expect(base32Encode("fo")).toBe("MZXQ===="); + expect(base32Encode("foo")).toBe("MZXW6==="); + expect(base32Encode("foobar")).toBe("MZXW6YTBOI======"); + }); + + it("omits padding when padding=false", () => { + expect(base32Encode("foobar", false)).toBe("MZXW6YTBOI"); + }); +}); + +describe("base32Decode", () => { + it("decodes RFC 4648 vectors (with or without padding)", () => { + expect(base32Decode("MZXW6YTBOI======")).toBe("foobar"); + expect(base32Decode("MZXW6YTBOI")).toBe("foobar"); + expect(base32Decode("MY======")).toBe("f"); + }); + + it("round-trips arbitrary input", () => { + for (const s of ["hello world", "Stellar ⭐", "a", "12345"]) { + expect(base32Decode(base32Encode(s))).toBe(s); + } + }); + + it("throws on invalid base32 characters", () => { + expect(() => base32Decode("0189")).toThrow(RangeError); // 0,1,8,9 not in alphabet + }); +}); diff --git a/app/api/routes-f/base32/route.ts b/app/api/routes-f/base32/route.ts new file mode 100644 index 00000000..18f6031d --- /dev/null +++ b/app/api/routes-f/base32/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** RFC 4648 base32 encode of a UTF-8 string. */ +export function base32Encode(input: string, padding = true): string { + const bytes = new TextEncoder().encode(input); + let bits = 0; + let value = 0; + let out = ""; + for (const b of bytes) { + value = ((value << 8) | b) >>> 0; + bits += 8; + while (bits >= 5) { + out += ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + value &= (1 << bits) - 1; + } + if (bits > 0) { + out += ALPHABET[(value << (5 - bits)) & 31]; + } + if (padding) { + while (out.length % 8 !== 0) out += "="; + } + return out; +} + +/** RFC 4648 base32 decode back to a UTF-8 string. Throws on invalid input. */ +export function base32Decode(input: string): string { + const clean = input.toUpperCase().replace(/=+$/, ""); + let bits = 0; + let value = 0; + const bytes: number[] = []; + for (const ch of clean) { + const idx = ALPHABET.indexOf(ch); + if (idx === -1) { + throw new RangeError(`invalid base32 character: ${ch}`); + } + value = ((value << 5) | idx) >>> 0; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + value &= (1 << bits) - 1; + } + return new TextDecoder().decode(new Uint8Array(bytes)); +} + +const schema = z.object({ + input: z.string(), + mode: z.enum(["encode", "decode"]), + padding: z.boolean().optional().default(true), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { input, mode, padding } = result.data; + + if (mode === "encode") { + return NextResponse.json({ output: base32Encode(input, padding) }); + } + try { + return NextResponse.json({ output: base32Decode(input) }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "invalid base32 input" }, + { status: 400 }, + ); + } +} diff --git a/app/api/routes-f/base64-validate/__tests__/route.test.ts b/app/api/routes-f/base64-validate/__tests__/route.test.ts new file mode 100644 index 00000000..31792191 --- /dev/null +++ b/app/api/routes-f/base64-validate/__tests__/route.test.ts @@ -0,0 +1,41 @@ +import { validateBase64 } from "../route"; + +describe("validateBase64", () => { + it("accepts well-formed standard base64", () => { + expect(validateBase64("Zm9vYmFy")).toEqual({ + valid: true, + variant_detected: "standard", + decoded_length: 6, + }); + }); + + it("accounts for padding in decoded_length", () => { + expect(validateBase64("Zm9vYg==")).toEqual({ + valid: true, + variant_detected: "standard", + decoded_length: 4, + }); + }); + + it("detects the url-safe alphabet", () => { + const r = validateBase64("a-b_"); + expect(r.valid).toBe(true); + expect(r.variant_detected).toBe("urlsafe"); + }); + + it("rejects misplaced padding", () => { + expect(validateBase64("Zm=9").valid).toBe(false); + }); + + it("rejects the wrong charset", () => { + expect(validateBase64("Zm9v$bcd").valid).toBe(false); + }); + + it("rejects an impossible length (len % 4 == 1)", () => { + expect(validateBase64("Zm9vY").valid).toBe(false); + }); + + it("rejects mixed alphabets", () => { + expect(validateBase64("ab+c-d").valid).toBe(false); + }); +}); diff --git a/app/api/routes-f/base64-validate/route.ts b/app/api/routes-f/base64-validate/route.ts new file mode 100644 index 00000000..0c4033cd --- /dev/null +++ b/app/api/routes-f/base64-validate/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +export type Base64Variant = "standard" | "urlsafe" | "auto"; + +export interface Base64Result { + valid: boolean; + variant_detected: "standard" | "urlsafe" | null; + decoded_length: number; +} + +const INVALID: Base64Result = { valid: false, variant_detected: null, decoded_length: 0 }; + +/** + * Validate whether `input` is well-formed base64 (standard or URL-safe): + * charset, padding placement, and length divisibility. + */ +export function validateBase64(input: string, variant: Base64Variant = "auto"): Base64Result { + if (input.length === 0) { + return { valid: true, variant_detected: variant === "auto" ? "standard" : variant, decoded_length: 0 }; + } + + const hasStd = /[+/]/.test(input); + const hasUrl = /[-_]/.test(input); + if (hasStd && hasUrl) return INVALID; // mixed alphabets + + let detected: "standard" | "urlsafe" = hasUrl ? "urlsafe" : "standard"; + if (variant !== "auto") { + if ((hasStd || hasUrl) && variant !== detected) return INVALID; + detected = variant; + } + + const charset = detected === "urlsafe" ? /^[A-Za-z0-9_-]+={0,2}$/ : /^[A-Za-z0-9+/]+={0,2}$/; + if (!charset.test(input)) return INVALID; + + const padIdx = input.indexOf("="); + const padding = padIdx === -1 ? 0 : input.length - padIdx; + // padding, if present, must be 1–2 '=' all at the very end + if (padIdx !== -1 && !/^={1,2}$/.test(input.slice(padIdx))) return INVALID; + + if (input.length % 4 === 1) return INVALID; // impossible base64 length + if (input.length % 4 !== 0) { + // only tolerate unpadded length for url-safe (padding is optional there) + if (padding > 0 || detected === "standard") return INVALID; + } else if (padding > 0 && input.length % 4 !== 0) { + return INVALID; + } + + const decoded_length = Math.floor((input.length - padding) * 3 / 4); + return { valid: true, variant_detected: detected, decoded_length }; +} + +const schema = z.object({ + input: z.string(), + variant: z.enum(["standard", "urlsafe", "auto"]).optional().default("auto"), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { input, variant } = result.data; + return NextResponse.json(validateBase64(input, variant)); +} diff --git a/app/api/routes-f/binary-to-text/route.ts b/app/api/routes-f/binary-to-text/route.ts new file mode 100644 index 00000000..e7e167e7 --- /dev/null +++ b/app/api/routes-f/binary-to-text/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const schema = z.object({ + input: z.string(), + mode: z.enum(["to_binary", "from_binary"]), + bits: z.number().int().positive().optional().default(8), +}); + +/** Encode a UTF-8 string to space-separated binary groups. */ +export function toBinary(input: string, bits: number): string { + const bytes = new TextEncoder().encode(input); + return Array.from(bytes) + .map((b) => b.toString(2).padStart(bits, "0")) + .join(" "); +} + +/** Decode space-separated binary groups back to a UTF-8 string. */ +export function fromBinary(input: string, bits: number): string { + const groups = input.trim().split(/\s+/); + + for (const g of groups) { + if (!/^[01]+$/.test(g)) { + throw new Error(`Invalid binary token: "${g}"`); + } + if (g.length !== bits) { + throw new Error(`Token "${g}" has ${g.length} bits, expected ${bits}`); + } + } + + const bytes = new Uint8Array(groups.map((g) => parseInt(g, 2))); + return new TextDecoder().decode(bytes); +} + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + + const { input, mode, bits } = result.data; + + try { + const output = mode === "to_binary" ? toBinary(input, bits) : fromBinary(input, bits); + return NextResponse.json({ result: output }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Conversion failed" }, + { status: 400 } + ); + } +} diff --git a/app/api/routes-f/bingo/__tests__/route.test.ts b/app/api/routes-f/bingo/__tests__/route.test.ts new file mode 100644 index 00000000..5f3bb309 --- /dev/null +++ b/app/api/routes-f/bingo/__tests__/route.test.ts @@ -0,0 +1,35 @@ +import { NextRequest } from "next/server"; +import { GET } from "../route"; + +describe("GET /api/routes-f/bingo", () => { + it("returns deterministic cards for same seed", async () => { + const reqA = new NextRequest("http://localhost/api/routes-f/bingo?seed=42"); + const reqB = new NextRequest("http://localhost/api/routes-f/bingo?seed=42"); + const resA = await GET(reqA); + const resB = await GET(reqB); + const bodyA = await resA.json(); + const bodyB = await resB.json(); + + expect(bodyA.cards).toEqual(bodyB.cards); + }); + + it("keeps values in column ranges and free center", async () => { + const req = new NextRequest("http://localhost/api/routes-f/bingo?seed=10"); + const res = await GET(req); + const { cards } = await res.json(); + const card = cards[0]; + + for (let row = 0; row < 5; row++) { + expect(card[row][0]).toBeGreaterThanOrEqual(1); + expect(card[row][0]).toBeLessThanOrEqual(15); + expect(card[row][1]).toBeGreaterThanOrEqual(16); + expect(card[row][1]).toBeLessThanOrEqual(30); + expect(card[row][3]).toBeGreaterThanOrEqual(46); + expect(card[row][3]).toBeLessThanOrEqual(60); + expect(card[row][4]).toBeGreaterThanOrEqual(61); + expect(card[row][4]).toBeLessThanOrEqual(75); + } + + expect(card[2][2]).toBe(0); + }); +}); diff --git a/app/api/routes-f/bingo/route.ts b/app/api/routes-f/bingo/route.ts new file mode 100644 index 00000000..756a1a20 --- /dev/null +++ b/app/api/routes-f/bingo/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; + +const DEFAULT_COUNT = 1; +const MAX_COUNT = 20; +const FREE_CENTER_VALUE = 0; + +function createRng(seed: number) { + let state = seed >>> 0; + return () => { + state = (1664525 * state + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +function shuffle(values: number[], rng: () => number) { + const arr = [...values]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function buildColumn( + min: number, + max: number, + picks: number, + rng: () => number +) { + return shuffle( + Array.from({ length: max - min + 1 }, (_, i) => min + i), + rng + ).slice(0, picks); +} + +function buildCard(rng: () => number) { + const b = buildColumn(1, 15, 5, rng); + const i = buildColumn(16, 30, 5, rng); + const n = buildColumn(31, 45, 4, rng); + const g = buildColumn(46, 60, 5, rng); + const o = buildColumn(61, 75, 5, rng); + + const card = Array.from({ length: 5 }, () => Array(5).fill(0)); + for (let row = 0; row < 5; row++) { + card[row][0] = b[row]; + card[row][1] = i[row]; + card[row][2] = row === 2 ? FREE_CENTER_VALUE : n[row > 2 ? row - 1 : row]; + card[row][3] = g[row]; + card[row][4] = o[row]; + } + return card; +} + +export function GET(request: NextRequest) { + const seedParam = request.nextUrl.searchParams.get("seed"); + const countParam = request.nextUrl.searchParams.get("count"); + + const seed = seedParam === null ? Date.now() : Number(seedParam); + if (!Number.isFinite(seed)) { + return NextResponse.json( + { error: "seed must be numeric" }, + { status: 400 } + ); + } + + const count = countParam === null ? DEFAULT_COUNT : Number(countParam); + if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) { + return NextResponse.json( + { error: `count must be an integer between 1 and ${MAX_COUNT}` }, + { status: 400 } + ); + } + + const rng = createRng(seed); + const cards = Array.from({ length: count }, () => buildCard(rng)); + return NextResponse.json({ cards }); +} diff --git a/app/api/routes-f/break-even/route.ts b/app/api/routes-f/break-even/route.ts new file mode 100644 index 00000000..01cd7f15 --- /dev/null +++ b/app/api/routes-f/break-even/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import type { BreakEvenRequest, BreakEvenResponse } from "./types"; + +const schema = z.object({ + fixed_costs: z.number(), + price_per_unit: z.number(), + variable_cost_per_unit: z.number(), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) { + return result; + } + + const { fixed_costs, price_per_unit, variable_cost_per_unit } = result.data; + + if (price_per_unit <= variable_cost_per_unit) { + return NextResponse.json( + { error: "Price must exceed variable cost per unit to break even" }, + { status: 400 } + ); + } + + const contribution_margin = price_per_unit - variable_cost_per_unit; + const break_even_units = Math.ceil(fixed_costs / contribution_margin); + const break_even_revenue = break_even_units * price_per_unit; + + const response: BreakEvenResponse = { + break_even_units, + break_even_revenue, + contribution_margin, + }; + + return NextResponse.json(response); +} diff --git a/app/api/routes-f/break-even/types.ts b/app/api/routes-f/break-even/types.ts new file mode 100644 index 00000000..43fe0ca9 --- /dev/null +++ b/app/api/routes-f/break-even/types.ts @@ -0,0 +1,11 @@ +export interface BreakEvenRequest { + fixed_costs: number; + price_per_unit: number; + variable_cost_per_unit: number; +} + +export interface BreakEvenResponse { + break_even_units: number; + break_even_revenue: number; + contribution_margin: number; +} diff --git a/app/api/routes-f/broadcast-live/__tests__/broadcast-live.test.ts b/app/api/routes-f/broadcast-live/__tests__/broadcast-live.test.ts new file mode 100644 index 00000000..1ded27ae --- /dev/null +++ b/app/api/routes-f/broadcast-live/__tests__/broadcast-live.test.ts @@ -0,0 +1,67 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +const BASE_URL = "http://localhost/api/routes-f/broadcast-live"; + +function makePost(body: unknown) { + return new NextRequest(BASE_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/routes-f/broadcast-live", () => { + it("notifies all eligible followers (notify_live=true and not muted)", async () => { + // c-001 has 5 followers: f-001(✓), f-002(✓), f-003(notify_live=false), f-004(muted), f-005(✓) → 3 eligible + const res = await POST(makePost({ creator_id: "c-001", stream_title: "Going Live!" })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.notified_count).toBe(3); + }); + + it("skips followers with notify_live=false", async () => { + // c-002 has 3 followers: f-006(✓), f-007(✓), f-008(notify_live=false) → 2 eligible + const res = await POST(makePost({ creator_id: "c-002", stream_title: "Art Stream" })); + const body = await res.json(); + expect(body.notified_count).toBe(2); + }); + + it("skips muted followers", async () => { + // f-004 follows c-001 but is muted — confirmed excluded in the c-001 test above + const res = await POST(makePost({ creator_id: "c-001", stream_title: "Live Again" })); + const body = await res.json(); + // 5 followers, minus notify_live=false (f-003) and muted (f-004) = 3 + expect(body.notified_count).toBe(3); + }); + + it("returns 0 for creator with no followers in seed", async () => { + const res = await POST(makePost({ creator_id: "c-unknown-999", stream_title: "Solo" })); + const body = await res.json(); + expect(body.notified_count).toBe(0); + }); + + it("400 — missing creator_id", async () => { + const res = await POST(makePost({ stream_title: "No Creator" })); + expect(res.status).toBe(400); + }); + + it("400 — missing stream_title", async () => { + const res = await POST(makePost({ creator_id: "c-001" })); + expect(res.status).toBe(400); + }); + + it("400 — invalid JSON body", async () => { + const res = await POST( + new NextRequest(BASE_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{bad json", + }) + ); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/broadcast-live/route.ts b/app/api/routes-f/broadcast-live/route.ts new file mode 100644 index 00000000..94f30a98 --- /dev/null +++ b/app/api/routes-f/broadcast-live/route.ts @@ -0,0 +1,56 @@ +/** + * POST /api/routes-f/broadcast-live + * + * Fan out a "creator is live" notification to all followers, skipping those + * who have muted the creator or have notify_live=false. + * Returns { notified_count }. + * Uses in-memory seed data — no real DB. + */ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +// --------------------------------------------------------------------------- +// Seed data +// --------------------------------------------------------------------------- +interface Follower { + follower_id: string; + creator_id: string; + notify_live: boolean; + muted: boolean; +} + +export const FOLLOWERS: Follower[] = [ + { follower_id: "f-001", creator_id: "c-001", notify_live: true, muted: false }, + { follower_id: "f-002", creator_id: "c-001", notify_live: true, muted: false }, + { follower_id: "f-003", creator_id: "c-001", notify_live: false, muted: false }, + { follower_id: "f-004", creator_id: "c-001", notify_live: true, muted: true }, + { follower_id: "f-005", creator_id: "c-001", notify_live: true, muted: false }, + { follower_id: "f-006", creator_id: "c-002", notify_live: true, muted: false }, + { follower_id: "f-007", creator_id: "c-002", notify_live: true, muted: false }, + { follower_id: "f-008", creator_id: "c-002", notify_live: false, muted: false }, +]; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- +const bodySchema = z.object({ + creator_id: z.string().min(1, "creator_id is required"), + stream_title: z.string().min(1, "stream_title is required"), +}); + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +export async function POST(req: NextRequest): Promise { + const result = await validateBody(req, bodySchema); + if (result instanceof NextResponse) return result; + + const { creator_id } = result.data; + + const eligible = FOLLOWERS.filter( + (f) => f.creator_id === creator_id && f.notify_live && !f.muted + ); + + return NextResponse.json({ notified_count: eligible.length }); +} diff --git a/app/api/routes-f/business-hours/route.ts b/app/api/routes-f/business-hours/route.ts new file mode 100644 index 00000000..4af4bc4c --- /dev/null +++ b/app/api/routes-f/business-hours/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; + +function parseTime(t: string): { h: number; m: number } { + const [h, m] = t.split(":").map(Number); + return { h, m }; +} + +function toMinutes(h: number, m: number): number { + return h * 60 + m; +} + +export async function POST(req: NextRequest) { + let body: { + timestamp?: unknown; + timezone?: unknown; + open?: unknown; + close?: unknown; + days?: unknown; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { + timestamp, + timezone = "UTC", + open = "09:00", + close = "17:00", + days = [1, 2, 3, 4, 5], + } = body; + + if (typeof timestamp !== "string") { + return NextResponse.json({ error: "timestamp is required (ISO string)" }, { status: 400 }); + } + + const date = new Date(timestamp); + if (isNaN(date.getTime())) { + return NextResponse.json({ error: "Invalid timestamp" }, { status: 400 }); + } + + const tz = typeof timezone === "string" ? timezone : "UTC"; + const allowedDays = Array.isArray(days) ? (days as number[]) : [1, 2, 3, 4, 5]; + + const localStr = date.toLocaleString("en-US", { timeZone: tz, hour12: false }); + const local = new Date(localStr); + const dayOfWeek = local.getDay(); // 0=Sun + const currentMins = toMinutes(local.getHours(), local.getMinutes()); + + const { h: oh, m: om } = parseTime(typeof open === "string" ? open : "09:00"); + const { h: ch, m: cm } = parseTime(typeof close === "string" ? close : "17:00"); + const openMins = toMinutes(oh, om); + const closeMins = toMinutes(ch, cm); + + const isOpen = + allowedDays.includes(dayOfWeek) && + currentMins >= openMins && + currentMins < closeMins; + + if (isOpen) { + return NextResponse.json({ is_open: true }); + } + + // Find next open time + let next = new Date(date); + for (let i = 1; i <= 7; i++) { + next = new Date(next.getTime() + 24 * 60 * 60 * 1000); + const nextLocalStr = next.toLocaleString("en-US", { timeZone: tz, hour12: false }); + const nextLocal = new Date(nextLocalStr); + if (allowedDays.includes(nextLocal.getDay())) { + // set to open time + nextLocal.setHours(oh, om, 0, 0); + return NextResponse.json({ is_open: false, next_open: nextLocal.toISOString() }); + } + } + + return NextResponse.json({ is_open: false }); +} diff --git a/app/api/routes-f/cagr/__tests__/route.test.ts b/app/api/routes-f/cagr/__tests__/route.test.ts new file mode 100644 index 00000000..f4c35e2b --- /dev/null +++ b/app/api/routes-f/cagr/__tests__/route.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/cagr", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/routes-f/cagr", () => { + it("calculates CAGR for a known growth example", async () => { + const res = await POST( + makeReq({ begin_value: 1000, end_value: 2000, years: 5 }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.cagr_percent).toBeCloseTo(14.869835, 5); + }); + + it("calculates negative CAGR when value declines", async () => { + const res = await POST( + makeReq({ begin_value: 1000, end_value: 500, years: 5 }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.cagr_percent).toBeCloseTo(-12.944943, 5); + }); + + it("rejects non-positive values and years", async () => { + const res = await POST( + makeReq({ begin_value: 0, end_value: 1000, years: 5 }) + ); + const yearsRes = await POST( + makeReq({ begin_value: 1000, end_value: 1100, years: -1 }) + ); + + expect(res.status).toBe(400); + expect(yearsRes.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/cagr/route.ts b/app/api/routes-f/cagr/route.ts new file mode 100644 index 00000000..94027641 --- /dev/null +++ b/app/api/routes-f/cagr/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; + +function finitePositive(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : null; +} + +export async function POST(req: NextRequest) { + let body: { begin_value?: unknown; end_value?: unknown; years?: unknown }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const beginValue = finitePositive(body.begin_value); + const endValue = finitePositive(body.end_value); + const years = finitePositive(body.years); + + if (beginValue === null || endValue === null || years === null) { + return NextResponse.json( + { error: "begin_value, end_value, and years must be positive numbers." }, + { status: 400 } + ); + } + + const cagrPercent = (Math.pow(endValue / beginValue, 1 / years) - 1) * 100; + + return NextResponse.json({ + cagr_percent: cagrPercent, + }); +} diff --git a/app/api/routes-f/case-convert/__tests__/route.test.ts b/app/api/routes-f/case-convert/__tests__/route.test.ts new file mode 100644 index 00000000..c3fd195d --- /dev/null +++ b/app/api/routes-f/case-convert/__tests__/route.test.ts @@ -0,0 +1,32 @@ +import { convertCase, splitWords } from "../route"; + +describe("splitWords", () => { + it("detects each source case", () => { + expect(splitWords("foo_bar_baz")).toEqual(["foo", "bar", "baz"]); + expect(splitWords("foo-bar-baz")).toEqual(["foo", "bar", "baz"]); + expect(splitWords("fooBarBaz")).toEqual(["foo", "bar", "baz"]); + expect(splitWords("FooBarBaz")).toEqual(["foo", "bar", "baz"]); + }); + + it("preserves embedded numbers", () => { + expect(splitWords("apiV2Client")).toEqual(["api", "v2", "client"]); + }); +}); + +describe("convertCase", () => { + const cases: Array<[string, CaseTargetLike]> = []; + type CaseTargetLike = "snake" | "camel" | "pascal" | "kebab"; + + it("converts mixed-case input to each target", () => { + expect(convertCase("fooBarBaz", "snake")).toBe("foo_bar_baz"); + expect(convertCase("foo_bar_baz", "camel")).toBe("fooBarBaz"); + expect(convertCase("foo-bar-baz", "pascal")).toBe("FooBarBaz"); + expect(convertCase("FooBarBaz", "kebab")).toBe("foo-bar-baz"); + void cases; + }); + + it("keeps numbers in the right place", () => { + expect(convertCase("apiV2Client", "snake")).toBe("api_v2_client"); + expect(convertCase("api_v2_client", "pascal")).toBe("ApiV2Client"); + }); +}); diff --git a/app/api/routes-f/case-convert/data.ts b/app/api/routes-f/case-convert/data.ts new file mode 100644 index 00000000..ca7b1f87 --- /dev/null +++ b/app/api/routes-f/case-convert/data.ts @@ -0,0 +1,97 @@ +export type CaseTarget = + | "camelCase" + | "snake_case" + | "kebab-case" + | "PascalCase" + | "CONSTANT_CASE" + | "Title Case" + | "Sentence case"; + +export const VALID_TARGETS: CaseTarget[] = [ + "camelCase", + "snake_case", + "kebab-case", + "PascalCase", + "CONSTANT_CASE", + "Title Case", + "Sentence case", +]; + +function tokenize(text: string): string[] { + return text + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/[-_]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean); +} + +function toCamel(words: string[]): string { + return words + .map((w, i) => (i === 0 ? w.toLowerCase() : w[0].toUpperCase() + w.slice(1).toLowerCase())) + .join(""); +} + +function toSnake(words: string[]): string { + return words.map((w) => w.toLowerCase()).join("_"); +} + +function toKebab(words: string[]): string { + return words.map((w) => w.toLowerCase()).join("-"); +} + +function toPascal(words: string[]): string { + return words.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join(""); +} + +function toConstant(words: string[]): string { + return words.map((w) => w.toUpperCase()).join("_"); +} + +function toTitle(words: string[]): string { + return words.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join(" "); +} + +function toSentence(words: string[]): string { + const result = words.map((w) => w.toLowerCase()).join(" "); + return result.charAt(0).toUpperCase() + result.slice(1); +} + +export function convertCase( + text: string, + target?: string +): Record | { result: string } { + const words = tokenize(text); + + if (target !== undefined) { + switch (target as CaseTarget) { + case "camelCase": + return { result: toCamel(words) }; + case "snake_case": + return { result: toSnake(words) }; + case "kebab-case": + return { result: toKebab(words) }; + case "PascalCase": + return { result: toPascal(words) }; + case "CONSTANT_CASE": + return { result: toConstant(words) }; + case "Title Case": + return { result: toTitle(words) }; + case "Sentence case": + return { result: toSentence(words) }; + default: + throw new Error("Invalid target case"); + } + } + + return { + camelCase: toCamel(words), + snake_case: toSnake(words), + "kebab-case": toKebab(words), + PascalCase: toPascal(words), + CONSTANT_CASE: toConstant(words), + "Title Case": toTitle(words), + "Sentence case": toSentence(words), + }; +} diff --git a/app/api/routes-f/case-convert/route.ts b/app/api/routes-f/case-convert/route.ts new file mode 100644 index 00000000..a15be1b5 --- /dev/null +++ b/app/api/routes-f/case-convert/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +/** + * Supported output targets. + * Kept local so tests mocking external modules don't shadow it. + */ +const VALID_TARGETS = [ + "camelCase", + "snake_case", + "kebab-case", + "PascalCase", + "CONSTANT_CASE", + "Title Case", + "Sentence case", +] as const; + +export type CaseTarget = (typeof VALID_TARGETS)[number]; + +/** + * Split text into lowercase words while handling: + * - camelCase + * - PascalCase + * - snake_case + * - kebab-case + * - spaces + */ +export function splitWords(text: string): string[] { + return text + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean) + .map((w) => w.toLowerCase()); +} + +const capitalize = (word: string) => + word ? word[0].toUpperCase() + word.slice(1) : word; + +/** Convert text into the requested target case. */ +export function convertCase(text: string, target: CaseTarget): string { + const words = splitWords(text); + + switch (target) { + case "camelCase": + return words + .map((w, i) => (i === 0 ? w : capitalize(w))) + .join(""); + + case "PascalCase": + return words.map(capitalize).join(""); + + case "snake_case": + return words.join("_"); + + case "kebab-case": + return words.join("-"); + + case "CONSTANT_CASE": + return words.join("_").toUpperCase(); + + case "Title Case": + return words.map(capitalize).join(" "); + + case "Sentence case": + return words.length + ? capitalize(words[0]) + + (words.length > 1 ? ` ${words.slice(1).join(" ")}` : "") + : ""; + + default: + return text; + } +} + +const schema = z.object({ + text: z.string(), + target: z.enum(VALID_TARGETS), +}); + +export async function POST(request: Request): Promise { + try { + const result = await validateBody(request, schema); + + if (result instanceof NextResponse) { + return result; + } + + const { text, target } = result.data; + + return NextResponse.json({ + result: convertCase(text, target), + }); + } catch { + return NextResponse.json( + { error: "Invalid JSON" }, + { status: 400 } + ); + } +} \ No newline at end of file diff --git a/app/api/routes-f/catalan/__tests__/route.test.ts b/app/api/routes-f/catalan/__tests__/route.test.ts new file mode 100644 index 00000000..74997c9d --- /dev/null +++ b/app/api/routes-f/catalan/__tests__/route.test.ts @@ -0,0 +1,43 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../route"; + +function makeReq(n: string) { + return new NextRequest(`http://localhost/api/routes-f/catalan?n=${n}`); +} + +describe("GET /api/routes-f/catalan", () => { + it("returns C(0)=1", async () => { + const res = await GET(makeReq("0")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.catalan).toBe("1"); + expect(body.sequence).toEqual(["1"]); + }); + + it("returns C(4)=14", async () => { + const res = await GET(makeReq("4")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.catalan).toBe("14"); + expect(body.sequence).toEqual(["1", "1", "2", "5", "14"]); + }); + + it("returns C(10)=16796", async () => { + const res = await GET(makeReq("10")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.catalan).toBe("16796"); + }); + + it("rejects n outside the supported range", async () => { + const res = await GET(makeReq("1001")); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/catalan/route.ts b/app/api/routes-f/catalan/route.ts new file mode 100644 index 00000000..3f06571f --- /dev/null +++ b/app/api/routes-f/catalan/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +const MAX_N = 1000; + +function parseN(req: NextRequest): number | null { + const value = req.nextUrl.searchParams.get("n"); + if (value === null || !/^\d+$/.test(value)) { + return null; + } + + const n = Number(value); + return Number.isInteger(n) && n >= 0 && n <= MAX_N ? n : null; +} + +function catalanSequence(n: number): bigint[] { + const sequence: bigint[] = [1n]; + + for (let i = 0; i < n; i += 1) { + const current = sequence[i]; + const next = (current * BigInt(2 * (2 * i + 1))) / BigInt(i + 2); + sequence.push(next); + } + + return sequence; +} + +export async function GET(req: NextRequest) { + const n = parseN(req); + + if (n === null) { + return NextResponse.json( + { error: `n must be an integer in [0, ${MAX_N}].` }, + { status: 400 } + ); + } + + const sequence = catalanSequence(n).map(value => value.toString()); + + return NextResponse.json({ + catalan: sequence[n], + sequence, + }); +} diff --git a/app/api/routes-f/categories/followed/route.ts b/app/api/routes-f/categories/followed/route.ts new file mode 100644 index 00000000..6cbd35ae --- /dev/null +++ b/app/api/routes-f/categories/followed/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; + +export interface Stream { + creator: string; + category: string; + viewer_count: number; +} + +// Seed category follows mapping: viewer_id -> list of followed categories +export const SEED_FOLLOWS: Record = { + "viewer-1": ["Gaming", "Music", "Talk Shows"], + "viewer-2": ["Gaming"], + "viewer-3": ["Crypto", "Coding"], +}; + +// Seed live streams +export const SEED_STREAMS: Stream[] = [ + { creator: "creator-gaming-1", category: "Gaming", viewer_count: 1500 }, + { creator: "creator-gaming-2", category: "Gaming", viewer_count: 800 }, + { creator: "creator-music-1", category: "Music", viewer_count: 450 }, + { creator: "creator-talk-1", category: "Talk Shows", viewer_count: 1200 }, + { creator: "creator-crypto-1", category: "Crypto", viewer_count: 3100 }, + { creator: "creator-coding-1", category: "Coding", viewer_count: 950 }, + { creator: "creator-sports-1", category: "Sports", viewer_count: 5000 }, +]; + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const viewerId = searchParams.get("viewer_id"); + + if (!viewerId) { + return NextResponse.json({ error: "viewer_id is required" }, { status: 400 }); + } + + // Get categories followed by the viewer + const followedCategories = SEED_FOLLOWS[viewerId] || []; + + // Filter streams to only those in the followed categories + const followedStreams = SEED_STREAMS.filter((stream) => + followedCategories.includes(stream.category) + ); + + // Sort by viewer_count descending + followedStreams.sort((a, b) => b.viewer_count - a.viewer_count); + + return NextResponse.json({ + streams: followedStreams, + }); +} diff --git a/app/api/routes-f/char-frequency/__tests__/route.test.ts b/app/api/routes-f/char-frequency/__tests__/route.test.ts new file mode 100644 index 00000000..79e431d8 --- /dev/null +++ b/app/api/routes-f/char-frequency/__tests__/route.test.ts @@ -0,0 +1,34 @@ +import { charFrequency } from "../route"; + +describe("charFrequency", () => { + it("counts characters and sorts by count descending", () => { + const { frequencies, total } = charFrequency("aaabb"); + expect(total).toBe(5); + expect(frequencies).toEqual([ + { char: "a", count: 3 }, + { char: "b", count: 2 }, + ]); + }); + + it("is case-insensitive by default and case-sensitive on request", () => { + expect(charFrequency("aA").frequencies).toEqual([{ char: "a", count: 2 }]); + expect(charFrequency("aA", { caseSensitive: true }).frequencies).toEqual([ + { char: "A", count: 1 }, + { char: "a", count: 1 }, + ]); + }); + + it("can ignore whitespace", () => { + const { frequencies, total } = charFrequency("a b\tc", { + ignoreWhitespace: true, + }); + expect(total).toBe(3); + expect(frequencies.find((f) => /\s/.test(f.char))).toBeUndefined(); + }); + + it("limits results with top", () => { + const { frequencies } = charFrequency("aaabbc", { top: 2 }); + expect(frequencies).toHaveLength(2); + expect(frequencies[0]).toEqual({ char: "a", count: 3 }); + }); +}); diff --git a/app/api/routes-f/char-frequency/route.ts b/app/api/routes-f/char-frequency/route.ts new file mode 100644 index 00000000..cf9d2a1b --- /dev/null +++ b/app/api/routes-f/char-frequency/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const MAX_INPUT_BYTES = 1_000_000; + +export interface CharCount { + char: string; + count: number; +} + +export interface CharFrequencyResult { + frequencies: CharCount[]; + total: number; +} + +export interface CharFrequencyOptions { + caseSensitive?: boolean; + ignoreWhitespace?: boolean; + top?: number; +} + +/** + * Count character frequency in `text`, sorted by count descending (ties broken + * by character for stable output). `total` is the number of counted characters. + */ +export function charFrequency( + text: string, + { caseSensitive = false, ignoreWhitespace = false, top }: CharFrequencyOptions = {}, +): CharFrequencyResult { + const normalized = caseSensitive ? text : text.toLowerCase(); + const counts = new Map(); + let total = 0; + + for (const char of normalized) { + if (ignoreWhitespace && /\s/.test(char)) continue; + counts.set(char, (counts.get(char) ?? 0) + 1); + total += 1; + } + + let frequencies: CharCount[] = Array.from(counts, ([char, count]) => ({ + char, + count, + })).sort((a, b) => b.count - a.count || a.char.localeCompare(b.char)); + + if (top !== undefined) { + frequencies = frequencies.slice(0, top); + } + + return { frequencies, total }; +} + +const schema = z.object({ + text: z.string().max(MAX_INPUT_BYTES, "text exceeds 1MB limit"), + case_sensitive: z.boolean().optional(), + ignore_whitespace: z.boolean().optional(), + top: z.number().int().positive().optional(), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { text, case_sensitive, ignore_whitespace, top } = result.data; + return NextResponse.json( + charFrequency(text, { + caseSensitive: case_sensitive, + ignoreWhitespace: ignore_whitespace, + top, + }), + ); +} diff --git a/app/api/routes-f/char-stats/_lib/helpers.ts b/app/api/routes-f/char-stats/_lib/helpers.ts new file mode 100644 index 00000000..a2eaa90d --- /dev/null +++ b/app/api/routes-f/char-stats/_lib/helpers.ts @@ -0,0 +1,122 @@ +import type { CharStatsResponse } from "./types"; + +const MAX_INPUT_BYTES = 1024 * 1024; // 1MB + +// Unicode property regexes +const RE_LETTER = /\p{L}/u; +const RE_DIGIT = /\p{N}/u; +const RE_WHITESPACE = /\p{Z}|\t|\n|\r/u; +const RE_PUNCTUATION = /\p{P}/u; +const RE_SYMBOL = /\p{S}/u; + +// Script ranges +const RE_LATIN = /\p{Script=Latin}/u; +const RE_CYRILLIC = /\p{Script=Cyrillic}/u; +const RE_CJK = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}/u; +const RE_ARABIC = /\p{Script=Arabic}/u; +const RE_DEVANAGARI = /\p{Script=Devanagari}/u; +const RE_GREEK = /\p{Script=Greek}/u; +const RE_HEBREW = /\p{Script=Hebrew}/u; + +// Emoji detection (handles ZWJ sequences and multi-codepoint emoji) +const RE_EMOJI = /\p{Emoji_Presentation}|\p{Extended_Pictographic}/u; +const RE_EMOJI_SEQUENCE = + /\p{Emoji_Presentation}(?:️?⃐-⃿|⃣|️)*(?:‍(?:\p{Emoji_Presentation}(?:️?⃐-⃿|⃣|️)*))*|\p{Extended_Pictographic}/gu; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function countEmoji(text: string): number { + return [...text.matchAll(RE_EMOJI_SEQUENCE)].length; +} + +export function computeCharStats(input: unknown): CharStatsResponse { + if (!isRecord(input)) { + throw new Error("Request body must be an object."); + } + + const { text } = input as Record; + + if (typeof text !== "string") { + throw new Error("text must be a string."); + } + + if (Buffer.byteLength(text, "utf8") > MAX_INPUT_BYTES) { + throw new Error("text must not exceed 1MB."); + } + + // Segment into grapheme clusters (handles multi-codepoint emoji) + const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const graphemes = [...segmenter.segment(text)].map((s) => s.segment); + + let letters = 0, + digits = 0, + whitespace = 0, + punctuation = 0, + symbols = 0, + other = 0; + + const scriptCounts = { + latin: 0, + cyrillic: 0, + cjk: 0, + arabic: 0, + devanagari: 0, + greek: 0, + hebrew: 0, + other: 0, + }; + + const emojiCount = countEmoji(text); + + // Build a set of emoji grapheme positions to avoid double-counting + const emojiSet = new Set(); + for (const m of text.matchAll(RE_EMOJI_SEQUENCE)) { + emojiSet.add(m[0]); + } + + for (const g of graphemes) { + // Check if it's an emoji grapheme + if (RE_EMOJI.test(g) || (g.length > 1 && emojiSet.has(g))) { + // counted separately + continue; + } + + const firstChar = g[0]; + + if (RE_WHITESPACE.test(firstChar)) { + whitespace++; + } else if (RE_LETTER.test(firstChar)) { + letters++; + if (RE_LATIN.test(firstChar)) scriptCounts.latin++; + else if (RE_CYRILLIC.test(firstChar)) scriptCounts.cyrillic++; + else if (RE_CJK.test(firstChar)) scriptCounts.cjk++; + else if (RE_ARABIC.test(firstChar)) scriptCounts.arabic++; + else if (RE_DEVANAGARI.test(firstChar)) scriptCounts.devanagari++; + else if (RE_GREEK.test(firstChar)) scriptCounts.greek++; + else if (RE_HEBREW.test(firstChar)) scriptCounts.hebrew++; + else scriptCounts.other++; + } else if (RE_DIGIT.test(firstChar)) { + digits++; + } else if (RE_PUNCTUATION.test(firstChar)) { + punctuation++; + } else if (RE_SYMBOL.test(firstChar)) { + symbols++; + } else { + other++; + } + } + + return { + total: graphemes.length, + letters, + digits, + whitespace, + punctuation, + symbols, + emoji: emojiCount, + other, + by_script: scriptCounts, + }; +} diff --git a/app/api/routes-f/char-stats/_lib/types.ts b/app/api/routes-f/char-stats/_lib/types.ts new file mode 100644 index 00000000..1b8e32fc --- /dev/null +++ b/app/api/routes-f/char-stats/_lib/types.ts @@ -0,0 +1,22 @@ +export interface ScriptCounts { + latin: number; + cyrillic: number; + cjk: number; + arabic: number; + devanagari: number; + greek: number; + hebrew: number; + other: number; +} + +export interface CharStatsResponse { + total: number; + letters: number; + digits: number; + whitespace: number; + punctuation: number; + symbols: number; + emoji: number; + other: number; + by_script: ScriptCounts; +} diff --git a/app/api/routes-f/char-stats/route.ts b/app/api/routes-f/char-stats/route.ts new file mode 100644 index 00000000..c2f30cc0 --- /dev/null +++ b/app/api/routes-f/char-stats/route.ts @@ -0,0 +1,19 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { computeCharStats } from "./_lib/helpers"; + +export async function POST(req: NextRequest) { + let body: unknown; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + try { + return NextResponse.json(computeCharStats(body)); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to compute char stats."; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/routes-f/chat-blocklist/__tests__/route.test.ts b/app/api/routes-f/chat-blocklist/__tests__/route.test.ts new file mode 100644 index 00000000..0908f81b --- /dev/null +++ b/app/api/routes-f/chat-blocklist/__tests__/route.test.ts @@ -0,0 +1,181 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, GET } from "../route"; + +function makeReq(method: string, body?: unknown) { + return new NextRequest("http://localhost/api/routes-f/chat-blocklist", { + method, + headers: { "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +describe("POST /api/routes-f/chat-blocklist", () => { + it("adds words to blocklist", async () => { + const res = await POST( + makeReq("POST", { + creator_id: "creator123", + add: ["spam", "scam"], + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.words).toContain("spam"); + expect(body.words).toContain("scam"); + }); + + it("removes words from blocklist", async () => { + // First add words + await POST( + makeReq("POST", { + creator_id: "creator456", + add: ["spam", "scam", "bot"], + }) + ); + + // Then remove one + const res = await POST( + makeReq("POST", { + creator_id: "creator456", + remove: ["spam"], + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.words).not.toContain("spam"); + expect(body.words).toContain("scam"); + expect(body.words).toContain("bot"); + }); + + it("handles case-insensitive storage and dedup", async () => { + await POST( + makeReq("POST", { + creator_id: "creator789", + add: ["Spam", "SPAM", "spam", "Scam"], + }) + ); + + const res = await POST( + makeReq("POST", { + creator_id: "creator789", + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.words).toHaveLength(2); + expect(body.words).toContain("spam"); + expect(body.words).toContain("scam"); + }); + + it("enforces cap at 200 entries", async () => { + const wordsToAdd = Array.from({ length: 201 }, (_, i) => `word${i}`); + const res = await POST( + makeReq("POST", { + creator_id: "creator999", + add: wordsToAdd, + }) + ); + + expect(res.status).toBe(400); + }); + + it("allows adding up to 200 entries", async () => { + const wordsToAdd = Array.from({ length: 200 }, (_, i) => `word${i}`); + const res = await POST( + makeReq("POST", { + creator_id: "creator200", + add: wordsToAdd, + }) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.words).toHaveLength(200); + }); + + it("rejects missing creator_id", async () => { + const res = await POST( + makeReq("POST", { + add: ["spam"], + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects non-array add parameter", async () => { + const res = await POST( + makeReq("POST", { + creator_id: "creator123", + add: "not-an-array", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects non-array remove parameter", async () => { + const res = await POST( + makeReq("POST", { + creator_id: "creator123", + remove: "not-an-array", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/chat-blocklist", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "invalid json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/routes-f/chat-blocklist", () => { + beforeEach(async () => { + // Setup: add words to blocklist + await POST( + makeReq("POST", { + creator_id: "getcreator", + add: ["spam", "scam", "bot"], + }) + ); + }); + + it("returns blocklist for creator", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/chat-blocklist?creator_id=getcreator" + ); + const res = await GET(req); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.words).toContain("spam"); + expect(body.words).toContain("scam"); + expect(body.words).toContain("bot"); + }); + + it("returns empty array for creator with no blocklist", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/chat-blocklist?creator_id=noblocklist" + ); + const res = await GET(req); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.words).toHaveLength(0); + }); + + it("returns 400 when creator_id is missing", async () => { + const req = new NextRequest("http://localhost/api/routes-f/chat-blocklist"); + const res = await GET(req); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/chat-blocklist/route.ts b/app/api/routes-f/chat-blocklist/route.ts new file mode 100644 index 00000000..9a033ce4 --- /dev/null +++ b/app/api/routes-f/chat-blocklist/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from "next/server"; + +// In-memory store for blocklists, keyed by creator_id +const blocklistStore = new Map>(); + +const MAX_ENTRIES = 200; + +function normalizeWord(word: string): string { + return word.trim().toLowerCase(); +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const creatorId = searchParams.get("creator_id"); + + if (!creatorId) { + return NextResponse.json( + { error: "creator_id is required" }, + { status: 400 } + ); + } + + const blocklist = blocklistStore.get(creatorId) || new Set(); + const words = Array.from(blocklist); + + return NextResponse.json({ words }); +} + +export async function POST(req: NextRequest) { + let body: { + creator_id?: unknown; + add?: unknown; + remove?: unknown; + }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { creator_id, add, remove } = body; + + if (!creator_id || typeof creator_id !== "string") { + return NextResponse.json( + { error: "creator_id is required and must be a string" }, + { status: 400 } + ); + } + + // Get or create blocklist for this creator + let blocklist = blocklistStore.get(creator_id); + if (!blocklist) { + blocklist = new Set(); + blocklistStore.set(creator_id, blocklist); + } + + // Process additions + if (add !== undefined) { + if (!Array.isArray(add)) { + return NextResponse.json( + { error: "add must be an array of strings" }, + { status: 400 } + ); + } + + for (const item of add) { + if (typeof item !== "string") { + return NextResponse.json( + { error: "add must be an array of strings" }, + { status: 400 } + ); + } + + const normalized = normalizeWord(item); + if (normalized) { + // Check cap before adding + if (blocklist.size >= MAX_ENTRIES && !blocklist.has(normalized)) { + return NextResponse.json( + { error: `Blocklist cap reached (max ${MAX_ENTRIES} entries)` }, + { status: 400 } + ); + } + blocklist.add(normalized); + } + } + } + + // Process removals + if (remove !== undefined) { + if (!Array.isArray(remove)) { + return NextResponse.json( + { error: "remove must be an array of strings" }, + { status: 400 } + ); + } + + for (const item of remove) { + if (typeof item !== "string") { + return NextResponse.json( + { error: "remove must be an array of strings" }, + { status: 400 } + ); + } + + const normalized = normalizeWord(item); + if (normalized) { + blocklist.delete(normalized); + } + } + } + + // Return updated blocklist + const words = Array.from(blocklist); + return NextResponse.json({ words }); +} diff --git a/app/api/routes-f/chat-emote-mode/[stream_id]/route.ts b/app/api/routes-f/chat-emote-mode/[stream_id]/route.ts new file mode 100644 index 00000000..f244f8ba --- /dev/null +++ b/app/api/routes-f/chat-emote-mode/[stream_id]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * DELETE /api/routes-f/chat-emote-mode/[stream_id] + * Disable emote-only mode for a stream + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ stream_id: string }> } +) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { stream_id } = await params; + + try { + const { rows: streamRows } = await sql` + SELECT creator_id FROM streams WHERE id = ${stream_id} + `; + + if (streamRows.length === 0) { + return NextResponse.json( + { error: "Stream not found" }, + { status: 404 } + ); + } + + if (streamRows[0].creator_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await sql` + UPDATE emote_mode_settings + SET enabled = false, updated_at = CURRENT_TIMESTAMP + WHERE stream_id = ${stream_id} + `; + + return NextResponse.json({ enabled: false, stream_id }); + } catch (error) { + console.error("[Chat Emote Mode API] Error disabling mode:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/chat-emote-mode/check/route.ts b/app/api/routes-f/chat-emote-mode/check/route.ts new file mode 100644 index 00000000..83163561 --- /dev/null +++ b/app/api/routes-f/chat-emote-mode/check/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateBody } from "../../_lib/validate"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const checkMessageSchema = z.object({ + message: z.string(), +}); + +function isEmoteOnly(message: string): boolean { + const trimmed = message.trim(); + if (trimmed.length === 0) return false; + + for (const char of trimmed) { + const code = char.codePointAt(0); + if (!code) continue; + + if (code < 0x1F000) { + if (!/[\p{Emoji}]/u.test(char)) { + return false; + } + } + } + + return true; +} + +/** + * POST /api/routes-f/chat-emote-mode/check + * Check if a message would be blocked by emote-only mode + */ +export async function POST(req: NextRequest) { + const bodyResult = await validateBody(req, checkMessageSchema); + if (bodyResult instanceof NextResponse) { + return bodyResult; + } + + const { message } = bodyResult.data; + const isEmote = isEmoteOnly(message); + + return NextResponse.json({ + is_emote_only: isEmote, + would_be_blocked: !isEmote, + }); +} diff --git a/app/api/routes-f/chat-emote-mode/route.ts b/app/api/routes-f/chat-emote-mode/route.ts new file mode 100644 index 00000000..d5ae3665 --- /dev/null +++ b/app/api/routes-f/chat-emote-mode/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "../_lib/validate"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const enableEmoteModeSchema = z.object({ + stream_id: z.string(), +}); + +function isEmoteOnly(message: string): boolean { + const trimmed = message.trim(); + if (trimmed.length === 0) return false; + + for (const char of trimmed) { + const code = char.codePointAt(0); + if (!code) continue; + + if (code < 0x1F000) { + if (!/[\p{Emoji}]/u.test(char)) { + return false; + } + } + } + + return true; +} + +/** + * POST /api/routes-f/chat-emote-mode + * Enable emote-only mode for a stream + */ +export async function POST(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, enableEmoteModeSchema); + if (bodyResult instanceof NextResponse) { + return bodyResult; + } + + const { stream_id } = bodyResult.data; + + try { + const { rows: streamRows } = await sql` + SELECT creator_id FROM streams WHERE id = ${stream_id} + `; + + if (streamRows.length === 0) { + return NextResponse.json( + { error: "Stream not found" }, + { status: 404 } + ); + } + + if (streamRows[0].creator_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await sql` + INSERT INTO emote_mode_settings (stream_id, enabled, created_at) + VALUES (${stream_id}, true, CURRENT_TIMESTAMP) + ON CONFLICT (stream_id) + DO UPDATE SET enabled = true, updated_at = CURRENT_TIMESTAMP + `; + + return NextResponse.json({ enabled: true, stream_id }); + } catch (error) { + console.error("[Chat Emote Mode API] Error enabling mode:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export { isEmoteOnly }; diff --git a/app/api/routes-f/chat-reactions/__tests__/route.test.ts b/app/api/routes-f/chat-reactions/__tests__/route.test.ts new file mode 100644 index 00000000..abebb743 --- /dev/null +++ b/app/api/routes-f/chat-reactions/__tests__/route.test.ts @@ -0,0 +1,560 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, GET } from "../route"; +import { reactionStore } from "../utils"; + +function makePostReq( + messageId: string, + emoji: string, + userId: string +): NextRequest { + return new NextRequest("http://localhost/api/routes-f/chat-reactions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message_id: messageId, + emoji, + user_id: userId, + }), + }); +} + +function makeGetReq(messageId: string, userId?: string): NextRequest { + let url = `http://localhost/api/routes-f/chat-reactions?message_id=${messageId}`; + if (userId) { + url += `&user_id=${userId}`; + } + return new NextRequest(url); +} + +describe("POST /api/routes-f/chat-reactions", () => { + beforeEach(() => { + // Clear reactions before each test + reactionStore.length = 0; + }); + + describe("Adding Reactions", () => { + it("adds a reaction to a message", async () => { + const res = await POST(makePostReq("msg123", "👍", "user1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.toggled).toBe(true); + expect(body.reactions.length).toBe(1); + expect(body.reactions[0]).toEqual({ + emoji: "👍", + count: 1, + reacted_by_me: true, + }); + }); + + it("adds multiple different emoji reactions", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + const res = await POST(makePostReq("msg123", "❤️", "user1")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.toggled).toBe(true); + expect(body.reactions.length).toBe(2); + + const emojis = body.reactions.map(r => r.emoji); + expect(emojis).toContain("👍"); + expect(emojis).toContain("❤️"); + }); + + it("adds same emoji from different users", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + const res = await POST(makePostReq("msg123", "👍", "user2")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.toggled).toBe(true); + expect(body.reactions.length).toBe(1); + expect(body.reactions[0]).toEqual({ + emoji: "👍", + count: 2, + reacted_by_me: true, + }); + }); + + it("handles single character emojis", async () => { + const res = await POST(makePostReq("msg123", "😊", "user1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reactions[0].emoji).toBe("😊"); + }); + + it("handles emoji with skin tone modifiers", async () => { + // 👋🏻 is wave with light skin tone + const res = await POST(makePostReq("msg123", "👋🏻", "user1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reactions.length).toBe(1); + }); + + it("handles emoji with zero-width joiners", async () => { + // 👨‍👩‍👧‍👦 is family emoji (ZWJ sequence) + const res = await POST(makePostReq("msg123", "👨‍👩‍👧‍👦", "user1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reactions.length).toBe(1); + }); + }); + + describe("Toggling Reactions", () => { + it("removes a reaction when user reacts with same emoji", async () => { + // Add reaction + await POST(makePostReq("msg123", "👍", "user1")); + + // Toggle off (remove) + const res = await POST(makePostReq("msg123", "👍", "user1")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.toggled).toBe(false); + expect(body.reactions.length).toBe(0); + }); + + it("removes only one user reaction, not all", async () => { + // User 1 and 2 react with thumbs up + await POST(makePostReq("msg123", "👍", "user1")); + await POST(makePostReq("msg123", "👍", "user2")); + + // User 1 removes their reaction + const res = await POST(makePostReq("msg123", "👍", "user1")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.toggled).toBe(false); + expect(body.reactions[0]).toEqual({ + emoji: "👍", + count: 1, + reacted_by_me: false, + }); + }); + + it("allows user to re-add after removing", async () => { + // Add + await POST(makePostReq("msg123", "👍", "user1")); + + // Remove + await POST(makePostReq("msg123", "👍", "user1")); + + // Re-add + const res = await POST(makePostReq("msg123", "👍", "user1")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.toggled).toBe(true); + expect(body.reactions[0]).toEqual({ + emoji: "👍", + count: 1, + reacted_by_me: true, + }); + }); + + it("does not affect other emojis when toggling", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + await POST(makePostReq("msg123", "❤️", "user1")); + await POST(makePostReq("msg123", "👍", "user2")); + + // Toggle off user1's thumbs up + const res = await POST(makePostReq("msg123", "👍", "user1")); + + const body = await res.json(); + const heartReaction = body.reactions.find(r => r.emoji === "❤️"); + expect(heartReaction).toEqual({ + emoji: "❤️", + count: 1, + reacted_by_me: true, + }); + }); + }); + + describe("Message Isolation", () => { + it("reactions are isolated per message", async () => { + await POST(makePostReq("msg1", "👍", "user1")); + await POST(makePostReq("msg2", "❤️", "user1")); + + const res = await POST(makePostReq("msg1", "❤️", "user2")); + + const body = await res.json(); + expect(body.reactions.length).toBe(2); + const emojis = body.reactions.map(r => r.emoji); + expect(emojis).toContain("👍"); + expect(emojis).toContain("❤️"); + }); + }); + + describe("Input Validation", () => { + it("rejects missing message_id", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/chat-reactions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + emoji: "👍", + user_id: "user1", + }), + } + ); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects empty message_id", async () => { + const res = await POST(makePostReq("", "👍", "user1")); + expect(res.status).toBe(400); + }); + + it("rejects missing emoji", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/chat-reactions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message_id: "msg123", + user_id: "user1", + }), + } + ); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects empty emoji", async () => { + const res = await POST(makePostReq("msg123", "", "user1")); + expect(res.status).toBe(400); + }); + + it("rejects multi-character string as emoji", async () => { + const res = await POST(makePostReq("msg123", "👍❤️", "user1")); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("single grapheme cluster"); + }); + + it("rejects ASCII letters as emoji", async () => { + const res = await POST(makePostReq("msg123", "a", "user1")); + expect(res.status).toBe(400); + }); + + it("rejects ASCII numbers as emoji", async () => { + const res = await POST(makePostReq("msg123", "5", "user1")); + expect(res.status).toBe(400); + }); + + it("accepts ASCII symbols as reaction (if single grapheme)", async () => { + // Some ASCII symbols that aren't letters/numbers might be acceptable + // but the implementation should handle this gracefully + const res = await POST(makePostReq("msg123", "!", "user1")); + // This should either succeed or fail consistently + expect([200, 400]).toContain(res.status); + }); + + it("rejects missing user_id", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/chat-reactions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message_id: "msg123", + emoji: "👍", + }), + } + ); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("rejects empty user_id", async () => { + const res = await POST(makePostReq("msg123", "👍", "")); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/chat-reactions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "invalid json", + } + ); + const res = await POST(req); + expect(res.status).toBe(400); + }); + }); + + describe("reacted_by_me Field", () => { + it("sets reacted_by_me to true for the user who reacted", async () => { + const res = await POST(makePostReq("msg123", "👍", "user1")); + const body = await res.json(); + expect(body.reactions[0].reacted_by_me).toBe(true); + }); + + it("sets reacted_by_me to false for other users", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + const res = await POST(makePostReq("msg123", "❤️", "user2")); + + const body = await res.json(); + const thumbsUp = body.reactions.find(r => r.emoji === "👍"); + expect(thumbsUp.reacted_by_me).toBe(false); + }); + + it("correctly reflects reacted_by_me after toggle", async () => { + // User adds reaction + let res = await POST(makePostReq("msg123", "👍", "user1")); + let body = await res.json(); + expect(body.reactions[0].reacted_by_me).toBe(true); + + // User removes reaction + res = await POST(makePostReq("msg123", "👍", "user1")); + body = await res.json(); + // After removing, no reactions should exist + expect(body.reactions.length).toBe(0); + }); + }); +}); + +describe("GET /api/routes-f/chat-reactions", () => { + beforeEach(() => { + reactionStore.length = 0; + }); + + describe("Basic Retrieval", () => { + it("returns reactions for a message", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + await POST(makePostReq("msg123", "❤️", "user2")); + + const res = await GET(makeGetReq("msg123")); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.reactions.length).toBe(2); + const emojis = body.reactions.map(r => r.emoji); + expect(emojis).toContain("👍"); + expect(emojis).toContain("❤️"); + }); + + it("returns empty array for message with no reactions", async () => { + const res = await GET(makeGetReq("no-reactions-msg")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reactions).toEqual([]); + }); + + it("returns 400 when message_id is missing", async () => { + const req = new NextRequest( + "http://localhost/api/routes-f/chat-reactions" + ); + const res = await GET(req); + expect(res.status).toBe(400); + }); + }); + + describe("Count Aggregation", () => { + it("aggregates count for same emoji", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + await POST(makePostReq("msg123", "👍", "user2")); + await POST(makePostReq("msg123", "👍", "user3")); + + const res = await GET(makeGetReq("msg123")); + const body = await res.json(); + + expect(body.reactions.length).toBe(1); + expect(body.reactions[0]).toEqual({ + emoji: "👍", + count: 3, + reacted_by_me: false, + }); + }); + + it("returns correct count for multiple emojis", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + await POST(makePostReq("msg123", "👍", "user2")); + await POST(makePostReq("msg123", "❤️", "user1")); + await POST(makePostReq("msg123", "❤️", "user2")); + await POST(makePostReq("msg123", "😂", "user1")); + + const res = await GET(makeGetReq("msg123")); + const body = await res.json(); + + expect(body.reactions.length).toBe(3); + + const thumbsUp = body.reactions.find(r => r.emoji === "👍"); + expect(thumbsUp.count).toBe(2); + + const heart = body.reactions.find(r => r.emoji === "❤️"); + expect(heart.count).toBe(2); + + const laugh = body.reactions.find(r => r.emoji === "😂"); + expect(laugh.count).toBe(1); + }); + }); + + describe("reacted_by_me Field", () => { + it("sets reacted_by_me true when user provided and has reacted", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + await POST(makePostReq("msg123", "❤️", "user1")); + + const res = await GET(makeGetReq("msg123", "user1")); + const body = await res.json(); + + body.reactions.forEach(r => { + expect(r.reacted_by_me).toBe(true); + }); + }); + + it("sets reacted_by_me false when user has not reacted", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + + const res = await GET(makeGetReq("msg123", "user2")); + const body = await res.json(); + + expect(body.reactions[0].reacted_by_me).toBe(false); + }); + + it("sets reacted_by_me false when user_id not provided", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + + const res = await GET(makeGetReq("msg123")); + const body = await res.json(); + + expect(body.reactions[0].reacted_by_me).toBe(false); + }); + + it("correctly identifies which emojis the user reacted to", async () => { + await POST(makePostReq("msg123", "👍", "user1")); + await POST(makePostReq("msg123", "👍", "user2")); + await POST(makePostReq("msg123", "❤️", "user1")); + await POST(makePostReq("msg123", "😂", "user2")); + + const res = await GET(makeGetReq("msg123", "user1")); + const body = await res.json(); + + const thumbsUp = body.reactions.find(r => r.emoji === "👍"); + expect(thumbsUp.reacted_by_me).toBe(true); + + const heart = body.reactions.find(r => r.emoji === "❤️"); + expect(heart.reacted_by_me).toBe(true); + + const laugh = body.reactions.find(r => r.emoji === "😂"); + expect(laugh.reacted_by_me).toBe(false); + }); + }); + + describe("Message Isolation", () => { + it("returns only reactions for specified message", async () => { + await POST(makePostReq("msg1", "👍", "user1")); + await POST(makePostReq("msg2", "❤️", "user1")); + + const res = await GET(makeGetReq("msg1")); + const body = await res.json(); + + expect(body.reactions.length).toBe(1); + expect(body.reactions[0].emoji).toBe("👍"); + }); + }); +}); + +describe("Integration: Full Workflow", () => { + beforeEach(() => { + reactionStore.length = 0; + }); + + it("completes full reaction lifecycle", async () => { + // 1. User adds reaction + let res = await POST(makePostReq("msg1", "👍", "user1")); + let body = await res.json(); + expect(body.toggled).toBe(true); + expect(body.reactions[0].count).toBe(1); + + // 2. Get reactions shows correct state + res = await GET(makeGetReq("msg1", "user1")); + body = await res.json(); + expect(body.reactions[0].reacted_by_me).toBe(true); + + // 3. Another user adds same emoji + res = await POST(makePostReq("msg1", "👍", "user2")); + body = await res.json(); + expect(body.reactions[0].count).toBe(2); + + // 4. First user removes reaction + res = await POST(makePostReq("msg1", "👍", "user1")); + body = await res.json(); + expect(body.toggled).toBe(false); + expect(body.reactions[0].count).toBe(1); + + // 5. Verify state + res = await GET(makeGetReq("msg1", "user1")); + body = await res.json(); + expect(body.reactions[0].reacted_by_me).toBe(false); + }); + + it("handles complex multi-user multi-emoji scenario", async () => { + const messageId = "complex-msg"; + + // Setup: Multiple users, multiple emojis + await POST(makePostReq(messageId, "👍", "user1")); + await POST(makePostReq(messageId, "👍", "user2")); + await POST(makePostReq(messageId, "👍", "user3")); + await POST(makePostReq(messageId, "❤️", "user1")); + await POST(makePostReq(messageId, "❤️", "user2")); + await POST(makePostReq(messageId, "😂", "user1")); + + // Query from user1's perspective + let res = await GET(makeGetReq(messageId, "user1")); + let body = await res.json(); + + const thumbsUp = body.reactions.find(r => r.emoji === "👍"); + expect(thumbsUp.count).toBe(3); + expect(thumbsUp.reacted_by_me).toBe(true); + + const heart = body.reactions.find(r => r.emoji === "❤️"); + expect(heart.count).toBe(2); + expect(heart.reacted_by_me).toBe(true); + + const laugh = body.reactions.find(r => r.emoji === "😂"); + expect(laugh.count).toBe(1); + expect(laugh.reacted_by_me).toBe(true); + + // User 2 removes heart + await POST(makePostReq(messageId, "❤️", "user2")); + + // Verify update + res = await GET(makeGetReq(messageId, "user2")); + body = await res.json(); + + const updatedHeart = body.reactions.find(r => r.emoji === "❤️"); + expect(updatedHeart.count).toBe(1); + expect(updatedHeart.reacted_by_me).toBe(false); + }); + + it("tracks separate messages independently", async () => { + // Message 1: thumbs up from users 1 and 2 + await POST(makePostReq("msg1", "👍", "user1")); + await POST(makePostReq("msg1", "👍", "user2")); + + // Message 2: heart from user 3 + await POST(makePostReq("msg2", "❤️", "user3")); + + // Verify msg1 only has thumbs up + let res = await GET(makeGetReq("msg1")); + let body = await res.json(); + expect(body.reactions.length).toBe(1); + expect(body.reactions[0].emoji).toBe("👍"); + expect(body.reactions[0].count).toBe(2); + + // Verify msg2 only has heart + res = await GET(makeGetReq("msg2")); + body = await res.json(); + expect(body.reactions.length).toBe(1); + expect(body.reactions[0].emoji).toBe("❤️"); + expect(body.reactions[0].count).toBe(1); + }); +}); diff --git a/app/api/routes-f/chat-reactions/route.ts b/app/api/routes-f/chat-reactions/route.ts new file mode 100644 index 00000000..9c2085a0 --- /dev/null +++ b/app/api/routes-f/chat-reactions/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateBody } from "../../_lib/validate"; +import { z } from "zod"; +import { + toggleReaction, + getReactionsForMessage, + validateReactionInput, +} from "./utils"; +import type { + PostReactionRequestBody, + PostReactionResponse, + ReactionResponse, +} from "./types"; + +const reactionSchema = z.object({ + message_id: z.string(), + emoji: z.string(), + user_id: z.string(), +}); + +export async function POST(req: NextRequest): Promise { + const validation = await validateBody(req, reactionSchema); + if (validation instanceof NextResponse) { + return validation; + } + + const body = validation.data as PostReactionRequestBody; + + const inputValidation = validateReactionInput( + body.message_id, + body.emoji, + body.user_id + ); + if (!inputValidation.valid) { + return NextResponse.json({ error: inputValidation.error }, { status: 400 }); + } + + // Toggle reaction + const toggled = toggleReaction(body.message_id, body.emoji, body.user_id); + + // Get updated reactions + const reactions = getReactionsForMessage(body.message_id, body.user_id); + + return NextResponse.json({ + toggled, + reactions, + } as PostReactionResponse); +} + +export async function GET(req: NextRequest): Promise { + const messageId = new URL(req.url).searchParams.get("message_id"); + const currentUserId = new URL(req.url).searchParams.get("user_id"); + + if (!messageId) { + return NextResponse.json( + { error: "message_id is required" }, + { status: 400 } + ); + } + + const reactions = getReactionsForMessage( + messageId, + currentUserId || undefined + ); + + return NextResponse.json({ + reactions, + } as ReactionResponse); +} diff --git a/app/api/routes-f/chat-reactions/types.ts b/app/api/routes-f/chat-reactions/types.ts new file mode 100644 index 00000000..bb8fe217 --- /dev/null +++ b/app/api/routes-f/chat-reactions/types.ts @@ -0,0 +1,26 @@ +export interface ReactionRecord { + message_id: string; + emoji: string; + user_id: string; +} + +export interface ReactionAggregate { + emoji: string; + count: number; + reacted_by_me: boolean; +} + +export interface ReactionResponse { + reactions: ReactionAggregate[]; +} + +export interface PostReactionRequestBody { + message_id: string; + emoji: string; + user_id: string; +} + +export interface PostReactionResponse { + toggled: boolean; // true if added, false if removed + reactions: ReactionAggregate[]; +} diff --git a/app/api/routes-f/chat-reactions/utils.ts b/app/api/routes-f/chat-reactions/utils.ts new file mode 100644 index 00000000..c4dadf74 --- /dev/null +++ b/app/api/routes-f/chat-reactions/utils.ts @@ -0,0 +1,130 @@ +import type { ReactionRecord, ReactionAggregate } from "./types"; + +export const reactionStore: ReactionRecord[] = []; + +/** + * Check if a string is a single grapheme cluster (emoji or character) + * Uses Array.from which properly handles emoji with zero-width joiners, skin tone modifiers, etc. + */ +export function isSingleGraphemeCluster(input: string): boolean { + if (typeof input !== "string" || input.length === 0) { + return false; + } + + // Use Array.from to split by grapheme clusters + const graphemes = Array.from(input); + return graphemes.length === 1; +} + +/** + * Check if a string is a valid emoji + * An emoji is a grapheme cluster that is not a regular ASCII letter/number + */ +export function isValidEmoji(input: string): boolean { + if (!isSingleGraphemeCluster(input)) { + return false; + } + + // Reject common ASCII letters, numbers, and symbols that aren't emoji + const codePoint = input.codePointAt(0) || 0; + + // Allow emoji ranges and common Unicode symbols + // This includes: emoji, emoticons, symbols, pictographs, etc. + // Reject: ASCII control chars, basic ASCII letters/numbers + if (codePoint < 127) { + // Only allow basic ASCII if it's not a letter or digit + return !/[a-zA-Z0-9]/.test(input); + } + + return true; +} + +export function getReactionsForMessage( + messageId: string, + currentUserId?: string +): ReactionAggregate[] { + // Find all reactions for this message + const messageReactions = reactionStore.filter( + r => r.message_id === messageId + ); + + if (messageReactions.length === 0) { + return []; + } + + // Aggregate by emoji + const aggregated = new Map }>(); + + for (const reaction of messageReactions) { + const existing = aggregated.get(reaction.emoji) || { + count: 0, + userIds: new Set(), + }; + existing.count += 1; + existing.userIds.add(reaction.user_id); + aggregated.set(reaction.emoji, existing); + } + + // Convert to response format + const result: ReactionAggregate[] = Array.from(aggregated.entries()).map( + ([emoji, data]) => ({ + emoji, + count: data.count, + reacted_by_me: currentUserId ? data.userIds.has(currentUserId) : false, + }) + ); + + return result; +} + +export function toggleReaction( + messageId: string, + emoji: string, + userId: string +): boolean { + // Check if this user already reacted with this emoji + const existingIndex = reactionStore.findIndex( + r => r.message_id === messageId && r.emoji === emoji && r.user_id === userId + ); + + if (existingIndex !== -1) { + // Remove the reaction (toggle off) + reactionStore.splice(existingIndex, 1); + return false; // Toggled off + } else { + // Add the reaction (toggle on) + reactionStore.push({ + message_id: messageId, + emoji, + user_id: userId, + }); + return true; // Toggled on + } +} + +export function validateReactionInput( + messageId: unknown, + emoji: unknown, + userId: unknown +): { valid: boolean; error?: string } { + if (typeof messageId !== "string" || messageId.trim().length === 0) { + return { valid: false, error: "message_id must be a non-empty string" }; + } + + if (typeof emoji !== "string" || emoji.length === 0) { + return { valid: false, error: "emoji must be a non-empty string" }; + } + + if (!isValidEmoji(emoji)) { + return { + valid: false, + error: "emoji must be a single grapheme cluster (emoji or character)", + }; + } + + if (typeof userId !== "string" || userId.trim().length === 0) { + return { valid: false, error: "user_id must be a non-empty string" }; + } + + return { valid: true }; +} diff --git a/app/api/routes-f/checksums/__tests__/route.test.ts b/app/api/routes-f/checksums/__tests__/route.test.ts new file mode 100644 index 00000000..13bf7f2f --- /dev/null +++ b/app/api/routes-f/checksums/__tests__/route.test.ts @@ -0,0 +1,28 @@ +import { crc32, adler32, checksums } from "../route"; + +describe("crc32", () => { + it("matches known vectors", () => { + expect(crc32("")).toBe("00000000"); + expect(crc32("123456789")).toBe("cbf43926"); + expect(crc32("The quick brown fox jumps over the lazy dog")).toBe("414fa339"); + }); +}); + +describe("adler32", () => { + it("matches known vectors", () => { + expect(adler32("")).toBe("00000001"); + expect(adler32("Wikipedia")).toBe("11e60398"); + }); +}); + +describe("checksums", () => { + it("returns both by default", () => { + const out = checksums("123456789"); + expect(out).toEqual({ crc32: "cbf43926", adler32: adler32("123456789") }); + }); + + it("returns only the requested algorithm", () => { + expect(checksums("abc", "crc32")).toEqual({ crc32: crc32("abc") }); + expect(checksums("abc", "adler32")).toEqual({ adler32: adler32("abc") }); + }); +}); diff --git a/app/api/routes-f/checksums/route.ts b/app/api/routes-f/checksums/route.ts new file mode 100644 index 00000000..cb210b32 --- /dev/null +++ b/app/api/routes-f/checksums/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const toHex8 = (n: number) => (n >>> 0).toString(16).padStart(8, "0"); + +/** CRC32 (IEEE 802.3) of UTF-8 encoded input, returned as 8-digit hex. */ +export function crc32(input: string): string { + const bytes = new TextEncoder().encode(input); + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let i = 0; i < 8; i += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return toHex8(crc ^ 0xffffffff); +} + +/** Adler-32 of UTF-8 encoded input, returned as 8-digit hex. */ +export function adler32(input: string): string { + const bytes = new TextEncoder().encode(input); + const MOD = 65521; + let a = 1; + let b = 0; + for (const byte of bytes) { + a = (a + byte) % MOD; + b = (b + a) % MOD; + } + return toHex8((b << 16) | a); +} + +export type ChecksumAlgorithm = "crc32" | "adler32" | "both"; + +export function checksums( + input: string, + algorithm: ChecksumAlgorithm = "both", +): { crc32?: string; adler32?: string } { + const out: { crc32?: string; adler32?: string } = {}; + if (algorithm === "crc32" || algorithm === "both") out.crc32 = crc32(input); + if (algorithm === "adler32" || algorithm === "both") out.adler32 = adler32(input); + return out; +} + +const schema = z.object({ + input: z.string(), + algorithm: z.enum(["crc32", "adler32", "both"]).optional(), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { input, algorithm } = result.data; + return NextResponse.json(checksums(input, algorithm)); +} diff --git a/app/api/routes-f/ciphers/__tests__/route.test.ts b/app/api/routes-f/ciphers/__tests__/route.test.ts new file mode 100644 index 00000000..f3a28ed6 --- /dev/null +++ b/app/api/routes-f/ciphers/__tests__/route.test.ts @@ -0,0 +1,33 @@ +import { + atbash, + railFenceEncode, + railFenceDecode, +} from "../route"; + +describe("atbash", () => { + it("mirrors letters and is its own inverse", () => { + expect(atbash("abc")).toBe("zyx"); + expect(atbash("Hello")).toBe("Svool"); + expect(atbash(atbash("Round Trip!"))).toBe("Round Trip!"); + }); + + it("leaves non-letters untouched", () => { + expect(atbash("a1 b2")).toBe("z1 y2"); + }); +}); + +describe("rail fence", () => { + it("encodes with the classic zig-zag", () => { + // "WEAREDISCOVEREDFLEEATONCE" with 3 rails -> known result + expect(railFenceEncode("WEAREDISCOVEREDFLEEATONCE", 3)).toBe( + "WECRLTEERDSOEEFEAOCAIVDEN", + ); + }); + + it("round-trips encode -> decode for several rail counts", () => { + const text = "the quick brown fox"; + for (const rails of [2, 3, 4, 5]) { + expect(railFenceDecode(railFenceEncode(text, rails), rails)).toBe(text); + } + }); +}); diff --git a/app/api/routes-f/ciphers/route.ts b/app/api/routes-f/ciphers/route.ts new file mode 100644 index 00000000..97135eda --- /dev/null +++ b/app/api/routes-f/ciphers/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +/** + * Atbash cipher: maps each Latin letter to its mirror (a<->z, A<->Z). It is its + * own inverse, so encoding and decoding are identical. + */ +export function atbash(text: string): string { + return text + .replace(/[a-z]/g, (c) => String.fromCharCode(219 - c.charCodeAt(0))) + .replace(/[A-Z]/g, (c) => String.fromCharCode(155 - c.charCodeAt(0))); +} + +/** Index of the zig-zag rail each character lands on, for a given length. */ +function railPattern(length: number, rails: number): number[] { + const pattern: number[] = []; + let row = 0; + let dir = 1; + for (let i = 0; i < length; i += 1) { + pattern.push(row); + if (row === 0) dir = 1; + else if (row === rails - 1) dir = -1; + row += dir; + } + return pattern; +} + +export function railFenceEncode(text: string, rails: number): string { + const rows: string[] = Array.from({ length: rails }, () => ""); + const pattern = railPattern(text.length, rails); + for (let i = 0; i < text.length; i += 1) { + rows[pattern[i]] += text[i]; + } + return rows.join(""); +} + +export function railFenceDecode(cipher: string, rails: number): string { + const pattern = railPattern(cipher.length, rails); + + const perRail = Array.from({ length: rails }, (_, r) => + pattern.filter((p) => p === r).length, + ); + const railStrings: string[] = []; + let idx = 0; + for (let r = 0; r < rails; r += 1) { + railStrings.push(cipher.slice(idx, idx + perRail[r])); + idx += perRail[r]; + } + + const railCursor = new Array(rails).fill(0); + let out = ""; + for (let i = 0; i < cipher.length; i += 1) { + const r = pattern[i]; + out += railStrings[r][railCursor[r]]; + railCursor[r] += 1; + } + return out; +} + +const schema = z + .object({ + text: z.string(), + cipher: z.enum(["atbash", "railfence"]), + rails: z.number().int().min(2).optional(), + mode: z.enum(["encode", "decode"]), + }) + .refine((v) => v.cipher !== "railfence" || v.rails !== undefined, { + message: "rails (>= 2) is required for the railfence cipher", + path: ["rails"], + }); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { text, cipher, rails, mode } = result.data; + + let output: string; + if (cipher === "atbash") { + // Symmetric — mode does not change the result. + output = atbash(text); + } else { + output = + mode === "encode" + ? railFenceEncode(text, rails as number) + : railFenceDecode(text, rails as number); + } + + return NextResponse.json({ result: output }); +} diff --git a/app/api/routes-f/clip-auto-tags/__tests__/route.test.ts b/app/api/routes-f/clip-auto-tags/__tests__/route.test.ts new file mode 100644 index 00000000..b5768106 --- /dev/null +++ b/app/api/routes-f/clip-auto-tags/__tests__/route.test.ts @@ -0,0 +1,99 @@ +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +function makePostReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/routes-f/clip-auto-tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/routes-f/clip-auto-tags", () => { + it("returns gaming tags for a gaming title", async () => { + const res = await POST( + makePostReq({ title: "Epic Valorant Ranked Grind" }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.tags)).toBe(true); + expect(data.tags).toContain("gaming"); + }); + + it("returns music tags for a music title", async () => { + const res = await POST( + makePostReq({ title: "Live DJ Set - Techno Beats" }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.tags)).toBe(true); + expect(data.tags).toContain("music"); + }); + + it("returns irl tags for an IRL title", async () => { + const res = await POST( + makePostReq({ title: "Daily Vlog - Travel Food Adventure" }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.tags)).toBe(true); + expect(data.tags).toContain("irl"); + }); + + it("returns crypto tags when description contains crypto keywords", async () => { + const res = await POST( + makePostReq({ + title: "Stream Updates", + description: "Discussing Bitcoin, Ethereum and Stellar XLM staking", + }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.tags).toContain("crypto"); + }); + + it("returns at most 5 tags", async () => { + const res = await POST( + makePostReq({ + title: "gaming music art tech chat crypto", + description: "irl sports competitive coding painting beats", + }) + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.tags.length).toBeLessThanOrEqual(5); + }); + + it("returns empty tags array for unrecognized title", async () => { + const res = await POST(makePostReq({ title: "xyzzy flurble" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.tags).toEqual([]); + }); + + it("returns 400 when title is missing", async () => { + const res = await POST(makePostReq({ description: "something" })); + expect(res.status).toBe(400); + }); + + it("returns 400 when title is empty string", async () => { + const res = await POST(makePostReq({ title: "" })); + expect(res.status).toBe(400); + }); + + it("returns 400 when description is not a string", async () => { + const res = await POST(makePostReq({ title: "test", description: 123 })); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON", async () => { + const res = await POST( + new NextRequest("http://localhost/api/routes-f/clip-auto-tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }) + ); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/clip-auto-tags/route.ts b/app/api/routes-f/clip-auto-tags/route.ts new file mode 100644 index 00000000..5ec5f9d4 --- /dev/null +++ b/app/api/routes-f/clip-auto-tags/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { ClipAutoTagsRequest, ClipAutoTagsResponse } from "./types"; +import { TAG_KEYWORDS } from "./tag-map"; + +const MAX_TAGS = 5; + +function generateTags(title: string, description?: string): string[] { + const text = `${title} ${description ?? ""}`.toLowerCase(); + const words = text.split(/\s+/); + + const scores: Record = {}; + + for (const [tag, keywords] of Object.entries(TAG_KEYWORDS)) { + let score = 0; + for (const keyword of keywords) { + if (keyword.includes(" ")) { + if (text.includes(keyword)) { + score += 2; + } + } else { + for (const word of words) { + if (word === keyword || word.startsWith(keyword)) { + score += 1; + } + } + } + } + if (score > 0) { + scores[tag] = score; + } + } + + return Object.entries(scores) + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_TAGS) + .map(([tag]) => tag); +} + +export async function POST(req: NextRequest): Promise { + let body: ClipAutoTagsRequest; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); + } + + const { title, description } = body; + + if (!title || typeof title !== "string" || title.trim().length === 0) { + return NextResponse.json( + { error: "title is required and must be a non-empty string" }, + { status: 400 } + ); + } + + if (description !== undefined && typeof description !== "string") { + return NextResponse.json( + { error: "description must be a string" }, + { status: 400 } + ); + } + + const tags = generateTags(title.trim(), description?.trim()); + + return NextResponse.json({ tags } as ClipAutoTagsResponse); +} diff --git a/app/api/routes-f/clip-auto-tags/tag-map.ts b/app/api/routes-f/clip-auto-tags/tag-map.ts new file mode 100644 index 00000000..e214f368 --- /dev/null +++ b/app/api/routes-f/clip-auto-tags/tag-map.ts @@ -0,0 +1,58 @@ +export const TAG_KEYWORDS: Record = { + gaming: [ + "game", "gaming", "play", "player", "fps", "rpg", "mmo", "moba", + "battle", "boss", "raid", "level", "rank", "competitive", "esports", + "stream", "twitch", "controller", "keyboard", "mouse", "gpu", "pc", + "console", "playstation", "xbox", "nintendo", "steam", "valorant", + "fortnite", "league", "minecraft", "apex", "cod", "overwatch", + "destiny", "diablo", "wow", "ffxiv", "genshin", "roblox", + ], + music: [ + "music", "song", "track", "album", "artist", "band", "concert", + "live", "performance", "dj", "remix", "beat", "rap", "hip hop", + "rock", "pop", "edm", "techno", "bass", "guitar", "piano", + "vocal", "sing", "karaoke", "playlist", "vinyl", "studio", + "producer", "beats", "melody", "rhythm", + ], + irl: [ + "irl", "vlog", "daily", "life", "travel", "food", "cook", "cooking", + "restaurant", "eat", "mukbang", "outdoor", "adventure", "city", + "walk", "explore", "nature", "beach", "mountain", "camp", + "fitness", "gym", "workout", "yoga", "pet", "dog", "cat", + "unboxing", "haul", "fashion", "style", + ], + crypto: [ + "crypto", "bitcoin", "btc", "ethereum", "eth", "solana", "sol", + "stellar", "xlm", "defi", "nft", "web3", "blockchain", "token", + "wallet", "mining", "staking", "yield", "liquidity", "swap", + "airdrop", "meme", "altcoin", "trading", "chart", "bull", "bear", + "hodl", "moon", "pump", + ], + creative: [ + "art", "draw", "paint", "design", "creative", "illustration", + "digital", "photoshop", "blender", "3d", "model", "animation", + "manga", "comic", "sketch", "canvas", "color", "pixel", + "timelapse", "speedpaint", "tutorial", "howto", "diy", "craft", + "make", "build", "woodworking", "sew", "knit", + ], + sports: [ + "sport", "football", "soccer", "basketball", "baseball", "tennis", + "golf", "mma", "ufc", "boxing", "wrestling", "f1", "racing", + "nfl", "nba", "mlb", "nhl", "olympics", "world cup", "championship", + "tournament", "league", "match", "game day", "highlight", + "replay", "analysis", "draft", + ], + tech: [ + "tech", "technology", "code", "coding", "programming", "developer", + "software", "hardware", "ai", "machine learning", "robot", + "hack", "cyber", "security", "linux", "mac", "windows", + "phone", "iphone", "android", "app", "review", "unbox", + "setup", "desk", "monitor", "keyboard", "usb", + ], + just_chatting: [ + "chat", "talk", "discussion", "debate", "q&a", "ama", "story", + "rant", "opinion", "news", "react", "reaction", "funny", + "meme", "comedy", "joke", "laugh", "chill", "hangout", + "podcast", "interview", "collab", "guest", + ], +}; diff --git a/app/api/routes-f/clip-auto-tags/types.ts b/app/api/routes-f/clip-auto-tags/types.ts new file mode 100644 index 00000000..a98edd89 --- /dev/null +++ b/app/api/routes-f/clip-auto-tags/types.ts @@ -0,0 +1,8 @@ +export interface ClipAutoTagsRequest { + title: string; + description?: string; +} + +export interface ClipAutoTagsResponse { + tags: string[]; +} diff --git a/app/api/routes-f/clips/most-liked/__tests__/route.test.ts b/app/api/routes-f/clips/most-liked/__tests__/route.test.ts new file mode 100644 index 00000000..434a71b6 --- /dev/null +++ b/app/api/routes-f/clips/most-liked/__tests__/route.test.ts @@ -0,0 +1,95 @@ +import { NextRequest } from "next/server"; +import { GET } from "../route"; + +function makeReq(params: Record = {}): NextRequest { + const url = new URL("http://localhost/api/routes-f/clips/most-liked"); + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + return new NextRequest(url.toString()); +} + +describe("GET /api/routes-f/clips/most-liked", () => { + it("returns all clips sorted by likes descending with all-time timeframe", async () => { + const res = await GET(makeReq({ timeframe: "all-time" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.clips)).toBe(true); + expect(data.timeframe).toBe("all-time"); + // Should be sorted descending + for (let i = 1; i < data.clips.length; i++) { + expect(data.clips[i - 1].likes).toBeGreaterThanOrEqual(data.clips[i].likes); + } + }); + + it("filters by 24h timeframe — only recent clips", async () => { + const res = await GET(makeReq({ timeframe: "24h" })); + expect(res.status).toBe(200); + const data = await res.json(); + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + for (const clip of data.clips) { + expect(clip.created_at).toBeGreaterThanOrEqual(cutoff); + } + }); + + it("filters by 7d timeframe", async () => { + const res = await GET(makeReq({ timeframe: "7d" })); + expect(res.status).toBe(200); + const data = await res.json(); + const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; + for (const clip of data.clips) { + expect(clip.created_at).toBeGreaterThanOrEqual(cutoff); + } + }); + + it("filters by 30d timeframe", async () => { + const res = await GET(makeReq({ timeframe: "30d" })); + expect(res.status).toBe(200); + const data = await res.json(); + const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; + for (const clip of data.clips) { + expect(clip.created_at).toBeGreaterThanOrEqual(cutoff); + } + }); + + it("filters by creator_id", async () => { + const res = await GET(makeReq({ creator_id: "creator_a", timeframe: "all-time" })); + expect(res.status).toBe(200); + const data = await res.json(); + for (const clip of data.clips) { + expect(clip.creator_id).toBe("creator_a"); + } + }); + + it("respects limit param", async () => { + const res = await GET(makeReq({ timeframe: "all-time", limit: "3" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.clips.length).toBeLessThanOrEqual(3); + }); + + it("assigns sequential rank starting at 1", async () => { + const res = await GET(makeReq({ timeframe: "all-time" })); + const data = await res.json(); + data.clips.forEach((clip: { rank: number }, idx: number) => { + expect(clip.rank).toBe(idx + 1); + }); + }); + + it("returns 400 for invalid timeframe", async () => { + const res = await GET(makeReq({ timeframe: "3months" })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/invalid timeframe/i); + }); + + it("returns 400 for invalid limit", async () => { + const res = await GET(makeReq({ timeframe: "all-time", limit: "0" })); + expect(res.status).toBe(400); + }); + + it("defaults to all-time when no timeframe given", async () => { + const res = await GET(makeReq({})); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.timeframe).toBe("all-time"); + }); +}); diff --git a/app/api/routes-f/clips/most-liked/route.ts b/app/api/routes-f/clips/most-liked/route.ts new file mode 100644 index 00000000..b3cdce2d --- /dev/null +++ b/app/api/routes-f/clips/most-liked/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { Timeframe, RankedClip, MostLikedResponse } from "./types"; +import { getClips } from "./seed"; + +const VALID_TIMEFRAMES: Timeframe[] = ["24h", "7d", "30d", "all-time"]; + +function timeframeCutoff(timeframe: Timeframe): number { + const now = Date.now(); + const h = 60 * 60 * 1000; + const d = 24 * h; + switch (timeframe) { + case "24h": + return now - 24 * h; + case "7d": + return now - 7 * d; + case "30d": + return now - 30 * d; + case "all-time": + return 0; + } +} + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const creatorId = searchParams.get("creator_id") ?? undefined; + const timeframeParam = searchParams.get("timeframe") ?? "all-time"; + const limitParam = searchParams.get("limit"); + + // Validate timeframe + if (!VALID_TIMEFRAMES.includes(timeframeParam as Timeframe)) { + return NextResponse.json( + { error: `invalid timeframe, must be one of: ${VALID_TIMEFRAMES.join(", ")}` }, + { status: 400 } + ); + } + const timeframe = timeframeParam as Timeframe; + + // Validate limit + let limit = 10; + if (limitParam !== null) { + const parsed = parseInt(limitParam, 10); + if (isNaN(parsed) || parsed < 1 || parsed > 100) { + return NextResponse.json( + { error: "limit must be an integer between 1 and 100" }, + { status: 400 } + ); + } + limit = parsed; + } + + const cutoff = timeframeCutoff(timeframe); + const clips = getClips(creatorId).filter(c => c.created_at >= cutoff); + + // Sort by likes descending + clips.sort((a, b) => b.likes - a.likes); + + const ranked: RankedClip[] = clips.slice(0, limit).map((clip, idx) => ({ + ...clip, + rank: idx + 1, + })); + + return NextResponse.json({ + clips: ranked, + timeframe, + total: ranked.length, + } as MostLikedResponse); +} diff --git a/app/api/routes-f/clips/most-liked/seed.ts b/app/api/routes-f/clips/most-liked/seed.ts new file mode 100644 index 00000000..2480e6c6 --- /dev/null +++ b/app/api/routes-f/clips/most-liked/seed.ts @@ -0,0 +1,116 @@ +import type { ClipRecord } from "./types"; + +const now = Date.now(); +const h = 60 * 60 * 1000; +const d = 24 * h; + +export const clipSeed: ClipRecord[] = [ + // creator_a clips + { + clip_id: "clip_001", + creator_id: "creator_a", + title: "Insane 1v5 clutch", + duration_seconds: 30, + likes: 4820, + created_at: now - 2 * h, + thumbnail_url: "https://stream.fi/thumbs/clip_001.jpg", + vod_id: "vod_a1", + }, + { + clip_id: "clip_002", + creator_id: "creator_a", + title: "World record speedrun attempt", + duration_seconds: 60, + likes: 3102, + created_at: now - 5 * h, + thumbnail_url: "https://stream.fi/thumbs/clip_002.jpg", + vod_id: "vod_a2", + }, + { + clip_id: "clip_003", + creator_id: "creator_a", + title: "Epic fail compilation", + duration_seconds: 45, + likes: 1280, + created_at: now - 10 * d, + thumbnail_url: "https://stream.fi/thumbs/clip_003.jpg", + vod_id: "vod_a3", + }, + { + clip_id: "clip_004", + creator_id: "creator_a", + title: "Pro tips for beginners", + duration_seconds: 90, + likes: 875, + created_at: now - 25 * d, + thumbnail_url: "https://stream.fi/thumbs/clip_004.jpg", + vod_id: "vod_a4", + }, + // creator_b clips + { + clip_id: "clip_005", + creator_id: "creator_b", + title: "Biggest tip ever received", + duration_seconds: 20, + likes: 9540, + created_at: now - 1 * h, + thumbnail_url: "https://stream.fi/thumbs/clip_005.jpg", + vod_id: "vod_b1", + }, + { + clip_id: "clip_006", + creator_id: "creator_b", + title: "Subscriber milestone reached", + duration_seconds: 35, + likes: 6210, + created_at: now - 3 * h, + thumbnail_url: "https://stream.fi/thumbs/clip_006.jpg", + vod_id: "vod_b2", + }, + { + clip_id: "clip_007", + creator_id: "creator_b", + title: "5000 XLM tip reaction", + duration_seconds: 25, + likes: 2340, + created_at: now - 8 * d, + thumbnail_url: "https://stream.fi/thumbs/clip_007.jpg", + vod_id: "vod_b3", + }, + { + clip_id: "clip_008", + creator_id: "creator_b", + title: "Late night stream highlights", + duration_seconds: 55, + likes: 430, + created_at: now - 35 * d, + thumbnail_url: "https://stream.fi/thumbs/clip_008.jpg", + vod_id: "vod_b4", + }, + // creator_c clips + { + clip_id: "clip_009", + creator_id: "creator_c", + title: "Blockchain tutorial clip", + duration_seconds: 60, + likes: 1600, + created_at: now - 6 * d, + thumbnail_url: "https://stream.fi/thumbs/clip_009.jpg", + vod_id: "vod_c1", + }, + { + clip_id: "clip_010", + creator_id: "creator_c", + title: "Stellar wallet setup", + duration_seconds: 40, + likes: 720, + created_at: now - 22 * h, + thumbnail_url: "https://stream.fi/thumbs/clip_010.jpg", + vod_id: "vod_c2", + }, +]; + +export function getClips(creatorId?: string): ClipRecord[] { + if (creatorId) return clipSeed.filter(c => c.creator_id === creatorId); + return clipSeed; +} diff --git a/app/api/routes-f/clips/most-liked/types.ts b/app/api/routes-f/clips/most-liked/types.ts new file mode 100644 index 00000000..6500c2a4 --- /dev/null +++ b/app/api/routes-f/clips/most-liked/types.ts @@ -0,0 +1,22 @@ +export type Timeframe = "24h" | "7d" | "30d" | "all-time"; + +export interface ClipRecord { + clip_id: string; + creator_id: string; + title: string; + duration_seconds: number; + likes: number; + created_at: number; // epoch ms + thumbnail_url: string; + vod_id: string; +} + +export interface RankedClip extends ClipRecord { + rank: number; +} + +export interface MostLikedResponse { + clips: RankedClip[]; + timeframe: Timeframe; + total: number; +} diff --git a/app/api/routes-f/collatz/__tests__/route.test.ts b/app/api/routes-f/collatz/__tests__/route.test.ts new file mode 100644 index 00000000..10724ab3 --- /dev/null +++ b/app/api/routes-f/collatz/__tests__/route.test.ts @@ -0,0 +1,20 @@ +import { collatz } from "../route"; + +describe("collatz", () => { + it("returns the trivial sequence for n=1", () => { + expect(collatz(1)).toEqual({ sequence: [1], steps: 0, max_value: 1 }); + }); + + it("matches the known sequence for n=27 (111 steps, max 9232)", () => { + const r = collatz(27); + expect(r.steps).toBe(111); + expect(r.max_value).toBe(9232); + expect(r.sequence[0]).toBe(27); + expect(r.sequence[r.sequence.length - 1]).toBe(1); + expect(r.sequence.length).toBe(112); // steps + the starting value + }); + + it("handles a small even start (n=6)", () => { + expect(collatz(6).sequence).toEqual([6, 3, 10, 5, 16, 8, 4, 2, 1]); + }); +}); diff --git a/app/api/routes-f/collatz/route.ts b/app/api/routes-f/collatz/route.ts new file mode 100644 index 00000000..13672551 --- /dev/null +++ b/app/api/routes-f/collatz/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const MAX_STEPS = 10000; + +export interface CollatzResult { + sequence: number[]; + steps: number; + max_value: number; +} + +/** + * Generate the Collatz sequence for a positive integer `n`: repeatedly halve + * if even, else 3n+1, until reaching 1. Capped at MAX_STEPS to bound output. + */ +export function collatz(n: number): CollatzResult { + const sequence: number[] = [n]; + let current = n; + let steps = 0; + let maxValue = n; + + while (current !== 1 && steps < MAX_STEPS) { + current = current % 2 === 0 ? current / 2 : 3 * current + 1; + sequence.push(current); + if (current > maxValue) maxValue = current; + steps += 1; + } + + return { sequence, steps, max_value: maxValue }; +} + +const schema = z.object({ + n: z.coerce.number().int().positive(), +}); + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const result = validateQuery(searchParams, schema); + if (result instanceof NextResponse) return result; + return NextResponse.json(collatz(result.data.n)); +} diff --git a/app/api/routes-f/continent/__tests__/route.test.ts b/app/api/routes-f/continent/__tests__/route.test.ts new file mode 100644 index 00000000..8fcbbc09 --- /dev/null +++ b/app/api/routes-f/continent/__tests__/route.test.ts @@ -0,0 +1,24 @@ +import { lookupContinent } from "../route"; + +describe("lookupContinent", () => { + it("maps countries across several continents", () => { + expect(lookupContinent("NG")).toEqual({ + country: "Nigeria", + continent: "Africa", + region: "Western Africa", + }); + expect(lookupContinent("JP")?.continent).toBe("Asia"); + expect(lookupContinent("BR")?.continent).toBe("South America"); + expect(lookupContinent("DE")?.continent).toBe("Europe"); + expect(lookupContinent("AU")?.continent).toBe("Oceania"); + expect(lookupContinent("US")?.continent).toBe("North America"); + }); + + it("is case-insensitive", () => { + expect(lookupContinent("ng")?.country).toBe("Nigeria"); + }); + + it("returns null for an unknown code", () => { + expect(lookupContinent("ZZ")).toBeNull(); + }); +}); diff --git a/app/api/routes-f/continent/route.ts b/app/api/routes-f/continent/route.ts new file mode 100644 index 00000000..be9776eb --- /dev/null +++ b/app/api/routes-f/continent/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +export interface CountryInfo { + country: string; + continent: string; + region: string; +} + +// ISO 3166-1 alpha-2 -> continent/region. Bundled in-folder (representative +// subset across all continents; extend as needed). +const COUNTRIES: Record = { + NG: { country: "Nigeria", continent: "Africa", region: "Western Africa" }, + ZA: { country: "South Africa", continent: "Africa", region: "Southern Africa" }, + EG: { country: "Egypt", continent: "Africa", region: "Northern Africa" }, + KE: { country: "Kenya", continent: "Africa", region: "Eastern Africa" }, + US: { country: "United States", continent: "North America", region: "Northern America" }, + CA: { country: "Canada", continent: "North America", region: "Northern America" }, + MX: { country: "Mexico", continent: "North America", region: "Central America" }, + BR: { country: "Brazil", continent: "South America", region: "South America" }, + AR: { country: "Argentina", continent: "South America", region: "South America" }, + GB: { country: "United Kingdom", continent: "Europe", region: "Northern Europe" }, + DE: { country: "Germany", continent: "Europe", region: "Western Europe" }, + FR: { country: "France", continent: "Europe", region: "Western Europe" }, + ES: { country: "Spain", continent: "Europe", region: "Southern Europe" }, + IT: { country: "Italy", continent: "Europe", region: "Southern Europe" }, + RU: { country: "Russia", continent: "Europe", region: "Eastern Europe" }, + CN: { country: "China", continent: "Asia", region: "Eastern Asia" }, + JP: { country: "Japan", continent: "Asia", region: "Eastern Asia" }, + IN: { country: "India", continent: "Asia", region: "Southern Asia" }, + SG: { country: "Singapore", continent: "Asia", region: "South-Eastern Asia" }, + AE: { country: "United Arab Emirates", continent: "Asia", region: "Western Asia" }, + SA: { country: "Saudi Arabia", continent: "Asia", region: "Western Asia" }, + AU: { country: "Australia", continent: "Oceania", region: "Australia and New Zealand" }, + NZ: { country: "New Zealand", continent: "Oceania", region: "Australia and New Zealand" }, + FJ: { country: "Fiji", continent: "Oceania", region: "Melanesia" }, + AQ: { country: "Antarctica", continent: "Antarctica", region: "Antarctica" }, +}; + +/** Look up continent/region for an ISO alpha-2 country code (case-insensitive). */ +export function lookupContinent(code: string): CountryInfo | null { + return COUNTRIES[code.trim().toUpperCase()] ?? null; +} + +const schema = z.object({ code: z.string() }); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const info = lookupContinent(result.data.code); + if (!info) { + return NextResponse.json( + { error: `unknown country code: ${result.data.code}` }, + { status: 404 }, + ); + } + return NextResponse.json(info); +} diff --git a/app/api/routes-f/cookie-parse/_lib/helpers.ts b/app/api/routes-f/cookie-parse/_lib/helpers.ts new file mode 100644 index 00000000..ed8c238f --- /dev/null +++ b/app/api/routes-f/cookie-parse/_lib/helpers.ts @@ -0,0 +1,106 @@ +import type { CookieBuildResponse, CookieParseResponse, ParsedCookie, SameSite } from "./types"; + +const VALID_SAME_SITE: SameSite[] = ["Strict", "Lax", "None"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseSingleCookie(cookieStr: string): [string, ParsedCookie] | null { + const parts = cookieStr.split(";").map((p) => p.trim()); + if (!parts[0]) return null; + + const eqIdx = parts[0].indexOf("="); + if (eqIdx === -1) return null; + + const name = decodeURIComponent(parts[0].slice(0, eqIdx).trim()); + const value = decodeURIComponent(parts[0].slice(eqIdx + 1).trim()); + + const cookie: ParsedCookie = { value, secure: false, http_only: false }; + + for (const attr of parts.slice(1)) { + const lower = attr.toLowerCase(); + if (lower === "secure") { + cookie.secure = true; + } else if (lower === "httponly") { + cookie.http_only = true; + } else if (lower.startsWith("expires=")) { + cookie.expires = attr.slice("expires=".length).trim(); + } else if (lower.startsWith("max-age=")) { + const age = parseInt(attr.slice("max-age=".length).trim(), 10); + if (!isNaN(age)) cookie.max_age = age; + } else if (lower.startsWith("domain=")) { + cookie.domain = attr.slice("domain=".length).trim(); + } else if (lower.startsWith("path=")) { + cookie.path = attr.slice("path=".length).trim(); + } else if (lower.startsWith("samesite=")) { + const raw = attr.slice("samesite=".length).trim(); + const normalized = (raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase()) as SameSite; + if (VALID_SAME_SITE.includes(normalized)) { + cookie.same_site = normalized; + } else { + throw new Error(`Invalid SameSite value: ${raw}. Must be Strict, Lax, or None.`); + } + } + } + + return [name, cookie]; +} + +export function parseCookies(input: unknown): CookieParseResponse { + if (typeof input !== "string") { + throw new Error("input must be a string for parse mode."); + } + + const result: CookieParseResponse = {}; + + // Each entry is a full Set-Cookie string; multiple entries split by newline + const entries = input.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + const cookies = entries.length > 1 ? entries : [input.trim()]; + + for (const cookieStr of cookies) { + const parsed = parseSingleCookie(cookieStr); + if (parsed) { + const [name, cookie] = parsed; + result[name] = cookie; + } + } + + return result; +} + +export function buildCookie(input: unknown): CookieBuildResponse { + if (!isRecord(input)) { + throw new Error("input must be an object for build mode."); + } + + const { name, value, expires, max_age, domain, path, secure, http_only, same_site } = + input as Record; + + if (typeof name !== "string" || !name) { + throw new Error("name must be a non-empty string."); + } + if (typeof value !== "string") { + throw new Error("value must be a string."); + } + + if (same_site !== undefined) { + if (!VALID_SAME_SITE.includes(same_site as SameSite)) { + throw new Error(`same_site must be Strict, Lax, or None.`); + } + } + + const parts: string[] = [ + `${encodeURIComponent(name)}=${encodeURIComponent(value as string)}`, + ]; + + if (expires) parts.push(`Expires=${expires}`); + if (max_age !== undefined) parts.push(`Max-Age=${max_age}`); + if (domain) parts.push(`Domain=${domain}`); + if (path) parts.push(`Path=${path}`); + if (secure) parts.push("Secure"); + if (http_only) parts.push("HttpOnly"); + if (same_site) parts.push(`SameSite=${same_site}`); + + return { header: parts.join("; ") }; +} diff --git a/app/api/routes-f/cookie-parse/_lib/types.ts b/app/api/routes-f/cookie-parse/_lib/types.ts new file mode 100644 index 00000000..763e72c0 --- /dev/null +++ b/app/api/routes-f/cookie-parse/_lib/types.ts @@ -0,0 +1,32 @@ +export type SameSite = "Strict" | "Lax" | "None"; + +export interface ParsedCookie { + value: string; + expires?: string; + max_age?: number; + domain?: string; + path?: string; + secure: boolean; + http_only: boolean; + same_site?: SameSite; +} + +export interface CookieParseResponse { + [name: string]: ParsedCookie; +} + +export interface CookieBuildInput { + name: string; + value: string; + expires?: string; + max_age?: number; + domain?: string; + path?: string; + secure?: boolean; + http_only?: boolean; + same_site?: SameSite; +} + +export interface CookieBuildResponse { + header: string; +} diff --git a/app/api/routes-f/cookie-parse/route.ts b/app/api/routes-f/cookie-parse/route.ts new file mode 100644 index 00000000..a21b2674 --- /dev/null +++ b/app/api/routes-f/cookie-parse/route.ts @@ -0,0 +1,37 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { buildCookie, parseCookies } from "./_lib/helpers"; + +export async function POST(req: NextRequest) { + let body: unknown; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + try { + if ( + typeof body !== "object" || + body === null || + Array.isArray(body) + ) { + return NextResponse.json({ error: "Request body must be an object." }, { status: 400 }); + } + + const { mode, input } = body as Record; + + if (mode === "parse") { + return NextResponse.json(parseCookies(input)); + } + + if (mode === "build") { + return NextResponse.json(buildCookie(input)); + } + + return NextResponse.json({ error: "mode must be parse or build." }, { status: 400 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to process cookie."; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/routes-f/country-flag/__tests__/route.test.ts b/app/api/routes-f/country-flag/__tests__/route.test.ts new file mode 100644 index 00000000..23e03a42 --- /dev/null +++ b/app/api/routes-f/country-flag/__tests__/route.test.ts @@ -0,0 +1,29 @@ +import { codeToFlag, flagToCode } from "../route"; + +describe("codeToFlag", () => { + it("converts codes to flag emoji", () => { + expect(codeToFlag("NG")).toBe("🇳🇬"); + expect(codeToFlag("us")).toBe("🇺🇸"); + expect(codeToFlag("GB")).toBe("🇬🇧"); + }); + it("rejects invalid codes", () => { + expect(() => codeToFlag("N")).toThrow(); + expect(() => codeToFlag("N1")).toThrow(); + }); +}); + +describe("flagToCode", () => { + it("converts flag emoji back to codes", () => { + expect(flagToCode("🇳🇬")).toBe("NG"); + expect(flagToCode("🇺🇸")).toBe("US"); + }); + it("round-trips", () => { + for (const c of ["NG", "US", "GB", "JP", "DE"]) { + expect(flagToCode(codeToFlag(c))).toBe(c); + } + }); + it("rejects non-flag input", () => { + expect(() => flagToCode("AB")).toThrow(); + expect(() => flagToCode("🇳")).toThrow(); + }); +}); diff --git a/app/api/routes-f/country-flag/route.ts b/app/api/routes-f/country-flag/route.ts new file mode 100644 index 00000000..9776c13f --- /dev/null +++ b/app/api/routes-f/country-flag/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const A = 0x1f1e6; // regional indicator 'A' + +/** Convert an ISO 3166-1 alpha-2 code (e.g. "NG") to its flag emoji. */ +export function codeToFlag(code: string): string { + const cc = code.toUpperCase(); + if (!/^[A-Z]{2}$/.test(cc)) { + throw new RangeError("code must be two ASCII letters"); + } + return String.fromCodePoint( + A + (cc.charCodeAt(0) - 65), + A + (cc.charCodeAt(1) - 65), + ); +} + +/** Convert a flag emoji (two regional indicators) back to its alpha-2 code. */ +export function flagToCode(flag: string): string { + const cps = Array.from(flag, (ch) => ch.codePointAt(0) ?? 0); + if (cps.length !== 2 || cps.some((cp) => cp < A || cp > A + 25)) { + throw new RangeError("flag must be two regional-indicator symbols"); + } + return cps.map((cp) => String.fromCharCode(cp - A + 65)).join(""); +} + +const schema = z.object({ + mode: z.enum(["to_flag", "to_code"]), + code: z.string().optional(), + flag: z.string().optional(), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { mode, code, flag } = result.data; + + try { + if (mode === "to_flag") { + if (!code) return NextResponse.json({ error: "code is required for to_flag" }, { status: 400 }); + return NextResponse.json({ flag: codeToFlag(code) }); + } + if (!flag) return NextResponse.json({ error: "flag is required for to_code" }, { status: 400 }); + return NextResponse.json({ code: flagToCode(flag) }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "invalid input" }, + { status: 400 }, + ); + } +} diff --git a/app/api/routes-f/creator-min-tip/check/route.ts b/app/api/routes-f/creator-min-tip/check/route.ts new file mode 100644 index 00000000..c728b739 --- /dev/null +++ b/app/api/routes-f/creator-min-tip/check/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkTip } from "../store"; + +export async function POST(req: NextRequest): Promise { + let body: Record; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const { creator_id, asset, amount } = body; + + if (typeof creator_id !== "string" || !creator_id.trim()) { + return NextResponse.json( + { error: "creator_id is required." }, + { status: 400 } + ); + } + if (typeof asset !== "string" || !asset.trim()) { + return NextResponse.json( + { error: "asset is required." }, + { status: 400 } + ); + } + if (typeof amount !== "number" || amount < 0) { + return NextResponse.json( + { error: "amount must be a number >= 0." }, + { status: 400 } + ); + } + + const result = checkTip(creator_id, asset, amount); + return NextResponse.json(result); +} diff --git a/app/api/routes-f/creator-min-tip/route.ts b/app/api/routes-f/creator-min-tip/route.ts new file mode 100644 index 00000000..200db828 --- /dev/null +++ b/app/api/routes-f/creator-min-tip/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getMinTip, setMinTip } from "./store"; + +export async function GET(req: NextRequest): Promise { + const creatorId = req.nextUrl.searchParams.get("creator_id"); + + if (!creatorId) { + return NextResponse.json( + { error: "creator_id is required." }, + { status: 400 } + ); + } + + const config = getMinTip(creatorId); + return NextResponse.json({ + min_xlm: config.min_xlm, + min_usdc: config.min_usdc, + }); +} + +export async function PUT(req: NextRequest): Promise { + let body: Record; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const { creator_id, min_xlm, min_usdc } = body; + + if (typeof creator_id !== "string" || !creator_id.trim()) { + return NextResponse.json( + { error: "creator_id is required." }, + { status: 400 } + ); + } + + if (min_xlm !== undefined) { + if (typeof min_xlm !== "number" || min_xlm < 0) { + return NextResponse.json( + { error: "min_xlm must be a number >= 0." }, + { status: 400 } + ); + } + } + + if (min_usdc !== undefined) { + if (typeof min_usdc !== "number" || min_usdc < 0) { + return NextResponse.json( + { error: "min_usdc must be a number >= 0." }, + { status: 400 } + ); + } + } + + const updated = setMinTip( + creator_id as string, + min_xlm as number | undefined, + min_usdc as number | undefined + ); + return NextResponse.json({ + min_xlm: updated.min_xlm, + min_usdc: updated.min_usdc, + }); +} diff --git a/app/api/routes-f/creator-min-tip/store.ts b/app/api/routes-f/creator-min-tip/store.ts new file mode 100644 index 00000000..c98c49f6 --- /dev/null +++ b/app/api/routes-f/creator-min-tip/store.ts @@ -0,0 +1,52 @@ +import type { MinTipConfig } from "./types"; + +export const minTipStore = new Map(); + +export function getMinTip(creator_id: string): MinTipConfig { + return minTipStore.get(creator_id) ?? { creator_id, min_xlm: 0, min_usdc: 0 }; +} + +export function setMinTip( + creator_id: string, + min_xlm?: number, + min_usdc?: number +): MinTipConfig { + const current = getMinTip(creator_id); + const updated: MinTipConfig = { + creator_id, + min_xlm: min_xlm ?? current.min_xlm, + min_usdc: min_usdc ?? current.min_usdc, + }; + minTipStore.set(creator_id, updated); + return updated; +} + +export function checkTip( + creator_id: string, + asset: string, + amount: number +): { allowed: boolean; reason?: string } { + const config = getMinTip(creator_id); + + if (asset === "XLM") { + if (amount < config.min_xlm) { + return { + allowed: false, + reason: `Minimum XLM tip is ${config.min_xlm}. You sent ${amount}.`, + }; + } + return { allowed: true }; + } + + if (asset === "USDC") { + if (amount < config.min_usdc) { + return { + allowed: false, + reason: `Minimum USDC tip is ${config.min_usdc}. You sent ${amount}.`, + }; + } + return { allowed: true }; + } + + return { allowed: false, reason: `Unsupported asset: "${asset}". Use XLM or USDC.` }; +} diff --git a/app/api/routes-f/creator-min-tip/types.ts b/app/api/routes-f/creator-min-tip/types.ts new file mode 100644 index 00000000..64727ab8 --- /dev/null +++ b/app/api/routes-f/creator-min-tip/types.ts @@ -0,0 +1,10 @@ +export interface MinTipConfig { + creator_id: string; + min_xlm: number; + min_usdc: number; +} + +export interface CheckTipResult { + allowed: boolean; + reason?: string; +} diff --git a/app/api/routes-f/creator/analytics/route.ts b/app/api/routes-f/creator/analytics/route.ts new file mode 100644 index 00000000..d1ee273b --- /dev/null +++ b/app/api/routes-f/creator/analytics/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { fetchPaymentsReceived } from "@/lib/stellar/horizon"; +import { + subDays, + startOfDay, + format, + eachDayOfInterval, + eachWeekOfInterval, + isSameDay, + isSameWeek, + parseISO, +} from "date-fns"; + +export const dynamic = "force-dynamic"; + +type Metric = "revenue" | "viewers" | "followers" | "tips"; +type Period = "7d" | "30d" | "90d"; +type Granularity = "day" | "week"; + +/** + * Converts a Stellar amount string to a BigInt of stroops (10^7) + * to prevent float precision loss. + */ +function toStroops(amount: string): bigint { + try { + const [whole, fraction = ""] = amount.split("."); + const paddedFraction = fraction.padEnd(7, "0").slice(0, 7); + return BigInt(whole + paddedFraction); + } catch { + return BigInt(0); + } +} + +/** + * Converts stroops back to a string with 7 decimal places. + */ +function fromStroops(stroops: bigint): string { + const s = stroops.toString().padStart(8, "0"); + const whole = s.slice(0, -7); + const fraction = s.slice(-7); + return `${whole}.${fraction}`; +} + +export async function GET(req: NextRequest) { + // 1. Authenticate creator + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { userId } = session; + + // 2. Parse and validate query parameters + const { searchParams } = new URL(req.url); + const metric = searchParams.get("metric") as Metric; + const period = (searchParams.get("period") || "7d") as Period; + const granularity = (searchParams.get("granularity") || "day") as Granularity; + + if (!["revenue", "viewers", "followers", "tips"].includes(metric)) { + return NextResponse.json( + { error: "Invalid metric. Must be one of: revenue, viewers, followers, tips" }, + { status: 400 } + ); + } + + if (!["7d", "30d", "90d"].includes(period)) { + return NextResponse.json( + { error: "Invalid period. Must be one of: 7d, 30d, 90d" }, + { status: 400 } + ); + } + + if (!["day", "week"].includes(granularity)) { + return NextResponse.json( + { error: "Invalid granularity. Must be one of: day, week" }, + { status: 400 } + ); + } + + try { + // 3. Define time range + const now = new Date(); + const daysToSub = period === "7d" ? 7 : period === "30d" ? 30 : 90; + const startDate = startOfDay(subDays(now, daysToSub)); + const endDate = now; + + // Generate date range points + const datePoints = + granularity === "day" + ? eachDayOfInterval({ start: startDate, end: endDate }) + : eachWeekOfInterval({ start: startDate, end: endDate }, { weekStartsOn: 1 }); + + let data: { date: string; value: string | number }[] = []; + + if (metric === "viewers") { + // Fetch viewer analytics from DB + // We use a subquery to handle the date_trunc safely + const { rows } = await sql` + SELECT + date_trunc(${granularity}, sv.joined_at) as point_date, + COUNT(DISTINCT sv.user_id) as viewer_count + FROM stream_viewers sv + JOIN stream_sessions ss ON sv.stream_session_id = ss.id + WHERE ss.user_id = ${userId} + AND sv.joined_at >= ${startDate.toISOString()} + GROUP BY point_date + ORDER BY point_date ASC + `; + + data = datePoints.map((point: Date) => { + const match = rows.find((r: any) => + granularity === "day" + ? isSameDay(new Date(r.point_date), point) + : isSameWeek(new Date(r.point_date), point, { weekStartsOn: 1 }) + ); + return { + date: format(point, "yyyy-MM-dd"), + value: match ? Number(match.viewer_count) : 0 + }; + }); + + } else if (metric === "revenue" || metric === "tips") { + // Fetch creator's Stellar public key (stored in 'wallet' column) + const userRes = await sql`SELECT wallet FROM users WHERE id = ${userId}`; + const publicKey = userRes.rows[0]?.wallet; + + if (!publicKey || !publicKey.startsWith("G")) { + data = datePoints.map((point: Date) => ({ date: format(point, "yyyy-MM-dd"), value: "0.0000000" })); + } else { + // Fetch tips from Stellar + const { tips } = await fetchPaymentsReceived({ + publicKey, + limit: 200, + }); + + // Group tips by date + data = datePoints.map((point: Date) => { + const dailyTips = tips.filter((tip: any) => { + const tipDate = parseISO(tip.timestamp); + return granularity === "day" + ? isSameDay(tipDate, point) + : isSameWeek(tipDate, point, { weekStartsOn: 1 }); + }); + + const totalStroops = dailyTips.reduce((sum: bigint, tip: any) => sum + toStroops(tip.amount), BigInt(0)); + return { + date: format(point, "yyyy-MM-dd"), + value: fromStroops(totalStroops) + }; + }); + } + + } else if (metric === "followers") { + // Fetch current follower count + const { rows } = await sql` + SELECT array_length(followers, 1) as follower_count + FROM users + WHERE id = ${userId} + `; + const currentCount = Number(rows[0]?.follower_count || 0); + + // Baseline implementation for followers history (current count for all points) + data = datePoints.map((point: Date) => ({ + date: format(point, "yyyy-MM-dd"), + value: currentCount + })); + } + + // 4. Return response with caching headers + return new NextResponse(JSON.stringify(data), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=300", + }, + }); + + } catch (error) { + console.error("[creator/analytics] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/creator/anniversary/__tests__/route.test.ts b/app/api/routes-f/creator/anniversary/__tests__/route.test.ts new file mode 100644 index 00000000..47ed92ef --- /dev/null +++ b/app/api/routes-f/creator/anniversary/__tests__/route.test.ts @@ -0,0 +1,90 @@ +import { NextRequest } from "next/server"; +import { GET } from "../route"; + +function makeReq(params: Record = {}): NextRequest { + const url = new URL("http://localhost/api/routes-f/creator/anniversary"); + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + return new NextRequest(url.toString()); +} + +// creator_c joined exactly 365 days ago with 100 streams/1000 followers — milestones today +const onDateToday = new Date().toISOString().split("T")[0]; + +// A date 10 days from now +function daysFromNow(n: number): string { + const d = new Date(); + d.setDate(d.getDate() + n); + return d.toISOString().split("T")[0]; +} + +describe("GET /api/routes-f/creator/anniversary", () => { + it("returns 400 when creator_id is missing", async () => { + const res = await GET(makeReq({})); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/creator_id/i); + }); + + it("returns 404 for unknown creator", async () => { + const res = await GET(makeReq({ creator_id: "creator_unknown" })); + expect(res.status).toBe(404); + }); + + it("returns 400 for invalid on_date", async () => { + const res = await GET(makeReq({ creator_id: "creator_a", on_date: "not-a-date" })); + expect(res.status).toBe(400); + }); + + it("responds with today and upcoming arrays", async () => { + const res = await GET(makeReq({ creator_id: "creator_a" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.today)).toBe(true); + expect(Array.isArray(data.upcoming)).toBe(true); + expect(typeof data.on_date).toBe("string"); + }); + + it("creator_c has 1-year anniversary today", async () => { + const res = await GET(makeReq({ creator_id: "creator_c", on_date: onDateToday })); + expect(res.status).toBe(200); + const data = await res.json(); + const hasBirthday = data.today.some( + (m: { kind: string }) => m.kind === "1_year_anniversary" + ); + expect(hasBirthday).toBe(true); + }); + + it("creator_d has no milestones in window (too new)", async () => { + const res = await GET(makeReq({ creator_id: "creator_d" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.today.length).toBe(0); + expect(data.upcoming.length).toBe(0); + }); + + it("on_date in the past works for creator_b 2-year anniversary", async () => { + // creator_b joined 730 days ago so their 2-year anniversary was ~today + const res = await GET(makeReq({ creator_id: "creator_b" })); + expect(res.status).toBe(200); + const data = await res.json(); + // May be in today or within upcoming window; just confirm valid response shape + expect(Array.isArray(data.today)).toBe(true); + expect(Array.isArray(data.upcoming)).toBe(true); + }); + + it("all milestones in today array have date matching on_date", async () => { + const res = await GET(makeReq({ creator_id: "creator_c", on_date: onDateToday })); + const data = await res.json(); + for (const m of data.today) { + expect(m.date).toBe(data.on_date); + } + }); + + it("all upcoming milestones have date after on_date", async () => { + const res = await GET(makeReq({ creator_id: "creator_a" })); + const data = await res.json(); + for (const m of data.upcoming) { + expect(m.date > data.on_date).toBe(true); + } + }); +}); diff --git a/app/api/routes-f/creator/anniversary/route.ts b/app/api/routes-f/creator/anniversary/route.ts new file mode 100644 index 00000000..940f67ff --- /dev/null +++ b/app/api/routes-f/creator/anniversary/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { CreatorStats, Milestone, MilestoneKind, AnniversaryResponse } from "./types"; +import { getCreatorStats } from "./seed"; + +const LOOK_AHEAD_DAYS = 14; +const DAY_MS = 24 * 60 * 60 * 1000; + +// Stream count milestones +const STREAM_MILESTONES = [100, 500, 1000]; +// Follower milestones +const FOLLOWER_MILESTONES = [100, 1000, 10000]; +// Year anniversaries to check +const YEAR_ANNIVERSARIES = [1, 2, 3]; + +function isoDate(epochMs: number): string { + return new Date(epochMs).toISOString().split("T")[0]; +} + +function computeMilestones(stats: CreatorStats, onDate: number): Milestone[] { + const milestones: Milestone[] = []; + const windowEnd = onDate + LOOK_AHEAD_DAYS * DAY_MS; + + // Year anniversaries: look at the anniversary date for each year + for (const years of YEAR_ANNIVERSARIES) { + const anniversaryDate = stats.joined_at + years * 365 * DAY_MS; + if (anniversaryDate >= onDate && anniversaryDate <= windowEnd) { + milestones.push({ + kind: `${years}_year_anniversary` as MilestoneKind, + label: `${years} Year Anniversary`, + date: isoDate(anniversaryDate), + creator_id: stats.creator_id, + creator_name: stats.display_name, + }); + } + } + + // Stream count milestones: if they're within a plausible range (already hit or hit today) + for (const target of STREAM_MILESTONES) { + if (stats.stream_count >= target) { + // Estimate when they hit this — assume ~1 stream/day rate + const streamsAgo = stats.stream_count - target; + const estimatedDate = onDate - streamsAgo * DAY_MS; + if (estimatedDate >= onDate && estimatedDate <= windowEnd) { + milestones.push({ + kind: `${target}th_stream` as MilestoneKind, + label: `${target}th Stream`, + date: isoDate(estimatedDate), + creator_id: stats.creator_id, + creator_name: stats.display_name, + }); + } + } + } + + // Follower milestones: same logic + for (const target of FOLLOWER_MILESTONES) { + if (stats.follower_count >= target) { + const followersAgo = stats.follower_count - target; + // Rough estimate: 10 followers/day growth rate + const estimatedDate = onDate - (followersAgo / 10) * DAY_MS; + if (estimatedDate >= onDate && estimatedDate <= windowEnd) { + milestones.push({ + kind: `${target}th_follower` as MilestoneKind, + label: `${target}th Follower`, + date: isoDate(estimatedDate), + creator_id: stats.creator_id, + creator_name: stats.display_name, + }); + } + } + } + + return milestones; +} + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const creatorId = searchParams.get("creator_id"); + const onDateParam = searchParams.get("on_date"); + + if (!creatorId) { + return NextResponse.json({ error: "creator_id is required" }, { status: 400 }); + } + + const stats = getCreatorStats(creatorId); + if (!stats) { + return NextResponse.json( + { error: `creator '${creatorId}' not found` }, + { status: 404 } + ); + } + + // Resolve the reference date + let onDate: number; + if (onDateParam) { + const parsed = Date.parse(onDateParam); + if (isNaN(parsed)) { + return NextResponse.json( + { error: "on_date must be a valid ISO date string (e.g. 2025-06-01)" }, + { status: 400 } + ); + } + onDate = parsed; + } else { + onDate = new Date().setHours(0, 0, 0, 0); + } + + const allMilestones = computeMilestones(stats, onDate); + const todayStr = isoDate(onDate); + + const today = allMilestones.filter(m => m.date === todayStr); + const upcoming = allMilestones.filter(m => m.date !== todayStr); + + return NextResponse.json({ + today, + upcoming, + on_date: todayStr, + } as AnniversaryResponse); +} diff --git a/app/api/routes-f/creator/anniversary/seed.ts b/app/api/routes-f/creator/anniversary/seed.ts new file mode 100644 index 00000000..3013f893 --- /dev/null +++ b/app/api/routes-f/creator/anniversary/seed.ts @@ -0,0 +1,48 @@ +import type { CreatorStats } from "./types"; + +const now = Date.now(); +const d = 24 * 60 * 60 * 1000; + +// Seed creator stats with a variety of ages and milestones +export const creatorStats: CreatorStats[] = [ + { + creator_id: "creator_a", + display_name: "AlphaStreamer", + // Joined exactly 1 year ago + 14 days so upcoming anniversary is in 14 days + joined_at: now - (365 - 14) * d, + stream_count: 98, // near 100th stream + follower_count: 985, // near 1000th follower + last_updated: now, + }, + { + creator_id: "creator_b", + display_name: "BetaCaster", + // Joined exactly 2 years ago today + joined_at: now - 730 * d, + stream_count: 502, + follower_count: 12000, + last_updated: now, + }, + { + creator_id: "creator_c", + display_name: "GammaBroadcast", + // Joined 1 year ago today - anniversary is today + joined_at: now - 365 * d, + stream_count: 100, // hits 100th stream today + follower_count: 1000, // hits 1000th follower today + last_updated: now, + }, + { + creator_id: "creator_d", + display_name: "DeltaLive", + // Joined 10 days ago — no anniversaries upcoming in 14-day window + joined_at: now - 10 * d, + stream_count: 5, + follower_count: 45, + last_updated: now, + }, +]; + +export function getCreatorStats(creatorId: string): CreatorStats | undefined { + return creatorStats.find(c => c.creator_id === creatorId); +} diff --git a/app/api/routes-f/creator/anniversary/types.ts b/app/api/routes-f/creator/anniversary/types.ts new file mode 100644 index 00000000..f4943fc5 --- /dev/null +++ b/app/api/routes-f/creator/anniversary/types.ts @@ -0,0 +1,33 @@ +export interface CreatorStats { + creator_id: string; + display_name: string; + joined_at: number; // epoch ms + stream_count: number; + follower_count: number; + last_updated: number; // epoch ms +} + +export type MilestoneKind = + | "1_year_anniversary" + | "2_year_anniversary" + | "3_year_anniversary" + | "100th_stream" + | "500th_stream" + | "1000th_stream" + | "100th_follower" + | "1000th_follower" + | "10000th_follower"; + +export interface Milestone { + kind: MilestoneKind; + label: string; + date: string; // ISO date string + creator_id: string; + creator_name: string; +} + +export interface AnniversaryResponse { + today: Milestone[]; + upcoming: Milestone[]; + on_date: string; // the date queried +} diff --git a/app/api/routes-f/deck/__tests__/route.test.ts b/app/api/routes-f/deck/__tests__/route.test.ts new file mode 100644 index 00000000..389081fe --- /dev/null +++ b/app/api/routes-f/deck/__tests__/route.test.ts @@ -0,0 +1,66 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { POST } from "../route"; + +function makeRequest(body: object) { + return new Request("http://localhost/api/routes-f/deck", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f deck", () => { + it("shuffles deterministically with the same seed", async () => { + const body = { + hands: 2, + cards_per_hand: 5, + seed: "streamfi-seed", + jokers: false, + }; + + const firstRes = await POST(makeRequest(body)); + const secondRes = await POST(makeRequest(body)); + const first = await firstRes.json(); + const second = await secondRes.json(); + + expect(first).toEqual(second); + }); + + it("deals unique cards without duplicates", async () => { + const res = await POST( + makeRequest({ + hands: 4, + cards_per_hand: 5, + seed: 42, + jokers: true, + }) + ); + const json = await res.json(); + const dealt = [...json.hands.flat(), ...json.remaining]; + const unique = new Set(dealt); + + expect(res.status).toBe(200); + expect(dealt).toHaveLength(54); + expect(unique.size).toBe(54); + }); + + it("rejects requests that exceed deck size", async () => { + const res = await POST( + makeRequest({ + hands: 11, + cards_per_hand: 5, + }) + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/deck/route.ts b/app/api/routes-f/deck/route.ts new file mode 100644 index 00000000..9749452e --- /dev/null +++ b/app/api/routes-f/deck/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const schema = z.object({ + hands: z.number().int().min(1).max(54).optional().default(1), + cards_per_hand: z.number().int().min(1).max(54).optional().default(5), + seed: z.union([z.string(), z.number()]).optional(), + jokers: z.boolean().optional().default(false), +}); + +const SUITS = ["C", "D", "H", "S"] as const; +const RANKS = [ + "A", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "J", + "Q", + "K", +] as const; + +function buildDeck(includeJokers: boolean): string[] { + const deck = SUITS.flatMap(suit => RANKS.map(rank => `${rank}${suit}`)); + return includeJokers ? [...deck, "BLACK_JOKER", "RED_JOKER"] : deck; +} + +function hashSeed(seed: string): number { + let hash = 2166136261; + + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; +} + +function createSeededRandom(seed: string | number | undefined): () => number { + if (seed === undefined) { + return Math.random; + } + + let state = hashSeed(String(seed)); + + return () => { + state += 0x6d2b79f5; + let next = state; + next = Math.imul(next ^ (next >>> 15), next | 1); + next ^= next + Math.imul(next ^ (next >>> 7), next | 61); + return ((next ^ (next >>> 14)) >>> 0) / 4294967296; + }; +} + +function shuffleDeck(deck: string[], random: () => number): string[] { + const copy = [...deck]; + + for (let index = copy.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]]; + } + + return copy; +} + +export async function POST(req: NextRequest) { + let rawBody: unknown; + + try { + rawBody = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = schema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { + error: "Validation failed", + issues: parsed.error.issues.map(issue => ({ + field: issue.path.join(".") || "body", + message: issue.message, + })), + }, + { status: 400 } + ); + } + + const { hands, cards_per_hand, seed, jokers } = parsed.data; + const deck = buildDeck(jokers); + + if (hands * cards_per_hand > deck.length) { + return NextResponse.json( + { error: "hands * cards_per_hand cannot exceed deck size" }, + { status: 400 } + ); + } + + const shuffled = shuffleDeck(deck, createSeededRandom(seed)); + const dealtHands: string[][] = []; + let cursor = 0; + + for (let handIndex = 0; handIndex < hands; handIndex += 1) { + dealtHands.push(shuffled.slice(cursor, cursor + cards_per_hand)); + cursor += cards_per_hand; + } + + return NextResponse.json({ + hands: dealtHands, + remaining: shuffled.slice(cursor), + }); +} diff --git a/app/api/routes-f/deep-merge/route.ts b/app/api/routes-f/deep-merge/route.ts new file mode 100644 index 00000000..1e535fd9 --- /dev/null +++ b/app/api/routes-f/deep-merge/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +type ArrayStrategy = "replace" | "concat" | "union"; + +const schema = z.object({ + objects: z.array(z.record(z.unknown())).min(1), + array_strategy: z.enum(["replace", "concat", "union"]).optional().default("replace"), +}); + +const MAX_SIZE = 2 * 1024 * 1024; // 2MB + +function isObject(val: unknown): val is Record { + return typeof val === "object" && val !== null && !Array.isArray(val); +} + +export function deepMerge( + objects: Record[], + arrayStrategy: ArrayStrategy +): Record { + if (objects.length === 0) return {}; + if (objects.length === 1) return objects[0]; + + const result: Record = {}; + + for (const obj of objects) { + for (const key in obj) { + const val = obj[key]; + + if (!(key in result)) { + result[key] = val; + continue; + } + + const existing = result[key]; + + if (Array.isArray(existing) && Array.isArray(val)) { + if (arrayStrategy === "replace") { + result[key] = val; + } else if (arrayStrategy === "concat") { + result[key] = existing.concat(val); + } else if (arrayStrategy === "union") { + result[key] = Array.from(new Set([...existing, ...val])); + } + } else if (isObject(existing) && isObject(val)) { + result[key] = deepMerge([existing, val], arrayStrategy); + } else { + result[key] = val; + } + } + } + + return result; +} + +export async function POST(request: Request): Promise { + const bodyText = await request.text(); + + if (bodyText.length > MAX_SIZE) { + return NextResponse.json( + { error: `Request body exceeds ${MAX_SIZE / 1024 / 1024}MB limit` }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = JSON.parse(bodyText); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { objects, array_strategy } = parsed.data; + const merged = deepMerge(objects, array_strategy); + + return NextResponse.json({ merged }); +} diff --git a/app/api/routes-f/dice-coefficient/__tests__/route.test.ts b/app/api/routes-f/dice-coefficient/__tests__/route.test.ts new file mode 100644 index 00000000..9e88e21d --- /dev/null +++ b/app/api/routes-f/dice-coefficient/__tests__/route.test.ts @@ -0,0 +1,31 @@ +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +function makeReq(body: object) { + return new NextRequest("http://localhost/api/routes-f/dice-coefficient", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("POST /api/routes-f/dice-coefficient", () => { + it("returns 1 for identical strings", async () => { + const res = await POST(makeReq({ a: "Hello", b: "hello" })); + const body = await res.json(); + expect(body.coefficient).toBe(1); + }); + + it("returns 0 for disjoint strings", async () => { + const res = await POST(makeReq({ a: "ab", b: "xy" })); + const body = await res.json(); + expect(body.coefficient).toBe(0); + }); + + it("returns fractional value for partial overlap", async () => { + const res = await POST(makeReq({ a: "night", b: "nacht" })); + const body = await res.json(); + expect(body.coefficient).toBeGreaterThan(0); + expect(body.coefficient).toBeLessThan(1); + }); +}); diff --git a/app/api/routes-f/dice-coefficient/route.ts b/app/api/routes-f/dice-coefficient/route.ts new file mode 100644 index 00000000..aa29cb22 --- /dev/null +++ b/app/api/routes-f/dice-coefficient/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; + +function getBigrams(value: string) { + const normalized = value.toLowerCase(); + if (normalized.length < 2) { + return [normalized]; + } + const out: string[] = []; + for (let i = 0; i < normalized.length - 1; i++) { + out.push(normalized.slice(i, i + 2)); + } + return out; +} + +function diceCoefficient(a: string, b: string) { + if (a === b) return 1; + const aBigrams = getBigrams(a); + const bBigrams = getBigrams(b); + + const counts = new Map(); + for (const gram of aBigrams) { + counts.set(gram, (counts.get(gram) ?? 0) + 1); + } + + let intersection = 0; + for (const gram of bBigrams) { + const count = counts.get(gram) ?? 0; + if (count > 0) { + intersection += 1; + counts.set(gram, count - 1); + } + } + + const denom = aBigrams.length + bBigrams.length; + return denom === 0 ? 1 : (2 * intersection) / denom; +} + +export async function POST(request: NextRequest) { + let body: { a?: string; b?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (typeof body.a !== "string" || typeof body.b !== "string") { + return NextResponse.json( + { error: "a and b must be strings" }, + { status: 400 } + ); + } + + return NextResponse.json({ coefficient: diceCoefficient(body.a, body.b) }); +} diff --git a/app/api/routes-f/digital-root/__tests__/route.test.ts b/app/api/routes-f/digital-root/__tests__/route.test.ts new file mode 100644 index 00000000..a4ac8410 --- /dev/null +++ b/app/api/routes-f/digital-root/__tests__/route.test.ts @@ -0,0 +1,48 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { GET } from "../route"; + +function makeRequest(path: string) { + return new Request( + `http://localhost${path}` + ) as unknown as import("next/server").NextRequest; +} + +describe("routes-f digital-root", () => { + it("returns zero for 0", async () => { + const res = await GET(makeRequest("/api/routes-f/digital-root?n=0")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json).toEqual({ digital_root: 0, persistence: 0 }); + }); + + it("returns zero persistence for a single-digit number", async () => { + const res = await GET(makeRequest("/api/routes-f/digital-root?n=7")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json).toEqual({ digital_root: 7, persistence: 0 }); + }); + + it("computes digital root and additive persistence for multi-step input", async () => { + const res = await GET(makeRequest("/api/routes-f/digital-root?n=12345")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json).toEqual({ digital_root: 6, persistence: 2 }); + }); + + it("rejects invalid input", async () => { + const res = await GET(makeRequest("/api/routes-f/digital-root?n=-9")); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/digital-root/route.ts b/app/api/routes-f/digital-root/route.ts new file mode 100644 index 00000000..a6a08016 --- /dev/null +++ b/app/api/routes-f/digital-root/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; + +function sumDigits(value: string): number { + return value.split("").reduce((sum, digit) => sum + Number(digit), 0); +} + +function getPersistence(value: string): number { + let current = value; + let steps = 0; + + while (current.length > 1) { + current = String(sumDigits(current)); + steps += 1; + } + + return steps; +} + +function getDigitalRoot(value: string): number { + if (value === "0") { + return 0; + } + + const numericValue = Number(value); + return 1 + ((numericValue - 1) % 9); +} + +export async function GET(req: NextRequest) { + const n = new URL(req.url).searchParams.get("n"); + + if (!n || !/^\d+$/.test(n)) { + return NextResponse.json( + { error: "n must be a non-negative integer" }, + { status: 400 } + ); + } + + return NextResponse.json({ + digital_root: getDigitalRoot(n), + persistence: getPersistence(n), + }); +} diff --git a/app/api/routes-f/dms-converter/__tests__/route.test.ts b/app/api/routes-f/dms-converter/__tests__/route.test.ts new file mode 100644 index 00000000..3d8feabd --- /dev/null +++ b/app/api/routes-f/dms-converter/__tests__/route.test.ts @@ -0,0 +1,210 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/dms-converter", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/routes-f/dms-converter", () => { + describe("to_decimal mode", () => { + it("converts DMS latitude to decimal (North)", async () => { + const res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 40, minutes: 26, seconds: 46, direction: "N" }, + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.decimal).toBeCloseTo(40.446111, 5); + }); + + it("converts DMS latitude to decimal (South)", async () => { + const res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 33, minutes: 51, seconds: 30, direction: "S" }, + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.decimal).toBeCloseTo(-33.858333, 5); + }); + + it("converts DMS longitude to decimal (East)", async () => { + const res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 151, minutes: 12, seconds: 30, direction: "E" }, + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.decimal).toBeCloseTo(151.208333, 5); + }); + + it("converts DMS longitude to decimal (West)", async () => { + const res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 74, minutes: 0, seconds: 21, direction: "W" }, + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.decimal).toBeCloseTo(-74.005833, 5); + }); + + it("rejects invalid direction or coordinate types", async () => { + // Invalid direction string + let res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 40, minutes: 26, seconds: 46, direction: "X" }, + }) + ); + expect(res.status).toBe(400); + + // Latitude with longitude direction + res = await POST( + makeReq({ + mode: "to_decimal", + type: "lat", + dms: { degrees: 40, minutes: 26, seconds: 46, direction: "W" }, + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid minutes/seconds ranges", async () => { + let res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 40, minutes: 61, seconds: 46, direction: "N" }, + }) + ); + expect(res.status).toBe(400); + + res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 40, minutes: 26, seconds: -1, direction: "N" }, + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects latitude out of bounds (> 90)", async () => { + const res = await POST( + makeReq({ + mode: "to_decimal", + dms: { degrees: 95, minutes: 0, seconds: 0, direction: "N" }, + }) + ); + expect(res.status).toBe(400); + }); + }); + + describe("to_dms mode", () => { + it("converts decimal latitude to DMS (North)", async () => { + const res = await POST( + makeReq({ + mode: "to_dms", + decimal: 40.446111, + type: "lat", + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.degrees).toBe(40); + expect(body.minutes).toBe(26); + expect(body.seconds).toBeCloseTo(46.0, 1); + expect(body.direction).toBe("N"); + }); + + it("converts decimal latitude to DMS (South)", async () => { + const res = await POST( + makeReq({ + mode: "to_dms", + decimal: -33.858333, + type: "lat", + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.degrees).toBe(33); + expect(body.minutes).toBe(51); + expect(body.seconds).toBeCloseTo(30.0, 1); + expect(body.direction).toBe("S"); + }); + + it("converts decimal longitude to DMS (East)", async () => { + const res = await POST( + makeReq({ + mode: "to_dms", + decimal: 151.208333, + type: "lng", + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.degrees).toBe(151); + expect(body.minutes).toBe(12); + expect(body.seconds).toBeCloseTo(30.0, 1); + expect(body.direction).toBe("E"); + }); + + it("handles rollover precision edge cases", async () => { + // 40.99999999 should round up and not cause seconds >= 60 + const res = await POST( + makeReq({ + mode: "to_dms", + decimal: 40.99999999, + type: "lat", + }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.degrees).toBe(41); + expect(body.minutes).toBe(0); + expect(body.seconds).toBe(0); + }); + + it("rejects latitude out of bounds (> 90)", async () => { + const res = await POST( + makeReq({ + mode: "to_dms", + decimal: 95.0, + type: "lat", + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects longitude out of bounds (> 180)", async () => { + const res = await POST( + makeReq({ + mode: "to_dms", + decimal: -185.0, + type: "lng", + }) + ); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/dms-converter/route.ts b/app/api/routes-f/dms-converter/route.ts new file mode 100644 index 00000000..003e8dee --- /dev/null +++ b/app/api/routes-f/dms-converter/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from "next/server"; + +type DMS = { + degrees: number; + minutes: number; + seconds: number; + direction: string; +}; + +export async function POST(req: NextRequest) { + let body: { + mode?: unknown; + dms?: Partial; + decimal?: unknown; + type?: unknown; + }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const { mode, dms, decimal, type } = body; + + if (mode !== "to_decimal" && mode !== "to_dms") { + return NextResponse.json( + { error: "mode must be either 'to_decimal' or 'to_dms'." }, + { status: 400 } + ); + } + + if (type !== undefined && type !== "lat" && type !== "lng") { + return NextResponse.json( + { error: "type must be either 'lat' or 'lng' if provided." }, + { status: 400 } + ); + } + + if (mode === "to_decimal") { + if (dms === undefined || typeof dms !== "object" || dms === null) { + return NextResponse.json( + { error: "dms object is required for to_decimal mode." }, + { status: 400 } + ); + } + + const degrees = Number(dms.degrees); + const minutes = Number(dms.minutes); + const seconds = Number(dms.seconds); + const direction = dms.direction; + + if ( + dms.degrees === undefined || + dms.minutes === undefined || + dms.seconds === undefined || + direction === undefined || + isNaN(degrees) || + isNaN(minutes) || + isNaN(seconds) || + typeof direction !== "string" + ) { + return NextResponse.json( + { error: "dms must contain degrees, minutes, seconds as numbers, and direction as a string." }, + { status: 400 } + ); + } + + const dirUpper = direction.trim().toUpperCase(); + if (!["N", "S", "E", "W"].includes(dirUpper)) { + return NextResponse.json( + { error: "direction must be 'N', 'S', 'E', or 'W'." }, + { status: 400 } + ); + } + + if (degrees < 0 || minutes < 0 || minutes >= 60 || seconds < 0 || seconds >= 60) { + return NextResponse.json( + { error: "degrees/minutes/seconds must be positive, with minutes and seconds less than 60." }, + { status: 400 } + ); + } + + // Determine type from direction if type is not provided + const resolvedType = type || (["N", "S"].includes(dirUpper) ? "lat" : "lng"); + + if (resolvedType === "lat" && !["N", "S"].includes(dirUpper)) { + return NextResponse.json( + { error: "Latitude direction must be 'N' or 'S'." }, + { status: 400 } + ); + } + + if (resolvedType === "lng" && !["E", "W"].includes(dirUpper)) { + return NextResponse.json( + { error: "Longitude direction must be 'E' or 'W'." }, + { status: 400 } + ); + } + + let decimalVal = degrees + minutes / 60 + seconds / 3600; + if (dirUpper === "S" || dirUpper === "W") { + decimalVal = -decimalVal; + } + + // Validate range limits + if (resolvedType === "lat" && (decimalVal < -90 || decimalVal > 90)) { + return NextResponse.json( + { error: "Latitude decimal degrees must be between -90 and 90." }, + { status: 400 } + ); + } + + if (resolvedType === "lng" && (decimalVal < -180 || decimalVal > 180)) { + return NextResponse.json( + { error: "Longitude decimal degrees must be between -180 and 180." }, + { status: 400 } + ); + } + + return NextResponse.json({ decimal: decimalVal }); + } else { + // mode === 'to_dms' + if (decimal === undefined || isNaN(Number(decimal)) || typeof decimal === "boolean") { + return NextResponse.json( + { error: "decimal is required and must be a number for to_dms mode." }, + { status: 400 } + ); + } + + const decimalVal = Number(decimal); + + // Resolve type: default to lat unless out of lat bounds, or type is specified + const resolvedType = type || (Math.abs(decimalVal) > 90 ? "lng" : "lat"); + + // Validate range limits + if (resolvedType === "lat" && (decimalVal < -90 || decimalVal > 90)) { + return NextResponse.json( + { error: "Latitude decimal degrees must be between -90 and 90." }, + { status: 400 } + ); + } + + if (resolvedType === "lng" && (decimalVal < -180 || decimalVal > 180)) { + return NextResponse.json( + { error: "Longitude decimal degrees must be between -180 and 180." }, + { status: 400 } + ); + } + + const absVal = Math.abs(decimalVal); + const degrees = Math.floor(absVal); + const minutesDecimal = (absVal - degrees) * 60; + const minutes = Math.floor(minutesDecimal); + let seconds = (minutesDecimal - minutes) * 60; + + // Handle numerical precision, round to 4 decimal places + seconds = Math.round(seconds * 10000) / 10000; + let minutesVal = minutes; + let degreesVal = degrees; + + if (seconds >= 60) { + seconds = 0; + minutesVal += 1; + } + if (minutesVal >= 60) { + minutesVal = 0; + degreesVal += 1; + } + + let direction = ""; + if (resolvedType === "lat") { + direction = decimalVal >= 0 ? "N" : "S"; + } else { + direction = decimalVal >= 0 ? "E" : "W"; + } + + return NextResponse.json({ + degrees: degreesVal, + minutes: minutesVal, + seconds, + direction, + }); + } +} diff --git a/app/api/routes-f/donations/history/__tests__/route.test.ts b/app/api/routes-f/donations/history/__tests__/route.test.ts new file mode 100644 index 00000000..c2e98b97 --- /dev/null +++ b/app/api/routes-f/donations/history/__tests__/route.test.ts @@ -0,0 +1,79 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); +jest.mock("@vercel/postgres", () => ({ sql: { query: jest.fn() } })); +jest.mock("@/lib/auth/verify-session", () => ({ verifySession: jest.fn() })); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET } from "../route"; + +const sqlQuery = (sql as unknown as { query: jest.Mock }).query; +const verify = verifySession as unknown as jest.Mock; + +function req(qs = ""): any { + return new Request(`http://localhost/api/routes-f/donations/history${qs}`); +} + +const row = { + id: "t1", + amount_usdc: "5.00", + message: "thanks!", + tx_hash: "hash1", + created_at: "2026-05-01T00:00:00.000Z", + sender_username: "alice", + recipient_username: "bob", +}; + +beforeEach(() => { + jest.clearAllMocks(); + verify.mockResolvedValue({ ok: true, userId: "user-1" }); +}); + +describe("GET /api/routes-f/donations/history", () => { + it("returns the session's 401 response when unauthenticated", async () => { + verify.mockResolvedValue({ ok: false, response: new Response("no", { status: 401 }) }); + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + it("rejects an invalid direction", async () => { + const res = await GET(req("?direction=weird")); + expect(res.status).toBe(400); + }); + + it("rejects a non-ISO from date", async () => { + const res = await GET(req("?from=not-a-date")); + expect(res.status).toBe(400); + }); + + it("returns the user's donation history", async () => { + sqlQuery.mockResolvedValue({ rows: [row] }); + const res = await GET(req("?direction=all&limit=20")); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.donations).toHaveLength(1); + expect(json.donations[0]).toMatchObject({ + amount_usdc: "5.00", + sender_username: "alice", + recipient_username: "bob", + }); + expect(json.pagination.hasMore).toBe(false); + }); + + it("sets hasMore + nextCursor when an extra row is returned", async () => { + const rows = Array.from({ length: 3 }, (_, k) => ({ ...row, id: `t${k}` })); + sqlQuery.mockResolvedValue({ rows }); + const res = await GET(req("?limit=2")); + const json = await res.json(); + expect(json.donations).toHaveLength(2); + expect(json.pagination.hasMore).toBe(true); + expect(json.pagination.nextCursor).toBe("2026-05-01T00:00:00.000Z"); + }); +}); diff --git a/app/api/routes-f/donations/history/route.ts b/app/api/routes-f/donations/history/route.ts new file mode 100644 index 00000000..6f42fc0e --- /dev/null +++ b/app/api/routes-f/donations/history/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/routes-f/donations/history (#466) + * + * Returns the authenticated user's tip/donation history (sent and/or received), + * with date-range filtering and keyset (created_at) cursor pagination. + * + * Query: ?direction=sent|received|all&limit=20&cursor=&from=&to= + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + const userId = session.userId; + + const { searchParams } = new URL(req.url); + + const direction = (searchParams.get("direction") ?? "all").toLowerCase(); + if (!["sent", "received", "all"].includes(direction)) { + return NextResponse.json( + { error: "direction must be one of sent | received | all" }, + { status: 400 }, + ); + } + + const limit = Math.min( + 100, + Math.max(1, Number.parseInt(searchParams.get("limit") ?? "20", 10) || 20), + ); + + const from = searchParams.get("from"); + const to = searchParams.get("to"); + const cursor = searchParams.get("cursor"); + for (const [name, value] of [["from", from], ["to", to], ["cursor", cursor]] as const) { + if (value && Number.isNaN(Date.parse(value))) { + return NextResponse.json({ error: `${name} must be an ISO timestamp` }, { status: 400 }); + } + } + + const conditions: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (direction === "sent") { + conditions.push(`t.sender_id = $${i++}`); + params.push(userId); + } else if (direction === "received") { + conditions.push(`t.recipient_id = $${i++}`); + params.push(userId); + } else { + conditions.push(`(t.sender_id = $${i} OR t.recipient_id = $${i})`); + i += 1; + params.push(userId); + } + if (from) { + conditions.push(`t.created_at >= $${i++}`); + params.push(from); + } + if (to) { + conditions.push(`t.created_at <= $${i++}`); + params.push(to); + } + if (cursor) { + conditions.push(`t.created_at < $${i++}`); + params.push(cursor); + } + + // Fetch one extra row to determine the next cursor. + const queryText = ` + SELECT t.id, + t.amount_usdc, + t.message, + t.tx_hash, + t.created_at, + s.username AS sender_username, + r.username AS recipient_username + FROM tips t + LEFT JOIN users s ON s.id = t.sender_id + LEFT JOIN users r ON r.id = t.recipient_id + WHERE ${conditions.join(" AND ")} + ORDER BY t.created_at DESC + LIMIT $${i}`; + params.push(limit + 1); + + try { + const { rows } = await sql.query(queryText, params); + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = + hasMore && items.length > 0 + ? new Date(items[items.length - 1].created_at).toISOString() + : null; + + return NextResponse.json({ + donations: items.map((row) => ({ + id: row.id, + amount_usdc: row.amount_usdc, + sender_username: row.sender_username, + recipient_username: row.recipient_username, + message: row.message ?? null, + tx_hash: row.tx_hash ?? null, + created_at: row.created_at, + })), + pagination: { limit, nextCursor, hasMore }, + }); + } catch { + return NextResponse.json( + { error: "Failed to load donation history" }, + { status: 500 }, + ); + } +} diff --git a/app/api/routes-f/duration/route.ts b/app/api/routes-f/duration/route.ts new file mode 100644 index 00000000..424d69da --- /dev/null +++ b/app/api/routes-f/duration/route.ts @@ -0,0 +1,73 @@ +// @ts-nocheck +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { + DurationComponents, + durationToSeconds, + formatDuration, + parseDuration, +} from "@/app/api/routes-f/_lib/duration"; + +const durationComponentsSchema = z.object({ + years: z.number().min(0).optional(), + months: z.number().min(0).optional(), + weeks: z.number().min(0).optional(), + days: z.number().min(0).optional(), + hours: z.number().min(0).optional(), + minutes: z.number().min(0).optional(), + seconds: z.number().min(0).optional(), +}); + +const durationBodySchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("parse"), text: z.string() }), + z.object({ mode: z.literal("format"), components: durationComponentsSchema }), +]); + +export async function POST(req: NextRequest) { + const validated = await validateBody(req, durationBodySchema); + if (validated instanceof NextResponse) { + return validated; + } + + const { mode } = validated.data; + + if (mode === "parse") { + const { text } = validated.data; + try { + const parsed = parseDuration(text); + return NextResponse.json({ + text, + components: parsed, + total_seconds: durationToSeconds(parsed), + }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Invalid ISO duration.", + }, + { status: 400 } + ); + } + } + + try { + const { components } = validated.data; + const formatted = formatDuration(components as DurationComponents); + const normalized = parseDuration(formatted); + return NextResponse.json({ + text: formatted, + components: normalized, + total_seconds: durationToSeconds(normalized), + }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to format duration.", + }, + { status: 400 } + ); + } +} diff --git a/app/api/routes-f/easter/route.ts b/app/api/routes-f/easter/route.ts new file mode 100644 index 00000000..480734dc --- /dev/null +++ b/app/api/routes-f/easter/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; + +function computeEaster(year: number): { easter: string; good_friday: string; easter_monday: string } { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + + const easterDate = new Date(year, month - 1, day); + const easterString = easterDate.toISOString().split('T')[0]; + + const goodFridayDate = new Date(easterDate); + goodFridayDate.setDate(goodFridayDate.getDate() - 2); + const goodFridayString = goodFridayDate.toISOString().split('T')[0]; + + const easterMondayDate = new Date(easterDate); + easterMondayDate.setDate(easterMondayDate.getDate() + 1); + const easterMondayString = easterMondayDate.toISOString().split('T')[0]; + + return { + easter: easterString, + good_friday: goodFridayString, + easter_monday: easterMondayString, + }; +} + +export async function GET(req: NextRequest) { + const year = parseInt(new URL(req.url).searchParams.get('year') || '', 10); + + if (isNaN(year)) { + return NextResponse.json({ error: 'year query param is required' }, { status: 400 }); + } + + if (year < 1583 || year > 4099) { + return NextResponse.json( + { error: 'year must be in range [1583, 4099]' }, + { status: 400 } + ); + } + + const result = computeEaster(year); + return NextResponse.json(result); +} diff --git a/app/api/routes-f/etag/__tests__/route.test.ts b/app/api/routes-f/etag/__tests__/route.test.ts new file mode 100644 index 00000000..57a87307 --- /dev/null +++ b/app/api/routes-f/etag/__tests__/route.test.ts @@ -0,0 +1,110 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/etag", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/routes-f/etag", () => { + it("generates a strong ETag by default", async () => { + const res = await POST(makeReq({ content: "hello world" })); + const body = await res.json(); + + expect(res.status).toBe(200); + // SHA-256 of "hello world" is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + // Truncated to 32 chars: "b94d27b9934d3e08a52e52d7da7dabfa" + expect(body.etag).toBe('"b94d27b9934d3e08a52e52d7da7dabfa"'); + expect(body.matches).toBeUndefined(); + }); + + it("generates a weak ETag when weak is true", async () => { + const res = await POST(makeReq({ content: "hello world", weak: true })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.etag).toBe('W/"b94d27b9934d3e08a52e52d7da7dabfa"'); + }); + + it("validates If-None-Match with exact match", async () => { + const etag = '"b94d27b9934d3e08a52e52d7da7dabfa"'; + const res = await POST( + makeReq({ content: "hello world", if_none_match: etag }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.etag).toBe(etag); + expect(body.matches).toBe(true); + }); + + it("validates If-None-Match with wildcard *", async () => { + const res = await POST( + makeReq({ content: "hello world", if_none_match: "*" }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.matches).toBe(true); + }); + + it("validates If-None-Match with weak comparison (weak vs strong)", async () => { + const strongEtag = '"b94d27b9934d3e08a52e52d7da7dabfa"'; + const weakEtag = 'W/"b94d27b9934d3e08a52e52d7da7dabfa"'; + + // Client sends weak, server generates strong + let res = await POST( + makeReq({ content: "hello world", if_none_match: weakEtag }) + ); + let body = await res.json(); + expect(res.status).toBe(200); + expect(body.etag).toBe(strongEtag); + expect(body.matches).toBe(true); + + // Client sends strong, server generates weak + res = await POST( + makeReq({ content: "hello world", weak: true, if_none_match: strongEtag }) + ); + body = await res.json(); + expect(res.status).toBe(200); + expect(body.etag).toBe(weakEtag); + expect(body.matches).toBe(true); + }); + + it("validates If-None-Match with comma-separated list of ETags", async () => { + const list = '"other-tag", W/"b94d27b9934d3e08a52e52d7da7dabfa", "another"'; + const res = await POST( + makeReq({ content: "hello world", if_none_match: list }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.matches).toBe(true); + }); + + it("returns matches: false for non-matching ETags", async () => { + const res = await POST( + makeReq({ content: "hello world", if_none_match: '"non-matching-tag"' }) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.matches).toBe(false); + }); + + it("rejects invalid input content", async () => { + // Missing content + let res = await POST(makeReq({})); + expect(res.status).toBe(400); + + // Non-string content + res = await POST(makeReq({ content: 12345 })); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/etag/route.ts b/app/api/routes-f/etag/route.ts new file mode 100644 index 00000000..21826c91 --- /dev/null +++ b/app/api/routes-f/etag/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; + +function cleanEtag(tag: string): string { + let t = tag.trim(); + if (t.startsWith("W/")) { + t = t.substring(2); + } + if (t.startsWith('"') && t.endsWith('"')) { + t = t.substring(1, t.length - 1); + } + return t; +} + +export async function POST(req: NextRequest) { + let body: { content?: unknown; weak?: unknown; if_none_match?: unknown }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + if (body.content === undefined || typeof body.content !== "string") { + return NextResponse.json( + { error: "content is required and must be a string." }, + { status: 400 } + ); + } + + const isWeak = body.weak === true; + const ifNoneMatch = body.if_none_match; + + if (ifNoneMatch !== undefined && typeof ifNoneMatch !== "string") { + return NextResponse.json( + { error: "if_none_match must be a string if provided." }, + { status: 400 } + ); + } + + // Generate SHA-256 hash and truncate to 32 characters + const hash = crypto.createHash("sha256").update(body.content).digest("hex"); + const truncatedHash = hash.substring(0, 32); + + const etag = isWeak ? `W/"${truncatedHash}"` : `"${truncatedHash}"`; + + const responseBody: { etag: string; matches?: boolean } = { etag }; + + if (ifNoneMatch !== undefined) { + let matches = false; + const trimmedIfNoneMatch = ifNoneMatch.trim(); + + if (trimmedIfNoneMatch === "*") { + matches = true; + } else { + const clientTags = trimmedIfNoneMatch.split(",").map(cleanEtag); + const generatedClean = cleanEtag(etag); + matches = clientTags.includes(generatedClean); + } + + responseBody.matches = matches; + } + + return NextResponse.json(responseBody); +} diff --git a/app/api/routes-f/explore-feed/__tests__/explore-feed.test.ts b/app/api/routes-f/explore-feed/__tests__/explore-feed.test.ts new file mode 100644 index 00000000..a72ed836 --- /dev/null +++ b/app/api/routes-f/explore-feed/__tests__/explore-feed.test.ts @@ -0,0 +1,71 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../route"; + +const BASE_URL = "http://localhost/api/routes-f/explore-feed"; + +function makeGet(params: Record) { + const url = new URL(BASE_URL); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return new NextRequest(url.toString(), { method: "GET" }); +} + +describe("GET /api/routes-f/explore-feed", () => { + it("returns four sections with correct titles", async () => { + const res = await GET(makeGet({ viewer_id: "v-rich-001" })); + expect(res.status).toBe(200); + const { sections } = await res.json(); + const titles = sections.map((s: { title: string }) => s.title); + expect(titles).toEqual([ + "For You", + "Continue Watching", + "Clips You Might Like", + "New Creators", + ]); + }); + + it("viewer with rich history gets personalised For You results", async () => { + const res = await GET(makeGet({ viewer_id: "v-rich-001" })); + const { sections } = await res.json(); + const forYou = sections.find((s: { title: string }) => s.title === "For You"); + expect(forYou.items.length).toBeGreaterThan(0); + // Rich viewer follows c-001, c-002, c-003 — at least one should appear first + const firstCreatorId = forYou.items[0].creator_id; + expect(["c-001", "c-002", "c-003"]).toContain(firstCreatorId); + }); + + it("viewer with rich history gets Continue Watching with progress", async () => { + const res = await GET(makeGet({ viewer_id: "v-rich-001" })); + const { sections } = await res.json(); + const cont = sections.find((s: { title: string }) => s.title === "Continue Watching"); + expect(cont.items.length).toBeGreaterThan(0); + for (const item of cont.items) { + expect(typeof item.progress_pct).toBe("number"); + } + }); + + it("cold-start viewer gets empty Continue Watching", async () => { + const res = await GET(makeGet({ viewer_id: "v-cold-new" })); + const { sections } = await res.json(); + const cont = sections.find((s: { title: string }) => s.title === "Continue Watching"); + expect(cont.items).toHaveLength(0); + }); + + it("cold-start viewer still gets For You, Clips, and New Creators populated", async () => { + const res = await GET(makeGet({ viewer_id: "v-cold-new" })); + const { sections } = await res.json(); + const forYou = sections.find((s: { title: string }) => s.title === "For You"); + const clips = sections.find((s: { title: string }) => s.title === "Clips You Might Like"); + const newCreators = sections.find((s: { title: string }) => s.title === "New Creators"); + expect(forYou.items.length).toBeGreaterThan(0); + expect(clips.items.length).toBeGreaterThan(0); + expect(newCreators.items.length).toBeGreaterThan(0); + }); + + it("400 — missing viewer_id", async () => { + const res = await GET(makeGet({})); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/explore-feed/route.ts b/app/api/routes-f/explore-feed/route.ts new file mode 100644 index 00000000..f04e2320 --- /dev/null +++ b/app/api/routes-f/explore-feed/route.ts @@ -0,0 +1,107 @@ +/** + * GET /api/routes-f/explore-feed?viewer_id= + * + * Returns a personalized explore feed mixing live streams, VODs, clips, and + * new creators. Seed data is bundled inline. A cold-start viewer (no history) + * gets a generic trending feed. + */ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +// --------------------------------------------------------------------------- +// Seed data +// --------------------------------------------------------------------------- +const LIVE_STREAMS = [ + { id: "ls-001", creator_id: "c-001", creator_name: "CryptoKing", title: "Stellar DeFi Deep Dive", viewers_now: 1420 }, + { id: "ls-002", creator_id: "c-002", creator_name: "ArtByLena", title: "Live Generative Art", viewers_now: 830 }, + { id: "ls-003", creator_id: "c-003", creator_name: "GamingGuru", title: "Speedrun Saturday", viewers_now: 3200 }, + { id: "ls-004", creator_id: "c-004", creator_name: "MusicMaven", title: "Lo-fi Coding Session", viewers_now: 560 }, +]; + +const VODS = [ + { id: "vod-001", creator_id: "c-001", title: "Intro to Stellar AMMs", duration_s: 1800, progress_pct: 0 }, + { id: "vod-002", creator_id: "c-003", title: "Top 10 Speedrun Fails", duration_s: 900, progress_pct: 0 }, + { id: "vod-003", creator_id: "c-005", creator_name: "DevDojo", title: "Smart Contracts 101", duration_s: 3600, progress_pct: 0 }, +]; + +const CLIPS = [ + { id: "clip-001", creator_id: "c-002", title: "Pixel-perfect generative circle", duration_s: 45 }, + { id: "clip-002", creator_id: "c-003", title: "World-record split", duration_s: 30 }, + { id: "clip-003", creator_id: "c-004", title: "Chillest lofi drop ever", duration_s: 60 }, +]; + +const NEW_CREATORS = [ + { id: "c-010", name: "StellarSam", followers: 12, streams_count: 3, category: "Finance" }, + { id: "c-011", name: "PixelPaula", followers: 8, streams_count: 1, category: "Art" }, + { id: "c-012", name: "CodeWithKai", followers: 20, streams_count: 5, category: "Dev" }, +]; + +// viewer_id → set of creator_ids the viewer has watched +const VIEWER_HISTORY: Record = { + "v-rich-001": ["c-001", "c-002", "c-003"], + "v-rich-002": ["c-003", "c-004"], +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function personalizedLive(viewerHistory: string[]) { + if (!viewerHistory.length) return LIVE_STREAMS.slice(0, 3); + const preferred = LIVE_STREAMS.filter((s) => viewerHistory.includes(s.creator_id)); + const rest = LIVE_STREAMS.filter((s) => !viewerHistory.includes(s.creator_id)); + return [...preferred, ...rest].slice(0, 3); +} + +function continueWatching(viewerHistory: string[]) { + return VODS.filter((v) => viewerHistory.includes(v.creator_id)).map((v) => ({ + ...v, + // Simulate partial progress for known creators. + progress_pct: 42, + })); +} + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- +const querySchema = z.object({ + viewer_id: z.string().min(1, "viewer_id is required"), +}); + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const result = validateQuery(searchParams, querySchema); + if (result instanceof NextResponse) return result; + + const { viewer_id } = result.data; + const history = VIEWER_HISTORY[viewer_id] ?? []; + const isColdStart = history.length === 0; + + const sections = [ + { + title: "For You", + items: personalizedLive(history), + }, + { + title: "Continue Watching", + items: isColdStart ? [] : continueWatching(history), + }, + { + title: "Clips You Might Like", + items: isColdStart + ? CLIPS + : CLIPS.filter((c) => history.includes(c.creator_id)).concat( + CLIPS.filter((c) => !history.includes(c.creator_id)) + ), + }, + { + title: "New Creators", + items: NEW_CREATORS, + }, + ]; + + return NextResponse.json({ sections }); +} diff --git a/app/api/routes-f/exponential-smoothing/__tests__/route.test.ts b/app/api/routes-f/exponential-smoothing/__tests__/route.test.ts new file mode 100644 index 00000000..706197a1 --- /dev/null +++ b/app/api/routes-f/exponential-smoothing/__tests__/route.test.ts @@ -0,0 +1,63 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST } from "../route"; +import { exponentialSmooth } from "../smoothing"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/exponential-smoothing", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/exponential-smoothing", () => { + it("follows the exponential smoothing recurrence", async () => { + const data = [10, 12, 13, 15]; + const alpha = 0.3; + const res = await POST(makeReq({ data, alpha, forecast: 2 })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.smoothed[0]).toBe(data[0]); + + for (let i = 1; i < data.length; i += 1) { + expect(body.smoothed[i]).toBeCloseTo( + alpha * data[i] + (1 - alpha) * body.smoothed[i - 1], + 10 + ); + } + + expect(body.forecast).toEqual([body.smoothed.at(-1), body.smoothed.at(-1)]); + }); + + it("defaults alpha to 0.3 and forecast to 1", async () => { + const res = await POST(makeReq({ data: [1, 2, 3] })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.forecast).toHaveLength(1); + expect(body.smoothed[1]).toBeCloseTo(0.3 * 2 + 0.7 * 1, 10); + }); + + it("rejects alpha outside (0, 1)", async () => { + const invalid = await POST(makeReq({ data: [1, 2], alpha: 1 })); + const zero = await POST(makeReq({ data: [1, 2], alpha: 0 })); + + expect(invalid.status).toBe(400); + expect(zero.status).toBe(400); + }); + + it("rejects empty data", async () => { + const res = await POST(makeReq({ data: [] })); + expect(res.status).toBe(400); + }); +}); + +describe("exponentialSmooth", () => { + it("returns an empty smoothed array for empty input", () => { + expect(exponentialSmooth([], 0.5, 2)).toEqual({ smoothed: [], forecast: [0, 0] }); + }); +}); diff --git a/app/api/routes-f/exponential-smoothing/route.ts b/app/api/routes-f/exponential-smoothing/route.ts new file mode 100644 index 00000000..86be1a72 --- /dev/null +++ b/app/api/routes-f/exponential-smoothing/route.ts @@ -0,0 +1,53 @@ +// @ts-nocheck +import { type NextRequest, NextResponse } from "next/server"; +import { exponentialSmooth } from "./smoothing"; + +type SmoothingBody = { + data?: unknown; + alpha?: unknown; + forecast?: unknown; +}; + +function badRequest(message: string) { + return NextResponse.json({ error: message }, { status: 400 }); +} + +export async function POST(req: NextRequest) { + let body: SmoothingBody; + + try { + body = (await req.json()) as SmoothingBody; + } catch { + return badRequest("Invalid JSON body."); + } + + const { data, alpha = 0.3, forecast = 1 } = body; + + if ( + !Array.isArray(data) || + data.length === 0 || + data.some(value => typeof value !== "number" || !Number.isFinite(value)) + ) { + return badRequest("data must be a non-empty array of finite numbers."); + } + + if ( + typeof alpha !== "number" || + !Number.isFinite(alpha) || + alpha <= 0 || + alpha >= 1 + ) { + return badRequest("alpha must be a number in the open interval (0, 1)."); + } + + if ( + typeof forecast !== "number" || + !Number.isInteger(forecast) || + forecast < 0 + ) { + return badRequest("forecast must be a non-negative integer."); + } + + const result = exponentialSmooth(data, alpha, forecast); + return NextResponse.json(result); +} diff --git a/app/api/routes-f/exponential-smoothing/smoothing.ts b/app/api/routes-f/exponential-smoothing/smoothing.ts new file mode 100644 index 00000000..43752596 --- /dev/null +++ b/app/api/routes-f/exponential-smoothing/smoothing.ts @@ -0,0 +1,20 @@ +export function exponentialSmooth( + data: number[], + alpha: number, + forecastSteps: number +): { smoothed: number[]; forecast: number[] } { + if (data.length === 0) { + return { smoothed: [], forecast: Array.from({ length: forecastSteps }, () => 0) }; + } + + const smoothed: number[] = [data[0]]; + + for (let i = 1; i < data.length; i += 1) { + smoothed.push(alpha * data[i] + (1 - alpha) * smoothed[i - 1]); + } + + const lastLevel = smoothed[smoothed.length - 1]; + const forecast = Array.from({ length: forecastSteps }, () => lastLevel); + + return { smoothed, forecast }; +} diff --git a/app/api/routes-f/extensions/catalog/route.ts b/app/api/routes-f/extensions/catalog/route.ts new file mode 100644 index 00000000..4fe338ef --- /dev/null +++ b/app/api/routes-f/extensions/catalog/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/routes-f/extensions/catalog + * Returns all active extensions available for streamers. + */ +export async function GET() { + try { + const { rows } = await sql` + SELECT id, name, description, json_schema as "jsonSchema", icon_url as "iconUrl" + FROM extension_catalog + WHERE is_active = TRUE + ORDER BY name ASC + `; + + return NextResponse.json({ + extensions: rows, + count: rows.length + }); + } catch (error) { + console.error("[Catalog API] Error fetching extensions:", error); + return NextResponse.json( + { error: "Failed to fetch extension catalog" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/fibonacci/__tests__/route.test.ts b/app/api/routes-f/fibonacci/__tests__/route.test.ts new file mode 100644 index 00000000..f519fdff --- /dev/null +++ b/app/api/routes-f/fibonacci/__tests__/route.test.ts @@ -0,0 +1,68 @@ +import { NextRequest } from "next/server"; +import { POST, fibNth } from "../route"; + +function req(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/fibonacci", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("fibNth", () => { + it("matches known values", () => { + expect(fibNth(1)).toBe(BigInt(1)); + expect(fibNth(2)).toBe(BigInt(1)); + expect(fibNth(10)).toBe(BigInt(55)); + expect(fibNth(20)).toBe(BigInt(6765)); + }); + + it("uses BigInt-safe outputs beyond precision boundary", () => { + expect(fibNth(78)).toBe(BigInt("8944394323791464")); + expect(fibNth(79)).toBe(BigInt("14472334024676221")); + }); +}); + +describe("POST /api/routes-f/fibonacci", () => { + it("returns first n sequence in count mode", async () => { + const res = await POST(req({ mode: "count", n: 7, format: "array" })); + expect(res.status).toBe(200); + expect((await res.json()).sequence).toEqual([ + "1", + "1", + "2", + "3", + "5", + "8", + "13", + ]); + }); + + it("returns nth value in count mode", async () => { + const res = await POST(req({ mode: "count", n: 10, format: "nth" })); + expect(res.status).toBe(200); + expect((await res.json()).value).toBe("55"); + }); + + it("returns all values <= max in until mode", async () => { + const res = await POST(req({ mode: "until", max: "34", format: "array" })); + expect(res.status).toBe(200); + expect((await res.json()).sequence).toEqual([ + "1", + "1", + "2", + "3", + "5", + "8", + "13", + "21", + "34", + ]); + }); + + it("validates input bounds", async () => { + const resN = await POST(req({ mode: "count", n: 0 })); + const resMax = await POST(req({ mode: "until", max: 0 })); + expect(resN.status).toBe(400); + expect(resMax.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/fibonacci/route.ts b/app/api/routes-f/fibonacci/route.ts new file mode 100644 index 00000000..b8946092 --- /dev/null +++ b/app/api/routes-f/fibonacci/route.ts @@ -0,0 +1,143 @@ +import { NextResponse } from "next/server"; + +type Mode = "count" | "until"; +type Format = "array" | "nth"; + +type Body = { + mode?: Mode; + n?: number; + max?: number | string; + format?: Format; +}; + +function fibBigInt(n: number): bigint { + if (n <= 0) { + throw new Error("n must be >= 1"); + } + if (n <= 2) { + return BigInt(1); + } + let a = BigInt(1); + let b = BigInt(1); + for (let i = 3; i <= n; i += 1) { + const c = a + b; + a = b; + b = c; + } + return b; +} + +function fibBinetSmall(n: number): number { + const sqrt5 = Math.sqrt(5); + const phi = (1 + sqrt5) / 2; + return Math.round((phi ** n - (-phi) ** -n) / sqrt5); +} + +export function fibNth(n: number): bigint { + // Binet is fast but starts drifting for larger n due to floating-point rounding. + return n <= 70 ? BigInt(fibBinetSmall(n)) : fibBigInt(n); +} + +export function fibCount(n: number): bigint[] { + const seq: bigint[] = []; + for (let i = 1; i <= n; i += 1) { + seq.push(fibNth(i)); + } + return seq; +} + +function parsePositiveBigInt(value: unknown): bigint | null { + if (typeof value === "number" && Number.isInteger(value) && value > 0) { + return BigInt(value); + } + if (typeof value === "string" && /^\d+$/.test(value) && value !== "0") { + return BigInt(value); + } + return null; +} + +export function fibUntil(max: bigint): bigint[] { + const seq: bigint[] = []; + let a = BigInt(1); + let b = BigInt(1); + while (a <= max) { + seq.push(a); + const next = a + b; + a = b; + b = next; + } + return seq; +} + +export async function POST(request: Request) { + let body: Body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const mode = body.mode; + const format: Format = body.format ?? "array"; + if (mode !== "count" && mode !== "until") { + return NextResponse.json( + { error: "mode must be count or until." }, + { status: 400 } + ); + } + if (format !== "array" && format !== "nth") { + return NextResponse.json( + { error: "format must be array or nth." }, + { status: 400 } + ); + } + + if (mode === "count") { + const n = body.n; + if (!Number.isInteger(n) || (n as number) < 1 || (n as number) > 10000) { + return NextResponse.json( + { error: "n must be an integer in [1, 10000]." }, + { status: 400 } + ); + } + const seq = fibCount(n as number); + if (format === "nth") { + return NextResponse.json({ + mode, + format, + n, + value: seq[seq.length - 1].toString(), + }); + } + return NextResponse.json({ + mode, + format, + n, + sequence: seq.map(v => v.toString()), + }); + } + + const max = parsePositiveBigInt(body.max); + if (max === null) { + return NextResponse.json( + { error: "max must be a positive integer." }, + { status: 400 } + ); + } + + const seq = fibUntil(max); + if (format === "nth") { + return NextResponse.json({ + mode, + format, + max: max.toString(), + value: (seq[seq.length - 1] ?? BigInt(0)).toString(), + }); + } + return NextResponse.json({ + mode, + format, + max: max.toString(), + sequence: seq.map(v => v.toString()), + }); +} diff --git a/app/api/routes-f/fiscal-quarter/__tests__/route.test.ts b/app/api/routes-f/fiscal-quarter/__tests__/route.test.ts new file mode 100644 index 00000000..b14ea28d --- /dev/null +++ b/app/api/routes-f/fiscal-quarter/__tests__/route.test.ts @@ -0,0 +1,35 @@ +import { fiscalQuarter } from "../route"; + +describe("fiscalQuarter", () => { + it("uses the calendar year by default (Jan start)", () => { + expect(fiscalQuarter("2026-02-15")).toEqual({ + quarter: 1, + fiscal_year: 2026, + quarter_start: "2026-01-01", + quarter_end: "2026-03-31", + }); + expect(fiscalQuarter("2026-08-10")).toEqual({ + quarter: 3, + fiscal_year: 2026, + quarter_start: "2026-07-01", + quarter_end: "2026-09-30", + }); + }); + + it("handles an offset fiscal year (April start)", () => { + // May is the first month of an April-start fiscal year -> Q1 of FY2026 + expect(fiscalQuarter("2026-05-15", 4)).toEqual({ + quarter: 1, + fiscal_year: 2026, + quarter_start: "2026-04-01", + quarter_end: "2026-06-30", + }); + // February falls in Q4 of the April-start fiscal year that began in 2025 + expect(fiscalQuarter("2026-02-15", 4)).toEqual({ + quarter: 4, + fiscal_year: 2025, + quarter_start: "2026-01-01", + quarter_end: "2026-03-31", + }); + }); +}); diff --git a/app/api/routes-f/fiscal-quarter/route.ts b/app/api/routes-f/fiscal-quarter/route.ts new file mode 100644 index 00000000..177a03ea --- /dev/null +++ b/app/api/routes-f/fiscal-quarter/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +export interface FiscalQuarterResult { + quarter: number; + fiscal_year: number; + quarter_start: string; + quarter_end: string; +} + +/** + * Return the fiscal quarter for a date given a configurable fiscal-year start + * month (1 = January = calendar year). `fiscal_year` is the calendar year in + * which the containing fiscal year begins. + */ +export function fiscalQuarter(dateStr: string, fiscalStartMonth = 1): FiscalQuarterResult { + const d = new Date(`${dateStr}T00:00:00Z`); + const month = d.getUTCMonth() + 1; + const year = d.getUTCFullYear(); + + const offset = (month - fiscalStartMonth + 12) % 12; // months into the fiscal year + const quarter = Math.floor(offset / 3) + 1; + const fyStartYear = month >= fiscalStartMonth ? year : year - 1; + + const quarterStartMonthAbs = fiscalStartMonth - 1 + (quarter - 1) * 3; + const start = new Date(Date.UTC(fyStartYear, quarterStartMonthAbs, 1)); + const end = new Date(Date.UTC(fyStartYear, quarterStartMonthAbs + 3, 0)); // last day of quarter + + const fmt = (x: Date) => x.toISOString().slice(0, 10); + return { + quarter, + fiscal_year: fyStartYear, + quarter_start: fmt(start), + quarter_end: fmt(end), + }; +} + +const schema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), + fiscal_start_month: z.coerce.number().int().min(1).max(12).optional().default(1), +}); + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const result = validateQuery(searchParams, schema); + if (result instanceof NextResponse) return result; + const { date, fiscal_start_month } = result.data; + return NextResponse.json(fiscalQuarter(date, fiscal_start_month)); +} diff --git a/app/api/routes-f/gcd-lcm/_lib/helpers.ts b/app/api/routes-f/gcd-lcm/_lib/helpers.ts new file mode 100644 index 00000000..8b0c1080 --- /dev/null +++ b/app/api/routes-f/gcd-lcm/_lib/helpers.ts @@ -0,0 +1,68 @@ +import type { GcdLcmInput, GcdLcmResponse, Operation } from "./types"; + +const MAX_NUMBERS = 100; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function gcdTwo(a: bigint, b: bigint): bigint { + while (b !== 0n) { + [a, b] = [b, a % b]; + } + return a; +} + +function lcmTwo(a: bigint, b: bigint): bigint { + return (a / gcdTwo(a, b)) * b; +} + +function computeGcd(nums: bigint[]): bigint { + return nums.reduce((acc, n) => gcdTwo(acc, n)); +} + +function computeLcm(nums: bigint[]): bigint { + return nums.reduce((acc, n) => lcmTwo(acc, n)); +} + +function normalizeOperation(value: unknown): Operation { + if (value === undefined || value === "both") return "both"; + if (value === "gcd" || value === "lcm") return value; + throw new Error("operation must be both, gcd, or lcm."); +} + +export function processGcdLcm(input: unknown): GcdLcmResponse { + if (!isRecord(input)) { + throw new Error("Request body must be an object."); + } + + const { numbers, operation: rawOp } = input as unknown as GcdLcmInput; + const operation = normalizeOperation(rawOp); + + if (!Array.isArray(numbers) || numbers.length === 0) { + throw new Error("numbers must be a non-empty array."); + } + + if (numbers.length > MAX_NUMBERS) { + throw new Error(`numbers array must not exceed ${MAX_NUMBERS} elements.`); + } + + const bigNums: bigint[] = numbers.map((n, i) => { + if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) { + throw new Error(`numbers[${i}] must be a positive integer.`); + } + return BigInt(n); + }); + + const result: GcdLcmResponse = { n_count: numbers.length }; + + if (operation === "gcd" || operation === "both") { + result.gcd = Number(computeGcd(bigNums)); + } + + if (operation === "lcm" || operation === "both") { + result.lcm = Number(computeLcm(bigNums)); + } + + return result; +} diff --git a/app/api/routes-f/gcd-lcm/_lib/types.ts b/app/api/routes-f/gcd-lcm/_lib/types.ts new file mode 100644 index 00000000..f9854c5b --- /dev/null +++ b/app/api/routes-f/gcd-lcm/_lib/types.ts @@ -0,0 +1,12 @@ +export type Operation = "both" | "gcd" | "lcm"; + +export interface GcdLcmInput { + numbers: number[]; + operation?: Operation; +} + +export interface GcdLcmResponse { + gcd?: number; + lcm?: number; + n_count: number; +} diff --git a/app/api/routes-f/gcd-lcm/route.ts b/app/api/routes-f/gcd-lcm/route.ts new file mode 100644 index 00000000..a7849f5a --- /dev/null +++ b/app/api/routes-f/gcd-lcm/route.ts @@ -0,0 +1,19 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { processGcdLcm } from "./_lib/helpers"; + +export async function POST(req: NextRequest) { + let body: unknown; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + try { + return NextResponse.json(processGcdLcm(body)); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to compute GCD/LCM."; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/routes-f/hamming/__tests__/route.test.ts b/app/api/routes-f/hamming/__tests__/route.test.ts new file mode 100644 index 00000000..88fae43a --- /dev/null +++ b/app/api/routes-f/hamming/__tests__/route.test.ts @@ -0,0 +1,18 @@ +import { hammingDistance } from "../route"; + +describe("hammingDistance", () => { + it("computes distance for equal-length strings", () => { + expect(hammingDistance("karolin", "kathrin")).toBe(3); + expect(hammingDistance("karolin", "kerstin")).toBe(3); + expect(hammingDistance("abc", "abc")).toBe(0); + }); + + it("computes distance for binary inputs", () => { + expect(hammingDistance("1011101", "1001001")).toBe(2); + expect(hammingDistance("0000", "1111")).toBe(4); + }); + + it("throws on unequal lengths", () => { + expect(() => hammingDistance("abc", "ab")).toThrow(RangeError); + }); +}); diff --git a/app/api/routes-f/hamming/route.ts b/app/api/routes-f/hamming/route.ts new file mode 100644 index 00000000..5992cb1d --- /dev/null +++ b/app/api/routes-f/hamming/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +/** + * Hamming distance: the number of positions at which two equal-length strings + * differ. Throws if the inputs are not the same length. + */ +export function hammingDistance(a: string, b: string): number { + if (a.length !== b.length) { + throw new RangeError("inputs must be of equal length"); + } + let distance = 0; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) distance += 1; + } + return distance; +} + +const schema = z.object({ + a: z.string(), + b: z.string(), + mode: z.enum(["string", "binary"]).optional().default("string"), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { a, b, mode } = result.data; + + if (mode === "binary" && (!/^[01]+$/.test(a) || !/^[01]+$/.test(b))) { + return NextResponse.json( + { error: "binary mode requires inputs containing only 0 and 1" }, + { status: 400 }, + ); + } + + if (a.length !== b.length) { + return NextResponse.json( + { error: "inputs must be of equal length" }, + { status: 400 }, + ); + } + + return NextResponse.json({ distance: hammingDistance(a, b) }); +} diff --git a/app/api/routes-f/happy-number/__tests__/route.test.ts b/app/api/routes-f/happy-number/__tests__/route.test.ts new file mode 100644 index 00000000..2cab652f --- /dev/null +++ b/app/api/routes-f/happy-number/__tests__/route.test.ts @@ -0,0 +1,69 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { analyzeHappyNumber } from "../happy"; +import { GET } from "../route"; + +// #863 feat(routes-f): happy number checker + +function makeRequest(path: string) { + return new Request(`http://localhost${path}`) as unknown as import("next/server").NextRequest; +} + +describe("analyzeHappyNumber", () => { + it("identifies 19 as happy with the known sequence", () => { + expect(analyzeHappyNumber(19)).toEqual({ + n: 19, + is_happy: true, + sequence: [19, 82, 68, 100, 1], + }); + }); + + it("identifies 7 as happy", () => { + expect(analyzeHappyNumber(7)).toEqual({ + n: 7, + is_happy: true, + sequence: [7, 49, 97, 130, 10, 1], + }); + }); + + it("identifies 4 as unhappy and terminates on cycle detection", () => { + const result = analyzeHappyNumber(4); + + expect(result.is_happy).toBe(false); + expect(result.sequence).toEqual([4, 16, 37, 58, 89, 145, 42, 20, 4]); + }); +}); + +describe("GET /api/routes-f/happy-number", () => { + it("returns happy number analysis for n=19", async () => { + const res = await GET(makeRequest("/api/routes-f/happy-number?n=19")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json).toEqual({ + n: 19, + is_happy: true, + sequence: [19, 82, 68, 100, 1], + }); + }); + + it("rejects non-positive integers", async () => { + const missing = await GET(makeRequest("/api/routes-f/happy-number")); + const zero = await GET(makeRequest("/api/routes-f/happy-number?n=0")); + const negative = await GET(makeRequest("/api/routes-f/happy-number?n=-4")); + const invalid = await GET(makeRequest("/api/routes-f/happy-number?n=abc")); + + expect(missing.status).toBe(400); + expect(zero.status).toBe(400); + expect(negative.status).toBe(400); + expect(invalid.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/happy-number/happy.ts b/app/api/routes-f/happy-number/happy.ts new file mode 100644 index 00000000..206a1a11 --- /dev/null +++ b/app/api/routes-f/happy-number/happy.ts @@ -0,0 +1,57 @@ +// #863 feat(routes-f): happy number checker + +export type HappyNumberResult = { + n: number; + is_happy: boolean; + sequence: number[]; +}; + +function sumOfSquaredDigits(n: number): number { + let sum = 0; + let value = n; + + while (value > 0) { + const digit = value % 10; + sum += digit * digit; + value = Math.floor(value / 10); + } + + return sum; +} + +/** + * Determine whether `n` is a happy number and return the iteration sequence + * until reaching 1 (happy) or detecting a cycle (unhappy). + */ +export function analyzeHappyNumber(n: number): HappyNumberResult { + const sequence: number[] = [n]; + const seen = new Set([n]); + let current = n; + + while (current !== 1) { + current = sumOfSquaredDigits(current); + + if (seen.has(current)) { + sequence.push(current); + return { n, is_happy: false, sequence }; + } + + seen.add(current); + sequence.push(current); + } + + return { n, is_happy: true, sequence }; +} + +export function parsePositiveInteger(value: string | null): number | null { + if (value === null || !/^\d+$/.test(value)) { + return null; + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + return null; + } + + return parsed; +} diff --git a/app/api/routes-f/happy-number/route.ts b/app/api/routes-f/happy-number/route.ts new file mode 100644 index 00000000..273fb72e --- /dev/null +++ b/app/api/routes-f/happy-number/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { analyzeHappyNumber, parsePositiveInteger } from "./happy"; + +// #863 feat(routes-f): happy number checker + +export async function GET(req: NextRequest) { + const nParam = new URL(req.url).searchParams.get("n"); + const n = parsePositiveInteger(nParam); + + if (n === null) { + return NextResponse.json( + { error: "n must be a positive integer." }, + { status: 400 } + ); + } + + return NextResponse.json(analyzeHappyNumber(n)); +} diff --git a/app/api/routes-f/hex-color/__tests__/route.test.ts b/app/api/routes-f/hex-color/__tests__/route.test.ts new file mode 100644 index 00000000..7bdbb20e --- /dev/null +++ b/app/api/routes-f/hex-color/__tests__/route.test.ts @@ -0,0 +1,45 @@ +import { normalizeHexColor } from "../route"; + +describe("normalizeHexColor", () => { + it("normalizes 3-digit to 6-digit", () => { + expect(normalizeHexColor("#abc")).toEqual({ + valid: true, + normalized: "#aabbcc", + has_alpha: false, + }); + }); + + it("normalizes 4-digit to 8-digit with alpha", () => { + expect(normalizeHexColor("#abcd")).toEqual({ + valid: true, + normalized: "#aabbccdd", + has_alpha: true, + }); + }); + + it("accepts 6-digit (no #) and lowercases", () => { + expect(normalizeHexColor("FF8800")).toEqual({ + valid: true, + normalized: "#ff8800", + has_alpha: false, + }); + }); + + it("accepts 8-digit with alpha", () => { + expect(normalizeHexColor("#ff8800cc")).toEqual({ + valid: true, + normalized: "#ff8800cc", + has_alpha: true, + }); + }); + + it("rejects invalid input", () => { + for (const bad of ["#12", "#xyz", "12345", "#1234567", "nope"]) { + expect(normalizeHexColor(bad)).toEqual({ + valid: false, + normalized: null, + has_alpha: false, + }); + } + }); +}); diff --git a/app/api/routes-f/hex-color/route.ts b/app/api/routes-f/hex-color/route.ts new file mode 100644 index 00000000..1b38ee99 --- /dev/null +++ b/app/api/routes-f/hex-color/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +export interface HexColorResult { + valid: boolean; + normalized: string | null; + has_alpha: boolean; +} + +/** + * Validate a hex color (#rgb, #rgba, #rrggbb, #rrggbbaa, with or without the + * leading #) and normalize it to 6-digit (#rrggbb) or 8-digit (#rrggbbaa) form. + */ +export function normalizeHexColor(input: string): HexColorResult { + const invalid: HexColorResult = { valid: false, normalized: null, has_alpha: false }; + const hex = input.trim().replace(/^#/, "").toLowerCase(); + + if (!/^[0-9a-f]+$/.test(hex)) return invalid; + + const expand = (s: string) => [...s].map((ch) => ch + ch).join(""); + + switch (hex.length) { + case 3: + return { valid: true, normalized: `#${expand(hex)}`, has_alpha: false }; + case 4: + return { valid: true, normalized: `#${expand(hex)}`, has_alpha: true }; + case 6: + return { valid: true, normalized: `#${hex}`, has_alpha: false }; + case 8: + return { valid: true, normalized: `#${hex}`, has_alpha: true }; + default: + return invalid; + } +} + +const schema = z.object({ color: z.string() }); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + return NextResponse.json(normalizeHexColor(result.data.color)); +} diff --git a/app/api/routes-f/hex-dump/__tests__/route.test.ts b/app/api/routes-f/hex-dump/__tests__/route.test.ts new file mode 100644 index 00000000..59f111db --- /dev/null +++ b/app/api/routes-f/hex-dump/__tests__/route.test.ts @@ -0,0 +1,28 @@ +import { hexDump } from "../route"; + +describe("hexDump", () => { + it("formats offset, hex bytes, and an ASCII gutter", () => { + const dump = hexDump("Hello"); + expect(dump.startsWith("00000000 48 65 6c 6c 6f")).toBe(true); + expect(dump).toContain("|Hello|"); + }); + + it("expands UTF-8 multibyte characters into their bytes", () => { + const dump = hexDump("é"); // U+00E9 -> 0xC3 0xA9 + expect(dump).toContain("c3 a9"); + expect(dump).toContain("|..|"); // non-printable bytes render as dots + }); + + it("wraps lines with incrementing offsets", () => { + const lines = hexDump("ABCDE", 4).split("\n"); + expect(lines).toHaveLength(2); + expect(lines[0].startsWith("00000000 41 42 43 44")).toBe(true); + expect(lines[0]).toContain("|ABCD|"); + expect(lines[1].startsWith("00000004 45")).toBe(true); + expect(lines[1]).toContain("|E|"); + }); + + it("returns an empty string for empty input", () => { + expect(hexDump("")).toBe(""); + }); +}); diff --git a/app/api/routes-f/hex-dump/route.ts b/app/api/routes-f/hex-dump/route.ts new file mode 100644 index 00000000..d4523ac4 --- /dev/null +++ b/app/api/routes-f/hex-dump/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const MAX_INPUT_BYTES = 1_000_000; +const DEFAULT_BYTES_PER_LINE = 16; + +/** + * Produce a classic hex dump (8-digit hex offset, space-separated hex bytes, and + * an ASCII gutter) of UTF-8 encoded `input`. Non-printable bytes render as `.`. + */ +export function hexDump(input: string, bytesPerLine = DEFAULT_BYTES_PER_LINE): string { + const bytes = new TextEncoder().encode(input); + const lines: string[] = []; + + for (let offset = 0; offset < bytes.length; offset += bytesPerLine) { + const slice = bytes.subarray(offset, offset + bytesPerLine); + + const hex = Array.from(slice, (b) => b.toString(16).padStart(2, "0")) + .join(" ") + .padEnd(bytesPerLine * 3 - 1, " "); + + const ascii = Array.from(slice, (b) => + b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : ".", + ).join(""); + + lines.push(`${offset.toString(16).padStart(8, "0")} ${hex} |${ascii}|`); + } + + return lines.join("\n"); +} + +const schema = z.object({ + input: z.string().max(MAX_INPUT_BYTES, "input exceeds 1MB limit"), + bytes_per_line: z.number().int().min(1).max(64).optional(), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { input, bytes_per_line } = result.data; + return NextResponse.json({ + dump: hexDump(input, bytes_per_line ?? DEFAULT_BYTES_PER_LINE), + }); +} diff --git a/app/api/routes-f/history/[streamId]/track/__tests__/route.test.ts b/app/api/routes-f/history/[streamId]/track/__tests__/route.test.ts new file mode 100644 index 00000000..d01f1523 --- /dev/null +++ b/app/api/routes-f/history/[streamId]/track/__tests__/route.test.ts @@ -0,0 +1,135 @@ +import { sql } from "@vercel/postgres"; +import { POST } from "../route"; +import { verifySession } from "@/lib/auth/verify-session"; + +// Polyfill NextResponse.json for jsdom test environment +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ + sql: jest.fn(), +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const makeRequest = (body: object) => + new Request("http://localhost/api/routes-f/history/live/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as any; + +describe("History Track API route", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns 401 when unauthorized", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + }); + + const res = await POST(makeRequest({}), { params: Promise.resolve({ streamId: "live" }) }); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid body", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: true, + userId: "user-123", + }); + + const res = await POST(makeRequest({ stream_type: "live" }), { params: Promise.resolve({ streamId: "live" }) }); + expect(res.status).toBe(400); + }); + + it("successfully tracks a live stream session (new)", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: true, + userId: "user-123", + }); + + // 1. Resolve streamer + sqlMock.mockResolvedValueOnce({ + rows: [{ id: "streamer-456", current_title: "Playing Valorant" }], + }); + // 2. Check existing + sqlMock.mockResolvedValueOnce({ rows: [] }); + // 3. Insert + sqlMock.mockResolvedValueOnce({ rowCount: 1 }); + + const res = await POST( + makeRequest({ + stream_type: "live", + streamer_username: "alice", + seconds_watched: 30, + }), + { params: Promise.resolve({ streamId: "live" }) } + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + + // Check if INSERT was called + expect(sqlMock).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining("INSERT INTO watch_history")]), + "user-123", + "streamer-456", + "live", + null, + "Playing Valorant", + 30, + expect.anything(), + false + ); + }); + + it("successfully tracks a live stream session (update)", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: true, + userId: "user-123", + }); + + // 1. Resolve streamer + sqlMock.mockResolvedValueOnce({ + rows: [{ id: "streamer-456", current_title: "Playing Valorant" }], + }); + // 2. Check existing + sqlMock.mockResolvedValueOnce({ rows: [{ id: "history-789", watch_seconds: 60 }] }); + // 3. Update + sqlMock.mockResolvedValueOnce({ rowCount: 1 }); + + const res = await POST( + makeRequest({ + stream_type: "live", + streamer_username: "alice", + seconds_watched: 30, + }), + { params: Promise.resolve({ streamId: "live" }) } + ); + + expect(res.status).toBe(200); + expect(sqlMock).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining("UPDATE watch_history")]), + 90, // 60 + 30 + expect.anything(), + "Playing Valorant", + false, + "history-789" + ); + }); +}); diff --git a/app/api/routes-f/history/[streamId]/track/route.ts b/app/api/routes-f/history/[streamId]/track/route.ts new file mode 100644 index 00000000..73ec4b1b --- /dev/null +++ b/app/api/routes-f/history/[streamId]/track/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +/** + * POST /api/routes-f/history/[streamId]/track + * Records or updates a watch session. + * Body: { stream_type, streamer_username, seconds_watched } + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ streamId: string }> } +) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { userId } = session; + const { streamId } = await params; + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const { stream_type, streamer_username, seconds_watched } = body; + + if ( + !stream_type || + !streamer_username || + typeof seconds_watched !== "number" + ) { + return NextResponse.json( + { error: "Missing or invalid required fields" }, + { status: 400 } + ); + } + + try { + // 1. Resolve streamer ID and current title + const streamerResult = await sql` + SELECT id, creator->>'streamTitle' as current_title + FROM users + WHERE LOWER(username) = LOWER(${streamer_username}) + LIMIT 1 + `; + + if (streamerResult.rows.length === 0) { + return NextResponse.json({ error: "Streamer not found" }, { status: 404 }); + } + + const streamer = streamerResult.rows[0]; + const streamerId = streamer.id; + + // 2. Resolve final stream_id (null for live) and title + const isLive = streamId === "live" || stream_type === "live"; + const finalStreamId = isLive ? null : streamId; + + let streamTitle = "Untitled Stream"; + let duration = 0; + + if (isLive) { + streamTitle = streamer.current_title || "Live Stream"; + } else { + // Look up recording title if available + const recordingResult = await sql` + SELECT title, duration + FROM stream_recordings + WHERE id::text = ${finalStreamId} + OR mux_asset_id = ${finalStreamId} + OR playback_id = ${finalStreamId} + LIMIT 1 + `; + if (recordingResult.rows.length > 0) { + streamTitle = recordingResult.rows[0].title || "Untitled Recording"; + duration = recordingResult.rows[0].duration || 0; + } + } + + // 3. Upsert watch history entry + // Since NULLs in UNIQUE constraints can be tricky, we use a manual check-then-upsert logic + // to ensure we always update the existing session if it matches. + const existing = await sql` + SELECT id, watch_seconds FROM watch_history + WHERE viewer_id = ${userId} + AND streamer_id = ${streamerId} + AND stream_type = ${stream_type} + AND ( + (stream_id IS NULL AND ${finalStreamId} IS NULL) OR + (stream_id = ${finalStreamId}) + ) + LIMIT 1 + `; + + if (existing.rows.length > 0) { + const newWatchSeconds = existing.rows[0].watch_seconds + seconds_watched; + // Simple completion logic: if VOD and watched > 90% of duration + const isCompleted = !isLive && duration > 0 && newWatchSeconds >= duration * 0.9; + + await sql` + UPDATE watch_history + SET watch_seconds = ${newWatchSeconds}, + last_seen_at = now(), + stream_title = ${streamTitle}, + completed = ${isCompleted} + WHERE id = ${existing.rows[0].id} + `; + } else { + const isCompleted = !isLive && duration > 0 && seconds_watched >= duration * 0.9; + + await sql` + INSERT INTO watch_history ( + viewer_id, streamer_id, stream_type, stream_id, stream_title, watch_seconds, last_seen_at, completed + ) VALUES ( + ${userId}, ${streamerId}, ${stream_type}, ${finalStreamId}, ${streamTitle}, ${seconds_watched}, now(), ${isCompleted} + ) + `; + } + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("[history-track] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/history/__tests__/route.test.ts b/app/api/routes-f/history/__tests__/route.test.ts new file mode 100644 index 00000000..584f0a75 --- /dev/null +++ b/app/api/routes-f/history/__tests__/route.test.ts @@ -0,0 +1,97 @@ +import { sql } from "@vercel/postgres"; +import { GET, DELETE } from "../route"; +import { verifySession } from "@/lib/auth/verify-session"; + +// Polyfill NextResponse.json for jsdom test environment +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ + sql: jest.fn(), +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const makeRequest = (method: string, body?: object, search?: string) => + new Request(`http://localhost/api/routes-f/history${search ?? ""}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as any; + +describe("History API routes", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /api/routes-f/history", () => { + it("returns 401 when unauthorized", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + }); + + const res = await GET(makeRequest("GET")); + expect(res.status).toBe(401); + }); + + it("returns history for authorized user", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: true, + userId: "user-123", + }); + + sqlMock.mockResolvedValueOnce({ + rows: [ + { + stream_type: "live", + stream_title: "Live Test", + watched_at: "2025-01-01T00:00:00Z", + watch_seconds: 300, + completed: false, + streamer_username: "alice", + streamer_avatar: "avatar.png", + }, + ], + }); + + const res = await GET(makeRequest("GET")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.history).toHaveLength(1); + expect(body.history[0].streamer.username).toBe("alice"); + }); + }); + + describe("DELETE /api/routes-f/history", () => { + it("clears history for authorized user", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: true, + userId: "user-123", + }); + + sqlMock.mockResolvedValueOnce({ rowCount: 1 }); + + const res = await DELETE(makeRequest("DELETE")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(sqlMock).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining("DELETE FROM watch_history")]), + "user-123" + ); + }); + }); +}); diff --git a/app/api/routes-f/history/route.ts b/app/api/routes-f/history/route.ts new file mode 100644 index 00000000..6fccac22 --- /dev/null +++ b/app/api/routes-f/history/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +/** + * GET /api/routes-f/history?cursor=...&limit=20 + * Returns the viewer's watch history, cursor-paginated. + */ +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { userId } = session; + const { searchParams } = new URL(req.url); + const cursor = searchParams.get("cursor"); // ISO date string of last_seen_at + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10))); + + try { + // Cursor-paginated query: most recent first + const result = cursor + ? await sql` + SELECT + wh.stream_type, + wh.stream_title, + wh.last_seen_at as watched_at, + wh.watch_seconds, + wh.completed, + u.username as streamer_username, + u.avatar as streamer_avatar + FROM watch_history wh + JOIN users u ON u.id = wh.streamer_id + WHERE wh.viewer_id = ${userId} + AND wh.last_seen_at < ${cursor} + ORDER BY wh.last_seen_at DESC + LIMIT ${limit} + ` + : await sql` + SELECT + wh.stream_type, + wh.stream_title, + wh.last_seen_at as watched_at, + wh.watch_seconds, + wh.completed, + u.username as streamer_username, + u.avatar as streamer_avatar + FROM watch_history wh + JOIN users u ON u.id = wh.streamer_id + WHERE wh.viewer_id = ${userId} + ORDER BY wh.last_seen_at DESC + LIMIT ${limit} + `; + + const history = result.rows.map((row) => ({ + streamer: { + username: row.streamer_username, + avatar: row.streamer_avatar, + }, + stream_type: row.stream_type, + stream_title: row.stream_title || (row.stream_type === "live" ? "Live Stream" : "Untitled Recording"), + watched_at: row.watched_at, + watch_seconds: row.watch_seconds, + completed: row.completed, + })); + + const nextCursor = + result.rows.length === limit + ? result.rows[result.rows.length - 1].watched_at + : null; + + return NextResponse.json({ + history, + next_cursor: nextCursor, + }); + } catch (error) { + console.error("[history] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/routes-f/history + * Clears the watch history for the current viewer. + */ +export async function DELETE(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { userId } = session; + + try { + await sql` + DELETE FROM watch_history + WHERE viewer_id = ${userId} + `; + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("[history] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/html-escape/data.ts b/app/api/routes-f/html-escape/data.ts new file mode 100644 index 00000000..4751757f --- /dev/null +++ b/app/api/routes-f/html-escape/data.ts @@ -0,0 +1,27 @@ +const ESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +const UNESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + "'": "'", +}; + +export function escapeHtml(str: string): string { + return str.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch] ?? ch); +} + +export function unescapeHtml(str: string): string { + return str + .replace(/&[a-z]+;/gi, (entity) => UNESCAPE_MAP[entity.toLowerCase()] ?? entity) + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); +} diff --git a/app/api/routes-f/html-escape/route.ts b/app/api/routes-f/html-escape/route.ts new file mode 100644 index 00000000..538f44a3 --- /dev/null +++ b/app/api/routes-f/html-escape/route.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { escapeHtml, unescapeHtml } from "./data"; + +const MAX_INPUT_BYTES = 1024 * 1024; // 1 MB + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const { input, mode } = body as { input?: unknown; mode?: unknown }; + + if (typeof input !== "string" || typeof mode !== "string") { + return NextResponse.json({ error: "Invalid request body: 'input' and 'mode' are required" }, { status: 400 }); + } + + if (mode !== "escape" && mode !== "unescape") { + return NextResponse.json({ error: "Invalid mode. Must be 'escape' or 'unescape'" }, { status: 400 }); + } + + if (Buffer.byteLength(input, "utf8") > MAX_INPUT_BYTES) { + return NextResponse.json({ error: "Input too large (max 1 MB)" }, { status: 413 }); + } + + const output = mode === "escape" ? escapeHtml(input) : unescapeHtml(input); + return NextResponse.json({ output }); +} diff --git a/app/api/routes-f/http-status/data.ts b/app/api/routes-f/http-status/data.ts new file mode 100644 index 00000000..f43d3d3f --- /dev/null +++ b/app/api/routes-f/http-status/data.ts @@ -0,0 +1,52 @@ +export interface HttpStatus { + code: number; + name: string; + description: string; + category: string; + rfc?: string; +} + +export const HTTP_STATUSES: HttpStatus[] = [ + { code: 100, name: "Continue", description: "The server has received the request headers and the client should proceed.", category: "1xx", rfc: "RFC 7231" }, + { code: 101, name: "Switching Protocols", description: "The requester has asked the server to switch protocols.", category: "1xx", rfc: "RFC 7231" }, + { code: 200, name: "OK", description: "The request succeeded.", category: "2xx", rfc: "RFC 7231" }, + { code: 201, name: "Created", description: "The request succeeded and a new resource was created.", category: "2xx", rfc: "RFC 7231" }, + { code: 202, name: "Accepted", description: "The request has been received but not yet acted upon.", category: "2xx", rfc: "RFC 7231" }, + { code: 204, name: "No Content", description: "There is no content to send for this request.", category: "2xx", rfc: "RFC 7231" }, + { code: 301, name: "Moved Permanently", description: "The URL of the requested resource has been changed permanently.", category: "3xx", rfc: "RFC 7231" }, + { code: 302, name: "Found", description: "The URI of requested resource has been changed temporarily.", category: "3xx", rfc: "RFC 7231" }, + { code: 304, name: "Not Modified", description: "The response has not been modified.", category: "3xx", rfc: "RFC 7232" }, + { code: 400, name: "Bad Request", description: "The server cannot or will not process the request due to client error.", category: "4xx", rfc: "RFC 7231" }, + { code: 401, name: "Unauthorized", description: "Authentication is required and has failed or not been provided.", category: "4xx", rfc: "RFC 7235" }, + { code: 403, name: "Forbidden", description: "The client does not have access rights to the content.", category: "4xx", rfc: "RFC 7231" }, + { code: 404, name: "Not Found", description: "The server can not find the requested resource.", category: "4xx", rfc: "RFC 7231" }, + { code: 405, name: "Method Not Allowed", description: "The request method is known by the server but is not supported.", category: "4xx", rfc: "RFC 7231" }, + { code: 409, name: "Conflict", description: "The request conflicts with the current state of the server.", category: "4xx", rfc: "RFC 7231" }, + { code: 410, name: "Gone", description: "The content has been permanently deleted from server.", category: "4xx", rfc: "RFC 7231" }, + { code: 413, name: "Payload Too Large", description: "The request entity is larger than limits defined by server.", category: "4xx", rfc: "RFC 7231" }, + { code: 422, name: "Unprocessable Entity", description: "The request was well-formed but could not be followed due to semantic errors.", category: "4xx", rfc: "RFC 4918" }, + { code: 429, name: "Too Many Requests", description: "The user has sent too many requests in a given amount of time.", category: "4xx", rfc: "RFC 6585" }, + { code: 500, name: "Internal Server Error", description: "The server has encountered a situation it does not know how to handle.", category: "5xx", rfc: "RFC 7231" }, + { code: 501, name: "Not Implemented", description: "The request method is not supported by the server.", category: "5xx", rfc: "RFC 7231" }, + { code: 502, name: "Bad Gateway", description: "The server got an invalid response while working as a gateway.", category: "5xx", rfc: "RFC 7231" }, + { code: 503, name: "Service Unavailable", description: "The server is not ready to handle the request.", category: "5xx", rfc: "RFC 7231" }, + { code: 504, name: "Gateway Timeout", description: "The server is acting as a gateway and cannot get a response in time.", category: "5xx", rfc: "RFC 7231" }, +]; + +export function getStatusByCode(code: number): HttpStatus | undefined { + return HTTP_STATUSES.find((s) => s.code === code); +} + +export function getStatusesByCategory(): Record { + return HTTP_STATUSES.reduce>((acc, s) => { + (acc[s.category] ??= []).push(s); + return acc; + }, {}); +} + +export function findNearestStatus(code: number): HttpStatus | undefined { + return HTTP_STATUSES.reduce((nearest, s) => { + if (!nearest) return s; + return Math.abs(s.code - code) < Math.abs(nearest.code - code) ? s : nearest; + }, undefined); +} diff --git a/app/api/routes-f/http-status/route.ts b/app/api/routes-f/http-status/route.ts new file mode 100644 index 00000000..88d754ad --- /dev/null +++ b/app/api/routes-f/http-status/route.ts @@ -0,0 +1,29 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getStatusByCode, getStatusesByCategory, findNearestStatus } from "./data"; + +export async function GET(req: NextRequest) { + const codeParam = new URL(req.url).searchParams.get("code"); + + if (!codeParam) { + return NextResponse.json(getStatusesByCategory()); + } + + const code = Number(codeParam); + if (!Number.isInteger(code) || isNaN(code)) { + return NextResponse.json({ error: "Invalid status code format" }, { status: 400 }); + } + + const status = getStatusByCode(code); + if (!status) { + const nearest = findNearestStatus(code); + return NextResponse.json( + { + error: `HTTP status code ${code} not found`, + suggestion: nearest ? `Did you mean ${nearest.code} (${nearest.name})?` : undefined, + }, + { status: 404 } + ); + } + + return NextResponse.json(status); +} diff --git a/app/api/routes-f/initials/__tests__/route.test.ts b/app/api/routes-f/initials/__tests__/route.test.ts new file mode 100644 index 00000000..b0a4d2ab --- /dev/null +++ b/app/api/routes-f/initials/__tests__/route.test.ts @@ -0,0 +1,20 @@ +import { extractInitials } from "../route"; + +describe("extractInitials", () => { + it("handles a single name", () => { + expect(extractInitials("John")).toBe("J"); + }); + it("handles two words", () => { + expect(extractInitials("John Smith")).toBe("JS"); + }); + it("caps at max for three words", () => { + expect(extractInitials("John Michael Smith")).toBe("JM"); + expect(extractInitials("John Michael Smith", 3)).toBe("JMS"); + }); + it("treats hyphenated names as separate parts", () => { + expect(extractInitials("Mary-Jane Watson")).toBe("MJ"); + }); + it("uppercases and ignores extra whitespace", () => { + expect(extractInitials(" ada lovelace ")).toBe("AL"); + }); +}); diff --git a/app/api/routes-f/initials/route.ts b/app/api/routes-f/initials/route.ts new file mode 100644 index 00000000..a3bdd1af --- /dev/null +++ b/app/api/routes-f/initials/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +/** + * Extract uppercased initials from a full name. Whitespace and hyphens both + * separate name parts (so "Mary-Jane" contributes M and J), capped at `max`. + */ +export function extractInitials(name: string, max = 2): string { + const parts = name + .trim() + .split(/\s+/) + .flatMap((w) => w.split("-")) + .filter(Boolean); + return parts + .map((p) => p[0].toUpperCase()) + .slice(0, max) + .join(""); +} + +const schema = z.object({ + name: z.string(), + max: z.number().int().positive().optional().default(2), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { name, max } = result.data; + return NextResponse.json({ initials: extractInitials(name, max) }); +} diff --git a/app/api/routes-f/iqr-outlier/route.test.ts b/app/api/routes-f/iqr-outlier/route.test.ts new file mode 100644 index 00000000..144bbb3f --- /dev/null +++ b/app/api/routes-f/iqr-outlier/route.test.ts @@ -0,0 +1,124 @@ +import { POST } from "./route"; + +describe("IQR Outlier Detection API", () => { + it("should return 400 for invalid JSON", async () => { + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: "bad json", + }); + const res = await POST(req as any); + expect(res.status).toBe(400); + }); + + it("should return 400 when data is missing", async () => { + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({}), + }); + const res = await POST(req as any); + expect(res.status).toBe(400); + }); + + it("should return 400 when data is empty", async () => { + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: [] }), + }); + const res = await POST(req as any); + expect(res.status).toBe(400); + }); + + it("should return 400 when data contains non-numbers", async () => { + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: [1, "two", 3] }), + }); + const res = await POST(req as any); + expect(res.status).toBe(400); + }); + + it("should return 400 for a negative multiplier", async () => { + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: [1, 2, 3, 4, 5], multiplier: -1 }), + }); + const res = await POST(req as any); + expect(res.status).toBe(400); + }); + + it("detects dataset with no outliers", async () => { + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }), + }); + const res = await POST(req as any); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.outliers).toEqual([]); + expect(typeof data.q1).toBe("number"); + expect(typeof data.q3).toBe("number"); + expect(typeof data.iqr).toBe("number"); + expect(typeof data.lower_bound).toBe("number"); + expect(typeof data.upper_bound).toBe("number"); + }); + + it("detects obvious outliers", async () => { + // Dataset: 1-10 with 100 as an outlier + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100] }), + }); + const res = await POST(req as any); + const data = await res.json(); + expect(data.outliers).toContain(100); + }); + + it("uses default multiplier of 1.5", async () => { + const dataset = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100]; + const req1 = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: dataset }), + }); + const req2 = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: dataset, multiplier: 1.5 }), + }); + const [res1, res2] = await Promise.all([POST(req1 as any), POST(req2 as any)]); + const [d1, d2] = await Promise.all([res1.json(), res2.json()]); + expect(d1).toEqual(d2); + }); + + it("custom multiplier changes bounds", async () => { + const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15]; + const reqDefault = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data, multiplier: 1.5 }), + }); + const reqStrict = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data, multiplier: 0.5 }), + }); + const [resDefault, resStrict] = await Promise.all([ + POST(reqDefault as any), + POST(reqStrict as any), + ]); + const [dDefault, dStrict] = await Promise.all([resDefault.json(), resStrict.json()]); + // Stricter multiplier yields narrower bounds, more outliers + expect(dStrict.upper_bound).toBeLessThan(dDefault.upper_bound); + }); + + it("computes correct IQR values for a known dataset", async () => { + // sorted: [2, 4, 6, 8, 10] → Q1=4, Q3=8, IQR=4 + const req = new Request("http://localhost/api/routes-f/iqr-outlier", { + method: "POST", + body: JSON.stringify({ data: [10, 2, 6, 8, 4] }), + }); + const res = await POST(req as any); + const data = await res.json(); + expect(data.q1).toBeCloseTo(4); + expect(data.q3).toBeCloseTo(8); + expect(data.iqr).toBeCloseTo(4); + expect(data.lower_bound).toBeCloseTo(4 - 1.5 * 4); // -2 + expect(data.upper_bound).toBeCloseTo(8 + 1.5 * 4); // 14 + }); +}); diff --git a/app/api/routes-f/iqr-outlier/route.ts b/app/api/routes-f/iqr-outlier/route.ts new file mode 100644 index 00000000..22136d60 --- /dev/null +++ b/app/api/routes-f/iqr-outlier/route.ts @@ -0,0 +1,54 @@ +import { type NextRequest, NextResponse } from "next/server"; + +/** + * Calculates the quartile value for a sorted dataset using linear interpolation. + * Uses the inclusive method (same as Excel's QUARTILE.INC / NumPy default). + */ +function quartile(sorted: number[], q: 0.25 | 0.75): number { + const pos = q * (sorted.length - 1); + const lower = Math.floor(pos); + const upper = Math.ceil(pos); + const frac = pos - lower; + return sorted[lower] + frac * (sorted[upper] - sorted[lower]); +} + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return NextResponse.json({ error: "Request body must be an object." }, { status: 400 }); + } + + const { data, multiplier } = body as Record; + + if (!Array.isArray(data) || data.length === 0 || data.some((v) => typeof v !== "number")) { + return NextResponse.json( + { error: "data must be a non-empty array of numbers." }, + { status: 400 } + ); + } + + const m = multiplier !== undefined ? multiplier : 1.5; + if (typeof m !== "number" || m < 0) { + return NextResponse.json( + { error: "multiplier must be a non-negative number." }, + { status: 400 } + ); + } + + const sorted = [...(data as number[])].sort((a, b) => a - b); + + const q1 = quartile(sorted, 0.25); + const q3 = quartile(sorted, 0.75); + const iqr = q3 - q1; + const lower_bound = q1 - m * iqr; + const upper_bound = q3 + m * iqr; + const outliers = sorted.filter((v) => v < lower_bound || v > upper_bound); + + return NextResponse.json({ q1, q3, iqr, lower_bound, upper_bound, outliers }); +} diff --git a/app/api/routes-f/items/[id]/route.ts b/app/api/routes-f/items/[id]/route.ts new file mode 100644 index 00000000..68e1dfce --- /dev/null +++ b/app/api/routes-f/items/[id]/route.ts @@ -0,0 +1,106 @@ +// @ts-nocheck +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { redis } from "@/lib/redis"; +import { getAuthUser } from "@/lib/auth"; + +const CATALOG_CACHE_KEY = "items_catalog"; + +async function invalidateCatalogCache() { + await redis.del(CATALOG_CACHE_KEY); + const keys = await redis.keys("items_catalog:type:*"); + if (keys.length > 0) { + await redis.del(...keys); + } +} + +/** + * GET /api/routes-f/items/[id] + * Returns a single catalog item by UUID. + */ +export async function GET( + _req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + + const cacheKey = `items_catalog:item:${id}`; + const cached = await redis.get(cacheKey); + if (cached) { + return NextResponse.json(JSON.parse(cached as string), { + headers: { "X-Cache": "HIT" }, + }); + } + + const { rows } = await db.query( + `SELECT id, type, name, slug, description, emoji, image_url, + price_usd, price_usdc, animation, tier, active, metadata + FROM items_catalog + WHERE id = $1`, + [id] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + + const response = { item: rows[0] }; + await redis.setex(cacheKey, 300, JSON.stringify(response)); + + return NextResponse.json(response, { headers: { "X-Cache": "MISS" } }); +} + +/** + * PATCH /api/routes-f/items/[id] + * Admin-only: update a catalog item. + */ +export async function PATCH( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const user = await getAuthUser(req); + if (!user || user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await context.params; + const body = await req.json(); + + const allowed = [ + "type", "name", "slug", "description", "emoji", "image_url", + "price_usd", "price_usdc", "animation", "tier", "sort_order", + "active", "metadata", + ]; + + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + + for (const key of allowed) { + if (key in body) { + fields.push(`${key} = $${idx}`); + values.push(key === "metadata" && body[key] ? JSON.stringify(body[key]) : body[key]); + idx++; + } + } + + if (fields.length === 0) { + return NextResponse.json({ error: "No valid fields to update" }, { status: 400 }); + } + + values.push(id); + const { rows } = await db.query( + `UPDATE items_catalog SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, + values + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + + // Invalidate caches + await invalidateCatalogCache(); + await redis.del(`items_catalog:item:${id}`); + + return NextResponse.json({ item: rows[0] }); +} diff --git a/app/api/routes-f/items/_catalog.ts b/app/api/routes-f/items/_catalog.ts new file mode 100644 index 00000000..c251271b --- /dev/null +++ b/app/api/routes-f/items/_catalog.ts @@ -0,0 +1,124 @@ +import { NextRequest } from "next/server"; + +// ----- Shared catalog store ----- +export const ITEMS_CATALOG: { + id: string; + type: string; + name: string; + slug: string; + description: string | null; + emoji: string | null; + image_url: string | null; + price_usd: number | null; + price_usdc: number | null; + animation: string | null; + tier: string | null; + sort_order: number; + active: boolean; + metadata: Record; +}[] = [ + { + id: "a1b2c3d4-0001-0000-0000-000000000001", + type: "gift", + name: "Flower", + slug: "gift-flower", + description: "A beautiful flower gift.", + emoji: "🌸", + image_url: null, + price_usd: 1.0, + price_usdc: 1.0, + animation: "flower", + tier: "common", + sort_order: 1, + active: true, + metadata: {}, + }, + { + id: "a1b2c3d4-0002-0000-0000-000000000002", + type: "gift", + name: "Candy", + slug: "gift-candy", + description: "Sweet candy for your favourite streamer.", + emoji: "🍬", + image_url: null, + price_usd: 5.0, + price_usdc: 5.0, + animation: "candy", + tier: "common", + sort_order: 2, + active: true, + metadata: {}, + }, + { + id: "a1b2c3d4-0003-0000-0000-000000000003", + type: "gift", + name: "Crown", + slug: "gift-crown", + description: "A rare crown fit for royalty.", + emoji: "👑", + image_url: null, + price_usd: 25.0, + price_usdc: 25.0, + animation: "crown", + tier: "rare", + sort_order: 3, + active: true, + metadata: {}, + }, + { + id: "a1b2c3d4-0004-0000-0000-000000000004", + type: "gift", + name: "Lion", + slug: "gift-lion", + description: "A majestic lion gift.", + emoji: "🦁", + image_url: null, + price_usd: 100.0, + price_usdc: 100.0, + animation: "lion", + tier: "rare", + sort_order: 4, + active: true, + metadata: {}, + }, + { + id: "a1b2c3d4-0005-0000-0000-000000000005", + type: "gift", + name: "Dragon", + slug: "gift-dragon", + description: "The legendary dragon — the ultimate gift.", + emoji: "🐉", + image_url: null, + price_usd: 500.0, + price_usdc: 500.0, + animation: "dragon", + tier: "legendary", + sort_order: 5, + active: true, + metadata: {}, + }, +]; + +// Simulated Redis cache (in-memory, 5 min TTL) +let catalogCache: { data: typeof ITEMS_CATALOG; expiresAt: number } | null = null; +const CACHE_TTL_MS = 5 * 60 * 1000; + +export function getCachedCatalog() { + if (catalogCache && Date.now() < catalogCache.expiresAt) { + return catalogCache.data; + } + return null; +} + +export function setCatalogCache(data: typeof ITEMS_CATALOG) { + catalogCache = { data, expiresAt: Date.now() + CACHE_TTL_MS }; +} + +export function invalidateCatalogCache() { + catalogCache = null; +} + +export function isAdmin(request: NextRequest): boolean { + const adminToken = request.headers.get("x-admin-token"); + return adminToken === process.env.ADMIN_SECRET_TOKEN; +} diff --git a/app/api/routes-f/items/route.ts b/app/api/routes-f/items/route.ts new file mode 100644 index 00000000..77dd4533 --- /dev/null +++ b/app/api/routes-f/items/route.ts @@ -0,0 +1,153 @@ +// @ts-nocheck +import { NextRequest, NextResponse } from "next/server"; + +// --------------------------------------------------------------------------- +// DB schema (apply once via migration): +// +// CREATE TYPE item_type AS ENUM ('gift', 'sticker', 'effect', 'badge_frame', 'chat_color'); +// +// CREATE TABLE items_catalog ( +// id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +// type item_type NOT NULL, +// name TEXT NOT NULL, +// slug TEXT NOT NULL UNIQUE, +// description TEXT, +// emoji TEXT, +// image_url TEXT, +// price_usd NUMERIC(10,2), +// price_usdc NUMERIC(10,2), +// animation TEXT, +// tier TEXT, +// sort_order INT DEFAULT 0, +// active BOOLEAN DEFAULT true, +// metadata JSONB +// ); +// +// Seed data (run once): +// INSERT INTO items_catalog (type, name, slug, emoji, price_usd, price_usdc, animation, tier, sort_order) +// VALUES +// ('gift', 'Flower', 'gift-flower', '🌸', 1.00, 1.00, 'flower', 'common', 1), +// ('gift', 'Candy', 'gift-candy', '🍬', 5.00, 5.00, 'candy', 'common', 2), +// ('gift', 'Crown', 'gift-crown', '👑', 25.00, 25.00, 'crown', 'rare', 3), +// ('gift', 'Lion', 'gift-lion', '🦁', 100.00, 100.00, 'lion', 'rare', 4), +// ('gift', 'Dragon', 'gift-dragon', '🐉', 500.00, 500.00, 'dragon', 'legendary', 5); +// --------------------------------------------------------------------------- + +import { db } from "@/lib/db"; +import { redis } from "@/lib/redis"; +import { getAuthUser } from "@/lib/auth"; + +const CACHE_KEY = "items_catalog"; +const CACHE_TTL = 300; // 5 minutes + +async function invalidateCatalogCache() { + await redis.del(CACHE_KEY); + // Invalidate any type-scoped keys + const keys = await redis.keys("items_catalog:type:*"); + if (keys.length > 0) { + await redis.del(...keys); + } +} + +/** + * GET /api/routes-f/items + * Returns all active items, optionally filtered by ?type=gift|sticker|effect|... + * Full catalog cached in Redis with 5-minute TTL. + */ +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const type = searchParams.get("type"); + + const cacheKey = type ? `items_catalog:type:${type}` : CACHE_KEY; + + // 1. Try cache + const cached = await redis.get(cacheKey); + if (cached) { + return NextResponse.json(JSON.parse(cached as string), { + headers: { "X-Cache": "HIT" }, + }); + } + + // 2. Query DB + const params: unknown[] = []; + let query = + "SELECT id, type, name, slug, emoji, image_url, price_usd, price_usdc, animation, tier, active FROM items_catalog WHERE active = true"; + + if (type) { + params.push(type); + query += ` AND type = $${params.length}`; + } + + query += " ORDER BY sort_order ASC, name ASC"; + + const { rows } = await db.query(query, params); + + const response = { items: rows }; + + // 3. Cache result + await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(response)); + + return NextResponse.json(response, { + headers: { "X-Cache": "MISS" }, + }); +} + +/** + * POST /api/routes-f/items + * Admin-only: create a new catalog item. + */ +export async function POST(req: NextRequest) { + const user = await getAuthUser(req); + if (!user || user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json(); + const { + type, + name, + slug, + description, + emoji, + image_url, + price_usd, + price_usdc, + animation, + tier, + sort_order = 0, + metadata, + } = body; + + if (!type || !name || !slug) { + return NextResponse.json( + { error: "type, name, and slug are required" }, + { status: 400 } + ); + } + + const { rows } = await db.query( + `INSERT INTO items_catalog + (type, name, slug, description, emoji, image_url, price_usd, price_usdc, animation, tier, sort_order, metadata) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + type, + name, + slug, + description ?? null, + emoji ?? null, + image_url ?? null, + price_usd ?? null, + price_usdc ?? null, + animation ?? null, + tier ?? null, + sort_order, + metadata ? JSON.stringify(metadata) : null, + ] + ); + + // Invalidate catalog cache + await invalidateCatalogCache(); + + return NextResponse.json({ item: rows[0] }, { status: 201 }); +} diff --git a/app/api/routes-f/jest.config.simple.cjs b/app/api/routes-f/jest.config.simple.cjs new file mode 100644 index 00000000..e8dbed9d --- /dev/null +++ b/app/api/routes-f/jest.config.simple.cjs @@ -0,0 +1,13 @@ +module.exports = { + testEnvironment: "node", + transform: { + "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }], + }, + moduleNameMapper: { + "^@/(.*)$": "/$1", + }, + rootDir: "../../..", + testMatch: [ + "/app/api/routes-f/__tests__/relative-date.test.ts" + ], +}; diff --git a/app/api/routes-f/json-format/route.ts b/app/api/routes-f/json-format/route.ts new file mode 100644 index 00000000..721da622 --- /dev/null +++ b/app/api/routes-f/json-format/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; + +const MAX_BYTES = 5 * 1024 * 1024; + +function sortKeys(obj: unknown): unknown { + if (Array.isArray(obj)) return obj.map(sortKeys); + if (obj !== null && typeof obj === "object") { + return Object.fromEntries( + Object.keys(obj as Record) + .sort() + .map((k) => [k, sortKeys((obj as Record)[k])]) + ); + } + return obj; +} + +export async function POST(req: NextRequest) { + const contentLength = Number(req.headers.get("content-length") ?? 0); + if (contentLength > MAX_BYTES) { + return NextResponse.json({ error: "Input exceeds 5MB limit" }, { status: 413 }); + } + + let body: { input?: unknown; mode?: unknown; indent?: unknown; sort_keys?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { input, mode, indent = 2, sort_keys = false } = body; + + if (typeof input !== "string") { + return NextResponse.json({ valid: false, error: "input must be a string" }, { status: 400 }); + } + if (mode !== "minify" && mode !== "prettify") { + return NextResponse.json({ error: "mode must be minify or prettify" }, { status: 400 }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (e) { + return NextResponse.json({ valid: false, error: (e as Error).message }); + } + + const data = sort_keys ? sortKeys(parsed) : parsed; + const output = + mode === "minify" + ? JSON.stringify(data) + : JSON.stringify(data, null, typeof indent === "number" ? indent : 2); + + return NextResponse.json({ output }); +} diff --git a/app/api/routes-f/json-schema-validate/__tests__/route.test.ts b/app/api/routes-f/json-schema-validate/__tests__/route.test.ts new file mode 100644 index 00000000..24bb1037 --- /dev/null +++ b/app/api/routes-f/json-schema-validate/__tests__/route.test.ts @@ -0,0 +1,59 @@ +import { validateAgainstSchema } from "../route"; + +describe("validateAgainstSchema", () => { + it("passes a valid object", () => { + const schema = { + type: "object", + required: ["name", "age"], + properties: { + name: { type: "string", minLength: 1, maxLength: 50 }, + age: { type: "integer", minimum: 0, maximum: 130 }, + role: { type: "string", enum: ["admin", "user"] }, + }, + }; + const r = validateAgainstSchema(schema, { name: "Ada", age: 36, role: "admin" }); + expect(r.valid).toBe(true); + expect(r.errors).toEqual([]); + }); + + it("flags a type mismatch", () => { + const r = validateAgainstSchema({ type: "number" }, "nope"); + expect(r.valid).toBe(false); + expect(r.errors[0].message).toMatch(/expected type number/); + }); + + it("flags missing required properties", () => { + const r = validateAgainstSchema( + { type: "object", required: ["id"], properties: {} }, + {}, + ); + expect(r.valid).toBe(false); + expect(r.errors[0]).toEqual({ path: "id", message: "required property is missing" }); + }); + + it("enforces minimum/maximum", () => { + expect(validateAgainstSchema({ type: "number", minimum: 10 }, 5).valid).toBe(false); + expect(validateAgainstSchema({ type: "number", maximum: 10 }, 20).valid).toBe(false); + expect(validateAgainstSchema({ type: "number", minimum: 0, maximum: 10 }, 5).valid).toBe(true); + }); + + it("enforces minLength/maxLength", () => { + expect(validateAgainstSchema({ type: "string", minLength: 3 }, "ab").valid).toBe(false); + expect(validateAgainstSchema({ type: "string", maxLength: 3 }, "abcd").valid).toBe(false); + }); + + it("enforces enum", () => { + expect(validateAgainstSchema({ enum: ["a", "b"] }, "c").valid).toBe(false); + expect(validateAgainstSchema({ enum: ["a", "b"] }, "a").valid).toBe(true); + }); + + it("reports nested property paths", () => { + const schema = { + type: "object", + properties: { user: { type: "object", properties: { age: { type: "integer", minimum: 0 } } } }, + }; + const r = validateAgainstSchema(schema, { user: { age: -1 } }); + expect(r.valid).toBe(false); + expect(r.errors[0].path).toBe("user.age"); + }); +}); diff --git a/app/api/routes-f/json-schema-validate/route.ts b/app/api/routes-f/json-schema-validate/route.ts new file mode 100644 index 00000000..fa3a6b61 --- /dev/null +++ b/app/api/routes-f/json-schema-validate/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +// Minimal JSON-schema subset validator (no ajv): supports type, required, +// properties, minimum/maximum, minLength/maxLength, enum. + +export interface SchemaError { + path: string; + message: string; +} + +type JsonSchema = Record; + +function typeOf(v: unknown): string { + if (v === null) return "null"; + if (Array.isArray(v)) return "array"; + return typeof v; +} + +const join = (path: string, key: string) => (path ? `${path}.${key}` : key); + +function walk(schema: JsonSchema, data: unknown, path: string, errors: SchemaError[]): void { + if (typeof schema !== "object" || schema === null) return; + + if (schema.type !== undefined) { + const expected = Array.isArray(schema.type) ? (schema.type as string[]) : [schema.type as string]; + const actual = typeOf(data); + const ok = expected.some((t) => + t === "integer" ? actual === "number" && Number.isInteger(data) : t === actual, + ); + if (!ok) { + errors.push({ path, message: `expected type ${expected.join(" | ")}, got ${actual}` }); + return; // further keyword checks are meaningless on a type mismatch + } + } + + if (Array.isArray(schema.enum)) { + const match = (schema.enum as unknown[]).some( + (e) => JSON.stringify(e) === JSON.stringify(data), + ); + if (!match) errors.push({ path, message: "value is not one of the allowed enum values" }); + } + + if (typeof data === "number") { + if (typeof schema.minimum === "number" && data < schema.minimum) { + errors.push({ path, message: `must be >= ${schema.minimum}` }); + } + if (typeof schema.maximum === "number" && data > schema.maximum) { + errors.push({ path, message: `must be <= ${schema.maximum}` }); + } + } + + if (typeof data === "string") { + if (typeof schema.minLength === "number" && data.length < schema.minLength) { + errors.push({ path, message: `length must be >= ${schema.minLength}` }); + } + if (typeof schema.maxLength === "number" && data.length > schema.maxLength) { + errors.push({ path, message: `length must be <= ${schema.maxLength}` }); + } + } + + if (typeOf(data) === "object") { + const obj = data as Record; + if (Array.isArray(schema.required)) { + for (const key of schema.required as string[]) { + if (!(key in obj)) errors.push({ path: join(path, key), message: "required property is missing" }); + } + } + if (schema.properties && typeof schema.properties === "object") { + for (const [key, sub] of Object.entries(schema.properties as Record)) { + if (key in obj) walk(sub, obj[key], join(path, key), errors); + } + } + } +} + +export function validateAgainstSchema( + schema: JsonSchema, + data: unknown, +): { valid: boolean; errors: SchemaError[] } { + const errors: SchemaError[] = []; + walk(schema, data, "", errors); + return { valid: errors.length === 0, errors }; +} + +const schema = z.object({ + schema: z.record(z.any()), + data: z.any(), +}); + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + const { schema: jsonSchema, data } = result.data; + return NextResponse.json(validateAgainstSchema(jsonSchema as JsonSchema, data)); +} diff --git a/app/api/routes-f/leap-year/route.ts b/app/api/routes-f/leap-year/route.ts new file mode 100644 index 00000000..70c443a0 --- /dev/null +++ b/app/api/routes-f/leap-year/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function getNextLeapYears(from: number, count = 5): number[] { + const result: number[] = []; + let y = from + 1; + while (result.length < count) { + if (isLeapYear(y)) result.push(y); + y++; + } + return result; +} + +function leapReason(year: number): string { + if (year % 400 === 0) return "Divisible by 400"; + if (year % 100 === 0) return "Divisible by 100 but not 400 — not a leap year"; + if (year % 4 === 0) return "Divisible by 4 but not 100"; + return "Not divisible by 4"; +} + +export async function GET(req: NextRequest) { + const yearParam = new URL(req.url).searchParams.get("year"); + const year = yearParam ? parseInt(yearParam, 10) : NaN; + + if (!yearParam || isNaN(year)) { + return NextResponse.json({ error: "year query param is required" }, { status: 400 }); + } + + return NextResponse.json({ + is_leap: isLeapYear(year), + reason: leapReason(year), + next_leap_years: getNextLeapYears(year), + }); +} diff --git a/app/api/routes-f/live/raid/incoming/route.ts b/app/api/routes-f/live/raid/incoming/route.ts new file mode 100644 index 00000000..3d6a0e11 --- /dev/null +++ b/app/api/routes-f/live/raid/incoming/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/routes-f/live/raid/incoming + * Target creator polls for incoming raid. + */ +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + // Find latest unacknowledged raid + const { rows } = await sql` + SELECT + r.id, + u.username as "raiderUsername", + r.viewer_count as "viewerCount", + r.raided_at as "raidedAt" + FROM raids r + JOIN users u ON r.raider_id = u.id + WHERE r.target_id = ${session.userId} + AND r.is_acknowledged = FALSE + ORDER BY r.raided_at DESC + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ raid: null }); + } + + const latestRaid = rows[0]; + + // Mark as acknowledged + await sql` + UPDATE raids + SET is_acknowledged = TRUE + WHERE id = ${latestRaid.id} + `; + + return NextResponse.json({ raid: latestRaid }); + } catch (error) { + console.error("[Raid API] Error fetching incoming raid:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/live/raid/route.ts b/app/api/routes-f/live/raid/route.ts new file mode 100644 index 00000000..9ec57303 --- /dev/null +++ b/app/api/routes-f/live/raid/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const raidSchema = z.object({ + targetUsername: z.string().min(1), + viewerCount: z.number().int().nonnegative(), +}); + +/** + * POST /api/routes-f/live/raid + * Initiate a raid. + */ +export async function POST(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + const body = await req.json(); + const result = raidSchema.safeParse(body); + if (!result.success) { + return NextResponse.json({ error: "Invalid request body", details: result.error.format() }, { status: 400 }); + } + + const { targetUsername, viewerCount } = result.data; + + // Check if raider is live + const { rows: raiderStatus } = await sql` + SELECT is_live FROM users WHERE id = ${session.userId} LIMIT 1 + `; + if (!raiderStatus[0]?.is_live) { + return NextResponse.json({ error: "Only active streamers can initiate a raid" }, { status: 400 }); + } + + // Find target + const { rows: target } = await sql` + SELECT id, is_live FROM users WHERE username = ${targetUsername} LIMIT 1 + `; + + if (target.length === 0) { + return NextResponse.json({ error: "Target user not found" }, { status: 404 }); + } + + if (target[0].id === session.userId) { + return NextResponse.json({ error: "You cannot raid yourself" }, { status: 400 }); + } + + if (!target[0].is_live) { + return NextResponse.json({ error: "Target streamer must be live to be raided" }, { status: 400 }); + } + + // Record the raid + await sql` + INSERT INTO raids (raider_id, target_id, viewer_count) + VALUES (${session.userId}, ${target[0].id}, ${viewerCount}) + `; + + return NextResponse.json({ message: `Raid initiated to ${targetUsername} with ${viewerCount} viewers` }); + } catch (error) { + console.error("[Raid API] Error initiating raid:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/loan-amortization/route.ts b/app/api/routes-f/loan-amortization/route.ts new file mode 100644 index 00000000..cc3a6067 --- /dev/null +++ b/app/api/routes-f/loan-amortization/route.ts @@ -0,0 +1,88 @@ +import { type NextRequest, NextResponse } from "next/server"; + +interface AmortizationRow { + month: number; + payment: number; + principal: number; + interest: number; + balance: number; +} + +interface AmortizationInput { + principal: number; + annual_rate: number; + years: number; + extra_monthly_payment?: number; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +function computeSchedule(input: AmortizationInput) { + const { principal, annual_rate, years, extra_monthly_payment = 0 } = input; + const monthlyRate = annual_rate / 100 / 12; + const totalMonths = years * 12; + + let monthly_payment: number; + if (monthlyRate === 0) { + monthly_payment = round2(principal / totalMonths); + } else { + const factor = Math.pow(1 + monthlyRate, totalMonths); + monthly_payment = round2((principal * monthlyRate * factor) / (factor - 1)); + } + + const schedule: AmortizationRow[] = []; + let balance = principal; + let month = 0; + + while (balance > 0) { + month++; + const interest = round2(balance * monthlyRate); + const principalPart = round2(Math.min(monthly_payment - interest + extra_monthly_payment, balance)); + const payment = round2(interest + principalPart); + balance = round2(balance - principalPart); + schedule.push({ month, payment, principal: principalPart, interest, balance: Math.max(0, balance) }); + if (month > totalMonths + 1000) break; // safety guard + } + + const total_interest = round2(schedule.reduce((sum, r) => sum + r.interest, 0)); + + return { monthly_payment, payoff_months: schedule.length, total_interest, schedule }; +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + if (!isRecord(body)) { + return NextResponse.json({ error: "Request body must be an object." }, { status: 400 }); + } + + const { principal, annual_rate, years, extra_monthly_payment } = body as Record; + + if (typeof principal !== "number" || principal <= 0) { + return NextResponse.json({ error: "principal must be a positive number." }, { status: 400 }); + } + if (typeof annual_rate !== "number" || annual_rate < 0) { + return NextResponse.json({ error: "annual_rate must be a non-negative number." }, { status: 400 }); + } + if (typeof years !== "number" || years <= 0 || years > 50) { + return NextResponse.json({ error: "years must be a positive number not exceeding 50." }, { status: 400 }); + } + if (extra_monthly_payment !== undefined && (typeof extra_monthly_payment !== "number" || extra_monthly_payment < 0)) { + return NextResponse.json({ error: "extra_monthly_payment must be a non-negative number." }, { status: 400 }); + } + + return NextResponse.json( + computeSchedule({ principal, annual_rate, years, extra_monthly_payment: extra_monthly_payment as number | undefined }) + ); +} diff --git a/app/api/routes-f/markdown-preview/__tests__/route.test.ts b/app/api/routes-f/markdown-preview/__tests__/route.test.ts new file mode 100644 index 00000000..2ee2eb04 --- /dev/null +++ b/app/api/routes-f/markdown-preview/__tests__/route.test.ts @@ -0,0 +1,299 @@ +import { POST } from "../route"; +import { NextRequest } from "next/server"; + +function makeReq(body: object) { + return new NextRequest("http://localhost/api/routes-f/markdown-preview", { + method: "POST", + body: JSON.stringify(body), + }); +} + +describe("POST /api/routes-f/markdown-preview", () => { + describe("markdown rendering", () => { + it("renders headings", async () => { + const res = await POST( + makeReq({ + markdown: "# Title\n## Subtitle\n### Sub-subtitle", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).toContain("

Title

"); + expect(body.html).toContain("

Subtitle

"); + expect(body.html).toContain("

Sub-subtitle

"); + }); + + it("renders bold text", async () => { + const res = await POST( + makeReq({ + markdown: "This is **bold** text", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).toContain("bold"); + }); + + it("renders italic text", async () => { + const res = await POST( + makeReq({ + markdown: "This is *italic* text", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).toContain("italic"); + }); + + it("renders inline code", async () => { + const res = await POST( + makeReq({ + markdown: "Use `const x = 5` in JavaScript", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).toContain("const x = 5"); + }); + + it("renders code blocks", async () => { + const res = await POST( + makeReq({ + markdown: "```\nconst x = 5;\nconst y = 10;\n```", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).toContain("
");
+      expect(body.html).toContain("const x = 5;");
+      expect(body.html).toContain("const y = 10;");
+    });
+
+    it("preserves code block content exactly", async () => {
+      const res = await POST(
+        makeReq({
+          markdown: "```\n\n```",
+        })
+      );
+      expect(res.status).toBe(200);
+      const body = await res.json();
+      expect(body.html).toContain("<script>");
+    });
+
+    it("renders links", async () => {
+      const res = await POST(
+        makeReq({
+          markdown: "Check [this link](https://example.com)",
+        })
+      );
+      expect(res.status).toBe(200);
+      const body = await res.json();
+      expect(body.html).toContain('this link');
+    });
+
+    it("renders unordered lists", async () => {
+      const res = await POST(
+        makeReq({
+          markdown: "- Item 1\n- Item 2\n- Item 3",
+        })
+      );
+      expect(res.status).toBe(200);
+      const body = await res.json();
+      expect(body.html).toContain("
    "); + expect(body.html).toContain("
  • Item 1
  • "); + expect(body.html).toContain("
  • Item 2
  • "); + expect(body.html).toContain("
"); + }); + + it("renders ordered lists", async () => { + const res = await POST( + makeReq({ + markdown: "1. First\n2. Second\n3. Third", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).toContain("
    "); + expect(body.html).toContain("
  1. First
  2. "); + expect(body.html).toContain("
"); + }); + + it("renders paragraphs", async () => { + const res = await POST( + makeReq({ + markdown: "This is a paragraph.\n\nThis is another.", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).toContain("

This is a paragraph.

"); + expect(body.html).toContain("

This is another.

"); + }); + }); + + describe("heading extraction", () => { + it("extracts headings with correct levels", async () => { + const res = await POST( + makeReq({ + markdown: "# H1\n## H2\n### H3", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.headings).toEqual([ + { level: 1, text: "H1" }, + { level: 2, text: "H2" }, + { level: 3, text: "H3" }, + ]); + }); + + it("preserves heading order", async () => { + const res = await POST( + makeReq({ + markdown: "# Title\nSome text\n## Section\nMore text\n### Subsection", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.headings.map((h: { text: string }) => h.text)).toEqual([ + "Title", + "Section", + "Subsection", + ]); + }); + }); + + describe("word count", () => { + it("counts words correctly", async () => { + const res = await POST( + makeReq({ + markdown: "This is a test with seven words here", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.word_count).toBe(7); + }); + + it("counts words across multiple lines", async () => { + const res = await POST( + makeReq({ + markdown: "Hello world\nfoo bar", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.word_count).toBe(4); + }); + }); + + describe("sanitization", () => { + it("strips script tags by default", async () => { + const res = await POST( + makeReq({ + markdown: "Hello", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.html).not.toContain("", + sanitize: false, + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + // Script tags should still be removed even with sanitize=false + expect(body.html).not.toContain("