Skip to content
Open
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
7 changes: 7 additions & 0 deletions __tests__/components/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,11 @@ describe('Badge', () => {
expect(el).toHaveAttribute('role', 'status');
expect(el.className).not.toMatch(/undefined|\[|\]/);
});

test('elite variant applies gold/amber color classes', () => {
render(<Badge variant="elite" label="Elite Tier" />);
const el = screen.getByText('Elite Tier');
expect(el).toHaveClass('bg-amber-400');
expect(el).toHaveClass('text-amber-950');
});
});
45 changes: 45 additions & 0 deletions __tests__/components/PlayerCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ jest.mock('swr', () => ({
mutate: jest.fn(),
}));

jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
'common.elite_tier': 'Elite Tier',
};
return translations[key] || key;
},
}));

// ── Typed mock handles ────────────────────────────────────────────────────────

import { useRouter } from 'next/navigation';
Expand Down Expand Up @@ -302,3 +311,39 @@ describe('PlayerCard — no idle RPC traffic', () => {
expect(mockMutate).not.toHaveBeenCalled();
});
});

// ── Elite Tier badge rendering ─────────────────────────────────────────────────

describe('PlayerCard — Elite Tier badge', () => {
it('renders Elite Tier badge for level 3 players', () => {
const elitePlayer = { ...mockPlayer, progressLevel: 3 as const };
render(<PlayerCard player={elitePlayer} />);
expect(screen.getByText('Elite Tier')).toBeInTheDocument();
});

it('does not render Elite Tier badge for level 0 players', () => {
const level0Player = { ...mockPlayer, progressLevel: 0 as const };
render(<PlayerCard player={level0Player} />);
expect(screen.queryByText('Elite Tier')).not.toBeInTheDocument();
});

it('does not render Elite Tier badge for level 1 players', () => {
const level1Player = { ...mockPlayer, progressLevel: 1 as const };
render(<PlayerCard player={level1Player} />);
expect(screen.queryByText('Elite Tier')).not.toBeInTheDocument();
});

it('does not render Elite Tier badge for level 2 players', () => {
const level2Player = { ...mockPlayer, progressLevel: 2 as const };
render(<PlayerCard player={level2Player} />);
expect(screen.queryByText('Elite Tier')).not.toBeInTheDocument();
});

it('Elite Tier badge has correct styling', () => {
const elitePlayer = { ...mockPlayer, progressLevel: 3 as const };
render(<PlayerCard player={elitePlayer} />);
const eliteBadge = screen.getByText('Elite Tier');
expect(eliteBadge).toHaveClass('bg-amber-400');
expect(eliteBadge).toHaveClass('text-amber-950');
});
});
276 changes: 276 additions & 0 deletions __tests__/components/RevokeForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import RevokeForm from '@/components/validator/RevokeForm';
import { useWallet } from '@/hooks/useWallet';
import { useValidator } from '@/hooks/useValidator';
import type { Player } from '@/types';

// ── Mocks ─────────────────────────────────────────────────────────────────────

jest.mock('@/lib/stellar', () => ({
rpc: {
getAccount: jest.fn(),
prepareTransaction: jest.fn(),
simulateTransaction: jest.fn(),
sendTransaction: jest.fn(),
getTransaction: jest.fn(),
},
NETWORK: 'TESTNET',
BASE_FEE: '100',
}));

jest.mock('@/hooks/useWallet', () => ({
useWallet: jest.fn(),
}));

jest.mock('@/hooks/useValidator', () => ({
useValidator: jest.fn(),
}));

jest.mock('@/hooks/useIsPaused', () => ({
__esModule: true,
default: jest.fn().mockReturnValue(false),
}));

jest.mock('@/components/ui/ConfirmDialog', () => ({
__esModule: true,
default: ({
isOpen,
onConfirm,
onCancel,
}: {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
confirmLabel: string;
loading: boolean;
}) =>
isOpen ? (
<div role="dialog">
<button onClick={onConfirm}>Confirm</button>
<button onClick={onCancel}>Cancel</button>
</div>
) : null,
}));

const mockedUseWallet = useWallet as jest.MockedFunction<typeof useWallet>;
const mockedUseValidator = useValidator as jest.MockedFunction<
typeof useValidator
>;

// ── Helpers ───────────────────────────────────────────────────────────────────

const VALIDATOR_KEY = 'GVALIDATORPUBLICKEY';

const player: Player = {
id: 'player-1',
wallet: 'GPLAYERPUBLICKEY',
vitals: {
name: 'Test Player',
age: 22,
position: 'ST',
region: 'nigeria',
nationality: 'Nigerian',
},
ipfsHash: 'QmTestHash',
progressLevel: 1,
milestones: [
{
id: 'milestone-1',
description: 'Scored 10 goals',
evidenceHash: 'QmEvidenceHash',
validator: VALIDATOR_KEY,
timestamp: 1700000000,
},
{
id: 'milestone-2',
description: 'Professional contract signed',
evidenceHash: 'QmEvidenceHash2',
validator: VALIDATOR_KEY,
timestamp: 1700000001,
},
],
createdAt: 1234567890,
};

