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
90 changes: 90 additions & 0 deletions app/api/routes-f/language/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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<string, string> } = { 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"] });
});
});
});
26 changes: 26 additions & 0 deletions app/api/routes-f/language/_lib/languages.ts
Original file line number Diff line number Diff line change
@@ -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<string, CreatorLanguage>();

export function getStore(): Map<string, CreatorLanguage> {
return store;
}

export function resetStore(): void {
store.clear();
}

export function isValidCode(code: string): boolean {
return SUPPORTED_LANGUAGES.includes(code);
}
67 changes: 67 additions & 0 deletions app/api/routes-f/language/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<NextResponse> {
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 });
}
118 changes: 118 additions & 0 deletions app/api/routes-f/now-playing/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { NextRequest } from "next/server";
import { POST, GET, DELETE } from "../route";

function makeRequest(method: string, body?: unknown, query?: Record<string, string>): 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<string, string> } = { 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);
});
});
85 changes: 85 additions & 0 deletions app/api/routes-f/now-playing/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, StreamState>();

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<NextResponse> {
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<NextResponse> {
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<NextResponse> {
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" });
}
Loading
Loading