From 5aea796fcb44472207cfe1ded196e8d0742607c1 Mon Sep 17 00:00:00 2001 From: samlogy1 Date: Fri, 26 Jun 2026 15:03:43 +0000 Subject: [PATCH] fix: enforce profileVisibility and PII privacy in user profile APIs - Gate GET /api/users/[id] by profileVisibility and requester relationship (self / follower / public) - Strip email and walletAddress unless requester is the user themselves or the corresponding showEmail/showWalletAddress flag is true - Add privacy gating to all profile sub-resource endpoints (posts, entries, stats, activity, followers, following) - Strip walletAddress from follower/following user lists unless permitted - Update existing test to expect anonymized PII for anonymous requests - Add tests for third-party privacy enforcement --- app/app/api/users/[id]/activity/route.ts | 107 +++++++-------- app/app/api/users/[id]/entries/route.ts | 84 +++++++----- app/app/api/users/[id]/followers/route.ts | 48 ++++++- app/app/api/users/[id]/following/route.ts | 48 ++++++- app/app/api/users/[id]/posts/route.ts | 135 +++++++++++-------- app/app/api/users/[id]/route.ts | 154 ++++++++++++---------- app/app/api/users/[id]/stats/route.ts | 97 ++++++++------ app/tests/api/users.test.ts | 71 +++++++++- 8 files changed, 476 insertions(+), 268 deletions(-) diff --git a/app/app/api/users/[id]/activity/route.ts b/app/app/api/users/[id]/activity/route.ts index 6a135a0..668f150 100644 --- a/app/app/api/users/[id]/activity/route.ts +++ b/app/app/api/users/[id]/activity/route.ts @@ -1,22 +1,18 @@ -'use server'; - -import { apiError, apiSuccess } from '@/lib/api-response'; - -import { NextRequest } from 'next/server'; -import { prisma } from '@/lib/prisma'; +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { getCurrentUser } from "@/lib/auth"; type ActivityItem = { id: string; type: 'posted' | 'entered' | 'won' | 'liked'; - timestamp: string; // ISO + timestamp: string; subject: { id: string | number; title?: string | null; slug?: string | null; - // for entries we include entryId entryId?: string | number; }; - // optional raw meta for consumers that need more details (e.g. thumbnail) meta?: Record; }; @@ -32,109 +28,102 @@ export async function GET ( return apiError('Missing user id', 400); } + const currentUser = await getCurrentUser(request); + + const user = await prisma.user.findUnique({ + where: { id }, + select: { id: true, profileVisibility: true }, + }); + + if (!user) { + return apiError("User not found", 404); + } + + const isFollowing = currentUser + ? await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: id, + }, + }, + }) + : null; + + const canAccessProfile = + currentUser?.id === user.id || + user.profileVisibility === 'public' || + (user.profileVisibility === 'followers' && isFollowing); + + if (!canAccessProfile) { + return apiError("Profile not accessible", 403); + } + const url = new URL(request.url); const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10)); let limit = parseInt(url.searchParams.get('limit') || '20', 10); if (Number.isNaN(limit) || limit <= 0) limit = 20; limit = Math.min(limit, MAX_LIMIT); - // Fetch activity in parallel (select only required fields) const [posted, entered, won, liked] = await Promise.all([ prisma.post.findMany({ where: { userId: id }, - select: { - id: true, - title: true, - slug: true, - createdAt: true, - }, + select: { id: true, title: true, slug: true, createdAt: true }, }), prisma.entry.findMany({ where: { userId: id }, select: { - id: true, - createdAt: true, - postId: true, - post: { - select: { id: true, title: true, slug: true }, - }, + id: true, createdAt: true, postId: true, + post: { select: { id: true, title: true, slug: true } }, }, }), prisma.entry.findMany({ where: { userId: id, isWinner: true }, select: { - id: true, - createdAt: true, - postId: true, - post: { - select: { id: true, title: true, slug: true }, - }, + id: true, createdAt: true, postId: true, + post: { select: { id: true, title: true, slug: true } }, }, }), prisma.interaction.findMany({ where: { userId: id, type: 'like' }, select: { - id: true, - createdAt: true, - postId: true, - post: { - select: { id: true, title: true, slug: true }, - }, + id: true, createdAt: true, postId: true, + post: { select: { id: true, title: true, slug: true } }, }, }), ]); - // Combine and normalize const activities: ActivityItem[] = [ ...posted.map((p) => ({ id: `post-${p.id}`, type: 'posted' as const, timestamp: p.createdAt.toISOString(), - subject: { - id: p.id, - title: p.title ?? null, - slug: p.slug ?? null, - }, + subject: { id: p.id, title: p.title ?? null, slug: p.slug ?? null }, meta: {}, })), ...entered.map((e) => ({ id: `entry-${e.id}`, type: 'entered' as const, timestamp: e.createdAt.toISOString(), - subject: { - id: e.postId, - title: e.post?.title ?? null, - slug: e.post?.slug ?? null, - entryId: e.id, - }, + subject: { id: e.postId, title: e.post?.title ?? null, slug: e.post?.slug ?? null, entryId: e.id }, meta: {}, })), ...won.map((e) => ({ id: `won-${e.id}`, type: 'won' as const, timestamp: e.createdAt.toISOString(), - subject: { - id: e.postId, - title: e.post?.title ?? null, - slug: e.post?.slug ?? null, - entryId: e.id, - }, + subject: { id: e.postId, title: e.post?.title ?? null, slug: e.post?.slug ?? null, entryId: e.id }, meta: {}, })), ...liked.map((i) => ({ id: `like-${i.id}`, type: 'liked' as const, timestamp: i.createdAt.toISOString(), - subject: { - id: i.postId, - title: i.post?.title ?? null, - slug: i.post?.slug ?? null, - }, + subject: { id: i.postId, title: i.post?.title ?? null, slug: i.post?.slug ?? null }, meta: {}, })), ]; - // Sort newest first activities.sort((a, b) => { const ta = new Date(a.timestamp).getTime(); const tb = new Date(b.timestamp).getTime(); @@ -157,4 +146,4 @@ export async function GET ( console.error('GET /api/users/[id]/activity error', error); return apiError('Failed to fetch activity', 500); } -} \ No newline at end of file +} diff --git a/app/app/api/users/[id]/entries/route.ts b/app/app/api/users/[id]/entries/route.ts index 2a793d4..0c8db12 100644 --- a/app/app/api/users/[id]/entries/route.ts +++ b/app/app/api/users/[id]/entries/route.ts @@ -1,6 +1,7 @@ -import { NextRequest } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { apiSuccess, apiError } from '@/lib/api-response'; +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { getCurrentUser } from "@/lib/auth"; export async function GET( request: NextRequest, @@ -9,42 +10,59 @@ export async function GET( try { const { id } = await params; - // Try to fetch from database first - try { - // Verify user exists - const userExists = await prisma.user.findUnique({ - where: { id }, - select: { id: true }, - }); - - if (!userExists) { - return apiError('User not found', 404); - } - - // Fetch user's entries - const userEntries = await prisma.entry.findMany({ - where: { userId: id }, - orderBy: { createdAt: 'desc' }, - include: { - post: { - select: { - id: true, - title: true, - type: true, - status: true, - createdAt: true, + const currentUser = await getCurrentUser(request); + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + profileVisibility: true, + }, + }); + + if (!user) { + return apiError("User not found", 404); + } + + const isFollowing = currentUser + ? await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: id, }, }, - }, - }); + }) + : null; - return apiSuccess(userEntries); + const canAccessProfile = + currentUser?.id === user.id || + user.profileVisibility === 'public' || + (user.profileVisibility === 'followers' && isFollowing); - } catch (dbError) { - return apiError('Failed to fetch user entries', 500); + if (!canAccessProfile) { + return apiError("Profile not accessible", 403); } + // Fetch user's entries + const userEntries = await prisma.entry.findMany({ + where: { userId: id }, + orderBy: { createdAt: "desc" }, + include: { + post: { + select: { + id: true, + title: true, + type: true, + status: true, + createdAt: true, + }, + }, + }, + }); + + return apiSuccess(userEntries); } catch (error) { - return apiError('Failed to fetch user entries', 500); + return apiError("Failed to fetch user entries", 500); } } \ No newline at end of file diff --git a/app/app/api/users/[id]/followers/route.ts b/app/app/api/users/[id]/followers/route.ts index 6ca4d0f..9c00082 100644 --- a/app/app/api/users/[id]/followers/route.ts +++ b/app/app/api/users/[id]/followers/route.ts @@ -1,6 +1,7 @@ -import { NextRequest } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { apiSuccess, apiError } from '@/lib/api-response'; +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { getCurrentUser } from "@/lib/auth"; export async function GET( request: NextRequest, @@ -12,6 +13,45 @@ export async function GET( const limit = parseInt(searchParams.get('limit') || '20'); const skip = parseInt(searchParams.get('skip') || '0'); + const currentUser = await getCurrentUser(request); + + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId }, + select: { + id: true, + profileVisibility: true, + showEmail: true, + showWalletAddress: true, + }, + }); + + if (!targetUser) { + return apiError("User not found", 404); + } + + const isFollowing = currentUser + ? await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: targetUserId, + }, + }, + }) + : null; + + const canAccessFollowers = + currentUser?.id === targetUser.id || + (targetUser.profileVisibility === 'public') || + (targetUser.profileVisibility === 'followers' && isFollowing); + + if (!canAccessFollowers) { + return apiError("Cannot access followers list", 403); + } + + // Determine if we should include walletAddress based on user relationship and privacy settings + const shouldIncludeWalletAddress = currentUser?.id === targetUserId || targetUser.showWalletAddress; + // Followers are users who follow the target user // i.e., followingId = targetUserId // we want to return the `user` relation (the follower) @@ -25,8 +65,8 @@ export async function GET( name: true, avatarUrl: true, xp: true, - walletAddress: true, bio: true, + ...(shouldIncludeWalletAddress && { walletAddress: true }), } } }, diff --git a/app/app/api/users/[id]/following/route.ts b/app/app/api/users/[id]/following/route.ts index 8e60fe2..d695ca6 100644 --- a/app/app/api/users/[id]/following/route.ts +++ b/app/app/api/users/[id]/following/route.ts @@ -1,6 +1,7 @@ -import { NextRequest } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { apiSuccess, apiError } from '@/lib/api-response'; +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { getCurrentUser } from "@/lib/auth"; export async function GET( request: NextRequest, @@ -12,6 +13,45 @@ export async function GET( const limit = parseInt(searchParams.get('limit') || '20'); const skip = parseInt(searchParams.get('skip') || '0'); + const currentUser = await getCurrentUser(request); + + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId }, + select: { + id: true, + profileVisibility: true, + showEmail: true, + showWalletAddress: true, + }, + }); + + if (!targetUser) { + return apiError("User not found", 404); + } + + const isFollowing = currentUser + ? await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: targetUserId, + }, + }, + }) + : null; + + const canAccessFollowing = + currentUser?.id === targetUser.id || + (targetUser.profileVisibility === 'public') || + (targetUser.profileVisibility === 'followers' && isFollowing); + + if (!canAccessFollowing) { + return apiError("Cannot access following list", 403); + } + + // Determine if we should include walletAddress based on user relationship and privacy settings + const shouldIncludeWalletAddress = currentUser?.id === targetUserId || targetUser.showWalletAddress; + // Following are users the target user follows // i.e., userId = targetUserId, followingId != null // we want to return the `following` relation (the person being followed) @@ -25,8 +65,8 @@ export async function GET( name: true, avatarUrl: true, xp: true, - walletAddress: true, bio: true, + ...(shouldIncludeWalletAddress && { walletAddress: true }), } } }, diff --git a/app/app/api/users/[id]/posts/route.ts b/app/app/api/users/[id]/posts/route.ts index 5b9779a..1931acf 100644 --- a/app/app/api/users/[id]/posts/route.ts +++ b/app/app/api/users/[id]/posts/route.ts @@ -1,77 +1,96 @@ -import { apiError, apiSuccess } from '@/lib/api-response'; +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { getCurrentUser } from "@/lib/auth"; -import { NextRequest } from 'next/server'; -import { prisma } from '@/lib/prisma'; - -export async function GET ( +export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { const { id } = await params; - // Try to fetch from database first - try { - // Verify user exists - const userExists = await prisma.user.findUnique({ - where: { id }, - select: { id: true }, - }); + const currentUser = await getCurrentUser(request); - if (!userExists) { - return apiError('User not found', 404); - } + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + profileVisibility: true, + showEmail: true, + showWalletAddress: true, + }, + }); - // Fetch user's posts (shape aligned with feed/detail for PostCard) - const userPosts = await prisma.post.findMany({ - where: { userId: id }, - orderBy: { createdAt: 'desc' }, - include: { - user: { - select: { - id: true, - name: true, - avatarUrl: true, - username: true, - rank: { - select: { - id: true, - level: true, - title: true, - color: true, - minPoints: true, - maxPoints: true, - }, - }, + if (!user) { + return apiError("User not found", 404); + } + + const isFollowing = currentUser + ? await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: id, }, }, - media: true, - entries: { - select: { - id: true, - userId: true, - content: true, - isWinner: true, - createdAt: true, + }) + : null; + + const canAccessProfile = + currentUser?.id === user.id || + user.profileVisibility === 'public' || + (user.profileVisibility === 'followers' && isFollowing); + + if (!canAccessProfile) { + return apiError("Profile not accessible", 403); + } + + // Fetch user's posts + const userPosts = await prisma.post.findMany({ + where: { userId: id }, + orderBy: { createdAt: "desc" }, + include: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + username: true, + rank: { + select: { + id: true, + level: true, + title: true, + color: true, + minPoints: true, + maxPoints: true, + }, }, }, - _count: { - select: { - entries: true, - interactions: true, - comments: true, - }, + }, + media: true, + entries: { + select: { + id: true, + userId: true, + content: true, + isWinner: true, + createdAt: true, }, }, - }); - - return apiSuccess(userPosts); - - } catch (dbError) { - return apiError('Failed to fetch user posts', 500); - } + _count: { + select: { + entries: true, + interactions: true, + comments: true, + }, + }, + }, + }); + return apiSuccess(userPosts); } catch (error) { - return apiError('Failed to fetch user posts', 500); + return apiError("Failed to fetch user posts", 500); } } \ No newline at end of file diff --git a/app/app/api/users/[id]/route.ts b/app/app/api/users/[id]/route.ts index d7a372d..85f8d95 100644 --- a/app/app/api/users/[id]/route.ts +++ b/app/app/api/users/[id]/route.ts @@ -7,80 +7,94 @@ import { readJsonBody } from "@/lib/parse-json-body"; const PROFILE_VISIBILITIES = new Set(["public", "followers", "private"]); export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - try { - const { id } = await params; - - try { - const currentUser = await getCurrentUser(request); - - const user = await prisma.user.findUnique({ - where: { id }, - select: { - id: true, - walletAddress: true, - name: true, - username: true, - bio: true, - email: true, - avatarUrl: true, - xp: true, - walletBalance: true, - profileVisibility: true, - showEmail: true, - showWalletAddress: true, - emailNotifications: true, - pushNotifications: true, - marketingNotifications: true, - createdAt: true, - updatedAt: true, - _count: { - select: { - followers: true, - followings: true, - }, - }, - badges: { - include: { badge: true }, - }, - }, - }); + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) { + try { + const { id } = await params; + + const currentUser = await getCurrentUser(request); + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + walletAddress: true, + name: true, + username: true, + bio: true, + email: true, + avatarUrl: true, + xp: true, + walletBalance: true, + profileVisibility: true, + showEmail: true, + showWalletAddress: true, + emailNotifications: true, + pushNotifications: true, + marketingNotifications: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + followers: true, + followings: true, + }, + }, + badges: { + include: { badge: true }, + }, + }, + }); + + if (!user) { + return apiError("User not found", 404); + } + + let isFollowing = false; + if (currentUser) { + const follow = await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: id, + }, + }, + }); + isFollowing = !!follow; + } + +const canAccessProfile = + currentUser?.id === user.id || + user.profileVisibility === 'public' || + (user.profileVisibility === 'followers' && isFollowing); + + if (!canAccessProfile) { + return apiError("Profile not accessible", 403); + } - if (user) { - let isFollowing = false; - if (currentUser) { - const follow = await prisma.follow.findUnique({ - where: { - userId_followingId: { - userId: currentUser.id, - followingId: id, - }, - }, - }); - isFollowing = !!follow; + const normalizedUser = { + ...user, + badges: (user.badges || []).map((userBadge: any) => ({ + ...userBadge.badge, + awardedAt: userBadge.awardedAt, + })), + }; + + if (currentUser?.id !== user.id) { + if (!normalizedUser.showEmail) { + delete normalizedUser.email; + } + if (!normalizedUser.showWalletAddress) { + delete normalizedUser.walletAddress; } - - const normalizedUser = { - ...user, - badges: (user.badges || []).map((userBadge: any) => ({ - ...userBadge.badge, - awardedAt: userBadge.awardedAt, - })), - }; - - return apiSuccess({ ...normalizedUser, isFollowing }); } - } catch (dbError) { - // Database error - fallback already handled above - } - return apiError("User not found", 404); - } catch (error) { - return apiError("Failed to fetch user", 500); - } -} + return apiSuccess({ ...normalizedUser, isFollowing }); + } catch (error) { + return apiError("Failed to fetch user", 500); + } + } export async function PATCH( request: NextRequest, diff --git a/app/app/api/users/[id]/stats/route.ts b/app/app/api/users/[id]/stats/route.ts index 308c785..a04475e 100644 --- a/app/app/api/users/[id]/stats/route.ts +++ b/app/app/api/users/[id]/stats/route.ts @@ -1,7 +1,7 @@ -import { apiError, apiSuccess } from '@/lib/api-response'; - -import { NextRequest } from 'next/server'; -import { prisma } from '@/lib/prisma'; +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { getCurrentUser } from "@/lib/auth"; export async function GET ( request: NextRequest, @@ -10,45 +10,64 @@ export async function GET ( try { const { id } = await params; - // Try to fetch from database first - try { - // Fetch user to verify existence - const user = await prisma.user.findUnique({ - where: { id }, - select: { xp: true }, - }); - - if (!user) { - return apiError('User not found', 404); - } - - // Calculate stats using database queries - const [totalPosts, totalEntries, wins] = await Promise.all([ - prisma.post.count({ - where: { userId: id }, - }), - prisma.entry.count({ - where: { userId: id }, - }), - prisma.entry.count({ + const currentUser = await getCurrentUser(request); + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + profileVisibility: true, + xp: true, + }, + }); + + if (!user) { + return apiError("User not found", 404); + } + + const isFollowing = currentUser + ? await prisma.follow.findUnique({ where: { - userId: id, - isWinner: true + userId_followingId: { + userId: currentUser.id, + followingId: id, + }, }, - }), - ]); - - return apiSuccess({ - total_posts: totalPosts, - total_entries: totalEntries, - wins, - xp: user.xp || 0, - }); - } catch (dbError) { - return apiError('Failed to fetch stats', 500); + }) + : null; + + const canAccessProfile = + currentUser?.id === user.id || + user.profileVisibility === 'public' || + (user.profileVisibility === 'followers' && isFollowing); + + if (!canAccessProfile) { + return apiError("Profile not accessible", 403); } + // Calculate stats using database queries + const [totalPosts, totalEntries, wins] = await Promise.all([ + prisma.post.count({ + where: { userId: id }, + }), + prisma.entry.count({ + where: { userId: id }, + }), + prisma.entry.count({ + where: { + userId: id, + isWinner: true + }, + }), + ]); + + return apiSuccess({ + total_posts: totalPosts, + total_entries: totalEntries, + wins, + xp: user.xp || 0, + }); } catch (error) { - return apiError('Failed to fetch stats', 500); + return apiError("Failed to fetch stats", 500); } } \ No newline at end of file diff --git a/app/tests/api/users.test.ts b/app/tests/api/users.test.ts index 632fcc2..3a4ec70 100644 --- a/app/tests/api/users.test.ts +++ b/app/tests/api/users.test.ts @@ -87,7 +87,9 @@ describe('Users API', () => { expect(status).toBe(200); expect(data.success).toBe(true); expect(data.data.id).toBe('user_1'); - expect(data.data.walletAddress).toBe('GUSER1WALLET'); + // Anonymous request should not see sensitive fields with default privacy + expect(data.data.email).not.toBeDefined(); + expect(data.data.walletAddress).not.toBeDefined(); }); it('should return 404 for a non-existent user', async () => { @@ -145,6 +147,73 @@ describe('Users API', () => { // No follow lookup should be performed for anonymous visitors expect(mockPrisma.follow.findUnique).not.toHaveBeenCalled(); }); + + it('should strip email when requester is third party and showEmail=false', async () => { + const userWithDefaultPrivacy = { + ...user2, + profileVisibility: 'public', + showEmail: false, + showWalletAddress: false, + }; + + (getCurrentUser as any).mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(userWithDefaultPrivacy); + + const request = createMockRequest('http://localhost:3000/api/users/user_2'); + const response = await GET(request, { params: Promise.resolve({ id: 'user_2' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.email).not.toBeDefined(); + expect(data.data.id).toBe('user_2'); + expect(data.data.walletAddress).not.toBeDefined(); + }); + + it('should strip email when requester is third party and profileVisibility=followers without following', async () => { + const userWithDefaultPrivacy = { + ...user2, + profileVisibility: 'followers', + showEmail: false, + showWalletAddress: false, + }; + + (getCurrentUser as any).mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(userWithDefaultPrivacy); + mockPrisma.follow.findUnique.mockResolvedValue(null); + + const request = createMockRequest('http://localhost:3000/api/users/user_2'); + const response = await GET(request, { params: Promise.resolve({ id: 'user_2' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(403); + expect(data.success).toBe(false); + expect(data.error).toBe('Profile not accessible'); + }); + + it('should allow third party to see email when profileVisibility=followers and user is following', async () => { + const userWithDefaultPrivacy = { + ...user2, + profileVisibility: 'followers', + showEmail: true, + showWalletAddress: true, + }; + + (getCurrentUser as any).mockResolvedValue(user1); + mockPrisma.user.findUnique.mockResolvedValue(userWithDefaultPrivacy); + mockPrisma.follow.findUnique.mockResolvedValue({ + userId: user1.id, + followingId: user2.id, + }); + + const request = createMockRequest('http://localhost:3000/api/users/user_2'); + const response = await GET(request, { params: Promise.resolve({ id: 'user_2' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.email).toBe('bob@example.com'); + expect(data.data.walletAddress).toBe('GUSER2WALLET'); + expect(data.data.isFollowing).toBe(true); + }); }); // ────────────────────────────────────────────────────────────