function renderComponent(
overrides: Partial<ReturnType<typeof useValidator>> = {},
onSuccess = jest.fn(),
) {
mockedUseWallet.mockReturnValue({
publicKey: VALIDATOR_KEY,
isAuthenticated: true,
isConnecting: false,
connect: jest.fn(),
disconnect: jest.fn(),
signAndSubmit: jest.fn(),
} as any);

mockedUseValidator.mockReturnValue({
isValidator: true,
checking: false,
approveMilestone: jest.fn(),
revokeMilestone: jest.fn().mockResolvedValue({}),
loading: false,
error: null,
...overrides,
});

return render(<RevokeForm player={player} onSuccess={onSuccess} />);
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('RevokeForm', () => {
beforeEach(() => {
jest.clearAllMocks();
});

// ── Render milestone selection list ────────────────────────────────────────

it('renders milestone selection list from player prop', () => {
renderComponent();
expect(screen.getByText('Scored 10 goals')).toBeInTheDocument();
expect(
screen.getByText('Professional contract signed'),
).toBeInTheDocument();
});

// ── Validation - revoke button disabled when no milestone selected ───────────

it('shows validation - revoke button disabled when no milestone selected', () => {
renderComponent();
const revokeButton = screen.getByRole('button', {
name: /revoke selected milestone/i,
});
expect(revokeButton).toBeDisabled();
});

it('enables revoke button when a milestone is selected', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /scored 10 goals/i }));
const revokeButton = screen.getByRole('button', {
name: /revoke selected milestone/i,
});
expect(revokeButton).not.toBeDisabled();
});

// ── Disables submit button during transaction submission ───────────────────

it('disables submit button during transaction submission', async () => {
let resolveRevoke: (val: any) => void;
const revokeMilestone = jest.fn().mockReturnValue(
new Promise((res) => {
resolveRevoke = res;
}),
);

renderComponent({ revokeMilestone });

// Select milestone
fireEvent.click(screen.getByRole('button', { name: /scored 10 goals/i }));
// Click revoke
fireEvent.click(
screen.getByRole('button', { name: /revoke selected milestone/i }),
);
// Confirm in dialog
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }));
});

await waitFor(() => {
const revokeButton = screen.getByRole('button', {
name: /revoking/i,
});
expect(revokeButton).toBeDisabled();
});

await act(async () => {
resolveRevoke!({});
});
});

// ── Calls onSuccess after successful revoke ────────────────────────────────

it('calls onSuccess after a successful revoke', async () => {
const revokeMilestone = jest.fn().mockResolvedValue({});
const onSuccess = jest.fn();

renderComponent({ revokeMilestone }, onSuccess);

// Select milestone
fireEvent.click(screen.getByRole('button', { name: /scored 10 goals/i }));
// Click revoke
fireEvent.click(
screen.getByRole('button', { name: /revoke selected milestone/i }),
);
// Confirm in dialog
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }));
});

expect(revokeMilestone).toHaveBeenCalledWith('player-1', 'milestone-1');
expect(onSuccess).toHaveBeenCalledTimes(1);
});

// ── Shows error message when contract call fails ───────────────────────────

it('shows error message when contract call fails (e.g. UnauthorizedValidator)', async () => {
const revokeMilestone = jest
.fn()
.mockRejectedValue(new Error('UnauthorizedValidator'));

renderComponent({ revokeMilestone });

// Select milestone
fireEvent.click(screen.getByRole('button', { name: /scored 10 goals/i }));
// Click revoke
fireEvent.click(
screen.getByRole('button', { name: /revoke selected milestone/i }),
);
// Confirm in dialog
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }));
});

await waitFor(() => {
expect(screen.getByText('UnauthorizedValidator')).toBeInTheDocument();
});
});

// ── Shows ConfirmDialog before submitting ───────────────────────────────────

it('shows a ConfirmDialog before submitting', () => {
renderComponent();

// Select milestone
fireEvent.click(screen.getByRole('button', { name: /scored 10 goals/i }));
// Click revoke - should open dialog
fireEvent.click(
screen.getByRole('button', { name: /revoke selected milestone/i }),
);

// Dialog should be visible
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^confirm$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^cancel$/i })).toBeInTheDocument();
});

it('does not call revokeMilestone when dialog is cancelled', () => {
const revokeMilestone = jest.fn().mockResolvedValue({});
renderComponent({ revokeMilestone });

// Select milestone
fireEvent.click(screen.getByRole('button', { name: /scored 10 goals/i }));
// Click revoke
fireEvent.click(
screen.getByRole('button', { name: /revoke selected milestone/i }),
);
// Cancel in dialog
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));

expect(revokeMilestone).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions app/[locale]/player/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
'use client';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useWallet } from '@/hooks/useWallet';
import { usePlayer } from '@/hooks/usePlayer';
import { usePayToContact } from '@/hooks/usePayToContact';
import { PLATFORM_CONTACT_FEE_XLM } from '@/lib/contract';
import ProgressBar from '@/components/ProgressBar';
import PlayerProfileSkeleton from '@/components/PlayerProfileSkeleton';
import TrialOfferForm from '@/components/scout/TrialOfferForm';
import Badge from '@/components/ui/Badge';
import { buildPayToContact } from '@/lib/contract';

export default function PlayerProfile() {
const { id } = useParams<{ id: string }>();
const { publicKey } = useWallet();
const { player, loading } = usePlayer(id ?? null);
const { unlock, loading: contacting } = usePayToContact();
const t = useTranslations('common');
const isElite = player?.progressLevel === 3;

async function handleContact() {
await unlock(id);
Expand All @@ -27,6 +31,18 @@ export default function PlayerProfile() {

return (
<div className="max-w-2xl mx-auto flex flex-col gap-8">
{/* Elite Tier Banner */}
{isElite && (
<div className="bg-gradient-to-r from-amber-500 to-amber-600 border border-amber-400 rounded-xl p-4 flex items-center justify-center">
<Badge
variant="elite"
label={t('elite_tier')}
aria-label={t('elite_tier')}
size="md"
/>
</div>
)}

{/* Header */}
<div className="bg-brand-card border border-gray-800 rounded-xl p-6 flex gap-6 items-start">
<div className="w-20 h-20 rounded-full bg-gray-700 overflow-hidden shrink-0">
Expand Down
Loading
Loading