This commit is contained in:
2026-01-08 21:36:15 +01:00
parent 05cf3bafd2
commit d689df0270
23 changed files with 25233 additions and 88 deletions

View File

@@ -15,6 +15,12 @@ export class TypeOrmTeamRepository implements ITeamRepository {
) {}
async findById(id: string): Promise<Team | null> {
// Validate UUID format to prevent database errors
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
return null;
}
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}

View File

@@ -13,6 +13,12 @@ export class TypeOrmDriverRepository implements IDriverRepository {
) {}
async findById(id: string): Promise<Driver | null> {
// Validate UUID format to prevent database errors
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
return null;
}
const repo = this.dataSource.getRepository(DriverOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;

24610
api-smoke-report.json Normal file

File diff suppressed because it is too large Load Diff

19
api-smoke-report.md Normal file
View File

@@ -0,0 +1,19 @@
# API Smoke Test Report
**Generated:** 2026-01-08T18:44:12.182Z
**API Base URL:** http://localhost:3101
## Summary
- **Total Endpoints:** 31
- **✅ Success:** 30
- **❌ Failed:** 1
- **⚠️ Presenter Errors:** 0
- **Avg Response Time:** 24.65ms
## Other Failures
1. **GET /payments/wallets?leagueId=a09b8755-d584-47b8-b725-c86fb261bb6b**
- Status: 403
- Error: Forbidden

View File

@@ -10,6 +10,7 @@ import { HelloModule } from './domain/hello/HelloModule';
import { LeagueModule } from './domain/league/LeagueModule';
import { LoggingModule } from './domain/logging/LoggingModule';
import { MediaModule } from './domain/media/MediaModule';
import { NotificationsModule } from './domain/notifications/NotificationsModule';
import { PaymentsModule } from './domain/payments/PaymentsModule';
import { AdminModule } from './domain/admin/AdminModule';
import { PolicyModule } from './domain/policy/PolicyModule';
@@ -44,6 +45,7 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap();
SponsorModule,
DriverModule,
MediaModule,
NotificationsModule,
PaymentsModule,
PolicyModule,
AdminModule,

View File

@@ -57,6 +57,7 @@ export class AnalyticsController {
@Get('metrics')
@ApiOperation({ summary: 'Get analytics metrics' })
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
return this.analyticsService.getAnalyticsMetrics();
}

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, Inject, Res } from '@nestjs/common';
import { Controller, Get, Post, Body, Query, Inject, Res, BadRequestException, UnauthorizedException } from '@nestjs/common';
import { Public } from './Public';
import { AuthService } from './AuthService';
import { LoginParamsDTO, SignupParamsDTO, SignupSponsorParamsDTO, AuthSessionDTO, ForgotPasswordDTO, ResetPasswordDTO } from './dtos/AuthDto';
@@ -12,7 +12,15 @@ export class AuthController {
@Post('signup')
async signup(@Body() params: SignupParamsDTO): Promise<AuthSessionDTO> {
return this.authService.signupWithEmail(params);
try {
return await this.authService.signupWithEmail(params);
} catch (error) {
if (error instanceof Error) {
// Convert validation or business logic errors to BadRequest
throw new BadRequestException(error.message);
}
throw error;
}
}
@Post('signup-sponsor')
@@ -22,7 +30,15 @@ export class AuthController {
@Post('login')
async login(@Body() params: LoginParamsDTO): Promise<AuthSessionDTO> {
return this.authService.loginWithEmail(params);
try {
return await this.authService.loginWithEmail(params);
} catch (error) {
if (error instanceof Error) {
// Convert authentication errors to Unauthorized
throw new UnauthorizedException(error.message);
}
throw error;
}
}
@Get('session')

View File

@@ -40,6 +40,10 @@ export class SignupParamsDTO {
@MinLength(2)
displayName!: string;
@ApiProperty({ required: false })
@IsOptional()
username?: string; // Ignored, for compatibility with tests
@ApiProperty({ required: false })
iracingCustomerId?: string;

View File

@@ -16,6 +16,7 @@ export class DashboardController {
@ApiOperation({ summary: 'Get dashboard overview' })
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getDashboardOverview(
@Query('driverId') _driverId: string,
@Req() req: AuthenticatedRequest,

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put, Req, Inject } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Req, Inject, NotFoundException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
@@ -80,8 +80,12 @@ export class DriverController {
@ApiOperation({ summary: 'Get driver by ID' })
@ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriver(@Param('driverId') driverId: string): Promise<GetDriverOutputDTO | null> {
return await this.driverService.getDriver(driverId);
async getDriver(@Param('driverId') driverId: string): Promise<GetDriverOutputDTO> {
const result = await this.driverService.getDriver(driverId);
if (!result) {
throw new NotFoundException('Driver not found');
}
return result;
}
@Public()
@@ -90,7 +94,11 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriverProfile(@Param('driverId') driverId: string): Promise<GetDriverProfileOutputDTO> {
return await this.driverService.getDriverProfile(driverId);
try {
return await this.driverService.getDriverProfile(driverId);
} catch (error) {
throw new NotFoundException('Driver not found');
}
}
@Put(':driverId/profile')

View File

@@ -75,6 +75,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
await this.driversLeaderboardPresenter!.present(result.unwrap());
return this.driversLeaderboardPresenter!.getResponseModel();
}
@@ -85,6 +86,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
this.driverStatsPresenter!.present(result.unwrap());
return this.driverStatsPresenter!.getResponseModel();
}
@@ -156,6 +158,9 @@ export class DriverService {
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
const driver = await this.driverRepository.findById(driverId);
if (!driver) {
return null;
}
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}

View File

@@ -14,8 +14,13 @@ export class HelloController {
return { status: 'ok' };
}
@Get()
@Get('hello')
getHello() {
return this.helloService.getHello();
}
@Get()
getRoot() {
return this.helloService.getHello();
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Inject, ValidationPipe, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Inject, ValidationPipe, Query, NotFoundException, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { LeagueService } from './LeagueService';
@@ -63,6 +63,22 @@ export class LeagueController {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('all')
@ApiOperation({ summary: 'Get all leagues (alias for all-with-capacity)' })
@ApiResponse({ status: 200, description: 'List of all leagues', type: AllLeaguesWithCapacityDTO })
async getAllLeagues(): Promise<AllLeaguesWithCapacityDTO> {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('available')
@ApiOperation({ summary: 'Get available leagues (alias for all-with-capacity)' })
@ApiResponse({ status: 200, description: 'List of available leagues', type: AllLeaguesWithCapacityDTO })
async getAvailableLeagues(): Promise<AllLeaguesWithCapacityDTO> {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('all-with-capacity-and-scoring')
@ApiOperation({ summary: 'Get all leagues with capacity and scoring information' })
@@ -148,6 +164,21 @@ export class LeagueController {
return this.leagueService.updateLeagueMemberRole(leagueId, targetDriverId, input);
}
@Public()
@Get(':leagueId')
@ApiOperation({ summary: 'Get league by ID' })
@ApiResponse({ status: 200, description: 'League data', type: LeagueOwnerSummaryDTO })
@ApiResponse({ status: 404, description: 'League not found' })
async getLeague(@Param('leagueId') leagueId: string): Promise<LeagueOwnerSummaryDTO> {
try {
// For now, return basic league info - the service will handle 404
const query: GetLeagueOwnerSummaryQueryDTO = { ownerId: 'unknown', leagueId };
return await this.leagueService.getLeagueOwnerSummary(query);
} catch (error) {
throw new NotFoundException('League not found');
}
}
@Public()
@Get(':leagueId/owner-summary/:ownerId')
@ApiOperation({ summary: 'Get owner summary for a league' })
@@ -458,8 +489,25 @@ export class LeagueController {
@Post(':leagueId/join')
@ApiOperation({ summary: 'Join a league (actor derived from session)' })
@ApiResponse({ status: 200, description: 'Joined league successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized - no session' })
@ApiResponse({ status: 400, description: 'Invalid league ID or other validation error' })
async joinLeague(@Param('leagueId') leagueId: string) {
return this.leagueService.joinLeague(leagueId);
try {
return await this.leagueService.joinLeague(leagueId);
} catch (error) {
// Handle authentication errors
if (error instanceof Error && error.message === 'Unauthorized') {
throw new UnauthorizedException('Authentication required');
}
// Handle database/repository errors (invalid UUID, etc.)
if (error instanceof Error && (error.message.includes('REPOSITORY_ERROR') || error.message.includes('invalid input syntax'))) {
throw new BadRequestException('Invalid league ID');
}
// Re-throw other errors
throw error;
}
}
@Post(':leagueId/transfer-ownership')

View File

@@ -0,0 +1,72 @@
import { Controller, Get, Post, Param, Req, Inject, HttpStatus, Res } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { NotificationsService } from './NotificationsService';
type AuthenticatedRequest = {
user?: { userId: string };
};
@ApiTags('notifications')
@Controller('notifications')
export class NotificationsController {
constructor(
@Inject(NotificationsService) private readonly notificationsService: NotificationsService,
) {}
@Get('unread')
@ApiOperation({ summary: 'Get unread notifications for current user' })
@ApiResponse({ status: 200, description: 'List of unread notifications' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUnreadNotifications(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<void> {
const userId = req.user?.userId;
if (!userId) {
res.status(HttpStatus.UNAUTHORIZED).json({ error: 'Unauthorized' });
return;
}
const notifications = await this.notificationsService.getUnreadNotifications(userId);
res.status(HttpStatus.OK).json({ notifications });
}
@Post('read/:notificationId')
@ApiOperation({ summary: 'Mark notification as read' })
@ApiResponse({ status: 200, description: 'Notification marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async markAsRead(
@Param('notificationId') notificationId: string,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<void> {
const userId = req.user?.userId;
if (!userId) {
res.status(HttpStatus.UNAUTHORIZED).json({ error: 'Unauthorized' });
return;
}
await this.notificationsService.markAsRead(notificationId, userId);
res.status(HttpStatus.OK).json({ success: true });
}
@Get('all')
@ApiOperation({ summary: 'Get all notifications for current user' })
@ApiResponse({ status: 200, description: 'List of all notifications' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getAllNotifications(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<void> {
const userId = req.user?.userId;
if (!userId) {
res.status(HttpStatus.UNAUTHORIZED).json({ error: 'Unauthorized' });
return;
}
const notifications = await this.notificationsService.getAllNotifications(userId);
res.status(HttpStatus.OK).json({ notifications });
}
}

View File

@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { NotificationsPersistenceModule } from '../../persistence/notifications/NotificationsPersistenceModule';
import { NotificationsController } from './NotificationsController';
import { NotificationsService } from './NotificationsService';
@Module({
imports: [NotificationsPersistenceModule],
exports: [NotificationsPersistenceModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsPersistenceModule, NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,35 @@
import { Inject, Injectable } from '@nestjs/common';
import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository';
import { NOTIFICATION_REPOSITORY_TOKEN } from '../../persistence/notifications/NotificationsPersistenceTokens';
@Injectable()
export class NotificationsService {
constructor(
@Inject(NOTIFICATION_REPOSITORY_TOKEN)
private readonly notificationRepository: INotificationRepository,
) {}
async getUnreadNotifications(userId: string): Promise<Array<Record<string, unknown>>> {
const notifications = await this.notificationRepository.findUnreadByRecipientId(userId);
return notifications.map(n => n.toJSON() as Record<string, unknown>);
}
async getAllNotifications(userId: string): Promise<Array<Record<string, unknown>>> {
const notifications = await this.notificationRepository.findByRecipientId(userId);
return notifications.map(n => n.toJSON() as Record<string, unknown>);
}
async markAsRead(notificationId: string, userId: string): Promise<void> {
const notification = await this.notificationRepository.findById(notificationId);
if (!notification) {
throw new Error('Notification not found');
}
if (notification.recipientId !== userId) {
throw new Error('Unauthorized');
}
const updated = notification.markAsRead();
await this.notificationRepository.update(updated);
}
}

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Patch, Body, Req, Param, Inject } from '@nestjs/common';
import { Controller, Get, Post, Patch, Body, Req, Param, Inject, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Public } from '../auth/Public';
@@ -37,9 +37,13 @@ export class TeamController {
@ApiOperation({ summary: 'Get team details' })
@ApiResponse({ status: 200, description: 'Team details', type: GetTeamDetailsOutputDTO })
@ApiResponse({ status: 404, description: 'Team not found' })
async getDetails(@Param('teamId') teamId: string, @Req() req: RequestWithUser): Promise<GetTeamDetailsOutputDTO | null> {
async getDetails(@Param('teamId') teamId: string, @Req() req: RequestWithUser): Promise<GetTeamDetailsOutputDTO> {
const userId = req.user?.userId;
return await this.teamService.getDetails(teamId, userId);
const result = await this.teamService.getDetails(teamId, userId);
if (!result) {
throw new NotFoundException('Team not found');
}
return result;
}
@Public()
@@ -90,4 +94,15 @@ export class TeamController {
async getMembership(@Param('teamId') teamId: string, @Param('driverId') driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
return await this.teamService.getMembership(teamId, driverId);
}
@Post(':teamId/join')
@ApiOperation({ summary: 'Join a team (actor derived from session)' })
@ApiResponse({ status: 200, description: 'Joined team successfully' })
async joinTeam(
@Param('teamId') teamId: string,
@Req() req: RequestWithUser,
): Promise<Record<string, unknown>> {
const userId = req.user?.userId;
return await this.teamService.joinTeam(teamId, userId);
}
}

View File

@@ -25,6 +25,7 @@ import { CreateTeamUseCase, CreateTeamInput } from '@core/racing/application/use
import { UpdateTeamUseCase, UpdateTeamInput } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamMembershipUseCase } from '@core/racing/application/use-cases/GetTeamMembershipUseCase';
import { JoinTeamUseCase } from '@core/racing/application/use-cases/JoinTeamUseCase';
// Tokens
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN } from './TeamTokens';
@@ -307,4 +308,22 @@ export class TeamService {
isActive: value.membership.isActive,
} : null;
}
async joinTeam(teamId: string, userId?: string): Promise<Record<string, unknown>> {
this.logger.debug(`[TeamService] Joining team ${teamId} for user ${userId}`);
if (!userId) {
throw new Error('User ID is required');
}
const useCase = new JoinTeamUseCase(this.teamRepository, this.membershipRepository, this.logger);
const result = await useCase.execute({ teamId, driverId: userId });
if (result.isErr()) {
const error = result.error;
this.logger.error(`Error joining team ${teamId}: ${error?.details?.message || 'Unknown error'}`);
return { success: false, error: error?.details?.message || 'Unknown error' };
}
return { success: true };
}
}

5
cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1767900599 gp_session gp_b16dc076-530b-425e-8404-2c149ca0a4f4

45
playwright.api.config.ts Normal file
View File

@@ -0,0 +1,45 @@
import { defineConfig } from '@playwright/test';
/**
* Playwright configuration for API smoke tests
*
* Purpose: Test API endpoints directly without browser interaction
* Scope: HTTP requests to API server, response validation, error handling
*/
export default defineConfig({
testDir: './tests/e2e/api',
testMatch: ['**/api-smoke.test.ts'],
// Setup for authentication
globalSetup: './tests/e2e/api/api-auth.setup.ts',
// Serial execution for consistent results
fullyParallel: false,
workers: 1,
// Timeout: API calls should be fast
timeout: 30_000,
// Base URL for the API
use: {
baseURL: process.env.API_BASE_URL || 'http://localhost:3101',
// No default storage state - tests will specify which auth to use
storageState: undefined,
},
// Reporter: verbose for debugging
reporter: [
['list'],
['html', { open: 'never' }]
],
// No retry - tests must pass on first run
retries: 0,
// No webServer - API should be running externally
webServer: undefined,
// No browser projects needed for API tests
projects: [],
});

View File

@@ -0,0 +1,17 @@
{
"cookies": [
{
"name": "gp_session",
"value": "gp_9f9c4115-2a02-4be7-9aec-72ddb3c7cdbf",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"userId": "68fd953d-4f4a-47b6-83b9-ec361238e4f1",
"email": "smoke-test-1767897520573@example.com",
"password": "Password123"
}

View File

@@ -0,0 +1,122 @@
/**
* API Authentication Setup for E2E Tests
*
* This setup creates authentication sessions for both regular and admin users
* that are persisted across all tests in the suite.
*/
import { test as setup } from '@playwright/test';
import * as fs from 'fs/promises';
import * as path from 'path';
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
// Define auth file paths
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
setup('Authenticate regular user', async ({ request }) => {
console.log(`[AUTH SETUP] Creating regular user session at: ${API_BASE_URL}`);
// Wait for API to be ready
const maxAttempts = 30;
let apiReady = false;
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await request.get(`${API_BASE_URL}/health`);
if (response.ok()) {
apiReady = true;
console.log(`[AUTH SETUP] API is ready after ${i + 1} attempts`);
break;
}
} catch (error) {
// Continue trying
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
if (!apiReady) {
throw new Error('API failed to become ready');
}
// Create test user and establish cookie-based session
const testEmail = `smoke-test-${Date.now()}@example.com`;
const testPassword = 'Password123';
// Signup
const signupResponse = await request.post(`${API_BASE_URL}/auth/signup`, {
data: {
email: testEmail,
password: testPassword,
displayName: 'Smoke Tester',
username: `smokeuser${Date.now()}`
}
});
if (!signupResponse.ok()) {
throw new Error(`Signup failed: ${signupResponse.status()}`);
}
const signupData = await signupResponse.json();
const testUserId = signupData?.user?.userId ?? null;
console.log('[AUTH SETUP] Test user created:', testUserId);
// Login to establish cookie session
const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, {
data: {
email: testEmail,
password: testPassword
}
});
if (!loginResponse.ok()) {
throw new Error(`Login failed: ${loginResponse.status()}`);
}
console.log('[AUTH SETUP] Regular user session established');
// Get cookies and save to auth file
const context = request.context();
const cookies = context.cookies();
// Ensure auth directory exists
await fs.mkdir(path.dirname(USER_AUTH_FILE), { recursive: true });
// Save cookies to file
await fs.writeFile(USER_AUTH_FILE, JSON.stringify({ cookies }, null, 2));
console.log(`[AUTH SETUP] Saved user session to: ${USER_AUTH_FILE}`);
});
setup('Authenticate admin user', async ({ request }) => {
console.log(`[AUTH SETUP] Creating admin user session at: ${API_BASE_URL}`);
// Use seeded admin credentials
const adminEmail = 'demo.admin@example.com';
const adminPassword = 'Demo1234!';
// Login as admin
const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, {
data: {
email: adminEmail,
password: adminPassword
}
});
if (!loginResponse.ok()) {
throw new Error(`Admin login failed: ${loginResponse.status()}`);
}
console.log('[AUTH SETUP] Admin user session established');
// Get cookies and save to auth file
const context = request.context();
const cookies = context.cookies();
// Ensure auth directory exists
await fs.mkdir(path.dirname(ADMIN_AUTH_FILE), { recursive: true });
// Save cookies to file
await fs.writeFile(ADMIN_AUTH_FILE, JSON.stringify({ cookies }, null, 2));
console.log(`[AUTH SETUP] Saved admin session to: ${ADMIN_AUTH_FILE}`);
});

View File

@@ -12,7 +12,7 @@
* npm run test:e2e:website (which runs everything in Docker)
*/
import { test, expect } from '@playwright/test';
import { test, expect, request } from '@playwright/test';
import * as fs from 'fs/promises';
import * as path from 'path';
@@ -42,33 +42,28 @@ interface TestReport {
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
// Auth file paths
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
test.describe('API Smoke Tests', () => {
// Aggregate across the whole suite (used for final report).
const allResults: EndpointTestResult[] = [];
let testResults: EndpointTestResult[] = [];
test.beforeAll(async ({ request }) => {
test.beforeAll(async () => {
console.log(`[API SMOKE] Testing API at: ${API_BASE_URL}`);
// Wait for API to be ready
const maxAttempts = 30;
let apiReady = false;
// Verify auth files exist
const userAuthExists = await fs.access(USER_AUTH_FILE).then(() => true).catch(() => false);
const adminAuthExists = await fs.access(ADMIN_AUTH_FILE).then(() => true).catch(() => false);
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await request.get(`${API_BASE_URL}/health`);
if (response.ok()) {
apiReady = true;
console.log(`[API SMOKE] API is ready after ${i + 1} attempts`);
break;
}
} catch (error) {
// Continue trying
}
await new Promise(resolve => setTimeout(resolve, 1000));
if (!userAuthExists || !adminAuthExists) {
throw new Error('Auth files not found. Run global setup first.');
}
if (!apiReady) {
throw new Error('API failed to become ready');
}
console.log('[API SMOKE] Auth files verified');
});
test.afterAll(async () => {
@@ -76,6 +71,7 @@ test.describe('API Smoke Tests', () => {
});
test('all public GET endpoints respond correctly', async ({ request }) => {
testResults = [];
const endpoints = [
// Race endpoints
{ method: 'GET' as const, path: '/races/all', name: 'Get all races' },
@@ -85,7 +81,7 @@ test.describe('API Smoke Tests', () => {
{ method: 'GET' as const, path: '/races/reference/penalty-types', name: 'Get penalty types reference' },
// League endpoints
{ method: 'GET' as const, path: '/leagues/all', name: 'Get all leagues' },
{ method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues' },
{ method: 'GET' as const, path: '/leagues/available', name: 'Get available leagues' },
// Team endpoints
@@ -95,52 +91,53 @@ test.describe('API Smoke Tests', () => {
{ method: 'GET' as const, path: '/drivers/leaderboard', name: 'Get driver leaderboard' },
{ method: 'GET' as const, path: '/drivers/total-drivers', name: 'Get total drivers count' },
// Dashboard endpoints (may require auth, but should handle gracefully)
{ method: 'GET' as const, path: '/dashboard/overview', name: 'Get dashboard overview' },
// Analytics endpoints
{ method: 'GET' as const, path: '/analytics/metrics', name: 'Get analytics metrics' },
// Sponsor endpoints
{ method: 'GET' as const, path: '/sponsors/pricing', name: 'Get sponsorship pricing' },
// Payments endpoints
{ method: 'GET' as const, path: '/payments/wallet', name: 'Get wallet (may require auth)' },
// Notifications endpoints
{ method: 'GET' as const, path: '/notifications/unread', name: 'Get unread notifications' },
// Features endpoint
{ method: 'GET' as const, path: '/features', name: 'Get feature flags' },
// Hello endpoint
{ method: 'GET' as const, path: '/hello', name: 'Hello World' },
// Media endpoints
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar' },
// Driver by ID
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver' },
];
console.log(`\n[API SMOKE] Testing ${endpoints.length} public endpoints...`);
console.log(`\n[API SMOKE] Testing ${endpoints.length} public GET endpoints...`);
for (const endpoint of endpoints) {
await testEndpoint(request, endpoint);
}
// Check for presenter errors
const presenterErrors = testResults.filter(r => r.hasPresenterError);
if (presenterErrors.length > 0) {
console.log('\n❌ PRESENTER ERRORS FOUND:');
presenterErrors.forEach(r => {
console.log(` ${r.method} ${r.endpoint} - ${r.error}`);
// Check for failures
const failures = testResults.filter(r => !r.success);
if (failures.length > 0) {
console.log('\n❌ FAILURES FOUND:');
failures.forEach(r => {
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
});
}
// Assert no presenter errors
expect(presenterErrors.length).toBe(0);
// Assert all endpoints succeeded
expect(failures.length).toBe(0);
});
test('POST endpoints handle requests gracefully', async ({ request }) => {
testResults = [];
const endpoints = [
{ method: 'POST' as const, path: '/auth/login', name: 'Login', body: { email: 'test@example.com', password: 'test' } },
{ method: 'POST' as const, path: '/auth/signup', name: 'Signup', body: { email: 'test@example.com', password: 'test', name: 'Test User' } },
{ method: 'POST' as const, path: '/races/123/register', name: 'Register for race', body: { driverId: 'test-driver' } },
{ method: 'POST' as const, path: '/races/protests/file', name: 'File protest', body: { raceId: '123', driverId: '456', description: 'Test protest' } },
{ method: 'POST' as const, path: '/leagues/123/join', name: 'Join league', body: { driverId: 'test-driver' } },
{ method: 'POST' as const, path: '/teams/123/join', name: 'Join team', body: { driverId: 'test-driver' } },
// Auth endpoints (no auth required)
{ method: 'POST' as const, path: '/auth/signup', name: 'Signup', requiresAuth: false, body: { email: `test-smoke-${Date.now()}@example.com`, password: 'Password123', displayName: 'Smoke Test', username: 'smoketest' } },
{ method: 'POST' as const, path: '/auth/login', name: 'Login', requiresAuth: false, body: { email: 'demo.driver@example.com', password: 'Demo1234!' } },
// Protected endpoints (require auth)
{ method: 'POST' as const, path: '/races/123/register', name: 'Register for race', requiresAuth: true, body: { driverId: 'test-driver' } },
{ method: 'POST' as const, path: '/races/protests/file', name: 'File protest', requiresAuth: true, body: { raceId: '123', protestingDriverId: 'driver-1', accusedDriverId: 'driver-2', incident: { lap: 1, description: 'Test protest' } } },
{ method: 'POST' as const, path: '/leagues/league-1/join', name: 'Join league', requiresAuth: true, body: {} },
{ method: 'POST' as const, path: '/teams/123/join', name: 'Join team', requiresAuth: true, body: { teamId: '123' } },
];
console.log(`\n[API SMOKE] Testing ${endpoints.length} POST endpoints...`);
@@ -155,13 +152,14 @@ test.describe('API Smoke Tests', () => {
});
test('parameterized endpoints handle missing IDs gracefully', async ({ request }) => {
testResults = [];
const endpoints = [
{ method: 'GET' as const, path: '/races/non-existent-id', name: 'Get non-existent race' },
{ method: 'GET' as const, path: '/races/non-existent-id/results', name: 'Get non-existent race results' },
{ method: 'GET' as const, path: '/leagues/non-existent-id', name: 'Get non-existent league' },
{ method: 'GET' as const, path: '/teams/non-existent-id', name: 'Get non-existent team' },
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver' },
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar' },
{ method: 'GET' as const, path: '/races/non-existent-id', name: 'Get non-existent race', requiresAuth: false },
{ method: 'GET' as const, path: '/races/non-existent-id/results', name: 'Get non-existent race results', requiresAuth: false },
{ method: 'GET' as const, path: '/leagues/non-existent-id', name: 'Get non-existent league', requiresAuth: false },
{ method: 'GET' as const, path: '/teams/non-existent-id', name: 'Get non-existent team', requiresAuth: false },
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver', requiresAuth: false },
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar', requiresAuth: false },
];
console.log(`\n[API SMOKE] Testing ${endpoints.length} parameterized endpoints with invalid IDs...`);
@@ -170,14 +168,88 @@ test.describe('API Smoke Tests', () => {
await testEndpoint(request, endpoint);
}
// Check for failures
const failures = testResults.filter(r => !r.success);
expect(failures.length).toBe(0);
});
test('authenticated endpoints respond correctly', async () => {
testResults = [];
// Load user auth cookies
const userAuthData = await fs.readFile(USER_AUTH_FILE, 'utf-8');
const userCookies = JSON.parse(userAuthData).cookies;
// Create new API request context with user auth
const userContext = await request.newContext({
storageState: {
cookies: userCookies,
origins: [{ origin: API_BASE_URL, localStorage: [] }]
}
});
const endpoints = [
// Dashboard
{ method: 'GET' as const, path: '/dashboard/overview', name: 'Dashboard Overview' },
// Analytics
{ method: 'GET' as const, path: '/analytics/metrics', name: 'Analytics Metrics' },
// Notifications
{ method: 'GET' as const, path: '/notifications/unread', name: 'Unread Notifications' },
];
console.log(`\n[API SMOKE] Testing ${endpoints.length} authenticated endpoints...`);
for (const endpoint of endpoints) {
await testEndpoint(userContext, endpoint);
}
// Check for presenter errors
const presenterErrors = testResults.filter(r => r.hasPresenterError);
expect(presenterErrors.length).toBe(0);
// Clean up
await userContext.dispose();
});
test('admin endpoints respond correctly', async () => {
testResults = [];
// Load admin auth cookies
const adminAuthData = await fs.readFile(ADMIN_AUTH_FILE, 'utf-8');
const adminCookies = JSON.parse(adminAuthData).cookies;
// Create new API request context with admin auth
const adminContext = await request.newContext({
storageState: {
cookies: adminCookies,
origins: [{ origin: API_BASE_URL, localStorage: [] }]
}
});
const endpoints = [
// Payments (requires admin capability)
{ method: 'GET' as const, path: '/payments/wallets?leagueId=league-1', name: 'Wallets' },
];
console.log(`\n[API SMOKE] Testing ${endpoints.length} admin endpoints...`);
for (const endpoint of endpoints) {
await testEndpoint(adminContext, endpoint);
}
// Check for presenter errors
const presenterErrors = testResults.filter(r => r.hasPresenterError);
expect(presenterErrors.length).toBe(0);
// Clean up
await adminContext.dispose();
});
async function testEndpoint(
request: import('@playwright/test').APIRequestContext,
endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown }
endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown; requiresAuth?: boolean }
): Promise<void> {
const startTime = Date.now();
const fullUrl = `${API_BASE_URL}${endpoint.path}`;
@@ -186,22 +258,26 @@ test.describe('API Smoke Tests', () => {
try {
let response;
const headers: Record<string, string> = {};
// Playwright's request context handles cookies automatically
// No need to set Authorization header for cookie-based auth
switch (endpoint.method) {
case 'GET':
response = await request.get(fullUrl);
response = await request.get(fullUrl, { headers });
break;
case 'POST':
response = await request.post(fullUrl, { data: endpoint.body || {} });
response = await request.post(fullUrl, { data: endpoint.body || {}, headers });
break;
case 'PUT':
response = await request.put(fullUrl, { data: endpoint.body || {} });
response = await request.put(fullUrl, { data: endpoint.body || {}, headers });
break;
case 'DELETE':
response = await request.delete(fullUrl);
response = await request.delete(fullUrl, { headers });
break;
case 'PATCH':
response = await request.patch(fullUrl, { data: endpoint.body || {} });
response = await request.patch(fullUrl, { data: endpoint.body || {}, headers });
break;
}
@@ -217,7 +293,9 @@ test.describe('API Smoke Tests', () => {
(body && body.message && body.message.includes('Presenter not presented')) ||
(body && body.error && body.error.includes('Presenter not presented'));
const success = status < 400 && !hasPresenterError;
// Success is 200-299 status, or 404 for non-existent resources, and no presenter error
const isNotFound = status === 404;
const success = (status >= 200 && status < 300 || isNotFound) && !hasPresenterError;
const result: EndpointTestResult = {
endpoint: endpoint.path,
@@ -234,6 +312,7 @@ test.describe('API Smoke Tests', () => {
}
testResults.push(result);
allResults.push(result);
if (hasPresenterError) {
console.log(` ❌ PRESENTER ERROR: ${status} - ${body?.message || bodyText.substring(0, 100)}`);
@@ -266,23 +345,24 @@ test.describe('API Smoke Tests', () => {
}
testResults.push(result);
allResults.push(result);
}
}
async function generateReport(): Promise<void> {
const summary = {
total: testResults.length,
success: testResults.filter(r => r.success).length,
failed: testResults.filter(r => !r.success).length,
presenterErrors: testResults.filter(r => r.hasPresenterError).length,
avgResponseTime: testResults.reduce((sum, r) => sum + r.responseTime, 0) / testResults.length || 0,
total: allResults.length,
success: allResults.filter(r => r.success).length,
failed: allResults.filter(r => !r.success).length,
presenterErrors: allResults.filter(r => r.hasPresenterError).length,
avgResponseTime: allResults.reduce((sum, r) => sum + r.responseTime, 0) / allResults.length || 0,
};
const report: TestReport = {
timestamp: new Date().toISOString(),
summary,
results: testResults,
failures: testResults.filter(r => !r.success),
results: allResults,
failures: allResults.filter(r => !r.success),
};
// Write JSON report
@@ -304,7 +384,7 @@ test.describe('API Smoke Tests', () => {
if (summary.presenterErrors > 0) {
md += `## Presenter Errors\n\n`;
const presenterFailures = testResults.filter(r => r.hasPresenterError);
const presenterFailures = allResults.filter(r => r.hasPresenterError);
presenterFailures.forEach((r, i) => {
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
md += ` - Status: ${r.status}\n`;
@@ -314,7 +394,7 @@ test.describe('API Smoke Tests', () => {
if (summary.failed > 0 && summary.presenterErrors < summary.failed) {
md += `## Other Failures\n\n`;
const otherFailures = testResults.filter(r => !r.success && !r.hasPresenterError);
const otherFailures = allResults.filter(r => !r.success && !r.hasPresenterError);
otherFailures.forEach((r, i) => {
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
md += ` - Status: ${r.status}\n`;