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
101 changes: 101 additions & 0 deletions app/api/routes-f/block-user/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { POST, GET, DELETE, _resetStore } from "../route";
import { POST as checkPOST } from "../check/route";

function makeReq(method: string, url: string, body?: unknown) {
return new NextRequest(`http://localhost${url}`, {
method,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
headers: { "Content-Type": "application/json" },
});
}

const BASE = "/api/routes-f/block-user";

describe("Block User API", () => {
beforeEach(() => _resetStore());

// ── POST block ────────────────────────────────────────────────────────────

it("blocks a user (POST)", async () => {
const res = await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2", reason: "spam" }));
expect(res.status).toBe(201);
const data = await res.json();
expect(data.blocked_at).toBeDefined();
});

it("400 when blocker_id is missing", async () => {
const res = await POST(makeReq("POST", BASE, { blocked_id: "u2" }));
expect(res.status).toBe(400);
});

it("400 when user tries to block themselves", async () => {
const res = await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u1" }));
expect(res.status).toBe(400);
});

// ── GET list ──────────────────────────────────────────────────────────────

it("lists blocked users (GET)", async () => {
await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" }));
await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u3" }));
const res = await GET(makeReq("GET", `${BASE}?blocker_id=u1`));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.blocked).toHaveLength(2);
});

it("400 GET without blocker_id", async () => {
const res = await GET(makeReq("GET", BASE));
expect(res.status).toBe(400);
});

// ── DELETE unblock ────────────────────────────────────────────────────────

it("unblocks a user (DELETE)", async () => {
await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" }));
const res = await DELETE(makeReq("DELETE", `${BASE}?blocker_id=u1&blocked_id=u2`));
expect(res.status).toBe(200);
expect((await res.json()).success).toBe(true);
});

it("404 DELETE on non-existent block", async () => {
const res = await DELETE(makeReq("DELETE", `${BASE}?blocker_id=x&blocked_id=y`));
expect(res.status).toBe(404);
});

// ── POST /check ───────────────────────────────────────────────────────────

it("check returns none when no block exists", async () => {
const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" }));
const data = await res.json();
expect(data.blocked).toBe(false);
expect(data.direction).toBe("none");
});

it("check detects a_blocks_b direction", async () => {
await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" }));
const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" }));
const data = await res.json();
expect(data.blocked).toBe(true);
expect(data.direction).toBe("a_blocks_b");
});

it("check detects b_blocks_a direction", async () => {
await POST(makeReq("POST", BASE, { blocker_id: "u2", blocked_id: "u1" }));
const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" }));
const data = await res.json();
expect(data.direction).toBe("b_blocks_a");
});

it("check detects both directions", async () => {
await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" }));
await POST(makeReq("POST", BASE, { blocker_id: "u2", blocked_id: "u1" }));
const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" }));
const data = await res.json();
expect(data.direction).toBe("both");
});
});
30 changes: 30 additions & 0 deletions app/api/routes-f/block-user/check/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* POST /api/routes-f/block-user/check
* { a, b } -> { blocked: boolean, direction: "a_blocks_b" | "b_blocks_a" | "both" | "none" }
*/
import { NextRequest, NextResponse } from "next/server";
import { blockStore, blockKey } from "../store";

export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const { a, b } = body as { a?: unknown; b?: unknown };
if (!a || typeof a !== "string") return NextResponse.json({ error: "a is required" }, { status: 400 });
if (!b || typeof b !== "string") return NextResponse.json({ error: "b is required" }, { status: 400 });

const aBlocksB = blockStore.has(blockKey(a, b));
const bBlocksA = blockStore.has(blockKey(b, a));

let direction: "a_blocks_b" | "b_blocks_a" | "both" | "none";
if (aBlocksB && bBlocksA) direction = "both";
else if (aBlocksB) direction = "a_blocks_b";
else if (bBlocksA) direction = "b_blocks_a";
else direction = "none";

