diff --git a/apps/backend/src/user/tests/user.controller.spec.ts b/apps/backend/src/user/tests/user.controller.spec.ts index 9ffdc139..76b2e11a 100644 --- a/apps/backend/src/user/tests/user.controller.spec.ts +++ b/apps/backend/src/user/tests/user.controller.spec.ts @@ -20,6 +20,29 @@ const allUsers = [ { id: 3, createdAt: mockDate, email: 'cindy@example.com', name: 'Cindy' }, ]; +const mockPublicUser = { + id: 1, + name: 'alice', + displayName: 'Alice', + avatarKey: null, + createdAt: mockDate.toISOString(), + bio: null, + city: null, + country: null, + isProfilePublic: true, +}; + +const mockPublicEvents = [ + { + id: 1, + title: 'Test Event', + slug: 'test-event', + startAt: mockDate.toISOString(), + imageKey: null, + location: null, + }, +]; + // Create a mock user service const mockUserService = { userGet: jest.fn().mockResolvedValue(allUsers), @@ -28,6 +51,8 @@ const mockUserService = { createdAt: mockDate, ...data, })), + userGetPublicByName: jest.fn().mockResolvedValue(mockPublicUser), + userGetPublicEventsByName: jest.fn().mockResolvedValue(mockPublicEvents), }; // Create the Nest testing module and replace the real UserService with a mock. @@ -65,6 +90,48 @@ describe('UserController', () => { }); }); + // Tests for getUserByUsername controller path. + describe('Get public user by username', () => { + it('should call userService.userGetPublicByName() with the username param', async () => { + const spy = jest.spyOn(userService, 'userGetPublicByName'); + await userController.getUserByUsername('alice'); + expect(spy).toHaveBeenCalledWith('alice', undefined); + }); + + it('should return the user from service', async () => { + const result = await userController.getUserByUsername('alice'); + expect(result).toEqual(mockPublicUser); + }); + + it('should throw NotFoundException when user does not exist', async () => { + jest.spyOn(userService, 'userGetPublicByName').mockResolvedValueOnce(null); + await expect(userController.getUserByUsername('nobody')).rejects.toThrow('User not found'); + }); + }); + + // Tests for getUserEventsByUsername controller path. + describe('Get public user events by username', () => { + const defaultQuery = { limit: 12, cursor: undefined }; + + it('should call userService.userGetPublicEventsByName() with the username and query params', async () => { + const spy = jest.spyOn(userService, 'userGetPublicEventsByName'); + await userController.getUserEventsByUsername('alice', defaultQuery); + expect(spy).toHaveBeenCalledWith('alice', undefined, 12, undefined); + }); + + it('should return events from service', async () => { + const result = await userController.getUserEventsByUsername('alice', defaultQuery); + expect(result).toEqual(mockPublicEvents); + }); + + it('should throw NotFoundException when user does not exist', async () => { + jest.spyOn(userService, 'userGetPublicEventsByName').mockResolvedValueOnce(null); + await expect(userController.getUserEventsByUsername('nobody', defaultQuery)).rejects.toThrow( + 'User not found' + ); + }); + }); + // Tests for the userPost controller path. // Test 1: Verifies that the controller delegates the call to userService.userPost. Also that it is called with correct argument. // Test 2: Verifies that the controller returns the value received from userService.userPost without altering it. diff --git a/apps/backend/src/user/user.controller.ts b/apps/backend/src/user/user.controller.ts index d39beb09..4b193faf 100644 --- a/apps/backend/src/user/user.controller.ts +++ b/apps/backend/src/user/user.controller.ts @@ -33,6 +33,7 @@ import { ResMyInvitedEventsDto, ResUserDeleteSchema, ResUserPatchSchema, + ReqUserPublicEventsDto, } from '@/user/user.schema'; import { UserService } from '@/user/user.service'; import { ResUserGetAllSchema, ResUserAdminGetAllSchema } from '@grit/schema'; @@ -199,28 +200,32 @@ export class UserController { return this.userService.userDelete(param.id, user); } - @Get(':id') + @Get(':username') @UseGuards(JwtAuthOptionalGuard) - async getUserById(@Param('id') id: string, @GetUser('id') requestingUserId?: number) { - const userId = parseInt(id, 10); - if (isNaN(userId)) { - throw new NotFoundException('User not found'); - } - const user = await this.userService.userGetPublic(userId, requestingUserId); + async getUserByUsername( + @Param('username') username: string, + @GetUser('id') requestingUserId?: number + ) { + const user = await this.userService.userGetPublicByName(username, requestingUserId); if (!user) { throw new NotFoundException('User not found'); } return user; } - @Get(':id/events') + @Get(':username/events') @UseGuards(JwtAuthOptionalGuard) - async getUserEvents(@Param('id') id: string, @GetUser('id') requestingUserId?: number) { - const userId = parseInt(id, 10); - if (isNaN(userId)) { - throw new NotFoundException('User not found'); - } - const events = await this.userService.userGetPublicEvents(userId, requestingUserId); + async getUserEventsByUsername( + @Param('username') username: string, + @Query() query: ReqUserPublicEventsDto, + @GetUser('id') requestingUserId?: number + ) { + const events = await this.userService.userGetPublicEventsByName( + username, + requestingUserId, + query.limit, + query.cursor + ); if (events === null) { throw new NotFoundException('User not found'); } diff --git a/apps/backend/src/user/user.schema.ts b/apps/backend/src/user/user.schema.ts index e881cf08..f7761cf3 100644 --- a/apps/backend/src/user/user.schema.ts +++ b/apps/backend/src/user/user.schema.ts @@ -27,6 +27,12 @@ export const ReqUserGetAllSchema = z.strictObject({ search: z.string().min(1).optional(), }); +// Query params for paginated public user events +export const ReqUserPublicEventsSchema = z.strictObject({ + limit: z.coerce.number().int().positive().max(100).default(12), + cursor: z.string().optional(), +}); + // Post a new user draft export const ReqUserPostSchema = z.object({ name: z.string(), @@ -106,3 +112,4 @@ export class ReqUserDeleteAvatarDto extends createZodDto(ReqUserDeleteAvatarSche export class ReqUserDeleteByIdDto extends createZodDto(ReqUserDeleteByIdSchema) {} export class ReqUserPatchDto extends createZodDto(ReqUserPatchSchema) {} export class ReqUserPatchByIdDto extends createZodDto(ReqUserPatchByIdSchema) {} +export class ReqUserPublicEventsDto extends createZodDto(ReqUserPublicEventsSchema) {} diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts index 3419927d..71b46676 100644 --- a/apps/backend/src/user/user.service.ts +++ b/apps/backend/src/user/user.service.ts @@ -747,6 +747,31 @@ export class UserService { }; } + async userGetPublicByName(name: string, requestingUserId?: number) { + const normalized = name.toLowerCase(); + const user = await this.prisma.user.findUnique({ + where: { name: normalized }, + select: { id: true }, + }); + if (!user) return null; + return this.userGetPublic(user.id, requestingUserId); + } + + async userGetPublicEventsByName( + name: string, + requestingUserId?: number, + limit = 12, + cursor?: string + ) { + const normalized = name.toLowerCase(); + const user = await this.prisma.user.findUnique({ + where: { name: normalized }, + select: { id: true }, + }); + if (!user) return null; + return this.userGetPublicEvents(user.id, requestingUserId, limit, cursor); + } + async userGetPublic(id: number, requestingUserId?: number) { const user = await this.prisma.user.findUnique({ where: { id }, @@ -816,7 +841,12 @@ export class UserService { }; } - async userGetPublicEvents(userId: number, requestingUserId?: number) { + async userGetPublicEvents( + userId: number, + requestingUserId?: number, + limit = 12, + cursor?: string + ) { // First check if the user's profile is accessible const user = await this.prisma.user.findUnique({ where: { id: userId }, @@ -848,27 +878,55 @@ export class UserService { } } + // Decode cursor: encodes startAt (ISO) + id, e.g. "2026-01-01T00:00:00.000Z|42" + let cursorFilter: Prisma.EventWhereInput = {}; + if (cursor) { + try { + const decoded = Buffer.from(cursor, 'base64').toString('utf-8'); + const [startAtStr, idStr] = decoded.split('|'); + const startAt = new Date(startAtStr); + const id = parseInt(idStr, 10); + if (isNaN(startAt.getTime()) || isNaN(id)) throw new Error('bad cursor'); + // ordered by startAt desc, id desc — so "after cursor" means earlier startAt, or same startAt with smaller id + cursorFilter = { + OR: [{ startAt: { lt: startAt } }, { startAt, id: { lt: id } }], + }; + } catch { + throw new BadRequestException('Invalid cursor provided'); + } + } + const events = await this.prisma.event.findMany({ where: { authorId: userId, isPublished: true, isPublic: true, + ...cursorFilter, }, include: { location: true, }, - orderBy: { - startAt: 'desc', - }, + orderBy: [{ startAt: 'desc' }, { id: 'desc' }], + take: limit + 1, }); - return events.map((event) => ({ - id: event.id, - title: event.title, - slug: event.slug, - startAt: event.startAt.toISOString(), - imageKey: event.imageKey, - location: event.location, - })); + const hasMore = events.length > limit; + const sliced = hasMore ? events.slice(0, limit) : events; + const last = sliced[sliced.length - 1]; + const nextCursor = hasMore + ? Buffer.from(`${last.startAt.toISOString()}|${String(last.id)}`).toString('base64') + : null; + + return { + data: sliced.map((event) => ({ + id: event.id, + title: event.title, + slug: event.slug, + startAt: event.startAt.toISOString(), + imageKey: event.imageKey, + location: event.location, + })), + pagination: { nextCursor, hasMore }, + }; } } diff --git a/apps/frontend/src/features/chat/ChatBoxHeader.tsx b/apps/frontend/src/features/chat/ChatBoxHeader.tsx index e9c71526..3d084737 100644 --- a/apps/frontend/src/features/chat/ChatBoxHeader.tsx +++ b/apps/frontend/src/features/chat/ChatBoxHeader.tsx @@ -67,7 +67,7 @@ export const ChatBoxHeader = ({ conversation }: { conversation: ResConversationS
{isDirect && isValidOtherUser ? ( - {avatarEl} + {avatarEl} ) : hasEvent ? ( {avatarEl} ) : ( @@ -76,7 +76,7 @@ export const ChatBoxHeader = ({ conversation }: { conversation: ResConversationS
{conversation.type}
{isDirect && isValidOtherUser ? ( - + {title} ) : hasEvent ? ( diff --git a/apps/frontend/src/features/chat/ChatBubble.tsx b/apps/frontend/src/features/chat/ChatBubble.tsx index 9e2478dc..922053e7 100644 --- a/apps/frontend/src/features/chat/ChatBubble.tsx +++ b/apps/frontend/src/features/chat/ChatBubble.tsx @@ -48,7 +48,7 @@ export const ChatBubble = ({ message }: { message: ResChatMessage }) => { {isDeletedUser ? ( ) : ( - + )} @@ -60,7 +60,7 @@ export const ChatBubble = ({ message }: { message: ResChatMessage }) => { {isDeletedUser ? ( {author.displayName ?? author.name} ) : ( - + {author.displayName ?? author.name} )} diff --git a/apps/frontend/src/features/chat/ConversationCard.tsx b/apps/frontend/src/features/chat/ConversationCard.tsx index 889ec7b9..c828ad22 100644 --- a/apps/frontend/src/features/chat/ConversationCard.tsx +++ b/apps/frontend/src/features/chat/ConversationCard.tsx @@ -80,7 +80,7 @@ export const ConversationCard = ({ conversation, isActive }: ConversationCardPro )}
{isDirect && isValidOtherUser ? ( - {avatarEl} + {avatarEl} ) : hasEvent ? ( {avatarEl} ) : ( @@ -90,7 +90,7 @@ export const ConversationCard = ({ conversation, isActive }: ConversationCardPro
{isDirect && isValidOtherUser ? ( {title} diff --git a/apps/frontend/src/features/search/GlobalSearch.tsx b/apps/frontend/src/features/search/GlobalSearch.tsx index 2960ef0e..568cebfb 100644 --- a/apps/frontend/src/features/search/GlobalSearch.tsx +++ b/apps/frontend/src/features/search/GlobalSearch.tsx @@ -126,10 +126,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { }); }; - const handleSelectUser = (id: number) => { + const handleSelectUser = (name: string) => { onOpenChange(false); requestAnimationFrame(() => { - void navigate(`/users/${String(id)}`); + void navigate(`/users/${name}`); }); }; @@ -240,7 +240,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { key={user.id} value={`user-${String(user.id)}-${user.name}`} onSelect={() => { - handleSelectUser(user.id); + handleSelectUser(user.name); }} > diff --git a/apps/frontend/src/pages/events/EventPage.tsx b/apps/frontend/src/pages/events/EventPage.tsx index 62cc60c2..024d8aa1 100644 --- a/apps/frontend/src/pages/events/EventPage.tsx +++ b/apps/frontend/src/pages/events/EventPage.tsx @@ -203,7 +203,7 @@ export const EventPage = () => { {event.author ? ( diff --git a/apps/frontend/src/pages/my-friends/Page.tsx b/apps/frontend/src/pages/my-friends/Page.tsx index aa7022bc..c46ac321 100644 --- a/apps/frontend/src/pages/my-friends/Page.tsx +++ b/apps/frontend/src/pages/my-friends/Page.tsx @@ -269,7 +269,7 @@ function FriendSearch({ actions={ <> @@ -328,7 +328,7 @@ function PendingSection({ requests, onAccept, onDecline }: PendingSectionProps) actions={ <> @@ -365,7 +365,7 @@ function OutgoingSection({ requests, onCancel }: OutgoingSectionProps) { actions={ <> @@ -469,7 +469,7 @@ function FriendsSection({ actions={ <> diff --git a/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx b/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx index b59f2eda..4aa99fa9 100644 --- a/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx +++ b/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx @@ -135,7 +135,7 @@ export function ProfileSidebar({ user, avatarUrl, onAvatarUpdate }: ProfileSideb { label: 'Public Profile', icon: Eye, - href: `/users/${user.id}`, + href: `/users/${user.name}`, }, ]; diff --git a/apps/frontend/src/pages/public-profile/Page.tsx b/apps/frontend/src/pages/public-profile/Page.tsx index 1a883f40..10ef533c 100644 --- a/apps/frontend/src/pages/public-profile/Page.tsx +++ b/apps/frontend/src/pages/public-profile/Page.tsx @@ -4,7 +4,7 @@ import { userService } from '@/services/userService'; import { useAuthStore } from '@/store/authStore'; import { useCurrentUserStore } from '@/store/currentUserStore'; import type { FriendshipStatus } from '@/types/friends'; -import type { ResUserPublicEvents } from '@grit/schema'; +import type { ResUserPublicEventsPaginated } from '@grit/schema'; import { useState } from 'react'; import { LoaderFunctionArgs, useLoaderData } from 'react-router-dom'; import { toast } from 'sonner'; @@ -35,17 +35,20 @@ const fetchAllRequests = async ( }; export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => { - const id = parseInt(params.id ?? '', 10); - if (isNaN(id)) { + const username = params.username ?? ''; + if (!username) { throw new Response('User not found', { status: 404 }); } - const user = await userService.getUserById(id); + const user = await userService.getUserByName(username); // Only fetch events if profile is public (or the field is not set, for backwards compatibility) - let events: ResUserPublicEvents = []; + let eventPage: ResUserPublicEventsPaginated = { + data: [], + pagination: { hasMore: false, nextCursor: null }, + }; if (user.isProfilePublic !== false) { - events = await userService.getUserEvents(id); + eventPage = await userService.getUserEventsByName({ username }); } // Only fetch friendship status if user is logged in @@ -54,13 +57,13 @@ export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => { const token = useAuthStore.getState().token; if (token) { try { - const status = await userService.getFriendshipStatus(id); + const status = await userService.getFriendshipStatus(user.id); friendshipStatus = status; // If there's a pending received request, fetch the request details to get the ID if (status === 'pending_received') { const incomingRequests = await fetchAllRequests(friendService.listIncomingRequests); - const request = incomingRequests.find((req) => req.requesterId === id); + const request = incomingRequests.find((req) => req.requesterId === user.id); if (request) { friendRequestId = request.id; } @@ -69,7 +72,7 @@ export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => { // If there's a pending sent request, fetch the request details to get the ID if (status === 'pending_sent') { const outgoingRequests = await fetchAllRequests(friendService.listOutgoingRequests); - const request = outgoingRequests.find((req) => req.receiverId === id); + const request = outgoingRequests.find((req) => req.receiverId === user.id); if (request) { friendRequestId = request.id; } @@ -80,7 +83,7 @@ export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => { } } - return { user, events, friendshipStatus, friendRequestId }; + return { user, eventPage, friendshipStatus, friendRequestId }; }; export default function PublicProfilePage() { @@ -205,7 +208,12 @@ export default function PublicProfilePage() { void handleCancelRequest(); }} /> - +
); } diff --git a/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx b/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx index 1c5ce656..87815ebc 100644 --- a/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx +++ b/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx @@ -1,22 +1,52 @@ +import { useState } from 'react'; +import { useInfiniteScroll, type Pagination } from '@/hooks/useInfiniteScroll'; +import { userService } from '@/services/userService'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Heading, Text } from '@/components/ui/typography'; -import type { ResUserPublic, ResUserPublicEvents } from '@grit/schema'; +import type { ResUserPublic, ResUserPublicEvent } from '@grit/schema'; import { PublicEventCard } from './PublicEventCard'; +import { toast } from 'sonner'; interface ProfileTabsProps { user: ResUserPublic; - events: ResUserPublicEvents; + username: string; + initialEvents: ResUserPublicEvent[]; + initialPagination: Pagination; } -export function ProfileTabs({ user, events }: ProfileTabsProps) { +export function ProfileTabs({ + user, + username, + initialEvents, + initialPagination, +}: ProfileTabsProps) { + const [activeTab, setActiveTab] = useState('info'); + + const { + items: events, + sentinelRef, + isLoading, + } = useInfiniteScroll( + initialEvents, + initialPagination, + async (cursor) => { + const result = await userService.getUserEventsByName({ username, cursor }); + return result; + }, + [activeTab], // recreate observer when tab becomes visible + () => { + toast.error('Failed to load more events'); + } + ); + return ( - + Info - Events ({events.length}) + Events {initialPagination.hasMore ? '' : `(${events.length})`} @@ -38,7 +68,7 @@ export function ProfileTabs({ user, events }: ProfileTabsProps) { - {events.length === 0 ? ( + {events.length === 0 && !isLoading ? (
No public events hosted yet.
@@ -49,6 +79,10 @@ export function ProfileTabs({ user, events }: ProfileTabsProps) { ))}
)} +
+ {isLoading && ( + Loading more... + )} ); diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx index 6a430b67..093217c3 100644 --- a/apps/frontend/src/router.tsx +++ b/apps/frontend/src/router.tsx @@ -67,7 +67,7 @@ export const router = createBrowserRouter([ path: 'users', children: [ { - path: ':id', + path: ':username', Component: PublicProfilePage, loader: publicProfileLoader, handle: { title: 'Profile' }, diff --git a/apps/frontend/src/services/userService.ts b/apps/frontend/src/services/userService.ts index 2dc66db4..48d44491 100644 --- a/apps/frontend/src/services/userService.ts +++ b/apps/frontend/src/services/userService.ts @@ -4,7 +4,7 @@ import { ResMyEvents, ResMyInvitedEvents, ResUserPublicSchema, - ResUserPublicEventsSchema, + ResUserPublicEventsPaginatedSchema, ResFriendshipStatusSchema, } from '@grit/schema'; import { useCurrentUserStore } from '@/store/currentUserStore'; @@ -113,14 +113,17 @@ export const userService = { await api.delete('users/me'); }, - getUserById: async (id: number) => { - const response = await api.get(`/users/${id}`); + getUserByName: async (username: string) => { + const response = await api.get(`/users/${username}`); return ResUserPublicSchema.parse(response.data); }, - getUserEvents: async (id: number) => { - const response = await api.get(`/users/${id}/events`); - return ResUserPublicEventsSchema.parse(response.data); + getUserEventsByName: async (params: { username: string; limit?: number; cursor?: string }) => { + const { username, limit = 12, cursor } = params; + const query = new URLSearchParams({ limit: String(limit) }); + if (cursor) query.set('cursor', cursor); + const response = await api.get(`/users/${username}/events?${query.toString()}`); + return ResUserPublicEventsPaginatedSchema.parse(response.data); }, getFriendshipStatus: async (id: number) => { diff --git a/apps/frontend/tests/e2e/global-search.spec.ts b/apps/frontend/tests/e2e/global-search.spec.ts index 06c7be06..aad821ec 100644 --- a/apps/frontend/tests/e2e/global-search.spec.ts +++ b/apps/frontend/tests/e2e/global-search.spec.ts @@ -45,7 +45,8 @@ const mockEvents = [ const mockUsers = [ { id: 10, - name: 'Alice Müller', + name: 'alice-abc', + displayName: 'Alice Müller', avatarKey: null, bio: null, city: 'Berlin', @@ -55,7 +56,8 @@ const mockUsers = [ }, { id: 11, - name: 'Bob Smith', + name: 'bob-xyz', + displayName: 'Bob Smith', avatarKey: null, bio: null, city: null, @@ -249,11 +251,13 @@ test.describe('Global Search', () => { await page.route('**/api/events/berlin-techno-night-abc123', async (route) => { await route.fulfill({ json: mockEvents[0] }); }); - await page.route('**/api/users/10', async (route) => { + await page.route('**/api/users/alice-abc', async (route) => { await route.fulfill({ json: mockUsers[0] }); }); - await page.route('**/api/users/10/events', async (route) => { - await route.fulfill({ json: [] }); + await page.route('**/api/users/alice-abc/events', async (route) => { + await route.fulfill({ + json: { data: [], pagination: { nextCursor: null, hasMore: false } }, + }); }); await page.route('**/api/users/me/friends/status/10', async (route) => { await route.fulfill({ json: { status: 'none' } }); @@ -296,7 +300,7 @@ test.describe('Global Search', () => { await searchInput(page).fill('alice'); await expect(page.getByText('Alice Müller')).toBeVisible({ timeout: 2000 }); await page.getByText('Alice Müller').click(); - await expect(page).toHaveURL('/users/10'); + await expect(page).toHaveURL('/users/alice-abc'); }); }); diff --git a/apps/frontend/tests/e2e/public-profile.spec.ts b/apps/frontend/tests/e2e/public-profile.spec.ts index feb967a0..4af0cc52 100644 --- a/apps/frontend/tests/e2e/public-profile.spec.ts +++ b/apps/frontend/tests/e2e/public-profile.spec.ts @@ -7,7 +7,7 @@ test.describe('Public Profile', () => { await route.fulfill({ json: { id: 1, - name: 'Test User', + name: 'test-user', email: 'test@example.com', avatarKey: null, }, @@ -26,12 +26,12 @@ test.describe('Public Profile', () => { }); test('should display public profile with user info', async ({ page }) => { - // Mock user profile with bio and location - await page.route(/\/api\/users\/2$/, async (route) => { + await page.route(/\/api\/users\/alice-johnson$/, async (route) => { await route.fulfill({ json: { id: 2, - name: 'Alice Johnson', + name: 'alice-johnson', + displayName: 'Alice Johnson', avatarKey: null, bio: 'Event organizer', city: 'New York', @@ -41,15 +41,17 @@ test.describe('Public Profile', () => { }); }); - await page.route(/\/api\/users\/2\/events/, async (route) => { - await route.fulfill({ json: [] }); + await page.route(/\/api\/users\/alice-johnson\/events/, async (route) => { + await route.fulfill({ + json: { data: [], pagination: { nextCursor: null, hasMore: false } }, + }); }); await page.route(/\/api\/users\/me\/friends\/status\/2/, async (route) => { await route.fulfill({ json: { status: 'none' } }); }); - await page.goto('/users/2'); + await page.goto('/users/alice-johnson'); await page.waitForLoadState('networkidle'); // Verify user info is displayed @@ -63,11 +65,12 @@ test.describe('Public Profile', () => { }); test('should show empty bio message when no bio', async ({ page }) => { - await page.route(/\/api\/users\/3$/, async (route) => { + await page.route(/\/api\/users\/bob-smith$/, async (route) => { await route.fulfill({ json: { id: 3, - name: 'Bob Smith', + name: 'bob-smith', + displayName: 'Bob Smith', avatarKey: null, bio: null, city: null, @@ -77,15 +80,17 @@ test.describe('Public Profile', () => { }); }); - await page.route(/\/api\/users\/3\/events/, async (route) => { - await route.fulfill({ json: [] }); + await page.route(/\/api\/users\/bob-smith\/events/, async (route) => { + await route.fulfill({ + json: { data: [], pagination: { nextCursor: null, hasMore: false } }, + }); }); await page.route(/\/api\/users\/me\/friends\/status\/3/, async (route) => { await route.fulfill({ json: { status: 'none' } }); }); - await page.goto('/users/3'); + await page.goto('/users/bob-smith'); await page.waitForLoadState('networkidle'); await expect(page.locator('h2:has-text("Bob Smith")')).toBeVisible(); @@ -96,11 +101,12 @@ test.describe('Public Profile', () => { }); test('should show hosted events', async ({ page }) => { - await page.route(/\/api\/users\/4$/, async (route) => { + await page.route(/\/api\/users\/charlie-day$/, async (route) => { await route.fulfill({ json: { id: 4, - name: 'Charlie Day', + name: 'charlie-day', + displayName: 'Charlie Day', avatarKey: null, bio: 'Party host', city: 'Chicago', @@ -110,29 +116,32 @@ test.describe('Public Profile', () => { }); }); - await page.route(/\/api\/users\/4\/events/, async (route) => { + await page.route(/\/api\/users\/charlie-day\/events/, async (route) => { await route.fulfill({ - json: [ - { - id: 10, - title: 'New Year Party', - slug: 'new-year-party', - startAt: '2026-12-31T21:00:00Z', - imageKey: null, - location: { + json: { + data: [ + { id: 10, - authorId: 4, - name: 'My House', - address: '123 Main St', - city: 'Chicago', - country: 'USA', - postalCode: '60601', - isPublic: true, - longitude: -87.6298, - latitude: 41.8781, + title: 'New Year Party', + slug: 'new-year-party', + startAt: '2026-12-31T21:00:00Z', + imageKey: null, + location: { + id: 10, + authorId: 4, + name: 'My House', + address: '123 Main St', + city: 'Chicago', + country: 'USA', + postalCode: '60601', + isPublic: true, + longitude: -87.6298, + latitude: 41.8781, + }, }, - }, - ], + ], + pagination: { nextCursor: null, hasMore: false }, + }, }); }); @@ -140,7 +149,7 @@ test.describe('Public Profile', () => { await route.fulfill({ json: { status: 'none' } }); }); - await page.goto('/users/4'); + await page.goto('/users/charlie-day'); await page.waitForLoadState('networkidle'); // Click on Events tab diff --git a/apps/frontend/tests/features/GlobalSearch.test.tsx b/apps/frontend/tests/features/GlobalSearch.test.tsx index 0fedbe08..996fba50 100644 --- a/apps/frontend/tests/features/GlobalSearch.test.tsx +++ b/apps/frontend/tests/features/GlobalSearch.test.tsx @@ -70,7 +70,8 @@ const mockEvents = [ const mockUsers = [ { id: 10, - name: 'Alice Müller', + name: 'alice-abc', + displayName: 'Alice Müller', avatarKey: null, bio: null, city: 'Berlin', @@ -80,7 +81,8 @@ const mockUsers = [ }, { id: 11, - name: 'Bob Smith', + name: 'bob-xyz', + displayName: 'Bob Smith', avatarKey: null, bio: null, city: null, @@ -115,7 +117,7 @@ function renderGlobalSearch() { [ { path: '/', Component: Wrapper }, { path: '/events/:id', Component: () =>
Event Page
}, - { path: '/users/:id', Component: () =>
User Page
}, + { path: '/users/:username', Component: () =>
User Page
}, ], { initialEntries: ['/'] } ); @@ -342,7 +344,7 @@ describe('GlobalSearch', () => { }); await user.click(screen.getByText('Alice Müller')); await waitFor(() => { - expect(router.state.location.pathname).toBe('/users/10'); + expect(router.state.location.pathname).toBe('/users/alice-abc'); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); }); }); diff --git a/packages/schema/src/user.ts b/packages/schema/src/user.ts index b341323f..34369d1b 100644 --- a/packages/schema/src/user.ts +++ b/packages/schema/src/user.ts @@ -96,9 +96,16 @@ export const ResUserPublicEventSchema = z.object({ imageKey: z.string().nullable().optional(), location: ResEventLocationSchema.nullable().optional(), }); +export type ResUserPublicEvent = z.infer; export const ResUserPublicEventsSchema = z.array(ResUserPublicEventSchema); export type ResUserPublicEvents = z.infer; +export const ResUserPublicEventsPaginatedSchema = z.object({ + data: ResUserPublicEventsSchema, + pagination: z.object({ nextCursor: z.string().nullable(), hasMore: z.boolean() }), +}); +export type ResUserPublicEventsPaginated = z.infer; + export const FriendshipStatusSchema = z.enum([ 'none', 'pending_sent',