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
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-markdown": "^10.1.0",
"swr": "^2.2.0",
"tailwindcss": "^3.4.0",
"zustand": "^4.5.0"
},
Expand Down
56 changes: 33 additions & 23 deletions apps/frontend/src/__tests__/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,40 @@ import { http, HttpResponse } from 'msw';

const BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';

const ALL_COURSES = [
{ id: '1', title: 'Intro to Stellar Blockchain', level: 'beginner', durationHours: 4, isPublished: true },
{ id: '2', title: 'Soroban Smart Contracts', level: 'intermediate', durationHours: 8, isPublished: true },
{ id: '3', title: 'DeFi on Stellar', level: 'advanced', durationHours: 12, isPublished: true },
];

export const handlers = [
http.get(`${BASE}/courses`, () =>
HttpResponse.json({
data: [
{
id: '1',
title: 'Intro to Stellar Blockchain',
level: 'beginner',
durationHours: 4,
isPublished: true,
},
{
id: '2',
title: 'Soroban Smart Contracts',
level: 'intermediate',
durationHours: 8,
isPublished: true,
},
],
total: 2,
page: 1,
limit: 20,
})
),
http.get(`${BASE}/courses`, (req) => {
const search = req.url.searchParams.get('search')?.toLowerCase() ?? '';
const level = req.url.searchParams.get('level')?.toLowerCase() ?? '';
const page = Number(req.url.searchParams.get('page') ?? '1');
const limit = Number(req.url.searchParams.get('limit') ?? '5');

let filtered = ALL_COURSES;

if (search) {
filtered = filtered.filter((course) => course.title.toLowerCase().includes(search));
}

if (level) {
filtered = filtered.filter((course) => course.level === level);
}

const total = filtered.length;
const offset = (Math.max(page, 1) - 1) * limit;
const paged = filtered.slice(offset, offset + limit);

return HttpResponse.json({
data: paged,
total,
page: Math.max(page, 1),
limit,
});
}),

http.get(`${BASE}/users/me`, () =>
HttpResponse.json({
Expand Down
55 changes: 32 additions & 23 deletions apps/frontend/src/__tests__/pages/CoursesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,66 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { server } from '../mocks/server';

// Mock next/navigation and next-intl so the component renders in jsdom
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(''),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
vi.mock('next/link', () => ({
default: ({ href, children, ...props }: any) => (

Check warning on line 14 in apps/frontend/src/__tests__/pages/CoursesPage.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend - Install, Build, Lint

Unexpected any. Specify a different type
<a href={href} {...props}>
{children}
</a>
),
}));

// Use the non-locale courses page (pure component, no async server component)
vi.mock('@/lib/auth-context', () => ({
useAuth: () => ({
state: {
isLoading: false,
token: 'fake-token',
user: { id: 'user-1', username: 'testuser', email: 'test@example.com' },
},
}),
}));

import CoursesPage from '@/app/courses/page';

beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('CoursesPage', () => {
it('renders the courses heading', () => {
it('loads and displays courses from API with pagination controls', async () => {
render(<CoursesPage />);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});

it('renders all static course titles', () => {
render(<CoursesPage />);
expect(screen.getByText('Intro to Stellar Blockchain')).toBeInTheDocument();
expect(screen.getByText('Courses')).toBeInTheDocument();

await waitFor(() => expect(screen.getByText('Intro to Stellar Blockchain')).toBeInTheDocument());

Check failure on line 43 in apps/frontend/src/__tests__/pages/CoursesPage.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend - Install, Build, Lint

Replace `·expect(screen.getByText('Intro·to·Stellar·Blockchain')).toBeInTheDocument()` with `⏎······expect(screen.getByText('Intro·to·Stellar·Blockchain')).toBeInTheDocument()⏎····`
expect(screen.getByText('Soroban Smart Contracts')).toBeInTheDocument();
expect(screen.getByText('DeFi on Stellar')).toBeInTheDocument();
});

it('renders view course links for each course', () => {
render(<CoursesPage />);
const links = screen.getAllByRole('link');
// Each course has a link to its detail page
expect(links.length).toBeGreaterThanOrEqual(3);
const nextButton = screen.getByRole('button', { name: 'Next' });
expect(nextButton).toBeEnabled();

fireEvent.click(nextButton);

await waitFor(() => expect(screen.getByText('Page 2 of')).toBeInTheDocument());
});

it('links point to correct course detail URLs', () => {
it('filters courses by search term', async () => {
render(<CoursesPage />);
const links = screen
.getAllByRole('link')
.filter((l) => l.getAttribute('href')?.startsWith('/courses/'));
expect(links[0]).toHaveAttribute('href', '/courses/1');
expect(links[1]).toHaveAttribute('href', '/courses/2');

await waitFor(() => expect(screen.getByText('Intro to Stellar Blockchain')).toBeInTheDocument());

Check failure on line 57 in apps/frontend/src/__tests__/pages/CoursesPage.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend - Install, Build, Lint

Replace `·expect(screen.getByText('Intro·to·Stellar·Blockchain')).toBeInTheDocument()` with `⏎······expect(screen.getByText('Intro·to·Stellar·Blockchain')).toBeInTheDocument()⏎····`

const input = screen.getByPlaceholderText('Search courses...');
fireEvent.change(input, { target: { value: 'DeFi' } });
fireEvent.click(screen.getByRole('button', { name: 'Search' }));

await waitFor(() => expect(screen.getByText('DeFi on Stellar')).toBeInTheDocument());
expect(screen.queryByText('Intro to Stellar Blockchain')).not.toBeInTheDocument();
});
});
65 changes: 2 additions & 63 deletions apps/frontend/src/app/[locale]/courses/page.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,3 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import CoursesPage from '@/app/courses/page';

export default function CoursesPage() {
const t = useTranslations('courses');

const courses = [
{ id: 1, title: 'Intro to Stellar Blockchain', level: 'Beginner' as const, duration: '4h' },
{ id: 2, title: 'Soroban Smart Contracts', level: 'Intermediate' as const, duration: '8h' },
{ id: 3, title: 'DeFi on Stellar', level: 'Advanced' as const, duration: '12h' },
];

return (
<ProtectedRoute>
<main className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">{t('title')}</h1>
{/* List semantics so screen readers announce item count */}
<ul className="grid gap-4 list-none p-0">
{courses.map((course) => (
<li
key={course.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-900 hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{course.title}</h2>
<p className="text-gray-700 dark:text-gray-400 mt-1">
<div className="grid gap-4">
{courses.map((course) => (
<div
key={course.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-900 hover:shadow-md dark:hover:shadow-gray-800 transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{course.title}</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t(`levels.${course.level}`)} · {course.duration}
</p>
<Link
href={`/courses/${course.id}`}
aria-label={t('viewCourseLabel', { title: course.title })}
className="mt-3 inline-block text-blue-700 dark:text-blue-400 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded"
>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{course.title}</h2>
<p className="text-gray-700 dark:text-gray-400 mt-1">
{t(`levels.${course.level}`)} · {course.duration}
</p>
<Link
href={`/courses/${course.id}`}
aria-label={t('viewCourseLabel', { title: course.title })}
className="mt-3 inline-block text-blue-700 dark:text-blue-400 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded"
>
{t('viewCourse')}
</Link>
</li>
))}
</ul>
</main>
</ProtectedRoute>
);
}
</div>
</main>
</ProtectedRoute>
);
}
export default CoursesPage;
Loading
Loading