return NextResponse.json({ blocked: aBlocksB || bBlocksA, direction }, { status: 200 });
}
81 changes: 81 additions & 0 deletions app/api/routes-f/block-user/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Block a user — issue #992
*
* POST { blocker_id, blocked_id, reason? } -> { blocked_at }
* DELETE ?blocker_id&blocked_id -> { success: true }
* GET ?blocker_id -> { blocked: BlockRecord[] }
*/
import { NextRequest, NextResponse } from "next/server";
import { blockStore, blockKey, _resetStore } from "./store";
import { BlockRecord } from "./types";

export { _resetStore };

function bad(msg: string) {
return NextResponse.json({ error: msg }, { status: 400 });
}

// ── POST ─────────────────────────────────────────────────────────────────────

export async function POST(req: NextRequest) {
// Route /check is handled by the check sub-route; here we handle plain block
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return bad("Invalid JSON body");
}

const { blocker_id, blocked_id, reason } = body as {
blocker_id?: unknown;
blocked_id?: unknown;
reason?: unknown;
};

if (!blocker_id || typeof blocker_id !== "string") return bad("blocker_id is required");
if (!blocked_id || typeof blocked_id !== "string") return bad("blocked_id is required");
if (blocker_id === blocked_id) return bad("A user cannot block themselves");

const record: BlockRecord = {
blocker_id,
blocked_id,
blocked_at: new Date().toISOString(),
...(reason && typeof reason === "string" ? { reason } : {}),
};

blockStore.set(blockKey(blocker_id, blocked_id), record);
return NextResponse.json({ blocked_at: record.blocked_at }, { status: 201 });
}

// ── DELETE ───────────────────────────────────────────────────────────────────

export async function DELETE(req: NextRequest) {
const params = new URL(req.url).searchParams;
const blocker_id = params.get("blocker_id");
const blocked_id = params.get("blocked_id");

if (!blocker_id) return bad("blocker_id is required");
if (!blocked_id) return bad("blocked_id is required");

const key = blockKey(blocker_id, blocked_id);
if (!blockStore.has(key)) {
return NextResponse.json({ error: "Block not found" }, { status: 404 });
}

blockStore.delete(key);
return NextResponse.json({ success: true }, { status: 200 });
}

// ── GET ──────────────────────────────────────────────────────────────────────

export async function GET(req: NextRequest) {
const blocker_id = new URL(req.url).searchParams.get("blocker_id");
if (!blocker_id) return bad("blocker_id is required");

const blocked: BlockRecord[] = [];
for (const record of blockStore.values()) {
if (record.blocker_id === blocker_id) blocked.push(record);
}

return NextResponse.json({ blocked }, { status: 200 });
}
12 changes: 12 additions & 0 deletions app/api/routes-f/block-user/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { BlockRecord } from "./types";

// Key: `${blocker_id}:${blocked_id}`
export const blockStore = new Map<string, BlockRecord>();

export function blockKey(blocker_id: string, blocked_id: string) {
return `${blocker_id}:${blocked_id}`;
}

export function _resetStore() {
blockStore.clear();
}
6 changes: 6 additions & 0 deletions app/api/routes-f/block-user/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface BlockRecord {
blocker_id: string;
blocked_id: string;
reason?: string;
blocked_at: string;
}
59 changes: 59 additions & 0 deletions app/api/routes-f/followed-feed/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { GET } from "../route";

function makeGet(viewer_id?: string) {
const url = viewer_id
? `http://localhost/api/routes-f/followed-feed?viewer_id=${viewer_id}`
: "http://localhost/api/routes-f/followed-feed";
return new NextRequest(url, { method: "GET" });
}

