Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions quotevote-frontend/.env.local
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Quote.Vote API — production
NEXT_PUBLIC_SERVER_URL=https://api.quote.vote
NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://api.quote.vote/graphql
# Quote.Vote API — local development
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000/graphql

# WebSocket is auto-derived by getGraphqlWsServerUrl():
# wss://api.quote.vote/graphql
# ws://localhost:4000/graphql
# Login is a REST endpoint (not GraphQL):
# POST https://api.quote.vote/login → { username, password } → { token }
# POST http://localhost:4000/login → { username, password } → { token }
Comment on lines +1 to +8
2 changes: 1 addition & 1 deletion quotevote-frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const nextConfig: NextConfig = {
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' http://localhost:4000",
"connect-src 'self' http://localhost:4000 ws://localhost:4000",
"frame-ancestors 'none'",
Comment on lines 56 to 60
].join("; "),
},
Expand Down
81 changes: 49 additions & 32 deletions quotevote-frontend/src/__tests__/app/auths/login.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { render, screen, fireEvent, waitFor, type MockedResponse } from '../../utils/test-utils'
import { render, screen, fireEvent, waitFor } from '../../utils/test-utils'
import LoginPageContent from '@/app/auths/login/PageContent'
import { LOGIN_MUTATION } from '@/graphql/mutations'

