diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index 9a46b3a..b583f26 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -338,6 +338,9 @@ const MatchCard = ({ match }) => {
const navigate = useNavigate();
const { partner, event, videoExchange, ratings } = match;
+ // Can rate when video exchange is complete and user hasn't rated yet
+ const canRate = videoExchange?.sentByMe && videoExchange?.receivedFromPartner && !ratings?.ratedByMe;
+
return (
@@ -374,14 +377,25 @@ const MatchCard = ({ match }) => {
- {/* Action */}
-
+ {/* Actions */}
+
+
+ {canRate && (
+
+ )}
+
);
diff --git a/frontend/src/pages/__tests__/DashboardPage.test.jsx b/frontend/src/pages/__tests__/DashboardPage.test.jsx
new file mode 100644
index 0000000..e694105
--- /dev/null
+++ b/frontend/src/pages/__tests__/DashboardPage.test.jsx
@@ -0,0 +1,476 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { BrowserRouter } from 'react-router-dom';
+
+// Mock the modules
+vi.mock('../../contexts/AuthContext', () => ({
+ useAuth: () => ({
+ user: { id: 1, username: 'testuser', firstName: 'Test' },
+ }),
+}));
+
+vi.mock('../../services/api', () => ({
+ dashboardAPI: {
+ getData: vi.fn(),
+ },
+ matchesAPI: {
+ acceptMatch: vi.fn(),
+ deleteMatch: vi.fn(),
+ getMatches: vi.fn().mockResolvedValue({ data: [] }),
+ },
+}));
+
+vi.mock('../../services/socket', () => ({
+ connectSocket: vi.fn(),
+ disconnectSocket: vi.fn(),
+ getSocket: vi.fn(() => ({
+ on: vi.fn(),
+ off: vi.fn(),
+ })),
+}));
+
+import DashboardPage from '../DashboardPage';
+import { dashboardAPI, matchesAPI } from '../../services/api';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+const renderWithRouter = (component) => {
+ return render({component});
+};
+
+describe('DashboardPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Loading State', () => {
+ it('should show loading spinner while fetching data', async () => {
+ dashboardAPI.getData.mockImplementation(() => new Promise(() => {}));
+
+ renderWithRouter();
+
+ expect(screen.getByText('Loading dashboard...')).toBeInTheDocument();
+ });
+ });
+
+ describe('Empty States', () => {
+ it('should show empty state when no events', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('No active events')).toBeInTheDocument();
+ });
+ });
+
+ it('should show empty state when no matches', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('No active matches')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Active Events', () => {
+ it('should display event cards', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [
+ {
+ id: 1,
+ slug: 'test-event',
+ name: 'Test Dance Event',
+ location: 'Warsaw',
+ startDate: '2025-12-01',
+ endDate: '2025-12-03',
+ participantsCount: 50,
+ myHeats: [],
+ },
+ ],
+ activeMatches: [],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Dance Event')).toBeInTheDocument();
+ expect(screen.getByText('Warsaw')).toBeInTheDocument();
+ expect(screen.getByText('50 participants')).toBeInTheDocument();
+ });
+ });
+
+ it('should navigate to event chat when clicking Enter Chat', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [
+ {
+ id: 1,
+ slug: 'test-event',
+ name: 'Test Event',
+ location: 'Warsaw',
+ startDate: '2025-12-01',
+ endDate: '2025-12-03',
+ participantsCount: 50,
+ myHeats: [],
+ },
+ ],
+ activeMatches: [],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Enter Chat')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('Enter Chat'));
+ expect(mockNavigate).toHaveBeenCalledWith('/events/test-event/chat');
+ });
+ });
+
+ describe('Active Matches', () => {
+ it('should display match cards with partner info', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [
+ {
+ id: 1,
+ slug: 'match-123',
+ partner: {
+ id: 2,
+ username: 'sarah_dancer',
+ firstName: 'Sarah',
+ lastName: 'Martinez',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ videoExchange: { sentByMe: false, receivedFromPartner: false },
+ ratings: { ratedByMe: false, ratedByPartner: false },
+ },
+ ],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Sarah Martinez')).toBeInTheDocument();
+ expect(screen.getByText('@sarah_dancer')).toBeInTheDocument();
+ expect(screen.getByText('Test Event')).toBeInTheDocument();
+ });
+ });
+
+ it('should show Rate button when video exchange is complete and not rated', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [
+ {
+ id: 1,
+ slug: 'match-123',
+ partner: {
+ id: 2,
+ username: 'sarah_dancer',
+ firstName: 'Sarah',
+ lastName: 'Martinez',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ videoExchange: { sentByMe: true, receivedFromPartner: true },
+ ratings: { ratedByMe: false, ratedByPartner: false },
+ },
+ ],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Rate')).toBeInTheDocument();
+ });
+ });
+
+ it('should NOT show Rate button when already rated', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [
+ {
+ id: 1,
+ slug: 'match-123',
+ partner: {
+ id: 2,
+ username: 'sarah_dancer',
+ firstName: 'Sarah',
+ lastName: 'Martinez',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ videoExchange: { sentByMe: true, receivedFromPartner: true },
+ ratings: { ratedByMe: true, ratedByPartner: false },
+ },
+ ],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Chat')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('Rate')).not.toBeInTheDocument();
+ });
+
+ it('should NOT show Rate button when video exchange incomplete', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [
+ {
+ id: 1,
+ slug: 'match-123',
+ partner: {
+ id: 2,
+ username: 'sarah_dancer',
+ firstName: 'Sarah',
+ lastName: 'Martinez',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ videoExchange: { sentByMe: true, receivedFromPartner: false },
+ ratings: { ratedByMe: false, ratedByPartner: false },
+ },
+ ],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Chat')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('Rate')).not.toBeInTheDocument();
+ });
+
+ it('should navigate to rate page when clicking Rate button', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [
+ {
+ id: 1,
+ slug: 'match-123',
+ partner: {
+ id: 2,
+ username: 'sarah_dancer',
+ firstName: 'Sarah',
+ lastName: 'Martinez',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ videoExchange: { sentByMe: true, receivedFromPartner: true },
+ ratings: { ratedByMe: false, ratedByPartner: false },
+ },
+ ],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Rate')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('Rate'));
+ expect(mockNavigate).toHaveBeenCalledWith('/matches/match-123/rate');
+ });
+ });
+
+ describe('Match Requests', () => {
+ it('should display incoming requests', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [],
+ matchRequests: {
+ incoming: [
+ {
+ id: 1,
+ slug: 'req-123',
+ requester: {
+ id: 3,
+ username: 'john_lead',
+ firstName: 'John',
+ lastName: 'Lead',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ requesterHeats: [],
+ createdAt: '2025-11-21T10:00:00Z',
+ },
+ ],
+ outgoing: [],
+ },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('John Lead')).toBeInTheDocument();
+ expect(screen.getByText('Incoming (1)')).toBeInTheDocument();
+ });
+ });
+
+ it('should display outgoing requests', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [],
+ matchRequests: {
+ incoming: [],
+ outgoing: [
+ {
+ id: 1,
+ slug: 'req-456',
+ recipient: {
+ id: 4,
+ username: 'anna_follow',
+ firstName: 'Anna',
+ lastName: 'Follow',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ createdAt: '2025-11-21T10:00:00Z',
+ },
+ ],
+ },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Anna Follow')).toBeInTheDocument();
+ expect(screen.getByText('Outgoing (1)')).toBeInTheDocument();
+ expect(screen.getByText('Waiting for response...')).toBeInTheDocument();
+ });
+ });
+
+ it('should accept incoming request', async () => {
+ matchesAPI.acceptMatch.mockResolvedValue({ success: true });
+ dashboardAPI.getData
+ .mockResolvedValueOnce({
+ activeEvents: [],
+ activeMatches: [],
+ matchRequests: {
+ incoming: [
+ {
+ id: 1,
+ slug: 'req-123',
+ requester: {
+ id: 3,
+ username: 'john_lead',
+ firstName: 'John',
+ lastName: 'Lead',
+ avatar: null,
+ },
+ event: { id: 1, name: 'Test Event' },
+ requesterHeats: [],
+ createdAt: '2025-11-21T10:00:00Z',
+ },
+ ],
+ outgoing: [],
+ },
+ })
+ .mockResolvedValueOnce({
+ activeEvents: [],
+ activeMatches: [],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('John Lead')).toBeInTheDocument();
+ });
+
+ // Find the accept button (green button with checkmark)
+ const acceptButtons = screen.getAllByRole('button');
+ const acceptButton = acceptButtons.find(btn => btn.title === 'Accept');
+ expect(acceptButton).toBeInTheDocument();
+
+ fireEvent.click(acceptButton);
+
+ await waitFor(() => {
+ expect(matchesAPI.acceptMatch).toHaveBeenCalledWith('req-123');
+ });
+ });
+ });
+
+ describe('Video Status Display', () => {
+ it('should show sent status when video sent by me', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [
+ {
+ id: 1,
+ slug: 'match-123',
+ partner: { id: 2, username: 'partner', firstName: null, lastName: null, avatar: null },
+ event: { id: 1, name: 'Test Event' },
+ videoExchange: { sentByMe: true, receivedFromPartner: false },
+ ratings: { ratedByMe: false, ratedByPartner: false },
+ },
+ ],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ // Check that "Sent" text exists with check icon (green color)
+ const sentTexts = screen.getAllByText('Sent');
+ expect(sentTexts.length).toBeGreaterThan(0);
+ });
+ });
+ });
+
+ describe('Rating Status Display', () => {
+ it('should show rating status indicators', async () => {
+ dashboardAPI.getData.mockResolvedValue({
+ activeEvents: [],
+ activeMatches: [
+ {
+ id: 1,
+ slug: 'match-123',
+ partner: { id: 2, username: 'partner', firstName: null, lastName: null, avatar: null },
+ event: { id: 1, name: 'Test Event' },
+ videoExchange: { sentByMe: false, receivedFromPartner: false },
+ ratings: { ratedByMe: true, ratedByPartner: false },
+ },
+ ],
+ matchRequests: { incoming: [], outgoing: [] },
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ // Check for "You" and "Partner" rating indicators
+ expect(screen.getByText('You')).toBeInTheDocument();
+ expect(screen.getByText('Partner')).toBeInTheDocument();
+ });
+ });
+ });
+});