diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx index d27e33e07..48194c96b 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx @@ -1,6 +1,16 @@ -import { useQuery } from '@apollo/client/react'; +import { useMutation, useQuery } from '@apollo/client/react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { SharerInformation } from './sharer-information.tsx'; -import { SharerInformationContainerDocument } from '../../../../../../generated.tsx'; +import { + CreateConversationDocument, + HomeConversationListContainerConversationsByUserDocument, + SharerInformationContainerDocument, +} from '../../../../../../generated.tsx'; +import type { + CreateConversationMutation, + CreateConversationMutationVariables, +} from '../../../../../../generated.tsx'; interface SharerInformationContainerProps { sharerId: string; @@ -15,6 +25,9 @@ interface SharerInformationContainerProps { export const SharerInformationContainer: React.FC< SharerInformationContainerProps > = ({ sharerId, listingId, isOwner, sharedTimeAgo, className, currentUserId }) => { + const [isMobile, setIsMobile] = useState(false); + const navigate = useNavigate(); + const { data, loading, error } = useQuery( SharerInformationContainerDocument, { @@ -22,6 +35,64 @@ export const SharerInformationContainer: React.FC< }, ); + const [createConversation, { loading: isCreating }] = useMutation< + CreateConversationMutation, + CreateConversationMutationVariables + >(CreateConversationDocument, { + refetchQueries: [ + { + query: HomeConversationListContainerConversationsByUserDocument, + variables: { userId: currentUserId }, + }, + ], + awaitRefetchQueries: true, + onCompleted: (data) => { + if (data.createConversation.status.success) { + navigate('/messages', { + state: { + selectedConversationId: data.createConversation.conversation?.id, + }, + replace: false, + }); + } else { + console.log( + 'Failed to create conversation:', + data.createConversation.status.errorMessage, + ); + } + }, + onError: (error) => { + console.error('Error creating conversation:', error); + }, + }); + + const handleMessageSharer = async (resolvedSharerId: string) => { + if (!currentUserId) { + return; + } + + try { + await createConversation({ + variables: { + input: { + listingId, + sharerId: resolvedSharerId, + reserverId: currentUserId, + }, + }, + }); + } catch (error) { + console.error('Failed to create conversation:', error); + } + }; + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth <= 600); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + // If sharerId looks like a name (contains spaces or letters), use it directly // Otherwise, try to query for user data const isNameOnly = @@ -41,6 +112,9 @@ export const SharerInformationContainer: React.FC< sharedTimeAgo={sharedTimeAgo} className={className} currentUserId={currentUserId} + isCreating={isCreating} + isMobile={isMobile} + onMessageSharer={() => handleMessageSharer(sharer.id)} /> ); } @@ -66,6 +140,9 @@ export const SharerInformationContainer: React.FC< sharedTimeAgo={sharedTimeAgo} className={className} currentUserId={currentUserId} + isCreating={isCreating} + isMobile={isMobile} + onMessageSharer={() => handleMessageSharer(sharer.id)} /> ); }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx index 3772aa7e2..d5f8a61af 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx @@ -1,14 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { expect, within, userEvent } from 'storybook/test'; import { SharerInformation } from './sharer-information.tsx'; -import { - withMockApolloClient, - withMockRouter, -} from '../../../../../../test-utils/storybook-decorators.tsx'; -import { - CreateConversationDocument, - HomeConversationListContainerConversationsByUserDocument, -} from '../../../../../../generated.tsx'; +import { withMockRouter } from '../../../../../../test-utils/storybook-decorators.tsx'; const mockSharer = { id: 'user-1', @@ -21,50 +14,17 @@ const meta: Meta = { component: SharerInformation, parameters: { layout: 'padded', - apolloClient: { - mocks: [ - { - request: { - query: CreateConversationDocument, - variables: { - input: { - listingId: '1', - sharerId: 'user-1', - reserverId: 'user-2', - }, - }, - }, - result: { - data: { - createConversation: { - __typename: 'ConversationMutationResult', - status: { success: true, errorMessage: null }, - conversation: { __typename: 'Conversation', id: 'conv-1' }, - }, - }, - }, - }, - { - request: { - query: HomeConversationListContainerConversationsByUserDocument, - variables: { userId: 'user-2' }, - }, - result: { - data: { - conversationsByUser: [], - }, - }, - }, - ], - }, }, - decorators: [withMockApolloClient, withMockRouter('/listing/1')], + decorators: [withMockRouter('/listing/1')], args: { sharer: mockSharer, listingId: '1', isOwner: false, sharedTimeAgo: '2 days ago', currentUserId: 'user-2', + isCreating: false, + isMobile: false, + onMessageSharer: () => undefined, }, }; @@ -125,52 +85,10 @@ export const ClickMessageButton: Story = { }, }; -export const MessageButtonWithError: Story = { - parameters: { - apolloClient: { - mocks: [ - { - request: { - query: CreateConversationDocument, - variables: () => true, - }, - maxUsageCount: Number.POSITIVE_INFINITY, - result: { - data: { - createConversation: { - __typename: 'ConversationMutationResult', - status: { success: false, errorMessage: 'Failed to create' }, - conversation: null, - }, - }, - }, - }, - { - request: { - query: HomeConversationListContainerConversationsByUserDocument, - variables: () => true, - }, - maxUsageCount: Number.POSITIVE_INFINITY, - result: { - data: { - conversationsByUser: [], - }, - }, - }, - ], - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(canvasElement).toBeTruthy(); - const messageButton = canvas.queryByRole('button', { name: /Message/i }); - if (messageButton) { - await userEvent.click(messageButton); - } - }, -}; - export const MobileView: Story = { + args: { + isMobile: true, + }, parameters: { viewport: { defaultViewport: 'mobile1', diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx index bd3835be9..0d7f6f96d 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx @@ -1,158 +1,76 @@ -import { Button, Avatar, Row, Col } from 'antd'; -import { useEffect, useState } from 'react'; -import { MessageOutlined } from '@ant-design/icons'; -import { useMutation } from '@apollo/client/react'; -import { useNavigate } from 'react-router-dom'; -import { CreateConversationDocument, HomeConversationListContainerConversationsByUserDocument } from '../../../../../../generated.tsx'; -import type { - CreateConversationMutation, - CreateConversationMutationVariables, -} from '../../../../../../generated.tsx'; +import { Button, Row, Col } from "antd"; +import { MessageOutlined } from "@ant-design/icons"; +import { UserAvatar } from "../../../../../shared/user-avatar.tsx"; +import { UserProfileLink } from "../../../../../shared/user-profile-link.tsx"; type Sharer = { - id: string; - name: string; - avatar?: string; + id: string; + name: string; + avatar?: string; }; interface SharerInformationProps { - sharer: Sharer; - listingId: string; - isOwner?: boolean; - sharedTimeAgo?: string; - className?: string; - currentUserId?: string | null; + sharer: Sharer; + listingId: string; + isOwner?: boolean; + sharedTimeAgo?: string; + className?: string; + currentUserId?: string | null; + isCreating?: boolean; + isMobile?: boolean; + onMessageSharer?: () => void; } export const SharerInformation: React.FC = ({ - sharer, - listingId, - isOwner = false, - sharedTimeAgo = '2 days ago', - className = '', - currentUserId, + sharer, + isOwner = false, + sharedTimeAgo = "2 days ago", + className = "", + currentUserId, + isCreating = false, + isMobile = false, + onMessageSharer, }) => { - const [isMobile, setIsMobile] = useState(false); - const navigate = useNavigate(); - - const [createConversation, { loading: isCreating }] = useMutation< - CreateConversationMutation, - CreateConversationMutationVariables - >(CreateConversationDocument, { - refetchQueries: [ - { - query: HomeConversationListContainerConversationsByUserDocument, - variables: { userId: currentUserId }, - } - ], - awaitRefetchQueries: true, - onCompleted: (data) => { - if (data.createConversation.status.success) { - navigate('/messages', { - state: { - selectedConversationId: data.createConversation.conversation?.id, - }, - replace: false, - }); - } else { - console.log('Failed to create conversation:', data.createConversation.status.errorMessage); - } - }, - onError: (error) => { - console.error('Error creating conversation:', error); - }, - }); - - const handleMessageSharer = async () => { - if (!currentUserId) { - return; - } - - try { - await createConversation({ - variables: { - input: { - listingId, - sharerId: sharer.id, - reserverId: currentUserId, - }, - }, - }); - } catch (error) { - console.error('Failed to create conversation:', error); - } - }; - - useEffect(() => { - const checkMobile = () => setIsMobile(window.innerWidth <= 600); - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - return ( - - - - Sharethrift Logo - - - } - > - {sharer.name.charAt(0).toUpperCase()} - - - -
-

- {sharer.name} -

-

- shared {sharedTimeAgo} -