const mockPush = jest.fn()
jest.mock('next/navigation', () => ({
Expand All @@ -13,58 +12,76 @@ jest.mock('@/store/useAppStore', () => ({
selector({ setUserData: jest.fn() }),
}))

const mockLoginUser = jest.fn()
jest.mock('@/lib/auth', () => ({
loginUser: (...args: unknown[]) => mockLoginUser(...args),
setToken: jest.fn(),
getToken: jest.fn(),
removeToken: jest.fn(),
}))

describe('LoginPageContent', () => {
beforeEach(() => jest.clearAllMocks())

it('renders email and password fields', () => {
it('renders username/email and password fields', () => {
render(<LoginPageContent />)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
})

it('shows validation errors on empty submit', async () => {
render(<LoginPageContent />)
fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
expect(screen.getByText(/username or email is required/i)).toBeInTheDocument()
})
})

it('calls mutation on valid submit', async () => {
const mocks: MockedResponse[] = [
{
request: {
query: LOGIN_MUTATION,
variables: { username: 'test@example.com', password: 'password123' },
},
result: {
data: {
login: {
token: 'test-token',
user: {
_id: '1',
id: '1',
username: 'test',
email: 'test@example.com',
name: '',
avatar: '',
admin: false,
accountStatus: 'active',
},
},
},
it('calls loginUser on valid submit and redirects', async () => {
mockLoginUser.mockResolvedValue({
success: true,
data: {
user: {
_id: '1',
username: 'test',
email: 'test@example.com',
},
token: 'test-token',
},
]
render(<LoginPageContent />, { mocks })
fireEvent.change(screen.getByLabelText(/email/i), {
})

render(<LoginPageContent />)
fireEvent.change(screen.getByLabelText(/username or email/i), {
target: { value: 'test@example.com' },
})
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: 'password123' },
})
fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/dashboard/search'))
await waitFor(() => {
expect(mockLoginUser).toHaveBeenCalledWith('test@example.com', 'password123')
expect(mockPush).toHaveBeenCalledWith('/dashboard/search')
})
})

it('shows error toast on login failure', async () => {
const { toast } = jest.requireMock('sonner')
mockLoginUser.mockResolvedValue({
success: false,
error: 'Invalid username or password.',
})

render(<LoginPageContent />)
fireEvent.change(screen.getByLabelText(/username or email/i), {
target: { value: 'bad@example.com' },
})
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: 'wrongpass' },
})
fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Invalid username or password.')
})
})

it('renders forgot password link', () => {
Expand Down
35 changes: 18 additions & 17 deletions quotevote-frontend/src/__tests__/app/dashboard/search/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,54 @@
import { render, screen } from '@testing-library/react';
import SearchPage from '@/app/dashboard/search/page';

// Mock the components used in SearchPage
// Mock the SubHeader component
jest.mock('@/components/SubHeader', () => ({
SubHeader: ({ headerName }: { headerName: string }) => (
<header data-testid="subheader">{headerName}</header>
),
}));

// Mock the LoadingSpinner
jest.mock('@/components/LoadingSpinner', () => ({
LoadingSpinner: () => <div data-testid="loading-spinner">Loading...</div>,
}));

jest.mock('@/components/SearchContainer', () => ({
SidebarSearchView: ({ Display }: { Display: string }) => (
<div data-testid="sidebar-search-view" data-display={Display}>
Search View
</div>
// Mock SearchContainer (uses useSearchParams, needs Suspense)
jest.mock('@/components/SearchContainer/SearchContainer', () => ({
__esModule: true,
default: () => (
<div data-testid="search-container">Search Container</div>
),
}));

describe('SearchPage', () => {
it('should render the page with SubHeader', () => {
render(<SearchPage />);

const subheader = screen.getByTestId('subheader');
expect(subheader).toBeInTheDocument();
expect(subheader).toHaveTextContent('Search');
});

it('should render SidebarSearchView with correct display prop', () => {
it('should render SearchContainer', async () => {
render(<SearchPage />);
const searchView = screen.getByTestId('sidebar-search-view');
expect(searchView).toBeInTheDocument();
expect(searchView).toHaveAttribute('data-display', 'block');

// SearchContainer is inside Suspense; in test it renders immediately since mock is sync
const container = await screen.findByTestId('search-container');
expect(container).toBeInTheDocument();
});

it('should have proper structure with space-y-4 class', () => {
const { container } = render(<SearchPage />);

const mainDiv = container.querySelector('.space-y-4');
expect(mainDiv).toBeInTheDocument();
});

it('should wrap content in max-w-4xl container', () => {
it('should have p-4 padding class', () => {
const { container } = render(<SearchPage />);
const maxWidthDiv = container.querySelector('.max-w-4xl');
expect(maxWidthDiv).toBeInTheDocument();

const paddedDiv = container.querySelector('.p-4');
expect(paddedDiv).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
/**
* PostController Component Tests
*
*
* Tests for the PostController component including:
* - Component renders correctly
* - Post ID extraction from params
* - Page state management
* - Edge cases
* - Loading state renders PostSkeleton
* - Error state redirects to /error
* - Successful data fetch renders Post component
* - Missing postId shows "Post not found"
* - Page state management via setSelectedPage
*/

import { render, screen } from '../../utils/test-utils'
import PostController from '../../../components/Post/PostController'
import { GET_POST } from '@/graphql/queries'

// Mock useParams
const mockParams = { postId: 'test-post-id' }
// Mock useRouter
const mockPush = jest.fn()
jest.mock('next/navigation', () => ({
useParams: () => mockParams,
useParams: () => ({ postId: 'test-post-id' }),
useRouter: () => ({
push: jest.fn(),
push: mockPush,
}),
}))

Expand All @@ -26,86 +28,84 @@ jest.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) => {
const state = {
setSelectedPage: mockSetSelectedPage,
user: {
data: {
_id: 'user-123',
admin: false,
_followingId: [],
},
},
}
return selector(state)
},
}))

describe('PostController Component', () => {
beforeEach(() => {
jest.clearAllMocks()
})

describe('Basic Rendering', () => {
it('renders controller component', () => {
render(<PostController />)
expect(screen.getByText(/PostController/)).toBeInTheDocument()
})
// Mock PostSkeleton
jest.mock('../../../components/Post/PostSkeleton', () => ({
__esModule: true,
default: () => <div data-testid="post-skeleton">Loading...</div>,
}))

it('displays post ID from params', () => {
render(<PostController />)
expect(screen.getByText(/test-post-id/)).toBeInTheDocument()
})
// Mock Post component
jest.mock('../../../components/Post/Post', () => ({
__esModule: true,
default: ({ post }: { post: { title?: string } }) => (
<div data-testid="post-component">{post.title}</div>
),
}))

it('displays post ID from prop when provided', () => {
render(<PostController postId="prop-post-id" />)
expect(screen.getByText(/prop-post-id/)).toBeInTheDocument()
})
const mockPost = {
_id: 'test-post-id',
userId: 'user-123',
created: '2024-01-01',
title: 'Test Post',
text: 'Test content',
url: '/dashboard/post/group/test/test-post-id',
comments: [],
votes: [],
quotes: [],
}

it('prefers prop postId over params postId', () => {
render(<PostController postId="prop-post-id" />)
expect(screen.getByText(/prop-post-id/)).toBeInTheDocument()
expect(screen.queryByText(/test-post-id/)).not.toBeInTheDocument()
})
describe('PostController Component', () => {
beforeEach(() => {
jest.clearAllMocks()
})

describe('Page State Management', () => {
it('calls setSelectedPage on mount', () => {
render(<PostController />)
expect(mockSetSelectedPage).toHaveBeenCalledWith('')
})

it('calls setSelectedPage when setSelectedPage changes', () => {
const { rerender } = render(<PostController />)
expect(mockSetSelectedPage).toHaveBeenCalledTimes(1)

rerender(<PostController />)
// Should be called again on rerender if dependency changes
expect(mockSetSelectedPage).toHaveBeenCalled()
describe('Loading State', () => {
it('renders PostSkeleton while loading', () => {
const mocks = [
{
request: {
query: GET_POST,
variables: { postId: 'test-post-id' },
},
result: {
data: { post: mockPost },
},
delay: 1000,
},
]
render(<PostController postId="test-post-id" />, { mocks })
expect(screen.getByTestId('post-skeleton')).toBeInTheDocument()
})
})

describe('Edge Cases', () => {
it('handles missing postId in params', () => {
mockParams.postId = undefined as unknown as string
render(<PostController />)
expect(screen.getByText(/PostController/)).toBeInTheDocument()
})

it('handles empty postId', () => {
mockParams.postId = ''
render(<PostController />)
expect(screen.getByText(/PostController/)).toBeInTheDocument()
describe('Missing postId', () => {
it('shows post not found when postId is empty', () => {
render(<PostController postId="" />)
expect(screen.getByText(/Post not found/i)).toBeInTheDocument()
})

it('handles missing params object', () => {
// Mock useParams to return empty object
jest.doMock('next/navigation', () => ({
useParams: () => ({}),
useRouter: () => ({
push: jest.fn(),
}),
}))
it('shows post not found when postId is undefined', () => {
render(<PostController />)
expect(screen.getByText(/PostController/)).toBeInTheDocument()
expect(screen.getByText(/Post not found/i)).toBeInTheDocument()
})
})

describe('Placeholder Message', () => {
it('displays placeholder message about PostPage implementation', () => {
render(<PostController />)
expect(screen.getByText(/PostPage component should be implemented/)).toBeInTheDocument()
describe('Page State Management', () => {
it('calls setSelectedPage with empty string on mount', () => {
render(<PostController postId="" />)
expect(mockSetSelectedPage).toHaveBeenCalledWith('')
})
})
})

Loading
Loading