describe("GET /api/routes-f/followed-feed", () => {
it("400 when viewer_id is missing", async () => {
const res = await GET(makeGet());
expect(res.status).toBe(400);
});

it("returns empty feed for viewer with no follows", async () => {
const res = await GET(makeGet("unknown-viewer"));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.live).toHaveLength(0);
expect(data.offline_recently).toHaveLength(0);
});

it("returns all-live when followed creators are live (viewer-2 follows alpha+delta, alpha is live)", async () => {
const res = await GET(makeGet("viewer-2"));
expect(res.status).toBe(200);
const data = await res.json();
// creator-alpha is live; creator-delta is offline_recently
expect(data.live.some((s: { creator_id: string }) => s.creator_id === "creator-alpha")).toBe(true);
expect(data.offline_recently.some((s: { creator_id: string }) => s.creator_id === "creator-delta")).toBe(true);
});

it("returns all-offline when followed creator went offline recently (viewer-3 follows gamma)", async () => {
const res = await GET(makeGet("viewer-3"));
const data = await res.json();
expect(data.live).toHaveLength(0);
expect(data.offline_recently).toHaveLength(1);
expect(data.offline_recently[0].creator_id).toBe("creator-gamma");
});

it("returns mixed live and offline_recently (viewer-1 follows all)", async () => {
const res = await GET(makeGet("viewer-1"));
const data = await res.json();
// alpha + beta are live, gamma + delta are offline_recently
expect(data.live.length).toBeGreaterThanOrEqual(1);
expect(data.offline_recently.length).toBeGreaterThanOrEqual(1);
});

it("returns empty for viewer following creator with no stream data (viewer-4)", async () => {
const res = await GET(makeGet("viewer-4"));
const data = await res.json();
expect(data.live).toHaveLength(0);
expect(data.offline_recently).toHaveLength(0);
});
});
41 changes: 41 additions & 0 deletions app/api/routes-f/followed-feed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Followed creators feed — issue #995
*
* GET ?viewer_id -> { live: [...], offline_recently: [...] }
* offline_recently = creators followed by viewer who went offline in last 24h
*/
import { NextRequest, NextResponse } from "next/server";
import { followsData, liveStreams, recentlyOfflineStreams } from "./seed";
import { FollowedFeedResponse } from "./types";

const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;

export async function GET(req: NextRequest): Promise<NextResponse> {
const viewer_id = new URL(req.url).searchParams.get("viewer_id");

if (!viewer_id) {
return NextResponse.json({ error: "viewer_id is required" }, { status: 400 });
}

const followed = followsData[viewer_id] ?? [];

if (followed.length === 0) {
return NextResponse.json<FollowedFeedResponse>(
{ live: [], offline_recently: [] },
{ status: 200 }
);
}

const followedSet = new Set(followed);
const now = Date.now();

const live = liveStreams.filter((s) => followedSet.has(s.creator_id));

const offline_recently = recentlyOfflineStreams.filter((s) => {
if (!followedSet.has(s.creator_id)) return false;
if (!s.ended_at) return false;
return now - new Date(s.ended_at).getTime() <= TWENTY_FOUR_HOURS_MS;
});

return NextResponse.json<FollowedFeedResponse>({ live, offline_recently }, { status: 200 });
}
58 changes: 58 additions & 0 deletions app/api/routes-f/followed-feed/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { CreatorStream } from "./types";

const now = new Date();

// Helpers
function hoursAgo(h: number) {
return new Date(now.getTime() - h * 60 * 60 * 1000).toISOString();
}

// viewer_id -> creator_ids they follow
export const followsData: Record<string, string[]> = {
"viewer-1": ["creator-alpha", "creator-beta", "creator-gamma", "creator-delta"],
"viewer-2": ["creator-alpha", "creator-delta"],
"viewer-3": ["creator-gamma"],
"viewer-4": ["creator-epsilon"], // follows someone with no recent stream
};

// Currently live streams
export const liveStreams: CreatorStream[] = [
{
creator_id: "creator-alpha",
username: "alpha_streams",
title: "Grinding ranked — XLM tipping enabled",
category: "Gaming",
viewer_count: 312,
started_at: hoursAgo(2),
},
{
creator_id: "creator-beta",
username: "beta_live",
title: "Stellar blockchain Q&A",
category: "Technology",
viewer_count: 87,
started_at: hoursAgo(1),
},
];

// Streams that ended within the last 24 hours
export const recentlyOfflineStreams: CreatorStream[] = [
{
creator_id: "creator-gamma",
username: "gamma_cast",
title: "Chill lo-fi session",
category: "Music",
viewer_count: 45,
started_at: hoursAgo(10),
ended_at: hoursAgo(8),
},
{
creator_id: "creator-delta",
username: "delta_play",
title: "Mux stream test",
category: "Just Chatting",
viewer_count: 150,
started_at: hoursAgo(5),
ended_at: hoursAgo(3),
},
];
Loading
Loading