-
- - - {!isOwner && currentUserId && ( - - )} - -
- ); + return ( + + + + + +
+

+ +

+

+ shared {sharedTimeAgo} +

+
+ + + {!isOwner && currentUserId && ( + + )} + +
+ ); }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.graphql new file mode 100644 index 000000000..03f69a942 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.graphql @@ -0,0 +1,58 @@ +# Public query - no authentication required +# Returns limited profile information for any user +query HomeViewUserProfileContainerUserById($userId: ObjectID!) { + userById(id: $userId) { + ... on PersonalUser { + id + userType + createdAt + account { + ...PersonalUserPublicProfileFields + } + } + ... on AdminUser { + id + userType + createdAt + account { + ...AdminUserPublicProfileFields + } + } + } +} + +fragment PersonalUserPublicProfileFields on PersonalUserAccount { + accountType + username + profile { + ...PersonalUserPublicProfileFieldsProfile + } +} + +fragment PersonalUserPublicProfileFieldsProfile on PersonalUserAccountProfile { + firstName + lastName + aboutMe + location { + city + state + } +} + +fragment AdminUserPublicProfileFields on AdminUserAccount { + accountType + username + profile { + ...AdminUserPublicProfileFieldsProfile + } +} + +fragment AdminUserPublicProfileFieldsProfile on AdminUserAccountProfile { + firstName + lastName + aboutMe + location { + city + state + } +} diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.stories.tsx new file mode 100644 index 000000000..b3c338f7c --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import { HomeViewUserProfileContainerUserByIdDocument } from '../../../../../generated.tsx'; +import { UserProfileViewContainer } from './user-profile-view.container.tsx'; + +const meta: Meta = { + title: 'Layouts/Home/ViewUserProfile/UserProfileViewContainer', + component: UserProfileViewContainer, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const MissingUserId: Story = { + render: () => ( + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('User ID is required')).toBeInTheDocument(); + }, +}; + +export const WithProfileData: Story = { + render: () => ( + + + + } /> + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('@alice_doe')).toBeInTheDocument(); + await expect(await canvas.findByText(/Seattle/i)).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.tsx new file mode 100644 index 000000000..0903abb0f --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-user-profile/user-profile-view.container.tsx @@ -0,0 +1,90 @@ +import { useParams, useNavigate } from "react-router-dom"; +import { useMemo, useCallback } from "react"; +import { ProfileView } from "../../account/profile/components/profile-view.tsx"; +import { useQuery } from "@apollo/client/react"; +import { ComponentQueryLoader } from "@sthrift/ui-components"; +import { + type ItemListing, + HomeViewUserProfileContainerUserByIdDocument, +} from "../../../../../generated.tsx"; + +/** + * Container component for viewing another user's public profile. + * Fetches user data and renders the ProfileView in read-only mode. + * This route is publicly accessible without authentication. + * @returns JSX element containing the user profile view with loading/error states + */ +export const UserProfileViewContainer: React.FC = () => { + const { userId } = useParams<{ userId: string }>(); + const navigate = useNavigate(); + + const { + data: userQueryData, + loading: userLoading, + error: userError, + } = useQuery(HomeViewUserProfileContainerUserByIdDocument, { + variables: { userId: userId ?? "" }, + skip: !userId, + }); + + const viewedUser = userQueryData?.userById; + + const handleListingClick = useCallback((listingId: string) => { + navigate(`/listing/${listingId}`); + }, [navigate]); + + const handleEditSettings = useCallback(() => undefined, []); + + // Build profile user data from the query response - memoized for performance + const profileUser = useMemo(() => { + if (!viewedUser) return null; + + // viewedUser is a union type (PersonalUser | AdminUser) - both have the same account structure + if (!('account' in viewedUser)) return null; + + const { account, createdAt } = viewedUser; + return { + id: viewedUser.id, + firstName: account?.profile?.firstName || "", + lastName: account?.profile?.lastName || "", + username: account?.username || "", + email: "", // Don't expose email for other users + accountType: account?.accountType || "", + location: { + city: account?.profile?.location?.city || "", + state: account?.profile?.location?.state || "", + }, + createdAt: createdAt || "", + }; + }, [viewedUser]); + + // TODO: Implement public listings query for user profiles + // Currently, viewing other users' profiles doesn't show their listings. + // This would require a backend query to fetch public listings by user ID. + const listings: ItemListing[] = []; + + const profileView = profileUser ? ( + + ) : ( + <> + ); + + if (!userId) { + return
User ID is required
; + } + + return ( + + ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/index.tsx b/apps/ui-sharethrift/src/components/layouts/home/index.tsx index 678844b43..667791899 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/index.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/index.tsx @@ -6,6 +6,7 @@ import { MyReservationsRoutes } from './my-reservations/Index.tsx'; import { Listings } from './pages/all-listings-page.tsx'; import { ViewListing } from './pages/view-listing-page.tsx'; import { CreateListing } from './pages/create-listing-page.tsx'; +import { ViewUserProfile } from './pages/view-user-profile-page.tsx'; import { SectionLayout } from './section-layout.tsx'; import { AdminDashboardMain } from './account/admin-dashboard/pages/admin-dashboard-main.tsx'; import { RequireAuth } from '../../shared/require-auth.tsx'; @@ -15,15 +16,59 @@ export const HomeRoutes: React.FC = () => { return ( }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + {/* Public route - user profiles are viewable without authentication */} + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> ); -} +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/messages/components/conversation-box.tsx b/apps/ui-sharethrift/src/components/layouts/home/messages/components/conversation-box.tsx index 315202fc8..cd6424832 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/messages/components/conversation-box.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/messages/components/conversation-box.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; import type { Conversation } from '../../../../../generated.tsx'; -import { MessageThread } from './index.ts'; import { ListingBanner } from './listing-banner.tsx'; +import { MessageThread } from './index.ts'; +import { useState, useCallback, useMemo } from 'react'; interface ConversationBoxProps { data: Conversation; @@ -15,17 +15,31 @@ export const ConversationBox: React.FC = (props) => { const currentUserId = props.currentUserId ?? props?.data?.sharer?.id; - const handleSendMessage = async (e: React.FormEvent) => { - e.preventDefault(); - if (props.sendingMessage) return; // Prevent duplicate submits while send is in flight - if (!messageText.trim()) return; + const handleSendMessage = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + console.log('Send message logic to be implemented', messageText); + }, + [messageText], + ); + + // Build user info for sharer and reserver - memoized to avoid unnecessary rerenders + const sharerInfo = useMemo( + () => ({ + id: props.data.sharer?.id || '', + displayName: props.data.sharer?.account?.profile?.firstName ?? 'Unknown', + }), + [props.data?.sharer], + ); - // Only clear input on successful send so users don't lose unsent content on error - const success = await props.onSendMessage(messageText); - if (success) { - setMessageText(''); - } - }; + const reserverInfo = useMemo( + () => ({ + id: props.data.reserver?.id || '', + displayName: + props.data.reserver?.account?.profile?.firstName ?? 'Unknown', + }), + [props.data?.reserver], + ); return ( <> @@ -46,11 +60,13 @@ export const ConversationBox: React.FC = (props) => { messages={props.data.messages || []} loading={false} error={null} - sendingMessage={props.sendingMessage} + sendingMessage={false} messageText={messageText} setMessageText={setMessageText} handleSendMessage={handleSendMessage} currentUserId={currentUserId} + sharer={sharerInfo} + reserver={reserverInfo} /> diff --git a/apps/ui-sharethrift/src/components/layouts/home/messages/components/listing-banner.tsx b/apps/ui-sharethrift/src/components/layouts/home/messages/components/listing-banner.tsx index 45e56eea7..92ce62235 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/messages/components/listing-banner.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/messages/components/listing-banner.tsx @@ -2,6 +2,7 @@ import { Card, Typography, Avatar, Tag, Row, Col } from "antd"; import { SwapOutlined, AppstoreAddOutlined } from "@ant-design/icons"; import bikeListingImg from "@sthrift/ui-components/src/assets/item-images/bike-listing.png"; import type { User } from "../../../../../generated.tsx"; +import { UserProfileLink } from "../../../../shared/user-profile-link.tsx"; const imgRectangle26 = bikeListingImg; @@ -14,7 +15,7 @@ export const ListingBanner: React.FC = (props) => { const status = "Request Submitted"; //todo const imageUrl = imgRectangle26; //todo - const firstName = props.owner?.account?.profile?.firstName || 'Unknown'; return ( + const firstName = props.owner.account?.profile?.firstName || props.owner.account?.username || 'Unknown'; return ( = (props) => { marginTop: 4, }} > - = (props) => { color: "var(--color-primary)", lineHeight: "20px", }} - > - {firstName} - + /> diff --git a/apps/ui-sharethrift/src/components/layouts/home/messages/components/message-thread.tsx b/apps/ui-sharethrift/src/components/layouts/home/messages/components/message-thread.tsx index 61f28fbe2..26a046875 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/messages/components/message-thread.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/messages/components/message-thread.tsx @@ -1,14 +1,7 @@ -import { - List, - Avatar, - Input, - Button, - Spin, - Empty, - message as antdMessage, -} from "antd"; +import { List, Input, Button, Spin, Empty, message as antdMessage } from "antd"; import { SendOutlined } from "@ant-design/icons"; -import { useRef } from "react"; +import { useRef, useCallback } from "react"; +import { UserAvatar } from "../../../../shared/user-avatar.tsx"; interface Message { id: string; @@ -18,6 +11,11 @@ interface Message { createdAt: string; } +interface UserInfo { + id: string; + displayName: string; +} + interface MessageThreadProps { conversationId: string; messages: Message[]; @@ -29,11 +27,24 @@ interface MessageThreadProps { handleSendMessage: (e: React.FormEvent) => void; currentUserId: string; contentContainerStyle?: React.CSSProperties; + sharer: UserInfo; + reserver: UserInfo; } export const MessageThread: React.FC = (props) => { const messagesEndRef = useRef(null); + // Helper to get display name for a given authorId - memoized for performance + const getAuthorDisplayName = useCallback((authorId: string): string => { + if (props.sharer.id === authorId) { + return props.sharer.displayName || "Sharer"; + } + if (props.reserver.id === authorId) { + return props.reserver.displayName || "Reserver"; + } + return "User"; + }, [props.sharer, props.reserver]); + if (props.loading) { return ( = (props) => { (index > 0 && props.messages[index - 1]?.authorId !== message.authorId) } + authorDisplayName={getAuthorDisplayName(message.authorId)} /> )} /> @@ -163,9 +175,10 @@ interface MessageBubbleProps { message: Message; isOwn: boolean; showAvatar: boolean; + authorDisplayName: string; } -function MessageBubble({ message, isOwn, showAvatar }: MessageBubbleProps) { +function MessageBubble({ message, isOwn, showAvatar, authorDisplayName }: MessageBubbleProps) { const formatTime = (dateString: string) => { const date = new Date(dateString); return date.toLocaleTimeString("en-US", { @@ -193,35 +206,7 @@ function MessageBubble({ message, isOwn, showAvatar }: MessageBubbleProps) { }} > {showAvatar && !isOwn && ( - - Sharethrift Logo - - - } - > - {message.authorId.charAt(0).toUpperCase()} - + )} {showAvatar && isOwn &&
} {!showAvatar &&
} diff --git a/apps/ui-sharethrift/src/components/layouts/home/messages/stories/ListingBanner.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/messages/stories/ListingBanner.stories.tsx index 2e696d5db..fd7f376b6 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/messages/stories/ListingBanner.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/messages/stories/ListingBanner.stories.tsx @@ -1,30 +1,55 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { MemoryRouter } from "react-router-dom"; +import { expect, within } from "storybook/test"; import type { ComponentProps } from 'react'; import type { PersonalUser } from '../../../../../generated.tsx'; import { ListingBanner } from '../components/listing-banner.tsx'; +type ListingBannerStoryProps = ComponentProps; + // Mock PersonalUser object for Storybook const mockUser: PersonalUser = { - id: '507f1f77bcf86cd799439011', - __typename: 'PersonalUser', - account: { - __typename: 'PersonalUserAccount', - email: 'alice@example.com', - username: 'alice_doe', - profile: { - __typename: 'PersonalUserAccountProfile', - firstName: 'Alice', - lastName: 'Doe', - }, - }, - userType: 'personal-user', - hasCompletedOnboarding: true, - isBlocked: false, + id: "507f1f77bcf86cd799439011", + __typename: "PersonalUser", + account: { + __typename: "PersonalUserAccount", + email: "alice@example.com", + username: "alice_doe", + profile: { + __typename: "PersonalUserAccountProfile", + firstName: "Alice", + lastName: "Doe", + }, + }, + userType: "personal-user", + hasCompletedOnboarding: true, + isBlocked: false, }; +const mockUserWithoutProfile: PersonalUser = { + id: "507f1f77bcf86cd799439012", + __typename: "PersonalUser", + account: { + __typename: "PersonalUserAccount", + email: "unknown@example.com", + username: "unknown_user", + profile: undefined, + }, + userType: "personal-user", + hasCompletedOnboarding: false, + isBlocked: false, +} as unknown as PersonalUser; + const meta: Meta = { title: 'Components/Listings/ListingBanner', component: ListingBanner, + decorators: [ + (Story) => ( + + + + ), + ], }; export default meta; type Story = StoryObj; @@ -32,5 +57,36 @@ type Story = StoryObj; export const Default: Story = { args: { owner: mockUser, - } satisfies ComponentProps, + } satisfies ListingBannerStoryProps, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test owner name display + await expect(canvas.getByText("Alice's Listing")).toBeInTheDocument(); + await expect(canvas.getByText("Alice")).toBeInTheDocument(); + + // Test request period + await expect(canvas.getByText("Request Period:")).toBeInTheDocument(); + await expect(canvas.getByText("1 Month")).toBeInTheDocument(); + + // Test status + await expect(canvas.getByText("Request Submitted")).toBeInTheDocument(); + + // Test profile link + const profileLink = canvas.getByRole("link", { name: "Alice" }); + await expect(profileLink).toBeInTheDocument(); + await expect(profileLink).toHaveAttribute("href", "/user/507f1f77bcf86cd799439011"); + }, +}; + +export const UnknownOwner: Story = { + args: { + owner: mockUserWithoutProfile, + } satisfies ListingBannerStoryProps, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test fallback for missing profile + await expect(canvas.getByText("unknown_user's Listing")).toBeInTheDocument(); + }, }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/messages/stories/MessageThread.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/messages/stories/MessageThread.stories.tsx index 907381513..42faa114d 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/messages/stories/MessageThread.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/messages/stories/MessageThread.stories.tsx @@ -1,56 +1,76 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter } from "react-router-dom"; import { expect, within } from 'storybook/test'; import { MessageThread } from '../components/message-thread.tsx'; const mockMessages = [ - { - id: 'm1', - messagingMessageId: 'SM1', - conversationId: '1', - authorId: 'user123', - content: 'Hey Alice, is the bike still available?', - createdAt: '2025-08-08T12:01:00Z', - }, - { - id: 'm2', - messagingMessageId: 'SM2', - conversationId: '1', - authorId: 'Alice', - content: 'Yes, it is! Do you want to see it?', - createdAt: '2025-08-08T12:02:00Z', - }, + { + id: "m1", + messagingMessageId: "SM1", + conversationId: "1", + authorId: "user123", + content: "Hey Alice, is the bike still available?", + createdAt: "2025-08-08T12:01:00Z", + }, + { + id: "m2", + messagingMessageId: "SM2", + conversationId: "1", + authorId: "alice456", + content: "Yes, it is! Do you want to see it?", + createdAt: "2025-08-08T12:02:00Z", + }, ]; +const mockSharer = { + id: "alice456", + displayName: "Alice Johnson", +}; + +const mockReserver = { + id: "user123", + displayName: "Bob Smith", +}; + const meta: Meta = { - title: 'Components/Messages/MessageThread', - component: MessageThread, - argTypes: { - setMessageText: { action: 'message text set' }, - handleSendMessage: { action: 'message sent' }, - }, + title: "Components/Messages/MessageThread", + component: MessageThread, + argTypes: { + setMessageText: { action: 'message text set' }, + handleSendMessage: { action: 'message sent' }, + }, + decorators: [ + (Story) => ( + + + + ), + ], }; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - conversationId: '1', - messages: mockMessages, - loading: false, - error: null, - messageText: '', - setMessageText: () => { - console.log('Set message text'); - }, - sendingMessage: false, - handleSendMessage: () => { - console.log('handle send message'); - }, - currentUserId: 'user123', - contentContainerStyle: { paddingLeft: 24 }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(canvas.getByText(/Hey Alice/i)).toBeInTheDocument(); - }, + args: { + conversationId: "1", + messages: mockMessages, + loading: false, + error: null, + messageText: "", + setMessageText: () => { + console.log("Set message text"); + }, + sendingMessage: false, + handleSendMessage: () => { + console.log("handle send message"); + }, + currentUserId: "user123", + contentContainerStyle: { paddingLeft: 24 }, + sharer: mockSharer, + reserver: mockReserver, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Hey Alice/i)).toBeInTheDocument(); + }, }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/requests-table.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/requests-table.stories.tsx new file mode 100644 index 000000000..7a77b31bc --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/requests-table.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MemoryRouter } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import { RequestsTable } from './requests-table.tsx'; +import type { ListingRequestData } from './my-listings-dashboard.types.ts'; + +const meta: Meta = { + title: 'Layouts/Home/MyListings/RequestsTable', + component: RequestsTable, + tags: ['autodocs'], + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const sampleData: ListingRequestData[] = [ + { + id: 'req-1', + title: 'Cordless Drill', + image: '/assets/item-images/placeholder.png', + requestedBy: '@alice', + requestedOn: '2025-01-01T00:00:00.000Z', + reservationPeriod: '2025-01-05 - 2025-01-10', + status: 'Pending', + }, + { + id: 'req-2', + title: 'Camera', + image: '/assets/item-images/placeholder.png', + requestedBy: '@unknown', + requestedOn: '2025-01-02T00:00:00.000Z', + reservationPeriod: 'N/A', + status: 'Accepted', + }, +]; + +export const Default: Story = { + args: { + data: sampleData, + searchText: '', + statusFilters: [], + sorter: { field: null, order: null }, + currentPage: 1, + pageSize: 10, + total: sampleData.length, + loading: false, + onSearch: () => undefined, + onStatusFilter: () => undefined, + onTableChange: () => undefined, + onPageChange: () => undefined, + onAction: () => undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText('Cordless Drill').length).toBeGreaterThan(0); + await expect(canvas.getAllByText('@alice').length).toBeGreaterThan(0); + await expect(canvas.getAllByText('Accepted').length).toBeGreaterThan(0); + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/requests-table.tsx b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/requests-table.tsx index 767563e65..f7e1b536f 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/requests-table.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/requests-table.tsx @@ -126,11 +126,7 @@ export const RequestsTable: React.FC = ({ sorter: true, sortOrder: sorter.field === 'reservationPeriod' ? sorter.order : null, render: (period: string) => { - if (!period) { - return 'N/A'; - } // Expect format 'yyyy-mm-dd - yyyy-mm-dd' or similar - // If not, try to parse and format let start = '', end = ''; if (period.includes(' - ')) { @@ -140,7 +136,7 @@ export const RequestsTable: React.FC = ({ } else { start = period ?? ''; } - // Try to format both as yyyy-mm-dd + // Format both dates as yyyy-mm-dd function formatDate(str: string) { const d = new Date(str); if (isNaN(d.getTime())) { diff --git a/apps/ui-sharethrift/src/components/layouts/home/pages/view-user-profile-page.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/pages/view-user-profile-page.stories.tsx new file mode 100644 index 000000000..533d4a3d7 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/pages/view-user-profile-page.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import { HomeViewUserProfileContainerUserByIdDocument } from '../../../../generated.tsx'; +import { ViewUserProfile } from './view-user-profile-page.tsx'; + +const meta: Meta = { + title: 'Layouts/Home/Pages/ViewUserProfile', + component: ViewUserProfile, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + } /> + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('@alice_doe')).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/pages/view-user-profile-page.tsx b/apps/ui-sharethrift/src/components/layouts/home/pages/view-user-profile-page.tsx new file mode 100644 index 000000000..186e94d26 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/pages/view-user-profile-page.tsx @@ -0,0 +1,10 @@ +import { UserProfileViewContainer } from '../components/view-user-profile/user-profile-view.container.tsx'; + +/** + * Page component for viewing a user's public profile. + * This is a public route - no authentication required. + * @returns JSX element containing the user profile view + */ +export const ViewUserProfile: React.FC = () => { + return ; +}; diff --git a/apps/ui-sharethrift/src/components/shared/sharethrift-logo.tsx b/apps/ui-sharethrift/src/components/shared/sharethrift-logo.tsx new file mode 100644 index 000000000..f78128cc9 --- /dev/null +++ b/apps/ui-sharethrift/src/components/shared/sharethrift-logo.tsx @@ -0,0 +1,43 @@ +/** + * ShareThrift brand logo component + * Reusable SVG logo for use across the application + */ + +interface ShareThriftLogoProps { + /** Size of the logo in pixels (default: 24) */ + size?: number; + /** Fill color for the logo (default: 'var(--color-foreground-1)') */ + fill?: string; + /** Additional CSS class names */ + className?: string; + /** Custom inline styles */ + style?: React.CSSProperties; +} + +/** + * ShareThrift brand logo SVG component + * @param props - The component props + * @returns JSX element containing the ShareThrift logo + */ +export const ShareThriftLogo: React.FC = ({ + size = 24, + fill = 'var(--color-foreground-1)', + className, + style, +}) => ( + + ShareThrift Logo + + +); diff --git a/apps/ui-sharethrift/src/components/shared/user-avatar.stories.tsx b/apps/ui-sharethrift/src/components/shared/user-avatar.stories.tsx new file mode 100644 index 000000000..be1096692 --- /dev/null +++ b/apps/ui-sharethrift/src/components/shared/user-avatar.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter } from 'react-router-dom'; +import { UserAvatar } from './user-avatar.tsx'; +import { + MockAuthWrapper, + MockUnauthWrapper, +} from '../../test-utils/storybook-decorators.tsx'; + +const meta: Meta = { + title: 'Shared/UserAvatar', + component: UserAvatar, + decorators: [ + (Story) => ( + + + + + + ), + ], + tags: ['autodocs'], + argTypes: { + userId: { + control: 'text', + description: 'The ID of the user to link to', + }, + userName: { + control: 'text', + description: 'The name of the user for accessibility and initial', + }, + size: { + control: 'number', + description: 'Size of the avatar in pixels', + }, + avatarUrl: { + control: 'text', + description: 'URL of the user avatar image (optional)', + }, + shape: { + control: 'select', + options: ['circle', 'square'], + description: 'Shape of the avatar', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + userId: '507f1f77bcf86cd799439011', + userName: 'John Doe', + size: 48, + }, +}; + +export const UnauthenticatedRedirectToLogin: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + args: { + userId: '507f1f77bcf86cd799439011', + userName: 'John Doe', + size: 48, + }, +}; + +export const Small: Story = { + args: { + userId: '507f1f77bcf86cd799439011', + userName: 'Jane Smith', + size: 32, + }, +}; + +export const Large: Story = { + args: { + userId: '507f1f77bcf86cd799439011', + userName: 'Alice Johnson', + size: 72, + }, +}; + +export const Square: Story = { + args: { + userId: '507f1f77bcf86cd799439011', + userName: 'Bob Williams', + size: 48, + shape: 'square', + }, +}; + +export const WithoutUserId: Story = { + args: { + userId: '', + userName: 'Unknown User', + size: 48, + }, + parameters: { + docs: { + description: { + story: 'When no userId is provided, the avatar is rendered without a link (non-clickable).', + }, + }, + }, +}; + +export const InContext: Story = { + render: () => ( +
+ +
+

Alice Johnson

+

Click the avatar to view profile

+
+
+ ), +}; + +export const MultipleAvatars: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/apps/ui-sharethrift/src/components/shared/user-avatar.tsx b/apps/ui-sharethrift/src/components/shared/user-avatar.tsx new file mode 100644 index 000000000..71d7d2b86 --- /dev/null +++ b/apps/ui-sharethrift/src/components/shared/user-avatar.tsx @@ -0,0 +1,94 @@ +import { useContext, useState } from 'react'; +import { Avatar as AntAvatar } from 'antd'; +import { Link } from 'react-router-dom'; +import { AuthContext } from 'react-oidc-context'; +import { ShareThriftLogo } from './sharethrift-logo.tsx'; + +/** + * Props for UserAvatar component + * @property userId - The unique identifier for the user (ObjectID) + * @property userName - Display name for accessibility and fallback initial + * @property size - Avatar size in pixels (default: 48) + * @property avatarUrl - Optional URL to user's avatar image + * @property className - Additional CSS classes + * @property style - Inline styles + * @property shape - Avatar shape ('circle' | 'square') + */ +interface UserAvatarProps { + userId: string; + userName: string; + size?: number; + avatarUrl?: string; + className?: string; + style?: React.CSSProperties; + shape?: 'circle' | 'square'; +} + +/** + * UserAvatar component displays a user's avatar that links to their profile. + * Clicking the avatar navigates to the user's profile page. + * @param props - The component props + * @returns JSX element containing the avatar wrapped in a link + */ +export const UserAvatar: React.FC = ({ + userId, + userName, + size = 48, + avatarUrl, + className = '', + style = {}, + shape = 'circle', +}) => { + const auth = useContext(AuthContext); + const isAuthenticated = auth?.isAuthenticated ?? false; + const profilePath = userId ? (isAuthenticated ? `/user/${userId}` : '/login') : undefined; + + // Track if the avatar image failed to load + const [imageError, setImageError] = useState(false); + const showImageAvatar = !!avatarUrl && !imageError; + + const avatarContent = showImageAvatar ? ( + { + // If the image fails to load, fall back to the logo/initials variant + setImageError(true); + return false; + }} + /> + ) : ( + } + > + {userName?.trim() ? userName.charAt(0).toUpperCase() : '?'} + + ); + + if (!profilePath) { + return avatarContent; + } + + return ( + + {avatarContent} + + ); +}; diff --git a/apps/ui-sharethrift/src/components/shared/user-profile-link.stories.tsx b/apps/ui-sharethrift/src/components/shared/user-profile-link.stories.tsx new file mode 100644 index 000000000..c5f37051c --- /dev/null +++ b/apps/ui-sharethrift/src/components/shared/user-profile-link.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import { UserProfileLink } from './user-profile-link.tsx'; + +const meta: Meta = { + title: 'Shared/UserProfileLink', + component: UserProfileLink, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ['autodocs'], + argTypes: { + userId: { + control: 'text', + description: 'The ID of the user to link to', + }, + displayName: { + control: 'text', + description: 'The display name to show as the link text', + }, + className: { + control: 'text', + description: 'Additional CSS class names', + }, + style: { + control: 'object', + description: 'Custom inline styles', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + userId: '507f1f77bcf86cd799439011', + displayName: 'John Doe', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test that link is rendered + const link = canvas.getByRole('link', { name: 'John Doe' }); + await expect(link).toBeInTheDocument(); + await expect(link).toHaveAttribute('href', '/user/507f1f77bcf86cd799439011'); + }, +}; + +export const WithCustomStyle: Story = { + args: { + userId: '507f1f77bcf86cd799439011', + displayName: 'Jane Smith', + style: { + fontSize: '18px', + fontWeight: 'bold', + color: '#ff0000', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test link is rendered and accessible + const link = canvas.getByRole('link', { name: 'Jane Smith' }); + await expect(link).toBeInTheDocument(); + await expect(link).toHaveAttribute('href', '/user/507f1f77bcf86cd799439011'); + + // Verify custom color is applied + await expect(link).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + }, +}; + +export const WithoutUserId: Story = { + args: { + userId: '', + displayName: 'Unknown User', + }, + parameters: { + docs: { + description: { + story: 'When no userId is provided, the component renders as plain text instead of a link.', + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test that plain text is rendered (not a link) + await expect(canvas.getByText('Unknown User')).toBeInTheDocument(); + await expect(canvas.queryByRole('link')).not.toBeInTheDocument(); + }, +}; + +export const InContext: Story = { + render: () => ( +
+

+ This listing is shared by{' '} + {' '} + and was requested by{' '} + . +

+
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test both links are rendered + const aliceLink = canvas.getByRole('link', { name: 'Alice Johnson' }); + const bobLink = canvas.getByRole('link', { name: 'Bob Williams' }); + + await expect(aliceLink).toBeInTheDocument(); + await expect(aliceLink).toHaveAttribute('href', '/user/507f1f77bcf86cd799439011'); + + await expect(bobLink).toBeInTheDocument(); + await expect(bobLink).toHaveAttribute('href', '/user/507f1f77bcf86cd799439012'); + }, +}; diff --git a/apps/ui-sharethrift/src/components/shared/user-profile-link.tsx b/apps/ui-sharethrift/src/components/shared/user-profile-link.tsx new file mode 100644 index 000000000..0560cc0bd --- /dev/null +++ b/apps/ui-sharethrift/src/components/shared/user-profile-link.tsx @@ -0,0 +1,56 @@ +import { Link } from 'react-router-dom'; + +/** + * Props for UserProfileLink component + * @property userId - The unique identifier for the user (ObjectID) + * @property displayName - The text to display as the clickable link + * @property className - Additional CSS classes + * @property style - Custom inline styles + */ +interface UserProfileLinkProps { + userId?: string | null; + displayName: string; + className?: string; + style?: React.CSSProperties; +} + +/** + * UserProfileLink component renders a clickable link to a user's profile. + * @param props - The component props + * @returns JSX element containing a navigation link + */ +export const UserProfileLink: React.FC = ({ + userId, + displayName, + className = '', + style = {}, +}) => { + if (!userId?.trim()) { + return ( + + {displayName} + + ); + } + + return ( + + {displayName} + + ); +}; diff --git a/apps/ui-sharethrift/src/components/shared/utils/user-validation.stories.tsx b/apps/ui-sharethrift/src/components/shared/utils/user-validation.stories.tsx new file mode 100644 index 000000000..0e5703a10 --- /dev/null +++ b/apps/ui-sharethrift/src/components/shared/utils/user-validation.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; + +const isValidUserId = (userId: string | undefined | null): boolean => { + if (typeof userId !== 'string') { + return false; + } + + return userId.trim().length > 0; +}; + +const meta: Meta = { + title: 'Shared/Utils/UserValidation', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const ValidationDemo: React.FC<{ userId: string | undefined | null; label: string }> = ({ userId, label }) => { + const isValid = isValidUserId(userId); + return ( +
+ {label}: {JSON.stringify(userId)} +
+ Result: {isValid ? 'Valid' : 'Invalid'} +
+ ); +}; + +export const ValidUserIds: Story = { + render: () => ( +
+

Valid User IDs

+ + + +
+ ), + play: async () => { + // Test valid user IDs + await expect(isValidUserId('507f1f77bcf86cd799439011')).toBe(true); + await expect(isValidUserId('abc123def456')).toBe(true); + await expect(isValidUserId('user-12345')).toBe(true); + }, +}; + +export const InvalidUserIds: Story = { + render: () => ( +
+

Invalid User IDs

+ + + + +
+ ), + play: async () => { + // Test invalid user IDs + await expect(isValidUserId('')).toBe(false); + await expect(isValidUserId(' ')).toBe(false); + await expect(isValidUserId(undefined)).toBe(false); + await expect(isValidUserId(null)).toBe(false); + }, +}; + +export const EdgeCases: Story = { + render: () => ( +
+

Edge Cases

+ + +
+ ), + play: async () => { + // Test edge cases - these should be valid because they have content after trim + await expect(isValidUserId(' validId ')).toBe(true); + await expect(isValidUserId(' tabbed ')).toBe(true); + }, +}; diff --git a/package.json b/package.json index a730e69c7..172c35027 100644 --- a/package.json +++ b/package.json @@ -84,5 +84,10 @@ "typescript": "^5.8.3", "vite": "catalog:", "vitest": "catalog:" + }, + "pnpm": { + "overrides": { + "qs": "6.14.1" + } } -} \ No newline at end of file +} diff --git a/packages/cellix/mock-oauth2-server/mock-users.json b/packages/cellix/mock-oauth2-server/mock-users.json index 63fdf1617..1bd05d953 100644 --- a/packages/cellix/mock-oauth2-server/mock-users.json +++ b/packages/cellix/mock-oauth2-server/mock-users.json @@ -1,36 +1,36 @@ { - "users": { - "915d7603-a146-4aef-b5ec-12861642eddd": { - "aud": "test-client-id", - "sub": "915d7603-a146-4aef-b5ec-12861642eddd", - "iss": "http://localhost:4001", - "email": "nkduy2011@gmail.com", - "given_name": "Duy", - "family_name": "Nguyen", - "tid": "test-tenant-id" - }, - "a53e5635-d0d1-4c15-92b2-ebb298c84772": { - "aud": "test-client-id", - "sub": "a53e5635-d0d1-4c15-92b2-ebb298c84772", - "iss": "http://localhost:4001", - "email": "nkduy2011@gmail.com", - "given_name": "Duy", - "family_name": "Nguyen", - "tid": "test-tenant-id" - }, - "9ddf640b-9676-44af-97b4-152883c03fdf": { - "aud": "test-client-id", - "sub": "9ddf640b-9676-44af-97b4-152883c03fdf", - "iss": "http://localhost:4001", - "email": "nkduy2011@gmail.com", - "given_name": "Duy", - "family_name": "Nguyen", - "tid": "test-tenant-id" - } - }, - "refreshTokens": { - "0d46da63-0b08-4e6b-a247-9c6a13f799d6": "915d7603-a146-4aef-b5ec-12861642eddd", - "aed57ef7-dfdd-40f5-8a0a-dfe3f79f34d6": "a53e5635-d0d1-4c15-92b2-ebb298c84772", - "4b397cc7-0367-43dc-ad23-08b450ff0454": "9ddf640b-9676-44af-97b4-152883c03fdf" - } -} \ No newline at end of file + "users": { + "915d7603-a146-4aef-b5ec-12861642eddd": { + "aud": "test-client-id", + "sub": "915d7603-a146-4aef-b5ec-12861642eddd", + "iss": "http://localhost:4001", + "email": "nkduy2011@gmail.com", + "given_name": "Duy", + "family_name": "Nguyen", + "tid": "test-tenant-id" + }, + "a53e5635-d0d1-4c15-92b2-ebb298c84772": { + "aud": "test-client-id", + "sub": "a53e5635-d0d1-4c15-92b2-ebb298c84772", + "iss": "http://localhost:4001", + "email": "nkduy2011@gmail.com", + "given_name": "Duy", + "family_name": "Nguyen", + "tid": "test-tenant-id" + }, + "9ddf640b-9676-44af-97b4-152883c03fdf": { + "aud": "test-client-id", + "sub": "9ddf640b-9676-44af-97b4-152883c03fdf", + "iss": "http://localhost:4001", + "email": "nkduy2011@gmail.com", + "given_name": "Duy", + "family_name": "Nguyen", + "tid": "test-tenant-id" + } + }, + "refreshTokens": { + "0d46da63-0b08-4e6b-a247-9c6a13f799d6": "915d7603-a146-4aef-b5ec-12861642eddd", + "aed57ef7-dfdd-40f5-8a0a-dfe3f79f34d6": "a53e5635-d0d1-4c15-92b2-ebb298c84772", + "4b397cc7-0367-43dc-ad23-08b450ff0454": "9ddf640b-9676-44af-97b4-152883c03fdf" + } +} diff --git a/packages/cellix/mock-oauth2-server/package.json b/packages/cellix/mock-oauth2-server/package.json index 1f402e89d..e95a82efe 100644 --- a/packages/cellix/mock-oauth2-server/package.json +++ b/packages/cellix/mock-oauth2-server/package.json @@ -1,31 +1,31 @@ { - "name": "@cellix/mock-oauth2-server", - "version": "1.0.0", - "author": "", - "license": "MIT", - "description": "Local OAuth2/OIDC mock server for dev/testing", - "type": "module", - "main": "dist/src/index.js", - "types": "dist/src/index.d.ts", - "scripts": { - "build": "tsc --build", - "clean": "rimraf dist node_modules tsconfig.tsbuildinfo && tsc --build --clean", - "lint": "biome lint", - "format": "biome format --write", - "start": "node dist/src/index.js", - "dev": "tsx watch src/index.ts", - "prebuild": "biome lint" - }, - "dependencies": { - "dotenv": "^16.4.5", - "express": "^4.22.1", - "jose": "^5.9.6" - }, - "devDependencies": { - "@cellix/typescript-config": "workspace:*", - "@types/express": "^4.17.23", - "rimraf": "^6.0.1", - "tsx": "^4.20.3", - "typescript": "^5.8.3" - } -} \ No newline at end of file + "name": "@cellix/mock-oauth2-server", + "version": "1.0.0", + "author": "", + "license": "MIT", + "description": "Local OAuth2/OIDC mock server for dev/testing", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc --build", + "clean": "rimraf dist node_modules tsconfig.tsbuildinfo && tsc --build --clean", + "lint": "biome lint", + "format": "biome format --write", + "start": "node dist/src/index.js", + "dev": "tsx watch src/index.ts", + "prebuild": "biome lint" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.22.1", + "jose": "^5.9.6" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@types/express": "^4.17.23", + "rimraf": "^6.0.1", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/cellix/mock-oauth2-server/src/env.d.ts b/packages/cellix/mock-oauth2-server/src/env.d.ts new file mode 100644 index 000000000..10c09a5e2 --- /dev/null +++ b/packages/cellix/mock-oauth2-server/src/env.d.ts @@ -0,0 +1,15 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + ALLOWED_REDIRECT_URI?: string; + Email?: string; + Admin_Email?: string; + Given_Name?: string; + Admin_Given_Name?: string; + Family_Name?: string; + Admin_Family_Name?: string; + } + } +} + +export {}; diff --git a/packages/cellix/mock-oauth2-server/src/index.ts b/packages/cellix/mock-oauth2-server/src/index.ts index 61f34b830..ce055ce1c 100644 --- a/packages/cellix/mock-oauth2-server/src/index.ts +++ b/packages/cellix/mock-oauth2-server/src/index.ts @@ -33,8 +33,7 @@ const redirectUriToAudience = new Map([ ]); // Deprecated: kept for backwards compatibility const allowedRedirectUri = - // biome-ignore lint:useLiteralKeys - process.env['ALLOWED_REDIRECT_URI'] || + process.env.ALLOWED_REDIRECT_URI || 'http://localhost:3000/auth-redirect-user'; // Type for user profile used in token claims interface TokenProfile { @@ -190,14 +189,14 @@ async function main() { // Use different credentials based on portal type const email = isAdminPortal - ? process.env['Admin_Email'] || process.env['Email'] || '' - : process.env['Email'] || ''; + ? process.env.Admin_Email || process.env.Email || '' + : process.env.Email || ''; const given_name = isAdminPortal - ? process.env['Admin_Given_Name'] || process.env['Given_Name'] || '' - : process.env['Given_Name'] || ''; + ? process.env.Admin_Given_Name || process.env.Given_Name || '' + : process.env.Given_Name || ''; const family_name = isAdminPortal - ? process.env['Admin_Family_Name'] || process.env['Family_Name'] || '' - : process.env['Family_Name'] || ''; + ? process.env.Admin_Family_Name || process.env.Family_Name || '' + : process.env.Family_Name || ''; const profile: TokenProfile = { aud: aud, // Now using proper audience identifier diff --git a/packages/cellix/mock-oauth2-server/src/setup-environment.ts b/packages/cellix/mock-oauth2-server/src/setup-environment.ts index 5ace1559d..9fa5346bf 100644 --- a/packages/cellix/mock-oauth2-server/src/setup-environment.ts +++ b/packages/cellix/mock-oauth2-server/src/setup-environment.ts @@ -1,8 +1,8 @@ import dotenv from 'dotenv'; export const setupEnvironment = () => { - console.log('Setting up environment variables'); - dotenv.config(); - dotenv.config({ path: `.env.local`, override: true }); - console.log('Environment variables set up'); -} \ No newline at end of file + console.log('Setting up environment variables'); + dotenv.config(); + dotenv.config({ path: `.env.local`, override: true }); + console.log('Environment variables set up'); +}; diff --git a/packages/cellix/mock-oauth2-server/turbo.json b/packages/cellix/mock-oauth2-server/turbo.json index a10129ea4..6403b5e05 100644 --- a/packages/cellix/mock-oauth2-server/turbo.json +++ b/packages/cellix/mock-oauth2-server/turbo.json @@ -1,4 +1,4 @@ { - "extends": ["//"], - "tags": ["backend"] -} \ No newline at end of file + "extends": ["//"], + "tags": ["backend"] +} diff --git a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/features/query-listing-requests-by-sharer-id.feature b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/features/query-listing-requests-by-sharer-id.feature index 36f6ccb6a..0f35fdd6f 100644 --- a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/features/query-listing-requests-by-sharer-id.feature +++ b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/features/query-listing-requests-by-sharer-id.feature @@ -4,10 +4,10 @@ Feature: Query Listing Reservation Requests By Sharer ID Given a valid sharer ID "user-123" And the sharer has listings with 4 reservation requests When the queryListingRequestsBySharerId command is executed - Then 4 reservation requests should be returned + Then 4 listing requests should be returned in a page Scenario: Retrieving requests for sharer with no listings or requests Given a valid sharer ID "user-456" And the sharer has no reservation requests When the queryListingRequestsBySharerId command is executed - Then an empty array should be returned + Then an empty page should be returned diff --git a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/index.ts b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/index.ts index e459f01a2..c125b4e7f 100644 --- a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/index.ts +++ b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/index.ts @@ -7,7 +7,7 @@ import { type ReservationRequestQueryByIdCommand, queryById } from './query-by-i import { type ReservationRequestCreateCommand, create } from './create.ts'; import { type ReservationRequestQueryOverlapByListingIdAndReservationPeriodCommand, queryOverlapByListingIdAndReservationPeriod } from './query-overlap-by-listing-id-and-reservation-period.ts'; import { type ReservationRequestQueryActiveByListingIdCommand, queryActiveByListingId } from './query-active-by-listing-id.ts'; -import { type ReservationRequestQueryListingRequestsBySharerIdCommand, queryListingRequestsBySharerId } from './query-listing-requests-by-sharer-id.ts'; +import { type ListingRequestPage, type ReservationRequestQueryListingRequestsBySharerIdCommand, queryListingRequestsBySharerId } from './query-listing-requests-by-sharer-id.ts'; export interface ReservationRequestApplicationService { queryById: (command: ReservationRequestQueryByIdCommand) => Promise, @@ -16,7 +16,7 @@ export interface ReservationRequestApplicationService { queryActiveByReserverIdAndListingId: (command: ReservationRequestQueryActiveByReserverIdAndListingIdCommand) => Promise, queryOverlapByListingIdAndReservationPeriod: (command: ReservationRequestQueryOverlapByListingIdAndReservationPeriodCommand) => Promise, queryActiveByListingId: (command: ReservationRequestQueryActiveByListingIdCommand) => Promise, - queryListingRequestsBySharerId: (command: ReservationRequestQueryListingRequestsBySharerIdCommand) => Promise, + queryListingRequestsBySharerId: (command: ReservationRequestQueryListingRequestsBySharerIdCommand) => Promise, create: (command: ReservationRequestCreateCommand) => Promise, } @@ -30,7 +30,7 @@ export const ReservationRequest = ( queryActiveByReserverIdAndListingId: queryActiveByReserverIdAndListingId(dataSources), queryOverlapByListingIdAndReservationPeriod: queryOverlapByListingIdAndReservationPeriod(dataSources), queryActiveByListingId: queryActiveByListingId(dataSources), - queryListingRequestsBySharerId: queryListingRequestsBySharerId(dataSources), + queryListingRequestsBySharerId: queryListingRequestsBySharerId(dataSources), create: create(dataSources), } } \ No newline at end of file diff --git a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.test.ts b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.test.ts index e5b567ddf..3ca501186 100644 --- a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.test.ts +++ b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.test.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import type { DataSources } from '@sthrift/persistence'; -import { expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { queryListingRequestsBySharerId, type ReservationRequestQueryListingRequestsBySharerIdCommand, @@ -37,7 +37,14 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // biome-ignore lint/suspicious/noExplicitAny: Test mock type assertion } as any; - command = { sharerId: 'user-123' }; + command = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }; result = undefined; }); @@ -45,7 +52,14 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { "Successfully retrieving reservation requests for sharer's listings", ({ Given, And, When, Then }) => { Given('a valid sharer ID "user-123"', () => { - command = { sharerId: 'user-123' }; + command = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }; }); And('the sharer has listings with 4 reservation requests', () => { @@ -87,9 +101,10 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }, ); - Then('4 reservation requests should be returned', () => { + Then('4 listing requests should be returned in a page', () => { expect(result).toBeDefined(); - expect(result.length).toBe(4); + expect(result.items.length).toBe(4); + expect(result.total).toBe(4); }); }, ); @@ -98,7 +113,14 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { 'Retrieving requests for sharer with no listings or requests', ({ Given, And, When, Then }) => { Given('a valid sharer ID "user-456"', () => { - command = { sharerId: 'user-456' }; + command = { + sharerId: 'user-456', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }; }); And('the sharer has no reservation requests', () => { @@ -118,10 +140,217 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }, ); - Then('an empty array should be returned', () => { + Then('an empty page should be returned', () => { expect(result).toBeDefined(); - expect(result.length).toBe(0); + expect(result.items.length).toBe(0); + expect(result.total).toBe(0); }); }, ); }); + +describe('queryListingRequestsBySharerId (unit)', () => { + function makeMockDataSources(requests: unknown[]): DataSources { + return { + readonlyDataSource: { + ReservationRequest: { + ReservationRequest: { + ReservationRequestReadRepo: { + getListingRequestsBySharerId: vi + .fn() + .mockResolvedValue(requests), + }, + }, + }, + }, + // biome-ignore lint/suspicious/noExplicitAny: Test mock type assertion + } as any; + } + + it('filters by trimmed, case-insensitive searchText', async () => { + const mockDataSources = makeMockDataSources([ + { id: 'req-1', state: 'Requested', listing: { title: 'Red Bike' } }, + { id: 'req-2', state: 'Requested', listing: { title: 'Blue Car' } }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: ' BIKE ', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.total).toBe(1); + expect(result.items[0]?.title).toBe('Red Bike'); + }); + + it('filters by statusFilters when provided', async () => { + const mockDataSources = makeMockDataSources([ + { id: 'req-1', state: 'Accepted', listing: { title: 'Item A' } }, + { id: 'req-2', state: 'Requested', listing: { title: 'Item B' } }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: ['Accepted'], + sorter: { field: 'requestedOn', order: 'descend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.total).toBe(1); + expect(result.items[0]?.status).toBe('Accepted'); + }); + + it('maps fallback values when listing/reserver/date fields are missing', async () => { + const mockDataSources = makeMockDataSources([ + { + id: 'req-1', + state: undefined, + listing: 'not-an-object', + reserver: { account: { username: null } }, + createdAt: 'not-a-date', + reservationPeriodStart: 'not-a-date', + reservationPeriodEnd: null, + }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.items[0]?.title).toBe('Unknown'); + expect(result.items[0]?.requestedBy).toBe('@unknown'); + expect(result.items[0]?.status).toBe('Pending'); + expect(result.items[0]?.reservationPeriod).toContain('N/A'); + expect(result.items[0]?.requestedOn).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:/, + ); + }); + + it('maps username, dates, and listing title when provided', async () => { + const mockDataSources = makeMockDataSources([ + { + id: 'req-1', + state: 'Requested', + listing: { title: 'Cordless Drill' }, + reserver: { account: { username: 'bob' } }, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + reservationPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + reservationPeriodEnd: new Date('2025-02-03T00:00:00.000Z'), + }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.items[0]?.title).toBe('Cordless Drill'); + expect(result.items[0]?.requestedBy).toBe('@bob'); + expect(result.items[0]?.requestedOn).toBe('2025-01-01T00:00:00.000Z'); + expect(result.items[0]?.reservationPeriod).toBe('2025-02-01 - 2025-02-03'); + }); + + it('falls back to "Unknown" when listing has a title field but it is undefined', async () => { + const mockDataSources = makeMockDataSources([ + { id: 'req-1', state: 'Requested', listing: { title: undefined } }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.items[0]?.title).toBe('Unknown'); + }); + + it('does not throw when sorting by an unknown field (null/undefined compare path)', async () => { + const mockDataSources = makeMockDataSources([ + { id: 'req-1', state: 'Requested', listing: { title: 'Item A' } }, + { id: 'req-2', state: 'Requested', listing: { title: 'Item B' } }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'doesNotExist', order: 'ascend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.total).toBe(2); + expect(result.items).toHaveLength(2); + }); + + it('sorts by title ascending', async () => { + const mockDataSources = makeMockDataSources([ + { id: 'req-1', state: 'Requested', listing: { title: 'C' } }, + { id: 'req-2', state: 'Requested', listing: { title: 'A' } }, + { id: 'req-3', state: 'Requested', listing: { title: 'B' } }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'title', order: 'ascend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.items.map((i) => i.title)).toEqual(['A', 'B', 'C']); + }); + + it('sorts by title descending', async () => { + const mockDataSources = makeMockDataSources([ + { id: 'req-1', state: 'Requested', listing: { title: 'A' } }, + { id: 'req-2', state: 'Requested', listing: { title: 'C' } }, + { id: 'req-3', state: 'Requested', listing: { title: 'B' } }, + ]); + + const command: ReservationRequestQueryListingRequestsBySharerIdCommand = { + sharerId: 'user-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'title', order: 'descend' }, + }; + + const queryFn = queryListingRequestsBySharerId(mockDataSources); + const result = await queryFn(command); + expect(result.items.map((i) => i.title)).toEqual(['C', 'B', 'A']); + }); +}); diff --git a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.ts b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.ts index b4b4b06ff..e3cd8f670 100644 --- a/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.ts +++ b/packages/sthrift/application-services/src/contexts/reservation-request/reservation-request/query-listing-requests-by-sharer-id.ts @@ -3,19 +3,112 @@ import type { DataSources } from '@sthrift/persistence'; export interface ReservationRequestQueryListingRequestsBySharerIdCommand { sharerId: string; + page: number; + pageSize: number; + searchText: string; + statusFilters: string[]; + sorter: { field: string; order: string }; fields?: string[]; } +export interface ListingRequestPage { + items: Array<{ + id: string; + title: string; + image?: string | null; + requestedBy: string; + requestedOn: string; + reservationPeriod: string; + status: string; + }>; + total: number; + page: number; + pageSize: number; +} + +type ListingRequestPageItem = ListingRequestPage['items'][number]; + // Temporary implementation backed by mock data in persistence read repository export const queryListingRequestsBySharerId = ( dataSources: DataSources, ) => { return async ( command: ReservationRequestQueryListingRequestsBySharerIdCommand, - ): Promise => { - return await dataSources.readonlyDataSource.ReservationRequest.ReservationRequest.ReservationRequestReadRepo.getListingRequestsBySharerId( - command.sharerId, - { fields: command.fields } - ); + ): Promise => { + const requests: Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference[] = + await dataSources.readonlyDataSource.ReservationRequest.ReservationRequest.ReservationRequestReadRepo.getListingRequestsBySharerId( + command.sharerId, + { fields: command.fields }, + ); + + const mapped: ListingRequestPage['items'] = requests.map((r) => { + const start = + r.reservationPeriodStart instanceof Date + ? r.reservationPeriodStart + : undefined; + const end = + r.reservationPeriodEnd instanceof Date ? r.reservationPeriodEnd : undefined; + return { + id: r.id, + title: + r.listing && typeof r.listing === 'object' && 'title' in r.listing + ? ((r.listing as { title?: string }).title ?? 'Unknown') + : 'Unknown', + image: '/assets/item-images/placeholder.png', + requestedBy: + r.reserver?.account?.username != null + ? `@${r.reserver.account.username}` + : '@unknown', + requestedOn: + r.createdAt instanceof Date + ? r.createdAt.toISOString() + : new Date().toISOString(), + reservationPeriod: `${start ? start.toISOString().slice(0, 10) : 'N/A'} - ${end ? end.toISOString().slice(0, 10) : 'N/A'}`, + status: r.state ?? 'Pending', + }; + }); + + let working = mapped; + const searchText = command.searchText.trim(); + if (searchText.length > 0) { + const term = searchText.toLowerCase(); + working = working.filter((m) => m.title.toLowerCase().includes(term)); + } + + if (command.statusFilters.length > 0) { + working = working.filter((m) => command.statusFilters.includes(m.status)); + } + + const order = command.sorter.order; + if (command.sorter.field && (order === 'ascend' || order === 'descend')) { + const field = command.sorter.field as keyof ListingRequestPageItem; + working = [...working].sort((a, b) => { + const A = a[field]; + const B = b[field]; + if (A == null) { + return order === 'ascend' ? -1 : 1; + } + if (B == null) { + return order === 'ascend' ? 1 : -1; + } + if (A < B) { + return order === 'ascend' ? -1 : 1; + } + if (A > B) { + return order === 'ascend' ? 1 : -1; + } + return 0; + }); + } + + const total = working.length; + const startIndex = (command.page - 1) * command.pageSize; + const endIndex = startIndex + command.pageSize; + return { + items: working.slice(startIndex, endIndex), + total, + page: command.page, + pageSize: command.pageSize, + }; }; }; diff --git a/packages/sthrift/graphql/src/schema/types/reservation-request/features/reservation-request.resolvers.feature b/packages/sthrift/graphql/src/schema/types/reservation-request/features/reservation-request.resolvers.feature index 68ddb228e..abc5914cc 100644 --- a/packages/sthrift/graphql/src/schema/types/reservation-request/features/reservation-request.resolvers.feature +++ b/packages/sthrift/graphql/src/schema/types/reservation-request/features/reservation-request.resolvers.feature @@ -42,28 +42,9 @@ So that I can view my reservations and make new ones through the GraphQL API Given a valid sharerId And valid pagination arguments (page, pageSize) When the myListingsRequests query is executed - Then it should call ReservationRequest.queryListingRequestsBySharerId with the provided sharerId - And it should paginate and map the results using paginateAndFilterListingRequests + Then it should call ReservationRequest.queryListingRequestsBySharerId with the provided arguments And it should return items, total, page, and pageSize - Scenario: Filtering myListingsRequests by search text - Given reservation requests for a sharer - And a searchText "camera" - When the myListingsRequests query is executed - Then only listings whose titles include "camera" should be returned - - Scenario: Filtering myListingsRequests by status - Given reservation requests with mixed statuses ["Pending", "Approved"] - And a statusFilters ["Approved"] - When the myListingsRequests query is executed - Then only requests with status "Approved" should be included - - Scenario: Sorting myListingsRequests by requestedOn descending - Given reservation requests with varying createdAt timestamps - And sorter field "requestedOn" with order "descend" - When the myListingsRequests query is executed - Then results should be sorted by requestedOn in descending order - Scenario: Error while querying myListingsRequests Given ReservationRequest.queryListingRequestsBySharerId throws an error When the myListingsRequests query is executed @@ -125,20 +106,3 @@ So that I can view my reservations and make new ones through the GraphQL API And ReservationRequest.create throws an error When the createReservationRequest mutation is executed Then it should propagate the error message - - Scenario: Mapping listing request fields - Given a ListingRequestDomainShape object with title, state, and reserver username - When paginateAndFilterListingRequests is called - Then it should map title, requestedBy, requestedOn, reservationPeriod, and status into ListingRequestUiShape - And missing fields should default to 'Unknown', '@unknown', or 'Pending' as appropriate - - Scenario: Paginating listing requests - Given 25 listing requests and a pageSize of 10 - When paginateAndFilterListingRequests is called for page 2 - Then it should return 10 items starting from index 10 and total 25 - - Scenario: Sorting listing requests by title ascending - Given multiple listing requests with varying titles - And sorter field "title" with order "ascend" - When paginateAndFilterListingRequests is called - Then the results should be sorted alphabetically by title \ No newline at end of file diff --git a/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.paginate.test.ts b/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.paginate.test.ts new file mode 100644 index 000000000..faa23c155 --- /dev/null +++ b/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.paginate.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import * as reservationRequestModule from './reservation-request.resolvers.ts'; + +describe('reservation-request.resolvers exports', () => { + it('does not export paginateAndFilterListingRequests', () => { + expect( + (reservationRequestModule as unknown as Record)[ + 'paginateAndFilterListingRequests' + ], + ).toBeUndefined(); + }); +}); diff --git a/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.test.ts b/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.test.ts index 4a20f5c4f..25c16449e 100644 --- a/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.test.ts +++ b/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.test.ts @@ -281,19 +281,22 @@ test.for(feature, ({ Scenario }) => { context = makeMockGraphContext(); }); And('valid pagination arguments (page, pageSize)', () => { - const mockRequests = [ - createMockReservationRequest({ - id: '1', - state: 'Requested', - createdAt: new Date('2024-01-01'), - reservationPeriodStart: new Date('2024-02-01'), - reservationPeriodEnd: new Date('2024-02-10'), - listing: { title: 'Test Item' } as ItemListingEntity, - reserver: { - account: { username: 'testuser' }, - } as PersonalUserEntity, - }), - ]; + const mockRequests = { + items: [ + { + id: '1', + title: 'Test Item', + image: '/assets/item-images/placeholder.png', + requestedBy: '@testuser', + requestedOn: new Date('2024-01-01').toISOString(), + reservationPeriod: '2024-02-01 - 2024-02-10', + status: 'Requested', + }, + ], + total: 1, + page: 1, + pageSize: 10, + }; vi.mocked( context.applicationServices.ReservationRequest.ReservationRequest .queryListingRequestsBySharerId, @@ -305,27 +308,38 @@ test.for(feature, ({ Scenario }) => { sharerId: string; page: number; pageSize: number; + searchText: string; + statusFilters: string[]; + sorter: { field: string; order: string }; }>; result = await resolver( {}, - { sharerId, page: 1, pageSize: 10 }, + { + sharerId, + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }, context, {} as never, ); }); Then( - 'it should call ReservationRequest.queryListingRequestsBySharerId with the provided sharerId', + 'it should call ReservationRequest.queryListingRequestsBySharerId with the provided arguments', () => { expect( context.applicationServices.ReservationRequest.ReservationRequest .queryListingRequestsBySharerId, - ).toHaveBeenCalledWith({ sharerId }); - }, - ); - And( - 'it should paginate and map the results using paginateAndFilterListingRequests', - () => { - expect(result).toBeDefined(); + ).toHaveBeenCalledWith({ + sharerId, + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }); }, ); And('it should return items, total, page, and pageSize', () => { @@ -337,130 +351,6 @@ test.for(feature, ({ Scenario }) => { }, ); - Scenario( - 'Filtering myListingsRequests by search text', - ({ Given, And, When, Then }) => { - Given('reservation requests for a sharer', () => { - context = makeMockGraphContext(); - const mockRequests = [ - createMockReservationRequest({ - id: '1', - state: 'Requested', - createdAt: new Date(), - listing: { title: 'Camera' } as ItemListingEntity, - reserver: { account: { username: 'user1' } } as PersonalUserEntity, - }), - createMockReservationRequest({ - id: '2', - state: 'Requested', - createdAt: new Date(), - listing: { title: 'Drone' } as ItemListingEntity, - reserver: { account: { username: 'user2' } } as PersonalUserEntity, - }), - ]; - vi.mocked( - context.applicationServices.ReservationRequest.ReservationRequest - .queryListingRequestsBySharerId, - ).mockResolvedValue(mockRequests); - }); - And('a searchText "camera"', () => { - // Searchtext will be used in the When step - }); - When('the myListingsRequests query is executed', async () => { - const resolver = reservationRequestResolvers.Query - ?.myListingsRequests as TestResolver<{ - sharerId: string; - page: number; - pageSize: number; - searchText: string; - }>; - result = await resolver( - {}, - { - sharerId: 'sharer-123', - page: 1, - pageSize: 10, - searchText: 'camera', - }, - context, - {} as never, - ); - }); - Then( - 'only listings whose titles include "camera" should be returned', - () => { - const items = (result as { items: { title: string }[] }).items; - expect(items).toHaveLength(1); - expect(items[0]?.title).toBe('Camera'); - }, - ); - }, - ); - - Scenario( - 'Filtering myListingsRequests by status', - ({ Given, And, When, Then }) => { - Given( - 'reservation requests with mixed statuses ["Pending", "Approved"]', - () => { - context = makeMockGraphContext(); - const mockRequests = [ - createMockReservationRequest({ - id: '1', - state: 'Accepted', - createdAt: new Date(), - listing: { title: 'Item 1' } as ItemListingEntity, - reserver: { - account: { username: 'user1' }, - } as PersonalUserEntity, - }), - createMockReservationRequest({ - id: '2', - state: 'Requested', - createdAt: new Date(), - listing: { title: 'Item 2' } as ItemListingEntity, - reserver: { - account: { username: 'user2' }, - } as PersonalUserEntity, - }), - ]; - vi.mocked( - context.applicationServices.ReservationRequest.ReservationRequest - .queryListingRequestsBySharerId, - ).mockResolvedValue(mockRequests); - }, - ); - And('a statusFilters ["Approved"]', () => { - // Status filters will be used in the When step - }); - When('the myListingsRequests query is executed', async () => { - const resolver = reservationRequestResolvers.Query - ?.myListingsRequests as unknown as TestResolver<{ - sharerId: string; - page: number; - pageSize: number; - statusFilters: string[]; - }>; - result = await resolver( - {}, - { - sharerId: 'sharer-123', - page: 1, - pageSize: 10, - statusFilters: ['Accepted'], - }, - context, - {} as never, - ); - }); - Then('only requests with status "Approved" should be included', () => { - const items = (result as { items: { status: string }[] }).items; - expect(items).toHaveLength(1); - expect(items[0]?.status).toBe('Accepted'); - }); - }, - ); - Scenario( 'Querying active reservation for a specific listing', ({ Given, When, Then, And }) => { @@ -806,74 +696,6 @@ test.for(feature, ({ Scenario }) => { }, ); - Scenario( - 'Sorting myListingsRequests by requestedOn descending', - ({ Given, And, When, Then }) => { - Given('reservation requests with varying createdAt timestamps', () => { - context = makeMockGraphContext(); - const mockRequests = [ - createMockReservationRequest({ - id: '1', - state: 'Requested', - createdAt: new Date('2024-01-01'), - listing: { title: 'Item 1' } as ItemListingEntity, - reserver: { account: { username: 'user1' } } as PersonalUserEntity, - }), - createMockReservationRequest({ - id: '2', - state: 'Requested', - createdAt: new Date('2024-01-03'), - listing: { title: 'Item 2' } as ItemListingEntity, - reserver: { account: { username: 'user2' } } as PersonalUserEntity, - }), - createMockReservationRequest({ - id: '3', - state: 'Requested', - createdAt: new Date('2024-01-02'), - listing: { title: 'Item 3' } as ItemListingEntity, - reserver: { account: { username: 'user3' } } as PersonalUserEntity, - }), - ]; - vi.mocked( - context.applicationServices.ReservationRequest.ReservationRequest - .queryListingRequestsBySharerId, - ).mockResolvedValue(mockRequests); - }); - And('sorter field "requestedOn" with order "descend"', () => { - // Sorter will be used in the When step - }); - When('the myListingsRequests query is executed', async () => { - const resolver = reservationRequestResolvers.Query - ?.myListingsRequests as TestResolver<{ - sharerId: string; - page: number; - pageSize: number; - sorter?: { field: string; order: 'ascend' | 'descend' }; - }>; - result = await resolver( - {}, - { - sharerId: 'sharer-123', - page: 1, - pageSize: 10, - sorter: { field: 'requestedOn', order: 'descend' }, - }, - context, - {} as never, - ); - }); - Then( - 'results should be sorted by requestedOn in descending order', - () => { - const items = (result as { items: { requestedOn: string }[] }).items; - expect(items.length).toBeGreaterThan(0); - // Just verify that sorting was applied (items are in expected order based on input) - // The actual sorting logic is tested by the implementation - expect(items.length).toBe(3); - }, - ); - }, - ); Scenario( 'Error while querying myListingsRequests', @@ -895,10 +717,20 @@ test.for(feature, ({ Scenario }) => { sharerId: string; page: number; pageSize: number; + searchText: string; + statusFilters: string[]; + sorter: { field: string; order: string }; }>; await resolver( {}, - { sharerId: 'sharer-123', page: 1, pageSize: 10 }, + { + sharerId: 'sharer-123', + page: 1, + pageSize: 10, + searchText: '', + statusFilters: [], + sorter: { field: 'requestedOn', order: 'descend' }, + }, context, {} as never, ); @@ -955,202 +787,4 @@ test.for(feature, ({ Scenario }) => { }, ); - Scenario('Mapping listing request fields', ({ Given, When, Then, And }) => { - // This is tested implicitly through other scenarios that use myListingsRequests - // as they all verify the mapping occurs correctly - Given( - 'a ListingRequestDomainShape object with title, state, and reserver username', - () => { - context = makeMockGraphContext(); - const mockRequests = [ - createMockReservationRequest({ - id: '1', - state: 'Requested', - createdAt: new Date('2024-01-01'), - reservationPeriodStart: new Date('2024-02-01'), - reservationPeriodEnd: new Date('2024-02-10'), - listing: { title: 'Test Item' } as ItemListingEntity, - reserver: { - account: { username: 'testuser' }, - } as PersonalUserEntity, - }), - ]; - vi.mocked( - context.applicationServices.ReservationRequest.ReservationRequest - .queryListingRequestsBySharerId, - ).mockResolvedValue(mockRequests); - }, - ); - When('paginateAndFilterListingRequests is called', async () => { - const resolver = reservationRequestResolvers.Query - ?.myListingsRequests as TestResolver<{ - sharerId: string; - page: number; - pageSize: number; - }>; - result = await resolver( - {}, - { sharerId: 'sharer-123', page: 1, pageSize: 10 }, - context, - {} as never, - ); - }); - Then( - 'it should map title, requestedBy, requestedOn, reservationPeriod, and status into ListingRequestUiShape', - () => { - const items = ( - result as { - items: { - title: string; - requestedBy: string; - requestedOn: string; - reservationPeriod: string; - status: string; - }[]; - } - ).items; - expect(items[0]).toHaveProperty('title'); - expect(items[0]).toHaveProperty('requestedBy'); - expect(items[0]).toHaveProperty('requestedOn'); - expect(items[0]).toHaveProperty('reservationPeriod'); - expect(items[0]).toHaveProperty('status'); - }, - ); - And( - "missing fields should default to 'Unknown', '@unknown', or 'Pending' as appropriate", - () => { - // Test with missing fields - const items = ( - result as { - items: { - title: string; - requestedBy: string; - status: string; - }[]; - } - ).items; - expect(items[0]?.title).toBe('Test Item'); - expect(items[0]?.requestedBy).toBe('@testuser'); - expect(items[0]?.status).toBe('Requested'); - }, - ); - }); - - Scenario('Paginating listing requests', ({ Given, When, Then }) => { - Given('25 listing requests and a pageSize of 10', () => { - context = makeMockGraphContext(); - const mockRequests = Array.from({ length: 25 }, (_, i) => - createMockReservationRequest({ - id: `${i + 1}`, - state: 'Requested', - createdAt: new Date(), - listing: { title: `Item ${i + 1}` } as ItemListingEntity, - reserver: { - account: { username: `user${i + 1}` }, - } as PersonalUserEntity, - }), - ); - vi.mocked( - context.applicationServices.ReservationRequest.ReservationRequest - .queryListingRequestsBySharerId, - ).mockResolvedValue(mockRequests); - }); - When('paginateAndFilterListingRequests is called for page 2', async () => { - const resolver = reservationRequestResolvers.Query - ?.myListingsRequests as TestResolver<{ - sharerId: string; - page: number; - pageSize: number; - }>; - result = await resolver( - {}, - { sharerId: 'sharer-123', page: 2, pageSize: 10 }, - context, - {} as never, - ); - }); - Then( - 'it should return 10 items starting from index 10 and total 25', - () => { - const paginatedResult = result as { - items: unknown[]; - total: number; - page: number; - pageSize: number; - }; - expect(paginatedResult.items.length).toBe(10); - expect(paginatedResult.total).toBe(25); - expect(paginatedResult.page).toBe(2); - expect(paginatedResult.pageSize).toBe(10); - }, - ); - }); - - Scenario( - 'Sorting listing requests by title ascending', - ({ Given, And, When, Then }) => { - Given('multiple listing requests with varying titles', () => { - context = makeMockGraphContext(); - const mockRequests = [ - createMockReservationRequest({ - id: '1', - state: 'Requested', - createdAt: new Date(), - listing: { title: 'Zebra Camera' } as ItemListingEntity, - reserver: { account: { username: 'user1' } } as PersonalUserEntity, - }), - createMockReservationRequest({ - id: '2', - state: 'Requested', - createdAt: new Date(), - listing: { title: 'Apple Drone' } as ItemListingEntity, - reserver: { account: { username: 'user2' } } as PersonalUserEntity, - }), - createMockReservationRequest({ - id: '3', - state: 'Requested', - createdAt: new Date(), - listing: { title: 'Microphone Beta' } as ItemListingEntity, - reserver: { account: { username: 'user3' } } as PersonalUserEntity, - }), - ]; - vi.mocked( - context.applicationServices.ReservationRequest.ReservationRequest - .queryListingRequestsBySharerId, - ).mockResolvedValue(mockRequests); - }); - And('sorter field "title" with order "ascend"', () => { - // Sorter will be used in the When step - }); - When('paginateAndFilterListingRequests is called', async () => { - const resolver = reservationRequestResolvers.Query - ?.myListingsRequests as TestResolver<{ - sharerId: string; - page: number; - pageSize: number; - sorter?: { field: string; order: 'ascend' | 'descend' }; - }>; - result = await resolver( - {}, - { - sharerId: 'sharer-123', - page: 1, - pageSize: 10, - sorter: { field: 'title', order: 'ascend' }, - }, - context, - {} as never, - ); - }); - Then('the results should be sorted alphabetically by title', () => { - const items = (result as { items: { title: string }[] }).items; - expect(items.length).toBe(3); - // Just verify that the sorting was applied and items are present - const titles = items.map((item) => item.title); - expect(titles).toContain('Apple Drone'); - expect(titles).toContain('Zebra Camera'); - expect(titles).toContain('Microphone Beta'); - }); - }, - ); }); diff --git a/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.ts b/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.ts index cdd60676d..9707f9c01 100644 --- a/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.ts +++ b/packages/sthrift/graphql/src/schema/types/reservation-request/reservation-request.resolvers.ts @@ -1,116 +1,12 @@ import type { GraphContext } from '../../../init/context.ts'; import type { GraphQLResolveInfo } from 'graphql'; import type { Resolvers } from '../../builder/generated.ts'; +import type { QueryMyListingsRequestsArgs } from '../../builder/generated.ts'; import { PopulateItemListingFromField, PopulateUserFromField, } from '../../resolver-helper.ts'; -interface ListingRequestDomainShape { - id: string; - state?: string; - createdAt?: Date; - reservationPeriodStart?: Date; - reservationPeriodEnd?: Date; - listing?: { title?: string; [k: string]: unknown }; - reserver?: { account?: { username?: string } }; - [k: string]: unknown; // allow passthrough -} - -interface ListingRequestUiShape { - id: string; - title: string; - image: string; - requestedBy: string; - requestedOn: string; - reservationPeriod: string; - status: string; - _raw: ListingRequestDomainShape; - [k: string]: unknown; // enable dynamic field sorting access -} - -function paginateAndFilterListingRequests( - requests: ListingRequestDomainShape[], - options: { - page: number; - pageSize: number; - searchText?: string; - statusFilters: string[]; - sorter?: { field: string | null; order: 'ascend' | 'descend' | null }; - }, -) { - const filtered = [...requests]; - - // Map domain objects into shape expected by client (flatten minimal fields) - const mapped: ListingRequestUiShape[] = filtered.map((r) => { - const start = - r.reservationPeriodStart instanceof Date - ? r.reservationPeriodStart - : undefined; - const end = - r.reservationPeriodEnd instanceof Date - ? r.reservationPeriodEnd - : undefined; - return { - id: r.id, - title: r.listing?.title ?? 'Unknown', - image: '/assets/item-images/placeholder.png', // TODO: map real image when available - requestedBy: r.reserver?.account?.username - ? `@${r.reserver.account.username}` - : '@unknown', - requestedOn: - r.createdAt instanceof Date - ? r.createdAt.toISOString() - : new Date().toISOString(), - reservationPeriod: `${start ? start.toISOString().slice(0, 10) : 'N/A'} - ${end ? end.toISOString().slice(0, 10) : 'N/A'}`, - status: r.state ?? 'Pending', - _raw: r, - }; - }); - - let working = mapped; - if (options.searchText) { - const term = options.searchText.toLowerCase(); - working = working.filter((m) => m.title.toLowerCase().includes(term)); - } - - if (options.statusFilters?.length) { - working = working.filter((m) => options.statusFilters?.includes(m.status)); - } - - if (options.sorter?.field) { - const { field, order } = options.sorter; - working.sort((a: ListingRequestUiShape, b: ListingRequestUiShape) => { - const sortField = field as keyof ListingRequestUiShape; - const A = a[sortField]; - const B = b[sortField]; - if (A == null) { - return order === 'ascend' ? -1 : 1; - } - if (B == null) { - return order === 'ascend' ? 1 : -1; - } - if (A < B) { - return order === 'ascend' ? -1 : 1; - } - if (A > B) { - return order === 'ascend' ? 1 : -1; - } - return 0; - }); - } - - const total = working.length; - const startIndex = (options.page - 1) * options.pageSize; - const endIndex = startIndex + options.pageSize; - return { - items: working.slice(startIndex, endIndex), - total, - page: options.page, - pageSize: options.pageSize, - }; -} - const reservationRequest: Resolvers = { ReservationRequest: { reserver: PopulateUserFromField('reserver'), @@ -143,23 +39,20 @@ const reservationRequest: Resolvers = { }, myListingsRequests: async ( _parent: unknown, - args, + args: QueryMyListingsRequestsArgs, context: GraphContext, ) => { - // Fetch reservation requests for listings owned by sharer from application services - const requests = - await context.applicationServices.ReservationRequest.ReservationRequest.queryListingRequestsBySharerId( - { - sharerId: args.sharerId, - }, - ); - return paginateAndFilterListingRequests( - requests as unknown as ListingRequestDomainShape[], + return await context.applicationServices.ReservationRequest.ReservationRequest.queryListingRequestsBySharerId( { + sharerId: args.sharerId, page: args.page, pageSize: args.pageSize, searchText: args.searchText, - statusFilters: [...(args.statusFilters ?? [])], + statusFilters: [...args.statusFilters], + sorter: { + field: args.sorter.field, + order: args.sorter.order, + }, }, ); }, diff --git a/packages/sthrift/messaging-service-mock/src/index.test.ts b/packages/sthrift/messaging-service-mock/src/index.test.ts index dda9dfb46..1d98192f5 100644 --- a/packages/sthrift/messaging-service-mock/src/index.test.ts +++ b/packages/sthrift/messaging-service-mock/src/index.test.ts @@ -12,9 +12,9 @@ describe('ServiceMessagingMock Integration Tests', () => { beforeAll(async () => { process.env['MESSAGING_MOCK_URL'] = MOCK_SERVER_URL; - + mockServer = await startServer(MOCK_SERVER_PORT, true); - + await new Promise((resolve) => setTimeout(resolve, 500)); }, 15000); diff --git a/packages/sthrift/mock-messaging-server/src/index.ts b/packages/sthrift/mock-messaging-server/src/index.ts index 93bc09228..0ae72ea80 100644 --- a/packages/sthrift/mock-messaging-server/src/index.ts +++ b/packages/sthrift/mock-messaging-server/src/index.ts @@ -67,7 +67,7 @@ export function createApp(): Application { } export function startServer(port = 10000, seedData = false): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const app = createApp(); const server = app.listen(port, () => { console.log(`Mock Twilio Server listening on port ${port}`); @@ -80,6 +80,7 @@ export function startServer(port = 10000, seedData = false): Promise { resolve(server); }); + server.on('error', reject); }); } diff --git a/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts b/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts index 9c50e9efb..8697369d0 100644 --- a/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts +++ b/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts @@ -5,7 +5,7 @@ export function toDomainMessage( messagingMessage: MessageInstance, authorId: Domain.Contexts.Conversation.Conversation.AuthorId, ): Domain.Contexts.Conversation.Conversation.MessageEntityReference { - // biome-ignore lint/complexity/useLiteralKeys: metadata is an index signature requiring bracket notation + // biome-ignore lint/complexity/useLiteralKeys: metadata is Record requiring bracket notation per TS4111 const messagingId = (messagingMessage.metadata?.['originalSid'] as string) || messagingMessage.id; diff --git a/packages/sthrift/persistence/src/datasources/readonly/user/admin-user/admin-user.read-repository.test.ts b/packages/sthrift/persistence/src/datasources/readonly/user/admin-user/admin-user.read-repository.test.ts index 0ba9eb2b2..bad9978a3 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/user/admin-user/admin-user.read-repository.test.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/user/admin-user/admin-user.read-repository.test.ts @@ -160,7 +160,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting all admin users with no results', ({ Given, When, Then }) => { Given('no AdminUser documents exist in the database', () => { - const mockDataSource = repository['mongoDataSource'] as unknown as { + const mockDataSource = repository.mongoDataSource as unknown as { find: ReturnType; }; mockDataSource.find = vi.fn(() => Promise.resolve([])); diff --git a/packages/sthrift/persistence/src/datasources/readonly/user/personal-user/personal-user.read-repository.test.ts b/packages/sthrift/persistence/src/datasources/readonly/user/personal-user/personal-user.read-repository.test.ts index f18751aeb..99542bdac 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/user/personal-user/personal-user.read-repository.test.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/user/personal-user/personal-user.read-repository.test.ts @@ -150,7 +150,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting all personal users with no results', ({ Given, When, Then }) => { Given('no PersonalUser documents exist in the database', () => { - const mockDataSource = repository['mongoDataSource'] as unknown as { + const mockDataSource = repository.mongoDataSource as unknown as { find: ReturnType; }; mockDataSource.find = vi.fn(() => Promise.resolve([])); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a455eece..f7798c67f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ catalogs: version: 4.0.15 overrides: - node-forge@<1.3.2: '>=1.3.2' + qs: 6.14.1 importers: @@ -9020,6 +9020,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + node-forge@1.3.2: resolution: {integrity: sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==} engines: {node: '>= 6.13.0'} @@ -9866,12 +9870,8 @@ packages: resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==} engines: {node: '>=12.20'} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} querystringify@2.2.0: @@ -16495,7 +16495,7 @@ snapshots: commander: 13.1.0 fs-extra: 11.3.2 hpagent: 1.2.0 - node-forge: 1.3.2 + node-forge: 1.3.1 properties-file: 3.6.1 proxy-from-env: 1.1.0 semver: 7.7.2 @@ -17937,7 +17937,7 @@ snapshots: http-errors: 2.0.0 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.13.0 + qs: 6.14.1 raw-body: 2.5.2 type-is: 1.6.18 unpipe: 1.0.0 @@ -17952,7 +17952,7 @@ snapshots: http-errors: 2.0.0 iconv-lite: 0.7.0 on-finished: 2.4.1 - qs: 6.14.0 + qs: 6.14.1 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -19353,7 +19353,7 @@ snapshots: parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.1 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.0 @@ -19389,7 +19389,7 @@ snapshots: parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.14.0 + qs: 6.14.1 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.0 @@ -21817,6 +21817,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-forge@1.3.1: {} + node-forge@1.3.2: {} node-int64@0.4.0: {} @@ -22734,11 +22736,7 @@ snapshots: dependencies: escape-goat: 4.0.0 - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - - qs@6.14.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -24155,7 +24153,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.14.0 + qs: 6.14.1 transitivePeerDependencies: - supports-color @@ -24490,7 +24488,7 @@ snapshots: dayjs: 1.11.18 https-proxy-agent: 5.0.1 jsonwebtoken: 9.0.3 - qs: 6.14.0 + qs: 6.14.1 scmp: 2.1.0 xmlbuilder: 13.0.2 transitivePeerDependencies: