Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions app/api/routes-f/featured-channel/__tests__/featured-channel.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {};
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<string>();
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);
});
});
264 changes: 264 additions & 0 deletions app/api/routes-f/featured-channel/helpers.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading