integration tests cleanup
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
import { vi } from 'vitest';
|
||||
import { MockLeaguesApiClient } from './mocks/MockLeaguesApiClient';
|
||||
import { CircuitBreakerRegistry } from '../../../apps/website/lib/api/base/RetryHandler';
|
||||
|
||||
export class WebsiteTestContext {
|
||||
public mockLeaguesApiClient: MockLeaguesApiClient;
|
||||
private originalFetch: typeof global.fetch;
|
||||
|
||||
private fetchMock = vi.fn();
|
||||
|
||||
constructor() {
|
||||
this.mockLeaguesApiClient = new MockLeaguesApiClient();
|
||||
this.originalFetch = global.fetch;
|
||||
}
|
||||
|
||||
static create() {
|
||||
return new WebsiteTestContext();
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.originalFetch = global.fetch;
|
||||
global.fetch = this.fetchMock;
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL = 'http://localhost:3001';
|
||||
process.env.API_BASE_URL = 'http://localhost:3001';
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
CircuitBreakerRegistry.getInstance().resetAll();
|
||||
}
|
||||
|
||||
teardown() {
|
||||
global.fetch = this.originalFetch;
|
||||
this.fetchMock.mockClear();
|
||||
this.mockLeaguesApiClient.clearMocks();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
CircuitBreakerRegistry.getInstance().resetAll();
|
||||
// Reset environment variables
|
||||
delete process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
delete process.env.API_BASE_URL;
|
||||
}
|
||||
|
||||
mockFetchResponse(data: any, status = 200, ok = true) {
|
||||
this.fetchMock.mockResolvedValueOnce(this.createMockResponse(data, status, ok));
|
||||
}
|
||||
|
||||
mockFetchError(error: Error) {
|
||||
this.fetchMock.mockRejectedValueOnce(error);
|
||||
}
|
||||
|
||||
mockFetchComplex(handler: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
|
||||
this.fetchMock.mockImplementation(handler);
|
||||
}
|
||||
|
||||
createMockResponse(data: any, status = 200, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? 'OK' : 'Error',
|
||||
headers: new Headers(),
|
||||
json: async () => data,
|
||||
text: async () => (typeof data === 'string' ? data : JSON.stringify(data)),
|
||||
blob: async () => new Blob(),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
formData: async () => new FormData(),
|
||||
clone: () => this.createMockResponse(data, status, ok),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
createMockErrorResponse(status: number, statusText: string, body: string): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
statusText,
|
||||
headers: new Headers(),
|
||||
text: async () => body,
|
||||
json: async () => ({ message: body }),
|
||||
blob: async () => new Blob(),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
formData: async () => new FormData(),
|
||||
clone: () => this.createMockErrorResponse(status, statusText, body),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
} as Response;
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { LeaguesApiClient } from '../../../../apps/website/lib/api/leagues/LeaguesApiClient';
|
||||
import { ApiError } from '../../../../apps/website/lib/api/base/ApiError';
|
||||
import type { Logger } from '../../../../apps/website/lib/interfaces/Logger';
|
||||
import type { ErrorReporter } from '../../../../apps/website/lib/interfaces/ErrorReporter';
|
||||
|
||||
/**
|
||||
* Mock LeaguesApiClient for testing
|
||||
* Allows controlled responses without making actual HTTP calls
|
||||
*/
|
||||
export class MockLeaguesApiClient extends LeaguesApiClient {
|
||||
private mockResponses: Map<string, any> = new Map();
|
||||
private mockErrors: Map<string, ApiError> = new Map();
|
||||
|
||||
constructor(
|
||||
baseUrl: string = 'http://localhost:3001',
|
||||
errorReporter: ErrorReporter = {
|
||||
report: () => {},
|
||||
} as any,
|
||||
logger: Logger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
} as any
|
||||
) {
|
||||
super(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a mock response for a specific endpoint
|
||||
*/
|
||||
setMockResponse(endpoint: string, response: any): void {
|
||||
this.mockResponses.set(endpoint, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a mock error for a specific endpoint
|
||||
*/
|
||||
setMockError(endpoint: string, error: ApiError): void {
|
||||
this.mockErrors.set(endpoint, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mock responses and errors
|
||||
*/
|
||||
clearMocks(): void {
|
||||
this.mockResponses.clear();
|
||||
this.mockErrors.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getAllWithCapacityAndScoring to return mock data
|
||||
*/
|
||||
async getAllWithCapacityAndScoring(): Promise<any> {
|
||||
const endpoint = '/leagues/all-with-capacity-and-scoring';
|
||||
|
||||
if (this.mockErrors.has(endpoint)) {
|
||||
throw this.mockErrors.get(endpoint);
|
||||
}
|
||||
|
||||
if (this.mockResponses.has(endpoint)) {
|
||||
return this.mockResponses.get(endpoint);
|
||||
}
|
||||
|
||||
// Default mock response
|
||||
return {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'A test league',
|
||||
ownerId: 'driver-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 5,
|
||||
settings: {
|
||||
maxDrivers: 10,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getMemberships to return mock data
|
||||
*/
|
||||
async getMemberships(leagueId: string): Promise<any> {
|
||||
const endpoint = `/leagues/${leagueId}/memberships`;
|
||||
|
||||
if (this.mockErrors.has(endpoint)) {
|
||||
throw this.mockErrors.get(endpoint);
|
||||
}
|
||||
|
||||
if (this.mockResponses.has(endpoint)) {
|
||||
return this.mockResponses.get(endpoint);
|
||||
}
|
||||
|
||||
// Default mock response
|
||||
return {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
role: 'owner',
|
||||
status: 'active',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getLeagueConfig to return mock data
|
||||
*/
|
||||
async getLeagueConfig(leagueId: string): Promise<any> {
|
||||
const endpoint = `/leagues/${leagueId}/config`;
|
||||
|
||||
if (this.mockErrors.has(endpoint)) {
|
||||
throw this.mockErrors.get(endpoint);
|
||||
}
|
||||
|
||||
if (this.mockResponses.has(endpoint)) {
|
||||
return this.mockResponses.get(endpoint);
|
||||
}
|
||||
|
||||
// Default mock response
|
||||
return {
|
||||
form: {
|
||||
scoring: {
|
||||
presetId: 'preset-1',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { LeagueDetailPageQuery } from '../../../../apps/website/lib/page-queries/LeagueDetailPageQuery';
|
||||
import { WebsiteTestContext } from '../WebsiteTestContext';
|
||||
|
||||
// Mock data factories
|
||||
const createMockLeagueData = (leagueId: string = 'league-1') => ({
|
||||
leagues: [
|
||||
{
|
||||
id: leagueId,
|
||||
name: 'Test League',
|
||||
description: 'A test league',
|
||||
ownerId: 'driver-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 5,
|
||||
settings: {
|
||||
maxDrivers: 10,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver' as const,
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createMockMembershipsData = () => ({
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver 1',
|
||||
},
|
||||
role: 'owner',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createMockRacesData = (leagueId: string = 'league-1') => ({
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: new Date().toISOString(),
|
||||
leagueId: leagueId,
|
||||
leagueName: 'Test League',
|
||||
status: 'scheduled',
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createMockDriverData = () => ({
|
||||
id: 'driver-1',
|
||||
name: 'Test Driver',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
});
|
||||
|
||||
const createMockConfigData = () => ({
|
||||
form: {
|
||||
scoring: {
|
||||
presetId: 'preset-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('LeagueDetailPageQuery Integration', () => {
|
||||
const ctx = WebsiteTestContext.create();
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.setup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctx.teardown();
|
||||
});
|
||||
|
||||
describe('Happy Path', () => {
|
||||
it('should return valid league detail data when API returns success', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
ctx.mockFetchResponse(createMockLeagueData(leagueId)); // For getAllWithCapacityAndScoring
|
||||
ctx.mockFetchResponse(createMockMembershipsData()); // For getMemberships
|
||||
ctx.mockFetchResponse(createMockRacesData(leagueId)); // For getPageData
|
||||
ctx.mockFetchResponse(createMockDriverData()); // For getDriver
|
||||
ctx.mockFetchResponse(createMockConfigData()); // For getLeagueConfig
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data.leagueId).toBe(leagueId);
|
||||
expect(data.name).toBe('Test League');
|
||||
expect(data.ownerSummary).toBeDefined();
|
||||
expect(data.ownerSummary?.driverName).toBe('Test Driver');
|
||||
});
|
||||
|
||||
it('should handle league without owner', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-2';
|
||||
const leagueData = createMockLeagueData(leagueId);
|
||||
leagueData.leagues[0].ownerId = ''; // No owner
|
||||
|
||||
ctx.mockFetchResponse(leagueData); // getAllWithCapacityAndScoring
|
||||
ctx.mockFetchResponse(createMockMembershipsData()); // getMemberships
|
||||
ctx.mockFetchResponse(createMockRacesData(leagueId)); // getPageData
|
||||
ctx.mockFetchResponse(createMockConfigData()); // getLeagueConfig
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.ownerSummary).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle league with no races', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-3';
|
||||
ctx.mockFetchResponse(createMockLeagueData(leagueId)); // getAllWithCapacityAndScoring
|
||||
ctx.mockFetchResponse(createMockMembershipsData()); // getMemberships
|
||||
ctx.mockFetchResponse({ races: [] }); // getPageData
|
||||
ctx.mockFetchResponse(createMockDriverData()); // getDriver
|
||||
ctx.mockFetchResponse(createMockConfigData()); // getLeagueConfig
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.info.racesCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle 404 error when league not found', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'non-existent-league';
|
||||
ctx.mockFetchResponse({ leagues: [] }); // getAllWithCapacityAndScoring
|
||||
ctx.mockFetchResponse(createMockMembershipsData()); // getMemberships
|
||||
ctx.mockFetchResponse(createMockRacesData(leagueId)); // getPageData
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('notFound');
|
||||
});
|
||||
|
||||
it('should handle 500 error when API server error', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false);
|
||||
ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false);
|
||||
ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false);
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('serverError');
|
||||
});
|
||||
|
||||
it('should handle network error', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchError(new Error('Network error: Unable to reach the API server'));
|
||||
ctx.mockFetchError(new Error('Network error: Unable to reach the API server'));
|
||||
ctx.mockFetchError(new Error('Network error: Unable to reach the API server'));
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('serverError');
|
||||
});
|
||||
|
||||
it('should handle timeout error', async () => {
|
||||
// Arrange
|
||||
const timeoutError = new Error('Request timed out after 30 seconds');
|
||||
timeoutError.name = 'AbortError';
|
||||
ctx.mockFetchError(timeoutError);
|
||||
ctx.mockFetchError(timeoutError);
|
||||
ctx.mockFetchError(timeoutError);
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('serverError');
|
||||
});
|
||||
|
||||
it('should handle unauthorized error', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false);
|
||||
ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false);
|
||||
ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false);
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('should handle forbidden error', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false);
|
||||
ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false);
|
||||
ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false);
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Missing Data', () => {
|
||||
it('should handle API returning partial data (missing memberships)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
ctx.mockFetchResponse(createMockLeagueData(leagueId));
|
||||
ctx.mockFetchResponse(null); // Missing memberships
|
||||
ctx.mockFetchResponse(createMockRacesData(leagueId));
|
||||
ctx.mockFetchResponse(createMockDriverData());
|
||||
ctx.mockFetchResponse(createMockConfigData());
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.info.membersCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle API returning partial data (missing races)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
ctx.mockFetchResponse(createMockLeagueData(leagueId));
|
||||
ctx.mockFetchResponse(createMockMembershipsData());
|
||||
ctx.mockFetchResponse(null); // Missing races
|
||||
ctx.mockFetchResponse(createMockDriverData());
|
||||
ctx.mockFetchResponse(createMockConfigData());
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.info.racesCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle API returning partial data (missing scoring config)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
ctx.mockFetchResponse(createMockLeagueData(leagueId));
|
||||
ctx.mockFetchResponse(createMockMembershipsData());
|
||||
ctx.mockFetchResponse(createMockRacesData(leagueId));
|
||||
ctx.mockFetchResponse(createMockDriverData());
|
||||
ctx.mockFetchResponse({ message: 'Config not found' }, 404, false); // Missing config
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.info.scoring).toBe('Standard');
|
||||
});
|
||||
|
||||
it('should handle API returning partial data (missing owner)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
ctx.mockFetchResponse(createMockLeagueData(leagueId));
|
||||
ctx.mockFetchResponse(createMockMembershipsData());
|
||||
ctx.mockFetchResponse(createMockRacesData(leagueId));
|
||||
ctx.mockFetchResponse(null); // Missing owner
|
||||
ctx.mockFetchResponse(createMockConfigData());
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.ownerSummary).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle API returning empty leagues array', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ leagues: [] });
|
||||
ctx.mockFetchResponse(createMockMembershipsData());
|
||||
ctx.mockFetchResponse(createMockRacesData('league-1'));
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('notFound');
|
||||
});
|
||||
|
||||
it('should handle API returning null data', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse(null);
|
||||
ctx.mockFetchResponse(createMockMembershipsData());
|
||||
ctx.mockFetchResponse(createMockRacesData('league-1'));
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('notFound');
|
||||
});
|
||||
|
||||
it('should handle API returning malformed data', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ someOtherKey: [] });
|
||||
ctx.mockFetchResponse(createMockMembershipsData());
|
||||
ctx.mockFetchResponse(createMockRacesData('league-1'));
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute('league-1');
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.getError()).toBe('notFound');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,309 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { LeaguesPageQuery } from '../../../../apps/website/lib/page-queries/LeaguesPageQuery';
|
||||
import { WebsiteTestContext } from '../WebsiteTestContext';
|
||||
|
||||
// Mock data factories
|
||||
const createMockLeaguesData = () => ({
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League 1',
|
||||
description: 'A test league',
|
||||
ownerId: 'driver-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 5,
|
||||
settings: {
|
||||
maxDrivers: 10,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver' as const,
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'league-2',
|
||||
name: 'Test League 2',
|
||||
description: 'Another test league',
|
||||
ownerId: 'driver-2',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 15,
|
||||
settings: {
|
||||
maxDrivers: 20,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver' as const,
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
const createMockEmptyLeaguesData = () => ({
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
describe('LeaguesPageQuery Integration', () => {
|
||||
const ctx = WebsiteTestContext.create();
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.setup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctx.teardown();
|
||||
});
|
||||
|
||||
describe('Happy Path', () => {
|
||||
it('should return valid leagues data when API returns success', async () => {
|
||||
// Arrange
|
||||
const mockData = createMockLeaguesData();
|
||||
ctx.mockFetchResponse(mockData);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
|
||||
expect(viewData).toBeDefined();
|
||||
expect(viewData.leagues).toBeDefined();
|
||||
expect(viewData.leagues.length).toBe(2);
|
||||
|
||||
// Verify first league
|
||||
expect(viewData.leagues[0].id).toBe('league-1');
|
||||
expect(viewData.leagues[0].name).toBe('Test League 1');
|
||||
expect(viewData.leagues[0].maxDrivers).toBe(10);
|
||||
expect(viewData.leagues[0].usedDriverSlots).toBe(5);
|
||||
|
||||
// Verify second league
|
||||
expect(viewData.leagues[1].id).toBe('league-2');
|
||||
expect(viewData.leagues[1].name).toBe('Test League 2');
|
||||
expect(viewData.leagues[1].maxDrivers).toBe(20);
|
||||
expect(viewData.leagues[1].usedDriverSlots).toBe(15);
|
||||
});
|
||||
|
||||
it('should handle single league correctly', async () => {
|
||||
// Arrange
|
||||
const mockData = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'single-league',
|
||||
name: 'Single League',
|
||||
description: 'Only one league',
|
||||
ownerId: 'driver-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 3,
|
||||
settings: {
|
||||
maxDrivers: 5,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver' as const,
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
ctx.mockFetchResponse(mockData);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
|
||||
expect(viewData.leagues.length).toBe(1);
|
||||
expect(viewData.leagues[0].id).toBe('single-league');
|
||||
expect(viewData.leagues[0].name).toBe('Single League');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty Results', () => {
|
||||
it('should handle empty leagues list from API', async () => {
|
||||
// Arrange
|
||||
const mockData = createMockEmptyLeaguesData();
|
||||
ctx.mockFetchResponse(mockData);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
|
||||
expect(viewData).toBeDefined();
|
||||
expect(viewData.leagues).toBeDefined();
|
||||
expect(viewData.leagues.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle 404 error when leagues endpoint not found', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Leagues not found' }, 404, false);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('notFound');
|
||||
});
|
||||
|
||||
it('should handle 500 error when API server error', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle network error', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchError(new Error('Network error: Unable to reach the API server'));
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle timeout error', async () => {
|
||||
// Arrange
|
||||
const timeoutError = new Error('Request timed out after 30 seconds');
|
||||
timeoutError.name = 'AbortError';
|
||||
ctx.mockFetchError(timeoutError);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle unauthorized error (redirect)', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('redirect');
|
||||
});
|
||||
|
||||
it('should handle forbidden error (redirect)', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('redirect');
|
||||
});
|
||||
|
||||
it('should handle unknown error type', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ message: 'Unknown error' }, 999, false);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle API returning null or undefined data', async () => {
|
||||
// Arrange
|
||||
ctx.mockFetchResponse({ leagues: null });
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('UNKNOWN_ERROR');
|
||||
});
|
||||
|
||||
it('should handle API returning malformed data', async () => {
|
||||
// Arrange
|
||||
const mockData = {
|
||||
// Missing 'leagues' property
|
||||
someOtherProperty: 'value',
|
||||
};
|
||||
ctx.mockFetchResponse(mockData);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('UNKNOWN_ERROR');
|
||||
});
|
||||
|
||||
it('should handle API returning leagues with missing required fields', async () => {
|
||||
// Arrange
|
||||
const mockData = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
// Missing other required fields
|
||||
settings: { maxDrivers: 10 },
|
||||
usedSlots: 5,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
ctx.mockFetchResponse(mockData);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
// Should still succeed - the builder should handle partial data
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
expect(viewData.leagues.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getWebsiteRouteContracts, ScenarioRole } from '../../../shared/website/RouteContractSpec';
|
||||
import { WebsiteRouteManager } from '../../../shared/website/WebsiteRouteManager';
|
||||
import { RouteScenarioMatrix } from '../../../shared/website/RouteScenarioMatrix';
|
||||
|
||||
describe('RouteContractSpec', () => {
|
||||
const contracts = getWebsiteRouteContracts();
|
||||
const manager = new WebsiteRouteManager();
|
||||
const inventory = manager.getWebsiteRouteInventory();
|
||||
|
||||
it('should cover all inventory routes', () => {
|
||||
expect(contracts.length).toBe(inventory.length);
|
||||
|
||||
const inventoryPaths = inventory.map(def =>
|
||||
manager.resolvePathTemplate(def.pathTemplate, def.params)
|
||||
);
|
||||
const contractPaths = contracts.map(c => c.path);
|
||||
|
||||
// Ensure every path in inventory has a corresponding contract
|
||||
inventoryPaths.forEach(path => {
|
||||
expect(contractPaths).toContain(path);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expectedStatus set for every contract', () => {
|
||||
contracts.forEach(contract => {
|
||||
expect(contract.expectedStatus).toBeDefined();
|
||||
expect(['ok', 'redirect', 'forbidden', 'notFoundAllowed', 'errorRoute']).toContain(contract.expectedStatus);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have required scenarios based on access level', () => {
|
||||
contracts.forEach(contract => {
|
||||
const scenarios = Object.keys(contract.scenarios) as ScenarioRole[];
|
||||
|
||||
// All routes must have unauth, auth, admin, sponsor scenarios
|
||||
expect(scenarios).toContain('unauth');
|
||||
expect(scenarios).toContain('auth');
|
||||
expect(scenarios).toContain('admin');
|
||||
expect(scenarios).toContain('sponsor');
|
||||
|
||||
// Admin and Sponsor routes must also have wrong-role scenario
|
||||
if (contract.accessLevel === 'admin' || contract.accessLevel === 'sponsor') {
|
||||
expect(scenarios).toContain('wrong-role');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct scenario expectations for admin routes', () => {
|
||||
const adminContracts = contracts.filter(c => c.accessLevel === 'admin');
|
||||
adminContracts.forEach(contract => {
|
||||
expect(contract.scenarios.unauth?.expectedStatus).toBe('redirect');
|
||||
expect(contract.scenarios.auth?.expectedStatus).toBe('redirect');
|
||||
expect(contract.scenarios.admin?.expectedStatus).toBe('ok');
|
||||
expect(contract.scenarios.sponsor?.expectedStatus).toBe('redirect');
|
||||
expect(contract.scenarios['wrong-role']?.expectedStatus).toBe('redirect');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct scenario expectations for sponsor routes', () => {
|
||||
const sponsorContracts = contracts.filter(c => c.accessLevel === 'sponsor');
|
||||
sponsorContracts.forEach(contract => {
|
||||
expect(contract.scenarios.unauth?.expectedStatus).toBe('redirect');
|
||||
expect(contract.scenarios.auth?.expectedStatus).toBe('redirect');
|
||||
expect(contract.scenarios.admin?.expectedStatus).toBe('redirect');
|
||||
expect(contract.scenarios.sponsor?.expectedStatus).toBe('ok');
|
||||
expect(contract.scenarios['wrong-role']?.expectedStatus).toBe('redirect');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expectedRedirectTo set for protected routes (unauth scenario)', () => {
|
||||
const protectedContracts = contracts.filter(c => c.accessLevel !== 'public');
|
||||
|
||||
// Filter out routes that might have overrides to not be 'redirect'
|
||||
const redirectingContracts = protectedContracts.filter(c => c.expectedStatus === 'redirect');
|
||||
|
||||
expect(redirectingContracts.length).toBeGreaterThan(0);
|
||||
|
||||
redirectingContracts.forEach(contract => {
|
||||
expect(contract.expectedRedirectTo).toBeDefined();
|
||||
expect(contract.expectedRedirectTo).toMatch(/^\//);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include default SSR sanity markers', () => {
|
||||
contracts.forEach(contract => {
|
||||
expect(contract.ssrMustContain).toContain('<!DOCTYPE html>');
|
||||
expect(contract.ssrMustContain).toContain('<body');
|
||||
expect(contract.ssrMustNotContain).toContain('__NEXT_ERROR__');
|
||||
expect(contract.ssrMustNotContain).toContain('Application error: a client-side exception has occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RouteScenarioMatrix', () => {
|
||||
it('should match the number of contracts', () => {
|
||||
expect(RouteScenarioMatrix.length).toBe(contracts.length);
|
||||
});
|
||||
|
||||
it('should correctly identify routes with param edge cases', () => {
|
||||
const edgeCaseRoutes = RouteScenarioMatrix.filter(m => m.hasParamEdgeCases);
|
||||
// Based on WebsiteRouteManager.getParamEdgeCases(), we expect at least /races/[id] and /leagues/[id]
|
||||
expect(edgeCaseRoutes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const paths = edgeCaseRoutes.map(m => m.path);
|
||||
expect(paths.some(p => p.startsWith('/races/'))).toBe(true);
|
||||
expect(paths.some(p => p.startsWith('/leagues/'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, test, beforeAll, afterAll } from 'vitest';
|
||||
import { routes } from '../../../../apps/website/lib/routing/RouteConfig';
|
||||
import { WebsiteServerHarness } from '../../harness/WebsiteServerHarness';
|
||||
import { ApiServerHarness } from '../../harness/ApiServerHarness';
|
||||
import { HttpDiagnostics } from '../../../shared/website/HttpDiagnostics';
|
||||
|
||||
const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3000';
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
type AuthRole = 'unauth' | 'auth' | 'admin' | 'sponsor';
|
||||
|
||||
async function loginViaApi(role: AuthRole): Promise<string | null> {
|
||||
if (role === 'unauth') return null;
|
||||
|
||||
const credentials = {
|
||||
admin: { email: 'demo.admin@example.com', password: 'Demo1234!' },
|
||||
sponsor: { email: 'demo.sponsor@example.com', password: 'Demo1234!' },
|
||||
auth: { email: 'demo.driver@example.com', password: 'Demo1234!' },
|
||||
}[role];
|
||||
|
||||
try {
|
||||
console.log(`[RouteProtection] Attempting login for role ${role} at ${API_BASE_URL}/auth/login`);
|
||||
const res = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[RouteProtection] Login failed for role ${role}: ${res.status} ${res.statusText}`);
|
||||
const body = await res.text();
|
||||
console.warn(`[RouteProtection] Login failure body: ${body}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const setCookie = res.headers.get('set-cookie') ?? '';
|
||||
console.log(`[RouteProtection] Login success. set-cookie: ${setCookie}`);
|
||||
const cookiePart = setCookie.split(';')[0] ?? '';
|
||||
return cookiePart.startsWith('gp_session=') ? cookiePart : null;
|
||||
} catch (e) {
|
||||
console.warn(`[RouteProtection] Could not connect to API at ${API_BASE_URL} for role ${role} login: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Route Protection Matrix', () => {
|
||||
let websiteHarness: WebsiteServerHarness | null = null;
|
||||
let apiHarness: ApiServerHarness | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
console.log(`[RouteProtection] beforeAll starting. WEBSITE_BASE_URL=${WEBSITE_BASE_URL}, API_BASE_URL=${API_BASE_URL}`);
|
||||
|
||||
// 1. Ensure API is running
|
||||
if (API_BASE_URL.includes('localhost')) {
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/health`);
|
||||
console.log(`[RouteProtection] API already running at ${API_BASE_URL}`);
|
||||
} catch (e) {
|
||||
console.log(`[RouteProtection] Starting API server harness on ${API_BASE_URL}...`);
|
||||
apiHarness = new ApiServerHarness({
|
||||
port: parseInt(new URL(API_BASE_URL).port) || 3001,
|
||||
});
|
||||
await apiHarness.start();
|
||||
console.log(`[RouteProtection] API Harness started.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ensure Website is running
|
||||
if (WEBSITE_BASE_URL.includes('localhost')) {
|
||||
try {
|
||||
console.log(`[RouteProtection] Checking if website is already running at ${WEBSITE_BASE_URL}`);
|
||||
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
|
||||
console.log(`[RouteProtection] Website already running.`);
|
||||
} catch (e) {
|
||||
console.log(`[RouteProtection] Website not running, starting harness...`);
|
||||
websiteHarness = new WebsiteServerHarness({
|
||||
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
|
||||
env: {
|
||||
API_BASE_URL: API_BASE_URL,
|
||||
NEXT_PUBLIC_API_BASE_URL: API_BASE_URL,
|
||||
},
|
||||
});
|
||||
await websiteHarness.start();
|
||||
console.log(`[RouteProtection] Website Harness started.`);
|
||||
}
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (websiteHarness) {
|
||||
await websiteHarness.stop();
|
||||
}
|
||||
if (apiHarness) {
|
||||
await apiHarness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
const testMatrix: Array<{
|
||||
role: AuthRole;
|
||||
path: string;
|
||||
expectedStatus: number | number[];
|
||||
expectedRedirect?: string;
|
||||
}> = [
|
||||
// Unauthenticated
|
||||
{ role: 'unauth', path: routes.public.home, expectedStatus: 200 },
|
||||
{ role: 'unauth', path: routes.protected.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.auth.login },
|
||||
{ role: 'unauth', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.auth.login },
|
||||
{ role: 'unauth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.auth.login },
|
||||
|
||||
// Authenticated (Driver)
|
||||
{ role: 'auth', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'auth', path: routes.protected.dashboard, expectedStatus: 200 },
|
||||
{ role: 'auth', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'auth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
|
||||
// Admin
|
||||
{ role: 'admin', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'admin', path: routes.protected.dashboard, expectedStatus: 200 },
|
||||
{ role: 'admin', path: routes.admin.root, expectedStatus: 200 },
|
||||
{ role: 'admin', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.admin.root },
|
||||
|
||||
// Sponsor
|
||||
{ role: 'sponsor', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'sponsor', path: routes.protected.dashboard, expectedStatus: 200 },
|
||||
{ role: 'sponsor', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.sponsor.dashboard },
|
||||
{ role: 'sponsor', path: routes.sponsor.dashboard, expectedStatus: 200 },
|
||||
];
|
||||
|
||||
test.each(testMatrix)('$role accessing $path', async ({ role, path, expectedStatus, expectedRedirect }) => {
|
||||
const cookie = await loginViaApi(role);
|
||||
|
||||
if (role !== 'unauth' && !cookie) {
|
||||
// If login fails, we can't test protected routes properly.
|
||||
// In a real CI environment, the API should be running.
|
||||
// For now, we'll skip the assertion if login fails to avoid false negatives when API is down.
|
||||
console.warn(`Skipping ${role} test because login failed`);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookie) {
|
||||
headers['Cookie'] = cookie;
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
const location = response.headers.get('location');
|
||||
const html = status >= 400 ? await response.text() : undefined;
|
||||
|
||||
const failureContext = {
|
||||
role,
|
||||
url,
|
||||
status,
|
||||
location,
|
||||
html,
|
||||
serverLogs: websiteHarness?.getLogTail(60),
|
||||
};
|
||||
|
||||
const formatFailure = (extra: string) => HttpDiagnostics.formatHttpFailure({ ...failureContext, extra });
|
||||
|
||||
if (Array.isArray(expectedStatus)) {
|
||||
if (!expectedStatus.includes(status)) {
|
||||
throw new Error(formatFailure(`Expected status to be one of [${expectedStatus.join(', ')}], but got ${status}`));
|
||||
}
|
||||
} else {
|
||||
if (status !== expectedStatus) {
|
||||
throw new Error(formatFailure(`Expected status ${expectedStatus}, but got ${status}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedRedirect) {
|
||||
if (!location || !location.includes(expectedRedirect)) {
|
||||
throw new Error(formatFailure(`Expected redirect to contain "${expectedRedirect}", but got "${location || 'N/A'}"`));
|
||||
}
|
||||
if (role === 'unauth' && expectedRedirect === routes.auth.login) {
|
||||
if (!location.includes('returnTo=')) {
|
||||
throw new Error(formatFailure(`Expected redirect to contain "returnTo=" for unauth login redirect`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, test, beforeAll, afterAll, expect } from 'vitest';
|
||||
import { getWebsiteRouteContracts, RouteContract } from '../../../shared/website/RouteContractSpec';
|
||||
import { WebsiteServerHarness } from '../../harness/WebsiteServerHarness';
|
||||
import { ApiServerHarness } from '../../harness/ApiServerHarness';
|
||||
import { HttpDiagnostics } from '../../../shared/website/HttpDiagnostics';
|
||||
|
||||
const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3005';
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3006';
|
||||
|
||||
// Ensure WebsiteRouteManager uses the same persistence mode as the API harness
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
|
||||
|
||||
describe('Website SSR Integration', () => {
|
||||
let websiteHarness: WebsiteServerHarness | null = null;
|
||||
let apiHarness: ApiServerHarness | null = null;
|
||||
const contracts = getWebsiteRouteContracts();
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1. Start API
|
||||
console.log(`[WebsiteSSR] Starting API harness on ${API_BASE_URL}...`);
|
||||
apiHarness = new ApiServerHarness({
|
||||
port: parseInt(new URL(API_BASE_URL).port) || 3006,
|
||||
});
|
||||
await apiHarness.start();
|
||||
console.log(`[WebsiteSSR] API Harness started.`);
|
||||
|
||||
// 2. Start Website
|
||||
console.log(`[WebsiteSSR] Starting website harness on ${WEBSITE_BASE_URL}...`);
|
||||
websiteHarness = new WebsiteServerHarness({
|
||||
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3005,
|
||||
env: {
|
||||
PORT: '3005',
|
||||
API_BASE_URL: API_BASE_URL,
|
||||
NEXT_PUBLIC_API_BASE_URL: API_BASE_URL,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
});
|
||||
await websiteHarness.start();
|
||||
console.log(`[WebsiteSSR] Website Harness started.`);
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (websiteHarness) {
|
||||
await websiteHarness.stop();
|
||||
}
|
||||
if (apiHarness) {
|
||||
await apiHarness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(contracts)('SSR for $path ($accessLevel)', async (contract: RouteContract) => {
|
||||
const url = `${WEBSITE_BASE_URL}${contract.path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
const status = response.status;
|
||||
const location = response.headers.get('location');
|
||||
const html = await response.text();
|
||||
|
||||
if (status === 500) {
|
||||
console.error(`[WebsiteSSR] 500 Error at ${contract.path}. HTML:`, html.substring(0, 10000));
|
||||
const errorMatch = html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/);
|
||||
if (errorMatch) {
|
||||
console.error(`[WebsiteSSR] Error details from HTML:`, errorMatch[1]);
|
||||
}
|
||||
const nextDataMatch = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
||||
if (nextDataMatch) {
|
||||
console.error(`[WebsiteSSR] NEXT_DATA:`, nextDataMatch[1]);
|
||||
}
|
||||
// Look for Next.js 13+ flight data or error markers
|
||||
const flightDataMatch = html.match(/self\.__next_f\.push\(\[1,"([^"]+)"\]\)/g);
|
||||
if (flightDataMatch) {
|
||||
console.error(`[WebsiteSSR] Flight Data found, checking for errors...`);
|
||||
flightDataMatch.forEach(m => {
|
||||
if (m.includes('Error') || m.includes('failed')) {
|
||||
console.error(`[WebsiteSSR] Potential error in flight data:`, m);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Check for specific error message in the body
|
||||
if (html.includes('Error:')) {
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/);
|
||||
if (bodyMatch) {
|
||||
console.error(`[WebsiteSSR] Body content:`, bodyMatch[1].substring(0, 1000));
|
||||
}
|
||||
}
|
||||
// Check for Next.js 14+ error markers
|
||||
const nextErrorMatch = html.match(/<meta name="next-error" content="([^"]+)"\/>/);
|
||||
if (nextErrorMatch) {
|
||||
console.error(`[WebsiteSSR] Next.js Error Marker:`, nextErrorMatch[1]);
|
||||
}
|
||||
// Check for "digest" error markers
|
||||
const digestMatch = html.match(/"digest":"([^"]+)"/);
|
||||
if (digestMatch) {
|
||||
console.error(`[WebsiteSSR] Error Digest:`, digestMatch[1]);
|
||||
}
|
||||
// Check for "notFound" in flight data
|
||||
if (html.includes('notFound')) {
|
||||
console.error(`[WebsiteSSR] "notFound" found in HTML source`);
|
||||
}
|
||||
// Check for "NEXT_NOT_FOUND"
|
||||
if (html.includes('NEXT_NOT_FOUND')) {
|
||||
console.error(`[WebsiteSSR] "NEXT_NOT_FOUND" found in HTML source`);
|
||||
}
|
||||
// Check for "Invariant: notFound() called in shell"
|
||||
if (html.includes('Invariant: notFound() called in shell')) {
|
||||
console.error(`[WebsiteSSR] "Invariant: notFound() called in shell" found in HTML source`);
|
||||
}
|
||||
// Check for "Error: notFound()"
|
||||
if (html.includes('Error: notFound()')) {
|
||||
console.error(`[WebsiteSSR] "Error: notFound()" found in HTML source`);
|
||||
}
|
||||
// Check for "DIGEST"
|
||||
if (html.includes('DIGEST')) {
|
||||
console.error(`[WebsiteSSR] "DIGEST" found in HTML source`);
|
||||
}
|
||||
// Check for "NEXT_REDIRECT"
|
||||
if (html.includes('NEXT_REDIRECT')) {
|
||||
console.error(`[WebsiteSSR] "NEXT_REDIRECT" found in HTML source`);
|
||||
}
|
||||
// Check for "Error: "
|
||||
const genericErrorMatch = html.match(/Error: ([^<]+)/);
|
||||
if (genericErrorMatch) {
|
||||
console.error(`[WebsiteSSR] Generic Error Match:`, genericErrorMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const failureContext = {
|
||||
url,
|
||||
status,
|
||||
location,
|
||||
html: html.substring(0, 1000), // Limit HTML in logs
|
||||
serverLogs: websiteHarness?.getLogTail(60),
|
||||
};
|
||||
|
||||
const formatFailure = (extra: string) => HttpDiagnostics.formatHttpFailure({ ...failureContext, extra });
|
||||
|
||||
// 1. Assert Status
|
||||
if (contract.expectedStatus === 'ok') {
|
||||
if (status !== 200) {
|
||||
throw new Error(formatFailure(`Expected status 200 OK, but got ${status}`));
|
||||
}
|
||||
} else if (contract.expectedStatus === 'redirect') {
|
||||
if (status !== 302 && status !== 307) {
|
||||
throw new Error(formatFailure(`Expected redirect status (302/307), but got ${status}`));
|
||||
}
|
||||
|
||||
// 2. Assert Redirect Location
|
||||
if (contract.expectedRedirectTo) {
|
||||
if (!location) {
|
||||
throw new Error(formatFailure(`Expected redirect to ${contract.expectedRedirectTo}, but got no Location header`));
|
||||
}
|
||||
const locationPathname = new URL(location, WEBSITE_BASE_URL).pathname;
|
||||
if (locationPathname !== contract.expectedRedirectTo) {
|
||||
throw new Error(formatFailure(`Expected redirect to pathname "${contract.expectedRedirectTo}", but got "${locationPathname}" (full: ${location})`));
|
||||
}
|
||||
}
|
||||
} else if (contract.expectedStatus === 'notFoundAllowed') {
|
||||
if (status !== 404 && status !== 200) {
|
||||
throw new Error(formatFailure(`Expected 404 or 200 (notFoundAllowed), but got ${status}`));
|
||||
}
|
||||
} else if (contract.expectedStatus === 'errorRoute') {
|
||||
// Error routes themselves should return 200 or their respective error codes (like 500)
|
||||
if (status >= 600) {
|
||||
throw new Error(formatFailure(`Error route returned unexpected status ${status}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Assert SSR HTML Markers (only if not a redirect)
|
||||
if (status === 200 || status === 404) {
|
||||
if (contract.ssrMustContain) {
|
||||
for (const marker of contract.ssrMustContain) {
|
||||
if (typeof marker === 'string') {
|
||||
if (!html.includes(marker)) {
|
||||
throw new Error(formatFailure(`SSR HTML missing expected marker: "${marker}"`));
|
||||
}
|
||||
} else if (marker instanceof RegExp) {
|
||||
if (!marker.test(html)) {
|
||||
throw new Error(formatFailure(`SSR HTML missing expected regex marker: ${marker}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contract.ssrMustNotContain) {
|
||||
for (const marker of contract.ssrMustNotContain) {
|
||||
if (typeof marker === 'string') {
|
||||
if (html.includes(marker)) {
|
||||
throw new Error(formatFailure(`SSR HTML contains forbidden marker: "${marker}"`));
|
||||
}
|
||||
} else if (marker instanceof RegExp) {
|
||||
if (marker.test(html)) {
|
||||
throw new Error(formatFailure(`SSR HTML contains forbidden regex marker: ${marker}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contract.minTextLength && html.length < contract.minTextLength) {
|
||||
throw new Error(formatFailure(`SSR HTML length ${html.length} is less than minimum ${contract.minTextLength}`));
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
Reference in New Issue
Block a user