From 2fb63fadc10beccf06b66360e9c4b1738eb704cd Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Fri, 26 Jun 2026 12:29:44 +0100 Subject: [PATCH] feat(routes-f): add weighted featured channel rotation endpoint and tests --- .../__tests__/featured-channel.test.ts | 130 +++++++++ app/api/routes-f/featured-channel/helpers.ts | 264 ++++++++++++++++++ app/api/routes-f/featured-channel/route.ts | 32 +++ app/api/routes-f/featured-channel/types.ts | 21 ++ 4 files changed, 447 insertions(+) create mode 100644 app/api/routes-f/featured-channel/__tests__/featured-channel.test.ts create mode 100644 app/api/routes-f/featured-channel/helpers.ts create mode 100644 app/api/routes-f/featured-channel/route.ts create mode 100644 app/api/routes-f/featured-channel/types.ts diff --git a/app/api/routes-f/featured-channel/__tests__/featured-channel.test.ts b/app/api/routes-f/featured-channel/__tests__/featured-channel.test.ts new file mode 100644 index 00000000..5ecab228 --- /dev/null +++ b/app/api/routes-f/featured-channel/__tests__/featured-channel.test.ts @@ -0,0 +1,130 @@ +import { NextRequest } from "next/server"; +import { GET, POST } from "../route"; +import { + CANDIDATES, + selectByWeight, + resetRotation, + getCurrentRotationId, +} from "../helpers"; + +const BASE_URL = "http://localhost/api/routes-f/featured-channel"; + +function makeGet() { + return new NextRequest(BASE_URL, { method: "GET" }); +} + +function makePost() { + return new NextRequest(`${BASE_URL}/next`, { method: "POST" }); +} + +beforeEach(() => { + resetRotation(); +}); + +describe("GET /api/routes-f/featured-channel", () => { + it("returns 200 with featured_creator, rotation_id, rotates_at", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("featured_creator"); + expect(body).toHaveProperty("rotation_id"); + expect(body).toHaveProperty("rotates_at"); + }); + + it("featured_creator has expected fields", async () => { + const res = await GET(); + const { featured_creator } = await res.json(); + expect(featured_creator).toHaveProperty("id"); + expect(featured_creator).toHaveProperty("name"); + expect(featured_creator).toHaveProperty("wallet_address"); + expect(featured_creator).toHaveProperty("avatar_url"); + expect(featured_creator).toHaveProperty("category"); + expect(featured_creator).toHaveProperty("followers"); + expect(typeof featured_creator.is_live).toBe("boolean"); + expect(typeof featured_creator.weight).toBe("number"); + }); + + it("returns the same creator for the same rotation_id (determinism)", async () => { + const res1 = await GET(); + const res2 = await GET(); + const body1 = await res1.json(); + const body2 = await res2.json(); + expect(body1.featured_creator.id).toBe(body2.featured_creator.id); + expect(body1.rotation_id).toBe(body2.rotation_id); + }); + + it("rotates_at is a valid future ISO date", async () => { + const res = await GET(); + const { rotates_at } = await res.json(); + const date = new Date(rotates_at); + expect(date.getTime()).toBeGreaterThan(Date.now()); + }); +}); + +describe("POST /api/routes-f/featured-channel (advance rotation)", () => { + it("returns 200 with new rotation_id and rotates_at", async () => { + const res = await POST(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("rotation_id"); + expect(body).toHaveProperty("rotates_at"); + }); + + it("advances rotation_id on each POST", async () => { + const r1 = await POST(); + const r2 = await POST(); + const b1 = await r1.json(); + const b2 = await r2.json(); + expect(b1.rotation_id).not.toBe(b2.rotation_id); + }); + + it("GET after POST returns a different rotation_id", async () => { + const getRes1 = await GET(); + const { rotation_id: rid1 } = await getRes1.json(); + + await POST(); + + const getRes2 = await GET(); + const { rotation_id: rid2 } = await getRes2.json(); + expect(rid1).not.toBe(rid2); + }); +}); + +describe("selectByWeight determinism", () => { + it("same rotation_id always produces the same creator", () => { + const a = selectByWeight(CANDIDATES, "test-seed-42"); + const b = selectByWeight(CANDIDATES, "test-seed-42"); + expect(a.id).toBe(b.id); + }); + + it("different rotation_ids can produce different creators", () => { + const ids = Array.from({ length: 50 }, (_, i) => `seed-${i}`); + const selected = new Set(ids.map(id => selectByWeight(CANDIDATES, id).id)); + // With 50 different seeds and 20 candidates, we expect multiple distinct picks + expect(selected.size).toBeGreaterThan(1); + }); + + it("higher-weight creators are selected more frequently", () => { + const counts: Record = {}; + const trials = 1000; + for (let i = 0; i < trials; i++) { + const c = selectByWeight(CANDIDATES, `distribution-${i}`); + counts[c.id] = (counts[c.id] || 0) + 1; + } + + // GamingGuru (weight 15) should appear more often than LunarLens (weight 1) + const highWeight = counts["fc-003"] ?? 0; + const lowWeight = counts["fc-012"] ?? 0; + expect(highWeight).toBeGreaterThan(lowWeight); + }); + + it("all candidates can potentially be selected", () => { + const selected = new Set(); + for (let i = 0; i < 5000; i++) { + const c = selectByWeight(CANDIDATES, `coverage-${i}`); + selected.add(c.id); + } + // With 5000 trials, all 20 candidates should appear at least once + expect(selected.size).toBe(CANDIDATES.length); + }); +}); diff --git a/app/api/routes-f/featured-channel/helpers.ts b/app/api/routes-f/featured-channel/helpers.ts new file mode 100644 index 00000000..777b8333 --- /dev/null +++ b/app/api/routes-f/featured-channel/helpers.ts @@ -0,0 +1,264 @@ +import type { FeaturedCreator } from "./types"; + +// ~20 candidate creators with weights for featured rotation +export const CANDIDATES: FeaturedCreator[] = [ + { + id: "fc-001", + name: "CryptoKing", + wallet_address: "GBZX...CK01", + avatar_url: "https://streamfi.xyz/avatars/crypto-king.webp", + category: "DeFi & Finance", + followers: 14200, + is_live: true, + weight: 10, + }, + { + id: "fc-002", + name: "ArtByLena", + wallet_address: "GCXY...LN02", + avatar_url: "https://streamfi.xyz/avatars/art-by-lena.webp", + category: "Digital Art", + followers: 8300, + is_live: false, + weight: 7, + }, + { + id: "fc-003", + name: "GamingGuru", + wallet_address: "GDAB...GG03", + avatar_url: "https://streamfi.xyz/avatars/gaming-guru.webp", + category: "Gaming", + followers: 32000, + is_live: true, + weight: 15, + }, + { + id: "fc-004", + name: "MusicMaven", + wallet_address: "GDEF...MM04", + avatar_url: "https://streamfi.xyz/avatars/music-maven.webp", + category: "Music & Production", + followers: 5600, + is_live: false, + weight: 5, + }, + { + id: "fc-005", + name: "DevDojo", + wallet_address: "GHIJ...DD05", + avatar_url: "https://streamfi.xyz/avatars/dev-dojo.webp", + category: "Dev & Programming", + followers: 11200, + is_live: true, + weight: 9, + }, + { + id: "fc-006", + name: "StellarSam", + wallet_address: "GKLM...SS06", + avatar_url: "https://streamfi.xyz/avatars/stellar-sam.webp", + category: "DeFi & Finance", + followers: 2300, + is_live: false, + weight: 3, + }, + { + id: "fc-007", + name: "PixelPaula", + wallet_address: "GNOP...PP07", + avatar_url: "https://streamfi.xyz/avatars/pixel-paula.webp", + category: "Digital Art", + followers: 1100, + is_live: false, + weight: 2, + }, + { + id: "fc-008", + name: "CodeWithKai", + wallet_address: "GQRS...CK08", + avatar_url: "https://streamfi.xyz/avatars/code-with-kai.webp", + category: "Dev & Programming", + followers: 4500, + is_live: true, + weight: 6, + }, + { + id: "fc-009", + name: "BeatsByNova", + wallet_address: "GTUV...BN09", + avatar_url: "https://streamfi.xyz/avatars/beats-by-nova.webp", + category: "Music & Production", + followers: 800, + is_live: false, + weight: 2, + }, + { + id: "fc-010", + name: "CryptoChess", + wallet_address: "GWXY...CC10", + avatar_url: "https://streamfi.xyz/avatars/crypto-chess.webp", + category: "Gaming", + followers: 1900, + is_live: false, + weight: 3, + }, + { + id: "fc-011", + name: "ZenYogi", + wallet_address: "GABC...ZY11", + avatar_url: "https://streamfi.xyz/avatars/zen-yogi.webp", + category: "Wellness & Lifestyle", + followers: 3400, + is_live: true, + weight: 4, + }, + { + id: "fc-012", + name: "LunarLens", + wallet_address: "GDEF...LL12", + avatar_url: "https://streamfi.xyz/avatars/lunar-lens.webp", + category: "Photography", + followers: 700, + is_live: false, + weight: 1, + }, + { + id: "fc-013", + name: "SorobanSally", + wallet_address: "GHIJ...SS13", + avatar_url: "https://streamfi.xyz/avatars/soroban-sally.webp", + category: "DeFi & Finance", + followers: 8800, + is_live: false, + weight: 8, + }, + { + id: "fc-014", + name: "NeonNinja", + wallet_address: "GKLM...NN14", + avatar_url: "https://streamfi.xyz/avatars/neon-ninja.webp", + category: "Gaming", + followers: 21000, + is_live: true, + weight: 12, + }, + { + id: "fc-015", + name: "ChillBeats", + wallet_address: "GNOP...CB15", + avatar_url: "https://streamfi.xyz/avatars/chill-beats.webp", + category: "Music & Production", + followers: 6200, + is_live: false, + weight: 5, + }, + { + id: "fc-016", + name: "BlockBrush", + wallet_address: "GQRS...BB16", + avatar_url: "https://streamfi.xyz/avatars/block-brush.webp", + category: "Digital Art", + followers: 4100, + is_live: false, + weight: 4, + }, + { + id: "fc-017", + name: "QuantumQoder", + wallet_address: "GTUV...QQ17", + avatar_url: "https://streamfi.xyz/avatars/quantum-qoder.webp", + category: "Dev & Programming", + followers: 15800, + is_live: true, + weight: 11, + }, + { + id: "fc-018", + name: "VoxelViv", + wallet_address: "GWXY...VV18", + avatar_url: "https://streamfi.xyz/avatars/voxel-viv.webp", + category: "Gaming", + followers: 9400, + is_live: false, + weight: 7, + }, + { + id: "fc-019", + name: "TokenTara", + wallet_address: "GABC...TT19", + avatar_url: "https://streamfi.xyz/avatars/token-tara.webp", + category: "DeFi & Finance", + followers: 12600, + is_live: true, + weight: 9, + }, + { + id: "fc-020", + name: "RhythmRex", + wallet_address: "GDEF...RR20", + avatar_url: "https://streamfi.xyz/avatars/rhythm-rex.webp", + category: "Music & Production", + followers: 3200, + is_live: false, + weight: 3, + }, +]; + +// Deterministic seeded PRNG (mulberry32) +function mulberry32(seed: number): () => number { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Convert a rotation_id string to a numeric seed +function rotationSeed(rotationId: string): number { + let hash = 0; + for (let i = 0; i < rotationId.length; i++) { + hash = ((hash << 5) - hash + rotationId.charCodeAt(i)) | 0; + } + return hash; +} + +// Pick a creator using weighted random selection, deterministic by rotation_id +export function selectByWeight( + candidates: FeaturedCreator[], + rotationId: string +): FeaturedCreator { + const rng = mulberry32(rotationSeed(rotationId)); + const totalWeight = candidates.reduce((sum, c) => sum + c.weight, 0); + const roll = rng() * totalWeight; + + let cumulative = 0; + for (const c of candidates) { + cumulative += c.weight; + if (roll < cumulative) return c; + } + return candidates[candidates.length - 1]; +} + +// In-memory rotation state +let currentRotationIndex = 0; + +export function getCurrentRotationId(): string { + return `rot-${currentRotationIndex}`; +} + +export function advanceRotation(): string { + currentRotationIndex++; + return getCurrentRotationId(); +} + +export function resetRotation(): void { + currentRotationIndex = 0; +} + +const ROTATION_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes + +export function getRotatesAt(): string { + return new Date(Date.now() + ROTATION_INTERVAL_MS).toISOString(); +} diff --git a/app/api/routes-f/featured-channel/route.ts b/app/api/routes-f/featured-channel/route.ts new file mode 100644 index 00000000..835d4d9b --- /dev/null +++ b/app/api/routes-f/featured-channel/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CANDIDATES, + selectByWeight, + getCurrentRotationId, + advanceRotation, + getRotatesAt, +} from "./helpers"; +import type { FeaturedChannelResponse } from "./types"; + +// GET — return the current featured creator for the active rotation +export async function GET(): Promise { + const rotation_id = getCurrentRotationId(); + const featured_creator = selectByWeight(CANDIDATES, rotation_id); + const rotates_at = getRotatesAt(); + + const body: FeaturedChannelResponse = { + featured_creator, + rotation_id, + rotates_at, + }; + + return NextResponse.json(body); +} + +// POST /next — advance to the next rotation and return the new state +export async function POST(): Promise { + const rotation_id = advanceRotation(); + const rotates_at = getRotatesAt(); + + return NextResponse.json({ rotation_id, rotates_at }); +} diff --git a/app/api/routes-f/featured-channel/types.ts b/app/api/routes-f/featured-channel/types.ts new file mode 100644 index 00000000..66b71e20 --- /dev/null +++ b/app/api/routes-f/featured-channel/types.ts @@ -0,0 +1,21 @@ +export interface FeaturedCreator { + id: string; + name: string; + wallet_address: string; + avatar_url: string; + category: string; + followers: number; + is_live: boolean; + weight: number; +} + +export interface FeaturedChannelResponse { + featured_creator: FeaturedCreator; + rotation_id: string; + rotates_at: string; +} + +export interface NextRotationResponse { + rotation_id: string; + rotates_at: string; +}