Skip to content
Open
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
107 changes: 107 additions & 0 deletions src/app/api/users/[username]/followers/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";

const mockFrom = vi.fn();

const supabaseClient = {
from: mockFrom,
};

vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(() => Promise.resolve(supabaseClient)),
}));

function makeRequest(searchParams?: Record<string, string>) {
let url = "http://localhost/api/users/testuser/followers";
if (searchParams) {
url += `?${new URLSearchParams(searchParams).toString()}`;
}
return new NextRequest(url, { method: "GET" });
}

const routeParams = { params: Promise.resolve({ username: "testuser" }) };

function profileChain(result: { data: unknown; error: unknown }) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
for (const method of ["select", "eq", "single"]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
chain.single.mockResolvedValue(result);
return chain;
}

function followsChain(result: { data: unknown[]; error: unknown; count: number }) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
for (const method of ["select", "eq", "order", "range"]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
chain.range.mockResolvedValue(result);
return chain;
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("GET /api/users/[username]/followers", () => {
it("returns 404 when user is not found", async () => {
mockFrom.mockReturnValue(profileChain({ data: null, error: { message: "not found" } }));

const res = await GET(makeRequest(), routeParams);

expect(res.status).toBe(404);
});

it("falls back for invalid pagination params", async () => {
const targetProfile = profileChain({ data: { id: "user-123" }, error: null });
const follows = followsChain({ data: [], error: null, count: 0 });

let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
return callCount === 1 ? targetProfile : follows;
});

const res = await GET(
makeRequest({ limit: "0", offset: "-10" }),
routeParams
);
const json = await res.json();

expect(res.status).toBe(200);
expect(follows.range).toHaveBeenCalledWith(0, 19);
expect(json.pagination.limit).toBe(20);
expect(json.pagination.offset).toBe(0);
});

it("caps large limits", async () => {
const targetProfile = profileChain({ data: { id: "user-123" }, error: null });
const follows = followsChain({ data: [], error: null, count: 0 });

let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
return callCount === 1 ? targetProfile : follows;
});

await GET(makeRequest({ limit: "500", offset: "5" }), routeParams);

expect(follows.range).toHaveBeenCalledWith(5, 104);
});

it("keeps valid pagination params", async () => {
const targetProfile = profileChain({ data: { id: "user-123" }, error: null });
const follows = followsChain({ data: [], error: null, count: 0 });

let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
return callCount === 1 ? targetProfile : follows;
});

await GET(makeRequest({ limit: "10", offset: "30" }), routeParams);

expect(follows.range).toHaveBeenCalledWith(30, 39);
});
});
168 changes: 92 additions & 76 deletions src/app/api/users/[username]/followers/route.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

// GET /api/users/[username]/followers — list a user's followers
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ username: string }> }
) {
try {
const { username } = await params;
const supabase = await createClient();
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(Number(searchParams.get("limit")) || 20, 100);
const offset = Number(searchParams.get("offset")) || 0;

// Look up target user
const { data: targetProfile, error: profileError } = await supabase
.from("profiles")
.select("id")
.eq("username", username)
.single();

if (profileError || !targetProfile) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

// Get followers with profile details
const { data: follows, error, count } = await supabase
.from("follows")
.select(
`
id,
created_at,
follower:profiles!follower_id (
id,
username,
full_name,
avatar_url,
bio,
is_available,
account_type,
verified,
verification_type
)
`,
{ count: "exact" }
)
.eq("following_id", targetProfile.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);

if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}

// Flatten: extract the profile from each follow record
const followers = (follows || []).map((f) => ({
...f.follower,
followed_at: f.created_at,
}));

return NextResponse.json({
data: followers,
pagination: {
total: count || 0,
limit,
offset,
},
});
} catch {
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

function parsePositiveInt(value: string | null, fallback: number, max: number): number {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.min(parsed, max);
}

function parseNonNegativeInt(value: string | null, fallback: number): number {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}

// GET /api/users/[username]/followers - list a user's followers
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ username: string }> }
) {
try {
const { username } = await params;
const supabase = await createClient();
const searchParams = request.nextUrl.searchParams;
const limit = parsePositiveInt(searchParams.get("limit"), 20, 100);
const offset = parseNonNegativeInt(searchParams.get("offset"), 0);

// Look up target user
const { data: targetProfile, error: profileError } = await supabase
.from("profiles")
.select("id")
.eq("username", username)
.single();

if (profileError || !targetProfile) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

// Get followers with profile details
const { data: follows, error, count } = await supabase
.from("follows")
.select(
`
id,
created_at,
follower:profiles!follower_id (
id,
username,
full_name,
avatar_url,
bio,
is_available,
account_type,
verified,
verification_type
)
`,
{ count: "exact" }
)
.eq("following_id", targetProfile.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);

if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}

// Flatten: extract the profile from each follow record
const followers = (follows || []).map((f) => ({
...f.follower,
followed_at: f.created_at,
}));

return NextResponse.json({
data: followers,
pagination: {
total: count || 0,
limit,
offset,
},
});
} catch {
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 }
);
}
}
107 changes: 107 additions & 0 deletions src/app/api/users/[username]/following/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";

const mockFrom = vi.fn();

const supabaseClient = {
from: mockFrom,
};

vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(() => Promise.resolve(supabaseClient)),
}));

function makeRequest(searchParams?: Record<string, string>) {
let url = "http://localhost/api/users/testuser/following";
if (searchParams) {
url += `?${new URLSearchParams(searchParams).toString()}`;
}
return new NextRequest(url, { method: "GET" });
}

const routeParams = { params: Promise.resolve({ username: "testuser" }) };

function profileChain(result: { data: unknown; error: unknown }) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
for (const method of ["select", "eq", "single"]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
chain.single.mockResolvedValue(result);
return chain;
}

function followsChain(result: { data: unknown[]; error: unknown; count: number }) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
for (const method of ["select", "eq", "order", "range"]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
chain.range.mockResolvedValue(result);
return chain;
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("GET /api/users/[username]/following", () => {
it("returns 404 when user is not found", async () => {
mockFrom.mockReturnValue(profileChain({ data: null, error: { message: "not found" } }));

const res = await GET(makeRequest(), routeParams);

expect(res.status).toBe(404);
});

it("falls back for invalid pagination params", async () => {
const targetProfile = profileChain({ data: { id: "user-123" }, error: null });
const follows = followsChain({ data: [], error: null, count: 0 });

let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
return callCount === 1 ? targetProfile : follows;
});

const res = await GET(
makeRequest({ limit: "-5", offset: "-10" }),
routeParams
);
const json = await res.json();

expect(res.status).toBe(200);
expect(follows.range).toHaveBeenCalledWith(0, 19);
expect(json.pagination.limit).toBe(20);
expect(json.pagination.offset).toBe(0);
});

it("caps large limits", async () => {
const targetProfile = profileChain({ data: { id: "user-123" }, error: null });
const follows = followsChain({ data: [], error: null, count: 0 });

let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
return callCount === 1 ? targetProfile : follows;
});

await GET(makeRequest({ limit: "500", offset: "5" }), routeParams);

expect(follows.range).toHaveBeenCalledWith(5, 104);
});

it("keeps valid pagination params", async () => {
const targetProfile = profileChain({ data: { id: "user-123" }, error: null });
const follows = followsChain({ data: [], error: null, count: 0 });

let callCount = 0;
mockFrom.mockImplementation(() => {
callCount++;
return callCount === 1 ? targetProfile : follows;
});

await GET(makeRequest({ limit: "10", offset: "30" }), routeParams);

expect(follows.range).toHaveBeenCalledWith(30, 39);
});
});
Loading