From 34673dbffdc97de6cc322f282c0df5c09177e851 Mon Sep 17 00:00:00 2001 From: JSE19 Date: Mon, 29 Jun 2026 13:50:38 +0100 Subject: [PATCH 1/3] Fix Issue 1107 --- .../now-playing/__tests__/route.test.ts | 118 ++++++++++++++++++ app/api/routes-f/now-playing/route.ts | 85 +++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 app/api/routes-f/now-playing/__tests__/route.test.ts create mode 100644 app/api/routes-f/now-playing/route.ts diff --git a/app/api/routes-f/now-playing/__tests__/route.test.ts b/app/api/routes-f/now-playing/__tests__/route.test.ts new file mode 100644 index 00000000..710b3c75 --- /dev/null +++ b/app/api/routes-f/now-playing/__tests__/route.test.ts @@ -0,0 +1,118 @@ +import { NextRequest } from "next/server"; +import { POST, GET, DELETE } from "../route"; + +function makeRequest(method: string, body?: unknown, query?: Record): NextRequest { + const url = new URL("http://localhost/api/routes-f/now-playing"); + if (query) { + for (const [k, v] of Object.entries(query)) { + url.searchParams.set(k, v); + } + } + const init: RequestInit & { headers?: Record } = { method }; + if (body !== undefined) { + init.body = JSON.stringify(body); + init.headers = { "Content-Type": "application/json" }; + } + return new NextRequest(url.toString(), init); +} + +describe("POST /now-playing", () => { + it("creates the first track for a stream", async () => { + const res = await POST(makeRequest("POST", { + stream_id: "stream-1", + artist: "Artist A", + title: "Track 1", + })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("updated_at"); + expect(typeof body.updated_at).toBe("string"); + }); + + it("moves current to history when a new track is posted", async () => { + await POST(makeRequest("POST", { + stream_id: "stream-2", + artist: "Artist A", + title: "First", + })); + await POST(makeRequest("POST", { + stream_id: "stream-2", + artist: "Artist B", + title: "Second", + album: "Album", + art_url: "https://example.com/art.jpg", + })); + const res = await GET(makeRequest("GET", undefined, { stream_id: "stream-2" })); + const body = await res.json(); + expect(body.current).toEqual({ + stream_id: "stream-2", + artist: "Artist B", + title: "Second", + album: "Album", + art_url: "https://example.com/art.jpg", + played_at: expect.any(String), + }); + expect(body.history).toHaveLength(1); + expect(body.history[0].title).toBe("First"); + }); + + it("rejects missing required fields", async () => { + const res = await POST(makeRequest("POST", { stream_id: "s-1" })); + expect(res.status).toBe(400); + }); +}); + +describe("GET /now-playing", () => { + it("returns null current and empty history for a fresh stream", async () => { + const res = await GET(makeRequest("GET", undefined, { stream_id: "unknown" })); + const body = await res.json(); + expect(body.current).toBeNull(); + expect(body.history).toEqual([]); + }); + + it("returns at most 10 history entries", async () => { + for (let i = 0; i < 15; i++) { + await POST(makeRequest("POST", { + stream_id: "stream-3", + artist: "Artist", + title: `Track ${i}`, + })); + } + const res = await GET(makeRequest("GET", undefined, { stream_id: "stream-3" })); + const body = await res.json(); + expect(body.history).toHaveLength(10); + expect(body.current.title).toBe("Track 14"); + expect(body.history[0].title).toBe("Track 13"); + }); + + it("rejects missing stream_id", async () => { + const res = await GET(makeRequest("GET", undefined, {})); + expect(res.status).toBe(400); + }); +}); + +describe("DELETE /now-playing", () => { + it("clears all track data for a stream", async () => { + await POST(makeRequest("POST", { + stream_id: "stream-4", + artist: "Artist", + title: "Track", + })); + const before = await GET(makeRequest("GET", undefined, { stream_id: "stream-4" })); + expect((await before.json()).current).not.toBeNull(); + + const delRes = await DELETE(makeRequest("DELETE", undefined, { stream_id: "stream-4" })); + expect(delRes.status).toBe(200); + expect((await delRes.json()).message).toBe("Now-playing cleared"); + + const after = await GET(makeRequest("GET", undefined, { stream_id: "stream-4" })); + const afterBody = await after.json(); + expect(afterBody.current).toBeNull(); + expect(afterBody.history).toEqual([]); + }); + + it("rejects missing stream_id", async () => { + const res = await DELETE(makeRequest("DELETE", undefined, {})); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/now-playing/route.ts b/app/api/routes-f/now-playing/route.ts new file mode 100644 index 00000000..74eccc63 --- /dev/null +++ b/app/api/routes-f/now-playing/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; + +interface Track { + stream_id: string; + artist: string; + title: string; + album?: string; + art_url?: string; + played_at: string; +} + +interface StreamState { + current: Track | null; + history: Track[]; +} + +const store = new Map(); + +const postSchema = z.object({ + stream_id: z.string().min(1), + artist: z.string().min(1), + title: z.string().min(1), + album: z.string().optional(), + art_url: z.string().url().optional(), +}); + +const getSchema = z.object({ + stream_id: z.string().min(1), +}); + +const deleteSchema = z.object({ + stream_id: z.string().min(1), +}); + +function getState(stream_id: string): StreamState { + if (!store.has(stream_id)) { + store.set(stream_id, { current: null, history: [] }); + } + return store.get(stream_id)!; +} + +export async function POST(req: NextRequest): Promise { + const result = await validateBody(req, postSchema); + if (result instanceof NextResponse) return result; + + const { stream_id, artist, title, album, art_url } = result.data; + const state = getState(stream_id); + + if (state.current) { + state.history.unshift({ ...state.current }); + if (state.history.length > 10) { + state.history = state.history.slice(0, 10); + } + } + + const now = new Date().toISOString(); + state.current = { stream_id, artist, title, album, art_url, played_at: now }; + + return NextResponse.json({ updated_at: now }); +} + +export async function GET(req: NextRequest): Promise { + const result = validateQuery(new URL(req.url).searchParams, getSchema); + if (result instanceof NextResponse) return result; + + const { stream_id } = result.data; + const state = getState(stream_id); + + return NextResponse.json({ + current: state.current, + history: state.history, + }); +} + +export async function DELETE(req: NextRequest): Promise { + const result = validateQuery(new URL(req.url).searchParams, deleteSchema); + if (result instanceof NextResponse) return result; + + const { stream_id } = result.data; + store.delete(stream_id); + + return NextResponse.json({ message: "Now-playing cleared" }); +} From 60959a52d479ea9b42944ad20d5424990d277cbf Mon Sep 17 00:00:00 2001 From: JSE19 Date: Mon, 29 Jun 2026 14:03:45 +0100 Subject: [PATCH 2/3] Fix Issue 1102 --- .../routes-f/polls/__tests__/route.test.ts | 216 ++++++++++++++++++ app/api/routes-f/polls/_lib/store.ts | 25 ++ app/api/routes-f/polls/route.ts | 72 ++++++ app/api/routes-f/polls/vote/route.ts | 46 ++++ app/api/routes-f/qa/__tests__/route.test.ts | 181 +++++++++++++++ app/api/routes-f/qa/_lib/store.ts | 22 ++ app/api/routes-f/qa/answer/route.ts | 31 +++ app/api/routes-f/qa/route.ts | 63 +++++ app/api/routes-f/qa/upvote/route.ts | 30 +++ 9 files changed, 686 insertions(+) create mode 100644 app/api/routes-f/polls/__tests__/route.test.ts create mode 100644 app/api/routes-f/polls/_lib/store.ts create mode 100644 app/api/routes-f/polls/route.ts create mode 100644 app/api/routes-f/polls/vote/route.ts create mode 100644 app/api/routes-f/qa/__tests__/route.test.ts create mode 100644 app/api/routes-f/qa/_lib/store.ts create mode 100644 app/api/routes-f/qa/answer/route.ts create mode 100644 app/api/routes-f/qa/route.ts create mode 100644 app/api/routes-f/qa/upvote/route.ts diff --git a/app/api/routes-f/polls/__tests__/route.test.ts b/app/api/routes-f/polls/__tests__/route.test.ts new file mode 100644 index 00000000..1b0d3967 --- /dev/null +++ b/app/api/routes-f/polls/__tests__/route.test.ts @@ -0,0 +1,216 @@ +import { NextRequest } from "next/server"; +import { POST as createPoll, GET as getPoll } from "../route"; +import { POST as votePoll } from "../vote/route"; +import { resetStore, getStore } from "../_lib/store"; + +function makeRequest(method: string, body?: unknown, query?: Record): NextRequest { + const url = new URL("http://localhost/api/routes-f/polls"); + if (query) { + for (const [k, v] of Object.entries(query)) { + url.searchParams.set(k, v); + } + } + const init: RequestInit & { headers?: Record } = { method }; + if (body !== undefined) { + init.body = JSON.stringify(body); + init.headers = { "Content-Type": "application/json" }; + } + return new NextRequest(url.toString(), init); +} + +function makeVoteRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/routes-f/polls/vote", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Poll creation", () => { + beforeEach(() => resetStore()); + + it("creates a poll and returns poll_id and ends_at", async () => { + const res = await createPoll( + makeRequest("POST", { + stream_id: "stream-1", + question: "Best track?", + options: ["Track A", "Track B", "Track C"], + duration_seconds: 60, + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("poll_id"); + expect(body).toHaveProperty("ends_at"); + expect(typeof body.poll_id).toBe("string"); + expect(typeof body.ends_at).toBe("string"); + }); + + it("rejects fewer than 2 options", async () => { + const res = await createPoll( + makeRequest("POST", { + stream_id: "stream-1", + question: "Best track?", + options: ["Only one"], + duration_seconds: 60, + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects more than 6 options", async () => { + const res = await createPoll( + makeRequest("POST", { + stream_id: "stream-1", + question: "Best track?", + options: ["A", "B", "C", "D", "E", "F", "G"], + duration_seconds: 60, + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects missing required fields", async () => { + const res = await createPoll( + makeRequest("POST", { stream_id: "stream-1" }) + ); + expect(res.status).toBe(400); + }); +}); + +describe("Get poll", () => { + let pollId: string; + + beforeEach(async () => { + resetStore(); + const res = await createPoll( + makeRequest("POST", { + stream_id: "stream-1", + question: "Best track?", + options: ["Track A", "Track B"], + duration_seconds: 60, + }) + ); + pollId = (await res.json()).poll_id; + }); + + it("returns poll details with zero votes initially", async () => { + const res = await getPoll(makeRequest("GET", undefined, { poll_id: pollId })); + const body = await res.json(); + expect(body.question).toBe("Best track?"); + expect(body.options).toEqual([ + { text: "Track A", votes: 0 }, + { text: "Track B", votes: 0 }, + ]); + expect(body.total_votes).toBe(0); + expect(body.ended).toBe(false); + }); + + it("returns 404 for unknown poll", async () => { + const res = await getPoll(makeRequest("GET", undefined, { poll_id: "nonexistent" })); + expect(res.status).toBe(404); + }); +}); + +describe("Voting", () => { + let pollId: string; + + beforeEach(async () => { + resetStore(); + const res = await createPoll( + makeRequest("POST", { + stream_id: "stream-1", + question: "Best track?", + options: ["Track A", "Track B"], + duration_seconds: 60, + }) + ); + pollId = (await res.json()).poll_id; + }); + + it("records a vote and increments the count", async () => { + const voteRes = await votePoll( + makeVoteRequest({ poll_id: pollId, viewer_id: "viewer-1", option_index: 0 }) + ); + expect(voteRes.status).toBe(200); + expect((await voteRes.json()).message).toBe("Vote recorded"); + + const getRes = await getPoll(makeRequest("GET", undefined, { poll_id: pollId })); + const body = await getRes.json(); + expect(body.options[0].votes).toBe(1); + expect(body.options[1].votes).toBe(0); + expect(body.total_votes).toBe(1); + }); + + it("rejects duplicate vote from the same viewer", async () => { + await votePoll( + makeVoteRequest({ poll_id: pollId, viewer_id: "viewer-1", option_index: 0 }) + ); + const dupRes = await votePoll( + makeVoteRequest({ poll_id: pollId, viewer_id: "viewer-1", option_index: 1 }) + ); + expect(dupRes.status).toBe(409); + + const getRes = await getPoll(makeRequest("GET", undefined, { poll_id: pollId })); + const body = await getRes.json(); + expect(body.total_votes).toBe(1); + }); + + it("allows different viewers to vote", async () => { + await votePoll( + makeVoteRequest({ poll_id: pollId, viewer_id: "viewer-1", option_index: 0 }) + ); + await votePoll( + makeVoteRequest({ poll_id: pollId, viewer_id: "viewer-2", option_index: 0 }) + ); + await votePoll( + makeVoteRequest({ poll_id: pollId, viewer_id: "viewer-3", option_index: 1 }) + ); + + const getRes = await getPoll(makeRequest("GET", undefined, { poll_id: pollId })); + const body = await getRes.json(); + expect(body.options[0].votes).toBe(2); + expect(body.options[1].votes).toBe(1); + expect(body.total_votes).toBe(3); + }); + + it("rejects vote with invalid option_index", async () => { + const res = await votePoll( + makeVoteRequest({ poll_id: pollId, viewer_id: "viewer-1", option_index: 99 }) + ); + expect(res.status).toBe(400); + }); + + it("rejects vote for nonexistent poll", async () => { + const res = await votePoll( + makeVoteRequest({ poll_id: "nonexistent", viewer_id: "viewer-1", option_index: 0 }) + ); + expect(res.status).toBe(404); + }); +}); + +describe("Poll expiry", () => { + it("rejects vote after poll ends", async () => { + resetStore(); + const res = await createPoll( + makeRequest("POST", { + stream_id: "stream-1", + question: "Quick poll", + options: ["Yes", "No"], + duration_seconds: 60, + }) + ); + const { poll_id } = await res.json(); + + const store = getStore(); + const poll = store.get(poll_id)!; + poll.ends_at = new Date(0).toISOString(); + + const voteRes = await votePoll( + makeVoteRequest({ poll_id, viewer_id: "viewer-1", option_index: 0 }) + ); + expect(voteRes.status).toBe(400); + const body = await voteRes.json(); + expect(body.error).toBe("Poll has ended"); + }); +}); diff --git a/app/api/routes-f/polls/_lib/store.ts b/app/api/routes-f/polls/_lib/store.ts new file mode 100644 index 00000000..deee15cf --- /dev/null +++ b/app/api/routes-f/polls/_lib/store.ts @@ -0,0 +1,25 @@ +export interface PollOption { + text: string; + votes: number; +} + +export interface Poll { + id: string; + stream_id: string; + question: string; + options: PollOption[]; + duration_seconds: number; + created_at: string; + ends_at: string; + voters: Set; +} + +const store = new Map(); + +export function getStore(): Map { + return store; +} + +export function resetStore(): void { + store.clear(); +} diff --git a/app/api/routes-f/polls/route.ts b/app/api/routes-f/polls/route.ts new file mode 100644 index 00000000..6aea31a2 --- /dev/null +++ b/app/api/routes-f/polls/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { getStore, Poll } from "./_lib/store"; + +const createSchema = z.object({ + stream_id: z.string().min(1), + question: z.string().min(1), + options: z.array(z.string().min(1)).min(2).max(6), + duration_seconds: z.number().int().positive(), +}); + +const getSchema = z.object({ + poll_id: z.string().min(1), +}); + +function generateId(): string { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).substring(2, 8); + return `poll_${ts}_${rand}`; +} + +function isExpired(ends_at: string): boolean { + return new Date() >= new Date(ends_at); +} + +export async function POST(req: NextRequest): Promise { + const result = await validateBody(req, createSchema); + if (result instanceof NextResponse) return result; + + const { stream_id, question, options, duration_seconds } = result.data; + + const id = generateId(); + const now = new Date(); + const ends_at = new Date(now.getTime() + duration_seconds * 1000); + + const poll: Poll = { + id, + stream_id, + question, + options: options.map((text) => ({ text, votes: 0 })), + duration_seconds, + created_at: now.toISOString(), + ends_at: ends_at.toISOString(), + voters: new Set(), + }; + + getStore().set(id, poll); + + return NextResponse.json({ poll_id: id, ends_at: poll.ends_at }); +} + +export async function GET(req: NextRequest): Promise { + const result = validateQuery(new URL(req.url).searchParams, getSchema); + if (result instanceof NextResponse) return result; + + const { poll_id } = result.data; + const poll = getStore().get(poll_id); + + if (!poll) { + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); + } + + const total_votes = poll.options.reduce((sum, o) => sum + o.votes, 0); + + return NextResponse.json({ + question: poll.question, + options: poll.options.map((o) => ({ text: o.text, votes: o.votes })), + total_votes, + ended: isExpired(poll.ends_at), + }); +} diff --git a/app/api/routes-f/polls/vote/route.ts b/app/api/routes-f/polls/vote/route.ts new file mode 100644 index 00000000..b34904c4 --- /dev/null +++ b/app/api/routes-f/polls/vote/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { getStore } from "../_lib/store"; + +const voteSchema = z.object({ + poll_id: z.string().min(1), + viewer_id: z.string().min(1), + option_index: z.number().int().nonnegative(), +}); + +export async function POST(req: NextRequest): Promise { + const result = await validateBody(req, voteSchema); + if (result instanceof NextResponse) return result; + + const { poll_id, viewer_id, option_index } = result.data; + const poll = getStore().get(poll_id); + + if (!poll) { + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); + } + + if (isExpired(poll.ends_at)) { + return NextResponse.json({ error: "Poll has ended" }, { status: 400 }); + } + + if (poll.voters.has(viewer_id)) { + return NextResponse.json( + { error: "Viewer has already voted" }, + { status: 409 } + ); + } + + if (option_index < 0 || option_index >= poll.options.length) { + return NextResponse.json({ error: "Invalid option index" }, { status: 400 }); + } + + poll.options[option_index].votes++; + poll.voters.add(viewer_id); + + return NextResponse.json({ message: "Vote recorded" }); +} + +function isExpired(ends_at: string): boolean { + return new Date() >= new Date(ends_at); +} diff --git a/app/api/routes-f/qa/__tests__/route.test.ts b/app/api/routes-f/qa/__tests__/route.test.ts new file mode 100644 index 00000000..2e397ced --- /dev/null +++ b/app/api/routes-f/qa/__tests__/route.test.ts @@ -0,0 +1,181 @@ +import { NextRequest } from "next/server"; +import { POST as submitQuestion, GET as getQueue } from "../route"; +import { POST as answerQuestion } from "../answer/route"; +import { POST as upvoteQuestion } from "../upvote/route"; +import { resetStore } from "../_lib/store"; + +function makeRequest(method: string, body?: unknown, query?: Record): NextRequest { + const url = new URL("http://localhost/api/routes-f/qa"); + if (query) { + for (const [k, v] of Object.entries(query)) { + url.searchParams.set(k, v); + } + } + const init: RequestInit & { headers?: Record } = { method }; + if (body !== undefined) { + init.body = JSON.stringify(body); + init.headers = { "Content-Type": "application/json" }; + } + return new NextRequest(url.toString(), init); +} + +function makeSubRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost/api/routes-f/qa${path}`, { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Submit question", () => { + beforeEach(() => resetStore()); + + it("submits a question and returns question_id and queued_at", async () => { + const res = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "viewer-1", question: "What is this track?" }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("question_id"); + expect(body).toHaveProperty("queued_at"); + expect(typeof body.question_id).toBe("string"); + expect(typeof body.queued_at).toBe("string"); + }); + + it("rejects missing fields", async () => { + const res = await submitQuestion(makeRequest("POST", { stream_id: "stream-1" })); + expect(res.status).toBe(400); + }); +}); + +describe("Get queue", () => { + beforeEach(() => resetStore()); + + it("returns pending questions sorted by queued_at", async () => { + await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "First?" }) + ); + await new Promise((r) => setTimeout(r, 5)); + await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v2", question: "Second?" }) + ); + + const res = await getQueue(makeRequest("GET", undefined, { stream_id: "stream-1" })); + const body = await res.json(); + expect(body.questions).toHaveLength(2); + expect(body.questions[0].question).toBe("First?"); + expect(body.questions[1].question).toBe("Second?"); + }); + + it("does not include answered questions", async () => { + const res1 = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "Q1" }) + ); + await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v2", question: "Q2" }) + ); + const { question_id } = await res1.json(); + + await answerQuestion(makeSubRequest("/answer", { question_id, creator_id: "creator-1" })); + + const qRes = await getQueue(makeRequest("GET", undefined, { stream_id: "stream-1" })); + const body = await qRes.json(); + expect(body.questions).toHaveLength(1); + expect(body.questions[0].question).toBe("Q2"); + }); + + it("returns empty array for stream with no questions", async () => { + const res = await getQueue(makeRequest("GET", undefined, { stream_id: "empty-stream" })); + const body = await res.json(); + expect(body.questions).toEqual([]); + }); +}); + +describe("Answer question", () => { + beforeEach(() => resetStore()); + + it("marks a question as answered", async () => { + const subRes = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "Q?" }) + ); + const { question_id } = await subRes.json(); + + const ansRes = await answerQuestion(makeSubRequest("/answer", { question_id, creator_id: "creator-1" })); + expect(ansRes.status).toBe(200); + expect((await ansRes.json()).message).toBe("Marked as answered"); + }); + + it("rejects answering already answered question", async () => { + const subRes = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "Q?" }) + ); + const { question_id } = await subRes.json(); + + await answerQuestion(makeSubRequest("/answer", { question_id, creator_id: "creator-1" })); + const dupRes = await answerQuestion(makeSubRequest("/answer", { question_id, creator_id: "creator-1" })); + expect(dupRes.status).toBe(409); + }); + + it("rejects answer for nonexistent question", async () => { + const res = await answerQuestion(makeSubRequest("/answer", { question_id: "nonexistent", creator_id: "c1" })); + expect(res.status).toBe(404); + }); +}); + +describe("Upvote question", () => { + beforeEach(() => resetStore()); + + it("increments score on upvote", async () => { + const subRes = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "Q?" }) + ); + const { question_id } = await subRes.json(); + + const upRes = await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v2" })); + expect(upRes.status).toBe(200); + const body = await upRes.json(); + expect(body.score).toBe(1); + }); + + it("rejects duplicate upvote from same viewer", async () => { + const subRes = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "Q?" }) + ); + const { question_id } = await subRes.json(); + + await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v2" })); + const dupRes = await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v2" })); + expect(dupRes.status).toBe(409); + }); + + it("allows multiple viewers to upvote the same question", async () => { + const subRes = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "Q?" }) + ); + const { question_id } = await subRes.json(); + + await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v2" })); + await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v3" })); + const upRes = await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v4" })); + expect((await upRes.json()).score).toBe(3); + }); + + it("renders upvote count in queue response", async () => { + const subRes = await submitQuestion( + makeRequest("POST", { stream_id: "stream-1", viewer_id: "v1", question: "Q?" }) + ); + const { question_id } = await subRes.json(); + + await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v2" })); + await upvoteQuestion(makeSubRequest("/upvote", { question_id, viewer_id: "v3" })); + + const qRes = await getQueue(makeRequest("GET", undefined, { stream_id: "stream-1" })); + const body = await qRes.json(); + expect(body.questions[0].upvotes).toBe(2); + }); + + it("rejects upvote for nonexistent question", async () => { + const res = await upvoteQuestion(makeSubRequest("/upvote", { question_id: "nonexistent", viewer_id: "v1" })); + expect(res.status).toBe(404); + }); +}); diff --git a/app/api/routes-f/qa/_lib/store.ts b/app/api/routes-f/qa/_lib/store.ts new file mode 100644 index 00000000..9742dd4f --- /dev/null +++ b/app/api/routes-f/qa/_lib/store.ts @@ -0,0 +1,22 @@ +export interface Question { + id: string; + stream_id: string; + viewer_id: string; + question: string; + score: number; + answered: boolean; + answered_by?: string; + answered_at?: string; + queued_at: string; + upvoters: Set; +} + +const store = new Map(); + +export function getStore(): Map { + return store; +} + +export function resetStore(): void { + store.clear(); +} diff --git a/app/api/routes-f/qa/answer/route.ts b/app/api/routes-f/qa/answer/route.ts new file mode 100644 index 00000000..161a738d --- /dev/null +++ b/app/api/routes-f/qa/answer/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { getStore } from "../_lib/store"; + +const answerSchema = z.object({ + question_id: z.string().min(1), + creator_id: z.string().min(1), +}); + +export async function POST(req: NextRequest): Promise { + const result = await validateBody(req, answerSchema); + if (result instanceof NextResponse) return result; + + const { question_id, creator_id } = result.data; + const question = getStore().get(question_id); + + if (!question) { + return NextResponse.json({ error: "Question not found" }, { status: 404 }); + } + + if (question.answered) { + return NextResponse.json({ error: "Already answered" }, { status: 409 }); + } + + question.answered = true; + question.answered_by = creator_id; + question.answered_at = new Date().toISOString(); + + return NextResponse.json({ message: "Marked as answered" }); +} diff --git a/app/api/routes-f/qa/route.ts b/app/api/routes-f/qa/route.ts new file mode 100644 index 00000000..f96245f4 --- /dev/null +++ b/app/api/routes-f/qa/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { getStore, Question } from "./_lib/store"; + +const submitSchema = z.object({ + stream_id: z.string().min(1), + viewer_id: z.string().min(1), + question: z.string().min(1), +}); + +const getSchema = z.object({ + stream_id: z.string().min(1), +}); + +function generateId(): string { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).substring(2, 8); + return `q_${ts}_${rand}`; +} + +export async function POST(req: NextRequest): Promise { + const result = await validateBody(req, submitSchema); + if (result instanceof NextResponse) return result; + + const { stream_id, viewer_id, question } = result.data; + + const id = generateId(); + const now = new Date().toISOString(); + + const q: Question = { + id, + stream_id, + viewer_id, + question, + score: 0, + answered: false, + queued_at: now, + upvoters: new Set(), + }; + + getStore().set(id, q); + + return NextResponse.json({ question_id: id, queued_at: now }); +} + +export async function GET(req: NextRequest): Promise { + const result = validateQuery(new URL(req.url).searchParams, getSchema); + if (result instanceof NextResponse) return result; + + const { stream_id } = result.data; + const store = getStore(); + + const pending = Array.from(store.values()) + .filter((q) => q.stream_id === stream_id && !q.answered) + .sort((a, b) => new Date(a.queued_at).getTime() - new Date(b.queued_at).getTime()) + .map(({ upvoters, ...rest }) => ({ + ...rest, + upvotes: upvoters.size, + })); + + return NextResponse.json({ questions: pending }); +} diff --git a/app/api/routes-f/qa/upvote/route.ts b/app/api/routes-f/qa/upvote/route.ts new file mode 100644 index 00000000..eaa44a0e --- /dev/null +++ b/app/api/routes-f/qa/upvote/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { getStore } from "../_lib/store"; + +const upvoteSchema = z.object({ + question_id: z.string().min(1), + viewer_id: z.string().min(1), +}); + +export async function POST(req: NextRequest): Promise { + const result = await validateBody(req, upvoteSchema); + if (result instanceof NextResponse) return result; + + const { question_id, viewer_id } = result.data; + const question = getStore().get(question_id); + + if (!question) { + return NextResponse.json({ error: "Question not found" }, { status: 404 }); + } + + if (question.upvoters.has(viewer_id)) { + return NextResponse.json({ error: "Already upvoted" }, { status: 409 }); + } + + question.upvoters.add(viewer_id); + question.score++; + + return NextResponse.json({ message: "Upvoted", score: question.score }); +} From 6d73494f365e446355e19768728063f390fc4145 Mon Sep 17 00:00:00 2001 From: JSE19 Date: Mon, 29 Jun 2026 14:11:11 +0100 Subject: [PATCH 3/3] Fix Issue 1110 --- .../routes-f/language/__tests__/route.test.ts | 90 +++++++++++++++++++ app/api/routes-f/language/_lib/languages.ts | 26 ++++++ app/api/routes-f/language/route.ts | 67 ++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 app/api/routes-f/language/__tests__/route.test.ts create mode 100644 app/api/routes-f/language/_lib/languages.ts create mode 100644 app/api/routes-f/language/route.ts diff --git a/app/api/routes-f/language/__tests__/route.test.ts b/app/api/routes-f/language/__tests__/route.test.ts new file mode 100644 index 00000000..3399371b --- /dev/null +++ b/app/api/routes-f/language/__tests__/route.test.ts @@ -0,0 +1,90 @@ +import { NextRequest } from "next/server"; +import { GET, PUT } from "../route"; +import { resetStore } from "../_lib/languages"; + +function makeRequest(method: string, body?: unknown, query?: Record): NextRequest { + const url = new URL("http://localhost/api/routes-f/language"); + if (query) { + for (const [k, v] of Object.entries(query)) { + url.searchParams.set(k, v); + } + } + const init: RequestInit & { headers?: Record } = { method }; + if (body !== undefined) { + init.body = JSON.stringify(body); + init.headers = { "Content-Type": "application/json" }; + } + return new NextRequest(url.toString(), init); +} + +describe("Language preference", () => { + beforeEach(() => resetStore()); + + describe("GET", () => { + it("returns empty strings for unknown creator", async () => { + const res = await GET(makeRequest("GET", undefined, { creator_id: "unknown" })); + const body = await res.json(); + expect(body).toEqual({ primary: "", secondary: [] }); + }); + + it("returns stored preferences", async () => { + await PUT(makeRequest("PUT", { creator_id: "c1", primary: "en", secondary: ["es", "fr"] })); + const res = await GET(makeRequest("GET", undefined, { creator_id: "c1" })); + const body = await res.json(); + expect(body).toEqual({ primary: "en", secondary: ["es", "fr"] }); + }); + }); + + describe("PUT", () => { + it("stores valid language codes", async () => { + const res = await PUT(makeRequest("PUT", { creator_id: "c1", primary: "en", secondary: ["es", "fr", "de"] })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ primary: "en", secondary: ["es", "fr", "de"] }); + }); + + it("rejects unsupported primary code", async () => { + const res = await PUT(makeRequest("PUT", { creator_id: "c1", primary: "xyz", secondary: [] })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("xyz"); + }); + + it("rejects unsupported secondary code", async () => { + const res = await PUT(makeRequest("PUT", { creator_id: "c1", primary: "en", secondary: ["xyz"] })); + expect(res.status).toBe(400); + }); + + it("rejects more than 4 secondary codes", async () => { + const res = await PUT( + makeRequest("PUT", { + creator_id: "c1", + primary: "en", + secondary: ["es", "fr", "de", "it", "ja"], + }) + ); + expect(res.status).toBe(400); + }); + + it("rejects duplicate secondary codes", async () => { + const res = await PUT( + makeRequest("PUT", { + creator_id: "c1", + primary: "en", + secondary: ["es", "es"], + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Duplicate"); + }); + + it("overwrites previous preferences", async () => { + await PUT(makeRequest("PUT", { creator_id: "c1", primary: "en", secondary: ["es"] })); + await PUT(makeRequest("PUT", { creator_id: "c1", primary: "fr", secondary: ["de", "it"] })); + const res = await GET(makeRequest("GET", undefined, { creator_id: "c1" })); + const body = await res.json(); + expect(body).toEqual({ primary: "fr", secondary: ["de", "it"] }); + }); + }); +}); diff --git a/app/api/routes-f/language/_lib/languages.ts b/app/api/routes-f/language/_lib/languages.ts new file mode 100644 index 00000000..972c72a1 --- /dev/null +++ b/app/api/routes-f/language/_lib/languages.ts @@ -0,0 +1,26 @@ +export const SUPPORTED_LANGUAGES: string[] = [ + "en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", + "ar", "hi", "bn", "pa", "ta", "te", "mr", "gu", "kn", "ml", + "th", "vi", "tr", "nl", "pl", "sv", "da", "fi", "nb", "cs", + "hu", "ro", "uk", "el", "he", "id", "ms", "tl", "sw", "hr", +]; + +export type CreatorLanguage = { + creator_id: string; + primary: string; + secondary: string[]; +}; + +const store = new Map(); + +export function getStore(): Map { + return store; +} + +export function resetStore(): void { + store.clear(); +} + +export function isValidCode(code: string): boolean { + return SUPPORTED_LANGUAGES.includes(code); +} diff --git a/app/api/routes-f/language/route.ts b/app/api/routes-f/language/route.ts new file mode 100644 index 00000000..f1226f3f --- /dev/null +++ b/app/api/routes-f/language/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { getStore, isValidCode } from "./_lib/languages"; + +const updateSchema = z.object({ + creator_id: z.string().min(1), + primary: z.string().min(1), + secondary: z.array(z.string().min(1)).max(4), +}); + +const getSchema = z.object({ + creator_id: z.string().min(1), +}); + +export async function GET(req: NextRequest): Promise { + const result = validateQuery(new URL(req.url).searchParams, getSchema); + if (result instanceof NextResponse) return result; + + const { creator_id } = result.data; + const entry = getStore().get(creator_id); + + if (!entry) { + return NextResponse.json( + { primary: "", secondary: [] } + ); + } + + return NextResponse.json({ + primary: entry.primary, + secondary: entry.secondary, + }); +} + +export async function PUT(req: NextRequest): Promise { + const result = await validateBody(req, updateSchema); + if (result instanceof NextResponse) return result; + + const { creator_id, primary, secondary } = result.data; + + if (!isValidCode(primary)) { + return NextResponse.json( + { error: `Unsupported language code: "${primary}"` }, + { status: 400 } + ); + } + + for (const code of secondary) { + if (!isValidCode(code)) { + return NextResponse.json( + { error: `Unsupported language code: "${code}"` }, + { status: 400 } + ); + } + } + + if (new Set(secondary).size !== secondary.length) { + return NextResponse.json( + { error: "Duplicate secondary language codes" }, + { status: 400 } + ); + } + + getStore().set(creator_id, { creator_id, primary, secondary }); + + return NextResponse.json({ primary, secondary }); +}