website refactor
This commit is contained in:
@@ -214,7 +214,10 @@
|
|||||||
"apps/website/app/**/page.tsx",
|
"apps/website/app/**/page.tsx",
|
||||||
"apps/website/app/**/page.ts",
|
"apps/website/app/**/page.ts",
|
||||||
"apps/website/app/**/layout.tsx",
|
"apps/website/app/**/layout.tsx",
|
||||||
"apps/website/app/**/layout.ts"
|
"apps/website/app/**/layout.ts",
|
||||||
|
"playwright.*.config.ts",
|
||||||
|
"vitest.*.config.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-default-export": "off",
|
"import/no-default-export": "off",
|
||||||
@@ -224,7 +227,9 @@
|
|||||||
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').",
|
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').",
|
||||||
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]"
|
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -236,6 +241,11 @@
|
|||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx"
|
"**/*.tsx"
|
||||||
],
|
],
|
||||||
|
"excludedFiles": [
|
||||||
|
"playwright.*.config.ts",
|
||||||
|
"vitest.*.config.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
|
],
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@typescript-eslint",
|
"@typescript-eslint",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"gridpilot-adapters-rules"
|
"gridpilot-adapters-rules"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-adapters-rules/no-index-files": "error",
|
"gridpilot-adapters-rules/no-index-files": "error"
|
||||||
"gridpilot-adapters-rules/adapter-naming": "error"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ import { getLeagueScoringPresetById } from './LeagueScoringPresets';
|
|||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
class SilentLogger implements Logger {
|
class SilentLogger implements Logger {
|
||||||
|
private getTimestamp(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
debug(..._args: unknown[]): void {
|
debug(..._args: unknown[]): void {
|
||||||
// console.debug(...args);
|
// console.debug(`[${this.getTimestamp()}]`, ...args);
|
||||||
}
|
}
|
||||||
info(..._args: unknown[]): void {
|
info(..._args: unknown[]): void {
|
||||||
// console.info(...args);
|
// console.info(`[${this.getTimestamp()}]`, ...args);
|
||||||
}
|
}
|
||||||
warn(..._args: unknown[]): void {
|
warn(..._args: unknown[]): void {
|
||||||
// console.warn(...args);
|
// console.warn(`[${this.getTimestamp()}]`, ...args);
|
||||||
}
|
}
|
||||||
error(..._args: unknown[]): void {
|
error(..._args: unknown[]): void {
|
||||||
// console.error(...args);
|
// console.error(`[${this.getTimestamp()}]`, ...args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ export class LeagueService {
|
|||||||
throw new Error(err.code);
|
throw new Error(err.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.allLeaguesWithCapacityPresenter.present(result.unwrap());
|
await this.allLeaguesWithCapacityPresenter.present(result.unwrap());
|
||||||
return this.allLeaguesWithCapacityPresenter.getViewModel();
|
return this.allLeaguesWithCapacityPresenter.getViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ export class LeagueService {
|
|||||||
throw new Error(err.code);
|
throw new Error(err.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.allLeaguesWithCapacityAndScoringPresenter.present(result.unwrap());
|
await this.allLeaguesWithCapacityAndScoringPresenter.present(result.unwrap());
|
||||||
return this.allLeaguesWithCapacityAndScoringPresenter.getViewModel();
|
return this.allLeaguesWithCapacityAndScoringPresenter.getViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.totalLeaguesPresenter.present(result.unwrap());
|
await this.totalLeaguesPresenter.present(result.unwrap());
|
||||||
return this.totalLeaguesPresenter.getResponseModel()!;
|
return this.totalLeaguesPresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.leagueJoinRequestsPresenter.present(result.unwrap());
|
await this.leagueJoinRequestsPresenter.present(result.unwrap());
|
||||||
return this.leagueJoinRequestsPresenter.getViewModel()!.joinRequests;
|
return this.leagueJoinRequestsPresenter.getViewModel()!.joinRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ export class LeagueService {
|
|||||||
throw new Error(err.code);
|
throw new Error(err.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.approveLeagueJoinRequestPresenter.present(result.unwrap());
|
await this.approveLeagueJoinRequestPresenter.present(result.unwrap());
|
||||||
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
|
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ export class LeagueService {
|
|||||||
throw new Error(err.code);
|
throw new Error(err.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rejectLeagueJoinRequestPresenter.present(result.unwrap());
|
await this.rejectLeagueJoinRequestPresenter.present(result.unwrap());
|
||||||
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
|
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ export class LeagueService {
|
|||||||
throw new Error(err.code);
|
throw new Error(err.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.approveLeagueJoinRequestPresenter.present(result.unwrap());
|
await this.approveLeagueJoinRequestPresenter.present(result.unwrap());
|
||||||
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
|
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@ export class LeagueService {
|
|||||||
throw new NotFoundException('Join request not found');
|
throw new NotFoundException('Join request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rejectLeagueJoinRequestPresenter.present(result.unwrap());
|
await this.rejectLeagueJoinRequestPresenter.present(result.unwrap());
|
||||||
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
|
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getLeagueAdminPermissionsPresenter.present(result.unwrap());
|
await this.getLeagueAdminPermissionsPresenter.present(result.unwrap());
|
||||||
return this.getLeagueAdminPermissionsPresenter.getResponseModel()!;
|
return this.getLeagueAdminPermissionsPresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +479,7 @@ export class LeagueService {
|
|||||||
throw new Error(err.code);
|
throw new Error(err.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.removeLeagueMemberPresenter.present(result.unwrap());
|
await this.removeLeagueMemberPresenter.present(result.unwrap());
|
||||||
return this.removeLeagueMemberPresenter.getViewModel()!;
|
return this.removeLeagueMemberPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +517,7 @@ export class LeagueService {
|
|||||||
throw new Error(err.code);
|
throw new Error(err.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateLeagueMemberRolePresenter.present(result.unwrap());
|
await this.updateLeagueMemberRolePresenter.present(result.unwrap());
|
||||||
return this.updateLeagueMemberRolePresenter.getViewModel()!;
|
return this.updateLeagueMemberRolePresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.getLeagueOwnerSummaryPresenter.present(result.unwrap());
|
await this.getLeagueOwnerSummaryPresenter.present(result.unwrap());
|
||||||
return this.getLeagueOwnerSummaryPresenter.getViewModel()!;
|
return this.getLeagueOwnerSummaryPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +539,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.leagueConfigPresenter.present(result.unwrap());
|
await this.leagueConfigPresenter.present(result.unwrap());
|
||||||
return this.leagueConfigPresenter.getViewModel();
|
return this.leagueConfigPresenter.getViewModel();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error getting league full config', error instanceof Error ? error : new Error(String(error)));
|
this.logger.error('Error getting league full config', error instanceof Error ? error : new Error(String(error)));
|
||||||
@@ -553,7 +553,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.leagueProtestsPresenter.present(result.unwrap());
|
await this.leagueProtestsPresenter.present(result.unwrap());
|
||||||
return this.leagueProtestsPresenter.getResponseModel()!;
|
return this.leagueProtestsPresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +563,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.getLeagueSeasonsPresenter.present(result.unwrap());
|
await this.getLeagueSeasonsPresenter.present(result.unwrap());
|
||||||
return this.getLeagueSeasonsPresenter.getResponseModel()!;
|
return this.getLeagueSeasonsPresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +573,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.getLeagueMembershipsPresenter.present(result.unwrap());
|
await this.getLeagueMembershipsPresenter.present(result.unwrap());
|
||||||
return this.getLeagueMembershipsPresenter.getViewModel()!.memberships;
|
return this.getLeagueMembershipsPresenter.getViewModel()!.memberships;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +589,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getLeagueRosterMembersPresenter.present(result.unwrap());
|
await this.getLeagueRosterMembersPresenter.present(result.unwrap());
|
||||||
return this.getLeagueRosterMembersPresenter.getViewModel()!;
|
return this.getLeagueRosterMembersPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +605,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getLeagueRosterJoinRequestsPresenter.present(result.unwrap());
|
await this.getLeagueRosterJoinRequestsPresenter.present(result.unwrap());
|
||||||
return this.getLeagueRosterJoinRequestsPresenter.getViewModel()!;
|
return this.getLeagueRosterJoinRequestsPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +615,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.leagueStandingsPresenter.present(result.unwrap());
|
await this.leagueStandingsPresenter.present(result.unwrap());
|
||||||
return this.leagueStandingsPresenter.getResponseModel()!;
|
return this.leagueStandingsPresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,7 +629,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.leagueSchedulePresenter.present(result.unwrap());
|
await this.leagueSchedulePresenter.present(result.unwrap());
|
||||||
return this.leagueSchedulePresenter.getViewModel()!;
|
return this.leagueSchedulePresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +649,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.publishLeagueSeasonSchedulePresenter.present(result.unwrap());
|
await this.publishLeagueSeasonSchedulePresenter.present(result.unwrap());
|
||||||
return this.publishLeagueSeasonSchedulePresenter.getResponseModel()!;
|
return this.publishLeagueSeasonSchedulePresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,7 +669,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.unpublishLeagueSeasonSchedulePresenter.present(result.unwrap());
|
await this.unpublishLeagueSeasonSchedulePresenter.present(result.unwrap());
|
||||||
return this.unpublishLeagueSeasonSchedulePresenter.getResponseModel()!;
|
return this.unpublishLeagueSeasonSchedulePresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +698,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.createLeagueSeasonScheduleRacePresenter.present(result.unwrap());
|
await this.createLeagueSeasonScheduleRacePresenter.present(result.unwrap());
|
||||||
return this.createLeagueSeasonScheduleRacePresenter.getResponseModel()!;
|
return this.createLeagueSeasonScheduleRacePresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,7 +731,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateLeagueSeasonScheduleRacePresenter.present(result.unwrap());
|
await this.updateLeagueSeasonScheduleRacePresenter.present(result.unwrap());
|
||||||
return this.updateLeagueSeasonScheduleRacePresenter.getResponseModel()!;
|
return this.updateLeagueSeasonScheduleRacePresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +749,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.deleteLeagueSeasonScheduleRacePresenter.present(result.unwrap());
|
await this.deleteLeagueSeasonScheduleRacePresenter.present(result.unwrap());
|
||||||
return this.deleteLeagueSeasonScheduleRacePresenter.getResponseModel()!;
|
return this.deleteLeagueSeasonScheduleRacePresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,7 +759,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.leagueStatsPresenter.present(result.unwrap());
|
await this.leagueStatsPresenter.present(result.unwrap());
|
||||||
return this.leagueStatsPresenter.getResponseModel()!;
|
return this.leagueStatsPresenter.getResponseModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,13 +778,13 @@ export class LeagueService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Present the full config result
|
// Present the full config result
|
||||||
this.leagueConfigPresenter.present(fullConfigResult.unwrap());
|
await this.leagueConfigPresenter.present(fullConfigResult.unwrap());
|
||||||
|
|
||||||
const ownerSummaryResult = await this.getLeagueOwnerSummaryUseCase.execute({ leagueId });
|
const ownerSummaryResult = await this.getLeagueOwnerSummaryUseCase.execute({ leagueId });
|
||||||
if (ownerSummaryResult.isErr()) {
|
if (ownerSummaryResult.isErr()) {
|
||||||
throw new Error(ownerSummaryResult.unwrapErr().code);
|
throw new Error(ownerSummaryResult.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.getLeagueOwnerSummaryPresenter.present(ownerSummaryResult.unwrap());
|
await this.getLeagueOwnerSummaryPresenter.present(ownerSummaryResult.unwrap());
|
||||||
const ownerSummary = this.getLeagueOwnerSummaryPresenter.getViewModel()!;
|
const ownerSummary = this.getLeagueOwnerSummaryPresenter.getViewModel()!;
|
||||||
|
|
||||||
const configForm = this.leagueConfigPresenter.getViewModel();
|
const configForm = this.leagueConfigPresenter.getViewModel();
|
||||||
@@ -817,7 +817,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.createLeaguePresenter.present(result.unwrap());
|
await this.createLeaguePresenter.present(result.unwrap());
|
||||||
return this.createLeaguePresenter.getViewModel()!;
|
return this.createLeaguePresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +829,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.leagueScoringConfigPresenter.present(result.unwrap());
|
await this.leagueScoringConfigPresenter.present(result.unwrap());
|
||||||
return this.leagueScoringConfigPresenter.getViewModel();
|
return this.leagueScoringConfigPresenter.getViewModel();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error getting league scoring config', error instanceof Error ? error : new Error(String(error)));
|
this.logger.error('Error getting league scoring config', error instanceof Error ? error : new Error(String(error)));
|
||||||
@@ -844,7 +844,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.leagueScoringPresetsPresenter.present(result.unwrap());
|
await this.leagueScoringPresetsPresenter.present(result.unwrap());
|
||||||
return this.leagueScoringPresetsPresenter.getViewModel()!;
|
return this.leagueScoringPresetsPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +856,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.joinLeaguePresenter.present(result.unwrap());
|
await this.joinLeaguePresenter.present(result.unwrap());
|
||||||
return this.joinLeaguePresenter.getViewModel()!;
|
return this.joinLeaguePresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,7 +877,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.transferLeagueOwnershipPresenter.present(result.unwrap());
|
await this.transferLeagueOwnershipPresenter.present(result.unwrap());
|
||||||
return this.transferLeagueOwnershipPresenter.getViewModel()!;
|
return this.transferLeagueOwnershipPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +888,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.seasonSponsorshipsPresenter.present(result.unwrap());
|
await this.seasonSponsorshipsPresenter.present(result.unwrap());
|
||||||
return this.seasonSponsorshipsPresenter.getViewModel()!;
|
return this.seasonSponsorshipsPresenter.getViewModel()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +904,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.leagueSchedulePresenter.present(result.unwrap());
|
await this.leagueSchedulePresenter.present(result.unwrap());
|
||||||
return {
|
return {
|
||||||
races: this.leagueSchedulePresenter.getViewModel()?.races ?? [],
|
races: this.leagueSchedulePresenter.getViewModel()?.races ?? [],
|
||||||
};
|
};
|
||||||
@@ -916,7 +916,7 @@ export class LeagueService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
this.getLeagueWalletPresenter.present(result.unwrap());
|
await this.getLeagueWalletPresenter.present(result.unwrap());
|
||||||
return this.getLeagueWalletPresenter.getResponseModel();
|
return this.getLeagueWalletPresenter.getResponseModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,7 +940,7 @@ export class LeagueService {
|
|||||||
throw new Error(result.unwrapErr().code);
|
throw new Error(result.unwrapErr().code);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.withdrawFromLeagueWalletPresenter.present(result.unwrap());
|
await this.withdrawFromLeagueWalletPresenter.present(result.unwrap());
|
||||||
return this.withdrawFromLeagueWalletPresenter.getResponseModel();
|
return this.withdrawFromLeagueWalletPresenter.getResponseModel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,14 +11,17 @@ export class InitializationLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log(message: string): void {
|
log(message: string): void {
|
||||||
console.log(`[Initialization] ${message}`);
|
const timestamp = new Date().toISOString();
|
||||||
|
console.log(`[${timestamp}] [Initialization] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string): void {
|
error(message: string): void {
|
||||||
console.error(`[Initialization] ${message}`);
|
const timestamp = new Date().toISOString();
|
||||||
|
console.error(`[${timestamp}] [Initialization] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string): void {
|
warn(message: string): void {
|
||||||
console.warn(`[Initialization] ${message}`);
|
const timestamp = new Date().toISOString();
|
||||||
|
console.warn(`[${timestamp}] [Initialization] ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,20 @@
|
|||||||
"gridpilot-rules/rsc-no-object-construction": "error",
|
"gridpilot-rules/rsc-no-object-construction": "error",
|
||||||
"gridpilot-rules/rsc-no-container-manager-calls": "error",
|
"gridpilot-rules/rsc-no-container-manager-calls": "error",
|
||||||
"gridpilot-rules/no-hardcoded-search-params": "error",
|
"gridpilot-rules/no-hardcoded-search-params": "error",
|
||||||
"gridpilot-rules/no-next-cookies-in-pages": "error"
|
"gridpilot-rules/no-next-cookies-in-pages": "error",
|
||||||
|
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||||
|
"gridpilot-rules/component-classification": "error",
|
||||||
|
"gridpilot-rules/no-raw-html-in-app": "error",
|
||||||
|
"gridpilot-rules/no-console": "error",
|
||||||
|
"import/no-default-export": "off",
|
||||||
|
"no-restricted-syntax": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react/no-unescaped-entities": "error",
|
||||||
|
"gridpilot-rules/no-index-files": "error",
|
||||||
|
"gridpilot-rules/no-direct-process-env": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -159,8 +159,12 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
|
const sourceCode = context.getSourceCode();
|
||||||
|
const hasUseClient = sourceCode.getText().includes("'use client'") || sourceCode.getText().includes('"use client"');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CallExpression(node) {
|
CallExpression(node) {
|
||||||
|
if (hasUseClient) return;
|
||||||
if (node.callee.type === 'MemberExpression' &&
|
if (node.callee.type === 'MemberExpression' &&
|
||||||
['sort', 'filter', 'reduce'].includes(node.callee.property.name) &&
|
['sort', 'filter', 'reduce'].includes(node.callee.property.name) &&
|
||||||
!isInComment(node) &&
|
!isInComment(node) &&
|
||||||
@@ -273,8 +277,12 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
|
const sourceCode = context.getSourceCode();
|
||||||
|
const hasUseClient = sourceCode.getText().includes("'use client'") || sourceCode.getText().includes('"use client"');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
FunctionDeclaration(node) {
|
FunctionDeclaration(node) {
|
||||||
|
if (hasUseClient) return;
|
||||||
// Skip if this is the main component (default export or ends with Page/Template)
|
// Skip if this is the main component (default export or ends with Page/Template)
|
||||||
const filename = context.getFilename();
|
const filename = context.getFilename();
|
||||||
const isMainComponent =
|
const isMainComponent =
|
||||||
@@ -294,6 +302,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
VariableDeclarator(node) {
|
VariableDeclarator(node) {
|
||||||
|
if (hasUseClient) return;
|
||||||
// Skip if this is the main component
|
// Skip if this is the main component
|
||||||
const isMainComponent =
|
const isMainComponent =
|
||||||
(node.parent && node.parent.parent && node.parent.parent.type === 'ExportDefaultDeclaration') ||
|
(node.parent && node.parent.parent && node.parent.parent.type === 'ExportDefaultDeclaration') ||
|
||||||
@@ -329,8 +338,12 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
|
const sourceCode = context.getSourceCode();
|
||||||
|
const hasUseClient = sourceCode.getText().includes("'use client'") || sourceCode.getText().includes('"use client"');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
NewExpression(node) {
|
NewExpression(node) {
|
||||||
|
if (hasUseClient) return;
|
||||||
if (node.callee.type === 'Identifier' &&
|
if (node.callee.type === 'Identifier' &&
|
||||||
/^[A-Z]/.test(node.callee.name) &&
|
/^[A-Z]/.test(node.callee.name) &&
|
||||||
!node.callee.name.endsWith('PageQuery') &&
|
!node.callee.name.endsWith('PageQuery') &&
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { WalletsApiClient } from './wallets/WalletsApiClient';
|
|||||||
import { ErrorReporter } from '../interfaces/ErrorReporter';
|
import { ErrorReporter } from '../interfaces/ErrorReporter';
|
||||||
import { Logger } from '../interfaces/Logger';
|
import { Logger } from '../interfaces/Logger';
|
||||||
|
|
||||||
|
import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
public readonly admin: AdminApiClient;
|
public readonly admin: AdminApiClient;
|
||||||
public readonly analytics: AnalyticsApiClient;
|
public readonly analytics: AnalyticsApiClient;
|
||||||
@@ -35,7 +37,7 @@ export class ApiClient {
|
|||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
// Default implementations for logger and error reporter if needed
|
// Default implementations for logger and error reporter if needed
|
||||||
const logger: Logger = console;
|
const logger: Logger = new ConsoleLogger();
|
||||||
const errorReporter: ErrorReporter = { report: (error) => console.error(error) };
|
const errorReporter: ErrorReporter = { report: (error) => console.error(error) };
|
||||||
|
|
||||||
this.admin = new AdminApiClient(baseUrl, errorReporter, logger);
|
this.admin = new AdminApiClient(baseUrl, errorReporter, logger);
|
||||||
|
|||||||
@@ -227,8 +227,13 @@ export function handleAuthFlow(
|
|||||||
|
|
||||||
case AuthActionType.SHOW_PERMISSION_ERROR:
|
case AuthActionType.SHOW_PERMISSION_ERROR:
|
||||||
// Redirect to user's home page instead of login (they're already logged in)
|
// Redirect to user's home page instead of login (they're already logged in)
|
||||||
|
const isAdmin = session?.role === 'admin' ||
|
||||||
|
session?.role === 'league-admin' ||
|
||||||
|
session?.role === 'system-owner' ||
|
||||||
|
session?.role === 'super-admin';
|
||||||
|
|
||||||
const homeUrl = session?.role === 'sponsor' ? routes.sponsor.dashboard :
|
const homeUrl = session?.role === 'sponsor' ? routes.sponsor.dashboard :
|
||||||
session?.role === 'admin' ? routes.admin.root :
|
isAdmin ? routes.admin.root :
|
||||||
routes.protected.dashboard;
|
routes.protected.dashboard;
|
||||||
logger.info('[handleAuthFlow] Returning SHOW_PERMISSION_ERROR, redirecting to home', { homeUrl, userRole: session?.role });
|
logger.info('[handleAuthFlow] Returning SHOW_PERMISSION_ERROR, redirecting to home', { homeUrl, userRole: session?.role });
|
||||||
return { shouldRedirect: true, redirectUrl: homeUrl };
|
return { shouldRedirect: true, redirectUrl: homeUrl };
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class ConsoleLogger implements Logger {
|
|||||||
const color = this.COLORS[level];
|
const color = this.COLORS[level];
|
||||||
const emoji = this.EMOJIS[level];
|
const emoji = this.EMOJIS[level];
|
||||||
const prefix = this.PREFIXES[level];
|
const prefix = this.PREFIXES[level];
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
// Edge runtime doesn't support console.groupCollapsed/groupEnd
|
// Edge runtime doesn't support console.groupCollapsed/groupEnd
|
||||||
// Fallback to simple logging for compatibility
|
// Fallback to simple logging for compatibility
|
||||||
@@ -44,13 +45,13 @@ export class ConsoleLogger implements Logger {
|
|||||||
if (supportsGrouping) {
|
if (supportsGrouping) {
|
||||||
// Safe to call - we've verified both functions exist
|
// Safe to call - we've verified both functions exist
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(console as any).groupCollapsed(`%c${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`);
|
(console as any).groupCollapsed(`%c${emoji} [${timestamp}] [${source.toUpperCase()}] ${prefix}: ${message}`, `color: ${color}; font-weight: bold;`);
|
||||||
} else {
|
} else {
|
||||||
// Simple format for edge runtime
|
// Simple format for edge runtime
|
||||||
console.log(`${emoji} [${source.toUpperCase()}] ${prefix}: ${message}`);
|
console.log(`${emoji} [${timestamp}] [${source.toUpperCase()}] ${prefix}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`%cTimestamp:`, 'color: #666; font-weight: bold;', new Date().toISOString());
|
console.log(`%cTimestamp:`, 'color: #666; font-weight: bold;', timestamp);
|
||||||
console.log(`%cSource:`, 'color: #666; font-weight: bold;', source);
|
console.log(`%cSource:`, 'color: #666; font-weight: bold;', source);
|
||||||
|
|
||||||
if (context) {
|
if (context) {
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ export function buildPath(
|
|||||||
let route: any = routes;
|
let route: any = routes;
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
route = route[part];
|
route = (route as Record<string, any>)[part];
|
||||||
if (!route) {
|
if (!route) {
|
||||||
throw new Error(`Unknown route: ${routeName}`);
|
throw new Error(`Unknown route: ${routeName}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,12 +132,12 @@ export class LeagueService implements Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllLeagues(): Promise<any> {
|
async getAllLeagues(): Promise<Result<AllLeaguesWithCapacityAndScoringDTO, DomainError>> {
|
||||||
try {
|
try {
|
||||||
const dto = await this.apiClient.getAllWithCapacityAndScoring();
|
const dto = await this.apiClient.getAllWithCapacityAndScoring();
|
||||||
return (dto as any).value || dto;
|
return Result.ok(dto);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throw error;
|
return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch leagues' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const mockGetSession = vi.fn();
|
const mockGetSessionFromRequest = vi.fn();
|
||||||
|
|
||||||
// Mock Next.js server components
|
// Mock Next.js server components
|
||||||
vi.mock('next/server', () => ({
|
vi.mock('next/server', () => ({
|
||||||
@@ -20,125 +20,36 @@ vi.mock('next/server', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock SessionGateway so tests can control session behavior via `mockGetSession`.
|
// Mock SessionGateway
|
||||||
vi.mock('@/lib/gateways/SessionGateway', () => ({
|
vi.mock('@/lib/gateways/SessionGateway', () => ({
|
||||||
SessionGateway: class {
|
SessionGateway: class {
|
||||||
getSession = mockGetSession;
|
getSessionFromRequest = mockGetSessionFromRequest;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/auth/RouteAccessPolicy', () => ({
|
// Mock RouteConfig to have deterministic behavior in tests
|
||||||
RouteAccessPolicy: vi.fn().mockImplementation(() => ({
|
|
||||||
isPublic: vi.fn(),
|
|
||||||
isAuthPage: vi.fn(),
|
|
||||||
requiredRoles: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth/PathnameInterpreter', () => ({
|
|
||||||
PathnameInterpreter: vi.fn().mockImplementation(() => ({
|
|
||||||
interpret: vi.fn((pathname: string) => ({
|
|
||||||
locale: null,
|
|
||||||
logicalPathname: pathname,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth/AuthRedirectBuilder', () => ({
|
|
||||||
AuthRedirectBuilder: vi.fn().mockImplementation(() => ({
|
|
||||||
toLogin: vi.fn(({ currentPathname }) => `/auth/login?returnTo=${encodeURIComponent(currentPathname)}`),
|
|
||||||
awayFromAuthPage: vi.fn(({ session }) => {
|
|
||||||
const role = session.user?.role;
|
|
||||||
if (role === 'sponsor') return '/sponsor/dashboard';
|
|
||||||
if (role === 'admin' || role === 'league-admin' || role === 'league-steward' || role === 'league-owner' || role === 'system-owner' || role === 'super-admin') return '/admin';
|
|
||||||
return '/dashboard';
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth/ReturnToSanitizer', () => ({
|
|
||||||
ReturnToSanitizer: vi.fn().mockImplementation(() => ({
|
|
||||||
sanitizeReturnTo: vi.fn((input, fallback) => input || fallback),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth/RoutePathBuilder', () => ({
|
|
||||||
RoutePathBuilder: vi.fn().mockImplementation(() => ({
|
|
||||||
build: vi.fn((routeId, params, options) => {
|
|
||||||
const paths: Record<string, string> = {
|
|
||||||
'auth.login': '/auth/login',
|
|
||||||
'protected.dashboard': '/dashboard',
|
|
||||||
'sponsor.dashboard': '/sponsor/dashboard',
|
|
||||||
'admin': '/admin',
|
|
||||||
};
|
|
||||||
const path = paths[routeId] || '/';
|
|
||||||
return options?.locale ? `/${options.locale}${path}` : path;
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/routing/RouteConfig', () => ({
|
vi.mock('@/lib/routing/RouteConfig', () => ({
|
||||||
routes: {
|
routes: {
|
||||||
auth: { login: '/auth/login', signup: '/auth/signup', forgotPassword: '/auth/forgot-password', resetPassword: '/auth/reset-password' },
|
auth: { login: '/auth/login' },
|
||||||
public: { home: '/', leagues: '/leagues', drivers: '/drivers', teams: '/teams', leaderboards: '/leaderboards', races: '/races', sponsorSignup: '/sponsor/signup' },
|
public: { home: '/' },
|
||||||
protected: { dashboard: '/dashboard', onboarding: '/onboarding', profile: '/profile', profileSettings: '/profile/settings' },
|
protected: { dashboard: '/dashboard' },
|
||||||
sponsor: { root: '/sponsor', dashboard: '/sponsor/dashboard', billing: '/sponsor/billing' },
|
sponsor: { root: '/sponsor', dashboard: '/sponsor/dashboard' },
|
||||||
admin: { root: '/admin', users: '/admin/users' },
|
admin: { root: '/admin' },
|
||||||
league: { detail: (id: string) => `/leagues/${id}`, scheduleAdmin: (id: string) => `/leagues/${id}/schedule/admin` },
|
|
||||||
race: { root: '/races', detail: (id: string) => `/races/${id}`, stewarding: (id: string) => `/races/${id}/stewarding` },
|
|
||||||
team: { root: '/teams', detail: (id: string) => `/teams/${id}` },
|
|
||||||
driver: { root: '/drivers', detail: (id: string) => `/drivers/${id}` },
|
|
||||||
error: { notFound: '/404', serverError: '/500' },
|
|
||||||
},
|
},
|
||||||
routeMatchers: {
|
routeMatchers: {
|
||||||
isInGroup: (path: string, group: string) => {
|
|
||||||
const groups: Record<string, string[]> = {
|
|
||||||
auth: ['/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/reset-password'],
|
|
||||||
sponsor: ['/sponsor', '/sponsor/dashboard', '/sponsor/billing'],
|
|
||||||
admin: ['/admin', '/admin/users'],
|
|
||||||
};
|
|
||||||
|
|
||||||
return groups[group]?.some((prefix) => path.startsWith(prefix)) ?? false;
|
|
||||||
},
|
|
||||||
isPublic: (path: string) => {
|
isPublic: (path: string) => {
|
||||||
const publicExact = new Set([
|
return ['/', '/auth/login'].includes(path);
|
||||||
'/',
|
},
|
||||||
'/leagues',
|
isInGroup: (path: string, group: string) => {
|
||||||
'/drivers',
|
if (group === 'admin') return path.startsWith('/admin');
|
||||||
'/teams',
|
if (group === 'sponsor') return path.startsWith('/sponsor');
|
||||||
'/leaderboards',
|
|
||||||
'/races',
|
|
||||||
'/sponsor/signup',
|
|
||||||
'/auth/login',
|
|
||||||
'/auth/signup',
|
|
||||||
'/auth/forgot-password',
|
|
||||||
'/auth/reset-password',
|
|
||||||
'/404',
|
|
||||||
'/500',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check exact matches
|
|
||||||
if (publicExact.has(path)) return true;
|
|
||||||
|
|
||||||
// Treat top-level detail pages as public (e2e relies on this)
|
|
||||||
// Examples: /leagues/:id, /races/:id, /drivers/:id, /teams/:id
|
|
||||||
const segments = path.split('/').filter(Boolean);
|
|
||||||
if (segments.length === 2) {
|
|
||||||
const [group, slug] = segments;
|
|
||||||
if (group === 'leagues' && slug !== 'create') return true;
|
|
||||||
if (group === 'races') return true;
|
|
||||||
if (group === 'drivers') return true;
|
|
||||||
if (group === 'teams') return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
requiresAuth: (path: string) => {
|
requiresAuth: (path: string) => {
|
||||||
const publicPaths = ['/', '/leagues', '/drivers', '/teams', '/leaderboards', '/races', '/sponsor/signup', '/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/reset-password', '/404', '/500'];
|
return !['/', '/auth/login'].includes(path);
|
||||||
return !publicPaths.includes(path) && !path.startsWith('/leagues/') && !path.startsWith('/drivers/') && !path.startsWith('/teams/') && !path.startsWith('/races/');
|
|
||||||
},
|
},
|
||||||
requiresRole: (path: string) => {
|
requiresRole: (path: string) => {
|
||||||
if (path.startsWith('/admin')) return ['league-admin'];
|
if (path.startsWith('/admin')) return ['admin'];
|
||||||
if (path.startsWith('/sponsor')) return ['sponsor'];
|
if (path.startsWith('/sponsor')) return ['sponsor'];
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -153,173 +64,73 @@ describe('Middleware - Route Protection', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetSession.mockReset();
|
mockGetSessionFromRequest.mockReset();
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
nextUrl: { pathname: '/' },
|
nextUrl: { pathname: '/' },
|
||||||
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
set: vi.fn(),
|
set: vi.fn(),
|
||||||
get: vi.fn(),
|
get: vi.fn().mockReturnValue(''),
|
||||||
},
|
|
||||||
cookies: {
|
|
||||||
get: vi.fn(),
|
|
||||||
getAll: vi.fn(),
|
|
||||||
set: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('x-pathname header', () => {
|
describe('Redirect logic and returnTo', () => {
|
||||||
it('should set x-pathname header for all requests', async () => {
|
it('should redirect unauthenticated users to login with returnTo', async () => {
|
||||||
mockRequest.nextUrl.pathname = '/dashboard';
|
mockRequest.nextUrl.pathname = '/dashboard';
|
||||||
mockGetSession.mockResolvedValue(null); // No session to trigger redirect
|
mockGetSessionFromRequest.mockResolvedValue(null);
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
// The response should have headers.set called
|
|
||||||
// For redirect responses, check that the mock was set up correctly
|
|
||||||
expect(response.headers).toBeDefined();
|
|
||||||
expect(response.headers.set).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Public routes', () => {
|
|
||||||
it('should allow access to public routes without authentication', async () => {
|
|
||||||
const publicRoutes = ['/', '/leagues', '/drivers', '/teams', '/leaderboards', '/races', '/sponsor/signup'];
|
|
||||||
|
|
||||||
for (const route of publicRoutes) {
|
|
||||||
mockRequest.nextUrl.pathname = route;
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
// Should call NextResponse.next() (no redirect)
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Protected routes without authentication', () => {
|
|
||||||
it('should redirect to login with returnTo parameter', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/dashboard';
|
|
||||||
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
const response = await middleware(mockRequest);
|
||||||
|
|
||||||
expect(response.url).toContain('/auth/login');
|
expect(response.url).toContain('/auth/login');
|
||||||
expect(response.url).toContain('returnTo=%2Fdashboard');
|
expect(response.url).toContain('returnTo=%2Fdashboard');
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Protected routes with authentication', () => {
|
it('should allow authenticated users to access protected routes', async () => {
|
||||||
it('should allow access to protected routes with valid session', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/dashboard';
|
mockRequest.nextUrl.pathname = '/dashboard';
|
||||||
|
mockGetSessionFromRequest.mockResolvedValue({
|
||||||
mockGetSession.mockResolvedValue({
|
user: { userId: '123', role: 'driver' },
|
||||||
token: 'test-token',
|
|
||||||
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
const response = await middleware(mockRequest);
|
||||||
|
|
||||||
|
// Should not be a redirect (no url property on NextResponse.next() mock)
|
||||||
expect(response.url).toBeUndefined();
|
expect(response.url).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should redirect authenticated users away from auth pages', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/auth/login';
|
|
||||||
|
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
token: 'test-token',
|
|
||||||
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
expect(response.url).toContain('/dashboard');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Role-based access control', () => {
|
describe('Role-based redirects', () => {
|
||||||
it('should allow admin user to access admin routes', async () => {
|
it('should redirect user with wrong role to their home page', async () => {
|
||||||
mockRequest.nextUrl.pathname = '/admin/users';
|
// Driver trying to access admin
|
||||||
|
mockRequest.nextUrl.pathname = '/admin';
|
||||||
mockGetSession.mockResolvedValue({
|
mockGetSessionFromRequest.mockResolvedValue({
|
||||||
token: 'test-token',
|
user: { userId: '123', role: 'driver' },
|
||||||
user: { userId: '123', role: 'league-admin', email: 'test@example.com', displayName: 'Test User' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
const response = await middleware(mockRequest);
|
||||||
|
|
||||||
expect(response.url).toBeUndefined();
|
// Should redirect to dashboard (driver's home)
|
||||||
|
expect(response.url).toBe('http://localhost:3000/dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should block regular user from admin routes', async () => {
|
it('should redirect sponsor with wrong role to sponsor dashboard', async () => {
|
||||||
mockRequest.nextUrl.pathname = '/admin/users';
|
// Sponsor trying to access admin
|
||||||
|
mockRequest.nextUrl.pathname = '/admin';
|
||||||
mockGetSession.mockResolvedValue({
|
mockGetSessionFromRequest.mockResolvedValue({
|
||||||
token: 'test-token',
|
user: { userId: '123', role: 'sponsor' },
|
||||||
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
const response = await middleware(mockRequest);
|
||||||
|
|
||||||
expect(response.url).toContain('/auth/login');
|
expect(response.url).toBe('http://localhost:3000/sponsor/dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow sponsor user to access sponsor routes', async () => {
|
it('should allow user with correct role to pass through', async () => {
|
||||||
mockRequest.nextUrl.pathname = '/sponsor/dashboard';
|
mockRequest.nextUrl.pathname = '/admin';
|
||||||
|
mockGetSessionFromRequest.mockResolvedValue({
|
||||||
mockGetSession.mockResolvedValue({
|
user: { userId: '123', role: 'admin' },
|
||||||
token: 'test-token',
|
|
||||||
user: { userId: '123', role: 'sponsor', email: 'test@example.com', displayName: 'Test User' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
expect(response.url).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block regular user from sponsor routes', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/sponsor/dashboard';
|
|
||||||
|
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
token: 'test-token',
|
|
||||||
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
expect(response.url).toContain('/auth/login');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Parameterized routes', () => {
|
|
||||||
it('should allow public access to parameterized public routes', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/leagues/123';
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should redirect parameterized protected routes without auth', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/leagues/123/schedule/admin';
|
|
||||||
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
expect(response.url).toContain('/auth/login');
|
|
||||||
expect(response.url).toContain('returnTo=%2Fleagues%2F123%2Fschedule%2Fadmin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow admin access to parameterized admin routes', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/leagues/123/schedule/admin';
|
|
||||||
|
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
token: 'test-token',
|
|
||||||
user: { userId: '123', role: 'league-admin', email: 'test@example.com', displayName: 'Test User' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
const response = await middleware(mockRequest);
|
||||||
@@ -328,38 +139,24 @@ describe('Middleware - Route Protection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
describe('Public routes', () => {
|
||||||
it('should handle missing session gracefully', async () => {
|
it('should allow access to public routes without session', async () => {
|
||||||
mockRequest.nextUrl.pathname = '/dashboard';
|
mockRequest.nextUrl.pathname = '/';
|
||||||
|
mockGetSessionFromRequest.mockResolvedValue(null);
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
const response = await middleware(mockRequest);
|
||||||
|
|
||||||
expect(response.url).toContain('/auth/login');
|
expect(response.url).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle session without user role', async () => {
|
describe('Special redirects', () => {
|
||||||
mockRequest.nextUrl.pathname = '/admin/users';
|
it('should handle /sponsor root redirect', async () => {
|
||||||
|
mockRequest.nextUrl.pathname = '/sponsor';
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
token: 'test-token',
|
|
||||||
user: { userId: '123', email: 'test@example.com', displayName: 'Test User' }, // no role
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
const response = await middleware(mockRequest);
|
||||||
|
|
||||||
expect(response.url).toContain('/auth/login');
|
expect(response.url).toBe('http://localhost:3000/sponsor/dashboard');
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve locale in redirects', async () => {
|
|
||||||
mockRequest.nextUrl.pathname = '/de/dashboard';
|
|
||||||
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await middleware(mockRequest);
|
|
||||||
|
|
||||||
expect(response.url).toContain('/de/auth/login');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"gridpilot-core-rules/no-index-files": "error",
|
"gridpilot-core-rules/no-index-files": "error",
|
||||||
"gridpilot-core-rules/no-framework-imports": "error"
|
"gridpilot-core-rules/no-framework-imports": "error",
|
||||||
|
"gridpilot-core-rules/domain-no-application": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e/website',
|
testDir: './tests',
|
||||||
testMatch: ['**/website-pages.e2e.test.ts'],
|
testMatch: process.env.RUN_EXHAUSTIVE_E2E === '1'
|
||||||
|
? ['**/e2e/website/*.e2e.test.ts', '**/nightly/website/*.e2e.test.ts']
|
||||||
|
: ['**/e2e/website/*.e2e.test.ts'],
|
||||||
testIgnore: ['**/electron-build.smoke.test.ts'],
|
testIgnore: ['**/electron-build.smoke.test.ts'],
|
||||||
|
|
||||||
// Serial execution for consistent results
|
// Serial execution for consistent results
|
||||||
@@ -38,9 +40,9 @@ export default defineConfig({
|
|||||||
// Base URL for the website (containerized)
|
// Base URL for the website (containerized)
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://website:3000',
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://website:3000',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'off',
|
||||||
video: 'retain-on-failure',
|
video: 'off',
|
||||||
trace: 'retain-on-failure',
|
trace: 'off',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reporter: verbose for debugging
|
// Reporter: verbose for debugging
|
||||||
|
|||||||
@@ -53,20 +53,24 @@ interface MigrationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MediaMigrationLogger implements Logger {
|
class MediaMigrationLogger implements Logger {
|
||||||
|
private getTimestamp(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
info(message: string): void {
|
info(message: string): void {
|
||||||
console.log(`[INFO] ${message}`);
|
console.log(`[${this.getTimestamp()}] [INFO] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string): void {
|
warn(message: string): void {
|
||||||
console.warn(`[WARN] ${message}`);
|
console.warn(`[${this.getTimestamp()}] [WARN] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string, trace?: string): void {
|
error(message: string, trace?: string): void {
|
||||||
console.error(`[ERROR] ${message}`, trace || '');
|
console.error(`[${this.getTimestamp()}] [ERROR] ${message}`, trace || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(message: string): void {
|
debug(message: string): void {
|
||||||
console.debug(`[DEBUG] ${message}`);
|
console.debug(`[${this.getTimestamp()}] [DEBUG] ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
tests/e2e/website/navigation.e2e.test.ts
Normal file
44
tests/e2e/website/navigation.e2e.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager';
|
||||||
|
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
|
||||||
|
|
||||||
|
const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
test.describe('Client-side Navigation', () => {
|
||||||
|
test('navigation from dashboard to leagues and back', async ({ browser, request }) => {
|
||||||
|
const auth = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||||
|
const capture = new ConsoleErrorCapture(auth.page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start at dashboard
|
||||||
|
await auth.page.goto(`${WEBSITE_BASE_URL}/dashboard`);
|
||||||
|
expect(auth.page.url()).toContain('/dashboard');
|
||||||
|
|
||||||
|
// Click on Leagues in sidebar or navigation
|
||||||
|
// Using href-based selector for stability as requested
|
||||||
|
const leaguesLink = auth.page.locator('a[href="/leagues"]').first();
|
||||||
|
await leaguesLink.click();
|
||||||
|
|
||||||
|
// Assert URL change
|
||||||
|
await auth.page.waitForURL(/\/leagues/);
|
||||||
|
expect(auth.page.url()).toContain('/leagues');
|
||||||
|
|
||||||
|
// Click on Dashboard back
|
||||||
|
const dashboardLink = auth.page.locator('a[href="/dashboard"]').first();
|
||||||
|
await dashboardLink.click();
|
||||||
|
|
||||||
|
// Assert URL change
|
||||||
|
await auth.page.waitForURL(/\/dashboard/);
|
||||||
|
expect(auth.page.url()).toContain('/dashboard');
|
||||||
|
|
||||||
|
// Assert no runtime errors during navigation
|
||||||
|
capture.setAllowlist(['hydration', 'warning:']);
|
||||||
|
if (capture.hasUnexpectedErrors()) {
|
||||||
|
throw new Error(`Found unexpected console errors during navigation:\n${capture.format()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await auth.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
46
tests/e2e/website/role-access.e2e.test.ts
Normal file
46
tests/e2e/website/role-access.e2e.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager';
|
||||||
|
|
||||||
|
const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
test.describe('Role-based Access Sanity', () => {
|
||||||
|
|
||||||
|
test('admin can access admin dashboard', async ({ browser, request }) => {
|
||||||
|
const admin = await WebsiteAuthManager.createAuthContext(browser, request, 'admin');
|
||||||
|
try {
|
||||||
|
await admin.page.goto(`${WEBSITE_BASE_URL}/admin`);
|
||||||
|
expect(admin.page.url()).toContain('/admin');
|
||||||
|
await expect(admin.page.locator('body')).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await admin.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regular user is redirected from admin dashboard', async ({ browser, request }) => {
|
||||||
|
const auth = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||||
|
try {
|
||||||
|
await auth.page.goto(`${WEBSITE_BASE_URL}/admin`);
|
||||||
|
// Should be redirected to dashboard or home
|
||||||
|
expect(auth.page.url()).not.toContain('/admin');
|
||||||
|
expect(auth.page.url()).toContain('/dashboard');
|
||||||
|
} finally {
|
||||||
|
await auth.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sponsor can access sponsor dashboard', async ({ browser, request }) => {
|
||||||
|
const sponsor = await WebsiteAuthManager.createAuthContext(browser, request, 'sponsor');
|
||||||
|
try {
|
||||||
|
await sponsor.page.goto(`${WEBSITE_BASE_URL}/sponsor/dashboard`);
|
||||||
|
expect(sponsor.page.url()).toContain('/sponsor/dashboard');
|
||||||
|
await expect(sponsor.page.locator('body')).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await sponsor.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unauthenticated user is redirected to login', async ({ page }) => {
|
||||||
|
await page.goto(`${WEBSITE_BASE_URL}/dashboard`);
|
||||||
|
expect(page.url()).toContain('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
46
tests/e2e/website/runtime-health.e2e.test.ts
Normal file
46
tests/e2e/website/runtime-health.e2e.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
|
||||||
|
|
||||||
|
const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const CRITICAL_ROUTES = [
|
||||||
|
'/',
|
||||||
|
'/dashboard',
|
||||||
|
'/leagues',
|
||||||
|
'/teams',
|
||||||
|
'/drivers',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLOWED_WARNINGS = [
|
||||||
|
'hydration',
|
||||||
|
'text content does not match',
|
||||||
|
'warning:',
|
||||||
|
'download the react devtools',
|
||||||
|
'connection refused',
|
||||||
|
'failed to load resource',
|
||||||
|
'network error',
|
||||||
|
'cors',
|
||||||
|
'react does not recognize the `%s` prop on a dom element',
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe('Runtime Health', () => {
|
||||||
|
for (const path of CRITICAL_ROUTES) {
|
||||||
|
test(`route ${path} should have no unexpected console errors`, async ({ page }) => {
|
||||||
|
const capture = new ConsoleErrorCapture(page);
|
||||||
|
capture.setAllowlist(ALLOWED_WARNINGS);
|
||||||
|
|
||||||
|
const response = await page.goto(`${WEBSITE_BASE_URL}${path}`);
|
||||||
|
|
||||||
|
// Some routes might redirect to login if not authenticated, which is fine for health check
|
||||||
|
// as long as the page itself doesn't crash.
|
||||||
|
expect(response?.status()).toBeLessThan(500);
|
||||||
|
|
||||||
|
// Wait a bit for client-side errors to surface
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
if (capture.hasUnexpectedErrors()) {
|
||||||
|
throw new Error(`Found unexpected console errors on ${path}:\n${capture.format()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -68,7 +68,7 @@ describe('Database Constraints - API Integration', () => {
|
|||||||
try {
|
try {
|
||||||
await operation();
|
await operation();
|
||||||
throw new Error('Expected operation to fail');
|
throw new Error('Expected operation to fail');
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
// Should throw an error
|
// Should throw an error
|
||||||
expect(error).toBeDefined();
|
expect(error).toBeDefined();
|
||||||
}
|
}
|
||||||
|
|||||||
91
tests/integration/harness/WebsiteServerHarness.ts
Normal file
91
tests/integration/harness/WebsiteServerHarness.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export interface WebsiteServerHarnessOptions {
|
||||||
|
port?: number;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebsiteServerHarness {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private logs: string[] = [];
|
||||||
|
private port: number;
|
||||||
|
|
||||||
|
constructor(options: WebsiteServerHarnessOptions = {}) {
|
||||||
|
this.port = options.port || 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cwd = join(process.cwd(), 'apps/website');
|
||||||
|
|
||||||
|
// Use 'npm run dev' or 'npm run start' depending on environment
|
||||||
|
// For integration tests, 'dev' is often easier if we don't want to build first,
|
||||||
|
// but 'start' is more realistic for SSR.
|
||||||
|
// Assuming 'npm run dev' for now as it's faster for local tests.
|
||||||
|
this.process = spawn('npm', ['run', 'dev', '--', '-p', this.port.toString()], {
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: this.port.toString(),
|
||||||
|
...((this.process as unknown as { env: Record<string, string> })?.env || {}),
|
||||||
|
},
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdout?.on('data', (data) => {
|
||||||
|
const str = data.toString();
|
||||||
|
this.logs.push(str);
|
||||||
|
if (str.includes('ready') || str.includes('started') || str.includes('Local:')) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on('data', (data) => {
|
||||||
|
const str = data.toString();
|
||||||
|
this.logs.push(str);
|
||||||
|
console.error(`[Website Server Error] ${str}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
if (code !== 0 && code !== null) {
|
||||||
|
console.error(`Website server exited with code ${code}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout after 30 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Website server failed to start within 30s'));
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill();
|
||||||
|
this.process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogs(): string[] {
|
||||||
|
return this.logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogTail(lines: number = 60): string {
|
||||||
|
return this.logs.slice(-lines).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
hasErrorPatterns(): boolean {
|
||||||
|
const errorPatterns = [
|
||||||
|
'uncaughtException',
|
||||||
|
'unhandledRejection',
|
||||||
|
'Error: ',
|
||||||
|
];
|
||||||
|
return this.logs.some(log => errorPatterns.some(pattern => log.includes(pattern)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* Make HTTP request to API
|
* Make HTTP request to API
|
||||||
*/
|
*/
|
||||||
private async request<T>(method: string, path: string, body?: any, headers: Record<string, string> = {}): Promise<T> {
|
private async request<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
|
||||||
const url = `${this.baseUrl}${path}`;
|
const url = `${this.baseUrl}${path}`;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||||
@@ -64,17 +64,17 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST requests
|
// POST requests
|
||||||
async post<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
|
async post<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
|
||||||
return this.request<T>('POST', path, body, headers);
|
return this.request<T>('POST', path, body, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT requests
|
// PUT requests
|
||||||
async put<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
|
async put<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
|
||||||
return this.request<T>('PUT', path, body, headers);
|
return this.request<T>('PUT', path, body, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH requests
|
// PATCH requests
|
||||||
async patch<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
|
async patch<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
|
||||||
return this.request<T>('PATCH', path, body, headers);
|
return this.request<T>('PATCH', path, body, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export class DataFactory {
|
|||||||
/**
|
/**
|
||||||
* Clean up specific entities
|
* Clean up specific entities
|
||||||
*/
|
*/
|
||||||
async deleteEntities(entities: { id: any }[], entityType: string) {
|
async deleteEntities(entities: { id: string | number }[], entityType: string) {
|
||||||
const repository = this.dataSource.getRepository(entityType);
|
const repository = this.dataSource.getRepository(entityType);
|
||||||
for (const entity of entities) {
|
for (const entity of entities) {
|
||||||
await repository.delete(entity.id);
|
await repository.delete(entity.id);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class DatabaseManager {
|
|||||||
/**
|
/**
|
||||||
* Execute query with automatic client management
|
* Execute query with automatic client management
|
||||||
*/
|
*/
|
||||||
async query(text: string, params?: any[]): Promise<QueryResult> {
|
async query(text: string, params?: unknown[]): Promise<QueryResult> {
|
||||||
const client = await this.getClient();
|
const client = await this.getClient();
|
||||||
return client.query(text, params);
|
return client.query(text, params);
|
||||||
}
|
}
|
||||||
@@ -138,8 +138,6 @@ export class DatabaseManager {
|
|||||||
* Seed minimal test data
|
* Seed minimal test data
|
||||||
*/
|
*/
|
||||||
async seedMinimalData(): Promise<void> {
|
async seedMinimalData(): Promise<void> {
|
||||||
const client = await this.getClient();
|
|
||||||
|
|
||||||
// Insert minimal required data for tests
|
// Insert minimal required data for tests
|
||||||
// This will be extended based on test requirements
|
// This will be extended based on test requirements
|
||||||
|
|
||||||
@@ -164,13 +162,13 @@ export class DatabaseManager {
|
|||||||
ORDER BY log_time DESC
|
ORDER BY log_time DESC
|
||||||
`, [since]);
|
`, [since]);
|
||||||
|
|
||||||
return result.rows.map(r => r.message);
|
return (result.rows as { message: string }[]).map(r => r.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get table constraints
|
* Get table constraints
|
||||||
*/
|
*/
|
||||||
async getTableConstraints(tableName: string): Promise<any[]> {
|
async getTableConstraints(tableName: string): Promise<unknown[]> {
|
||||||
const client = await this.getClient();
|
const client = await this.getClient();
|
||||||
|
|
||||||
const result = await client.query(`
|
const result = await client.query(`
|
||||||
|
|||||||
@@ -155,26 +155,27 @@ export class IntegrationTestHarness {
|
|||||||
* Helper to verify constraint violations
|
* Helper to verify constraint violations
|
||||||
*/
|
*/
|
||||||
async expectConstraintViolation(
|
async expectConstraintViolation(
|
||||||
operation: () => Promise<any>,
|
operation: () => Promise<unknown>,
|
||||||
expectedConstraint?: string
|
expectedConstraint?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await operation();
|
await operation();
|
||||||
throw new Error('Expected constraint violation but operation succeeded');
|
throw new Error('Expected constraint violation but operation succeeded');
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
// Check if it's a constraint violation
|
// Check if it's a constraint violation
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const isConstraintError =
|
const isConstraintError =
|
||||||
error.message?.includes('constraint') ||
|
message.includes('constraint') ||
|
||||||
error.message?.includes('23505') || // Unique violation
|
message.includes('23505') || // Unique violation
|
||||||
error.message?.includes('23503') || // Foreign key violation
|
message.includes('23503') || // Foreign key violation
|
||||||
error.message?.includes('23514'); // Check violation
|
message.includes('23514'); // Check violation
|
||||||
|
|
||||||
if (!isConstraintError) {
|
if (!isConstraintError) {
|
||||||
throw new Error(`Expected constraint violation but got: ${error.message}`);
|
throw new Error(`Expected constraint violation but got: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expectedConstraint && !error.message.includes(expectedConstraint)) {
|
if (expectedConstraint && !message.includes(expectedConstraint)) {
|
||||||
throw new Error(`Expected constraint '${expectedConstraint}' but got: ${error.message}`);
|
throw new Error(`Expected constraint '${expectedConstraint}' but got: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe('Race Results Import - API Integration', () => {
|
|||||||
|
|
||||||
it('should reject empty results array', async () => {
|
it('should reject empty results array', async () => {
|
||||||
const raceId = 'test-race-1';
|
const raceId = 'test-race-1';
|
||||||
const emptyResults: any[] = [];
|
const emptyResults: unknown[] = [];
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
api.post(`/races/${raceId}/import-results`, {
|
api.post(`/races/${raceId}/import-results`, {
|
||||||
|
|||||||
@@ -306,4 +306,34 @@ describe('Website DI Container Integration', () => {
|
|||||||
expect(typeof config2).toBe('function');
|
expect(typeof config2).toBe('function');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SSR Boot Safety', () => {
|
||||||
|
it('resolves all tokens required for SSR entry rendering', () => {
|
||||||
|
process.env.API_BASE_URL = 'http://localhost:3001';
|
||||||
|
const container = createContainer();
|
||||||
|
|
||||||
|
// Tokens typically used in SSR (middleware, layouts, initial page loads)
|
||||||
|
const ssrTokens = [
|
||||||
|
LOGGER_TOKEN,
|
||||||
|
CONFIG_TOKEN,
|
||||||
|
SESSION_SERVICE_TOKEN,
|
||||||
|
AUTH_SERVICE_TOKEN,
|
||||||
|
LEAGUE_SERVICE_TOKEN,
|
||||||
|
DRIVER_SERVICE_TOKEN,
|
||||||
|
DASHBOARD_SERVICE_TOKEN,
|
||||||
|
// API clients are often resolved by services
|
||||||
|
AUTH_API_CLIENT_TOKEN,
|
||||||
|
LEAGUE_API_CLIENT_TOKEN,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const token of ssrTokens) {
|
||||||
|
try {
|
||||||
|
const service = container.get(token);
|
||||||
|
expect(service, `Failed to resolve ${token.toString()}`).toBeDefined();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`SSR Boot Safety Failure: Could not resolve ${token.toString()}. Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
53
tests/integration/website/RouteContractSpec.test.ts
Normal file
53
tests/integration/website/RouteContractSpec.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getWebsiteRouteContracts } from '../../shared/website/RouteContractSpec';
|
||||||
|
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
|
||||||
|
|
||||||
|
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', 'notFoundAllowed', 'errorRoute']).toContain(contract.expectedStatus);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
152
tests/integration/website/RouteProtection.test.ts
Normal file
152
tests/integration/website/RouteProtection.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, test, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { routes } from '../../../apps/website/lib/routing/RouteConfig';
|
||||||
|
import { WebsiteServerHarness } from '../harness/WebsiteServerHarness';
|
||||||
|
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:3101';
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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(`Login failed for role ${role}: ${res.status} ${res.statusText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCookie = res.headers.get('set-cookie') ?? '';
|
||||||
|
const cookiePart = setCookie.split(';')[0] ?? '';
|
||||||
|
return cookiePart.startsWith('gp_session=') ? cookiePart : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Could not connect to API at ${API_BASE_URL} for role ${role} login.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Route Protection Matrix', () => {
|
||||||
|
let harness: WebsiteServerHarness | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (WEBSITE_BASE_URL.includes('localhost')) {
|
||||||
|
try {
|
||||||
|
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
|
||||||
|
} catch (e) {
|
||||||
|
harness = new WebsiteServerHarness({
|
||||||
|
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
|
||||||
|
});
|
||||||
|
await harness.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (harness) {
|
||||||
|
await harness.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: 200 },
|
||||||
|
{ 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: 200 },
|
||||||
|
{ 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: 200 },
|
||||||
|
{ 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 url = `${WEBSITE_BASE_URL}${path}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: harness?.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,15 +1,15 @@
|
|||||||
export class MockAutomationLifecycleEmitter {
|
export class MockAutomationLifecycleEmitter {
|
||||||
private callbacks: Set<(event: any) => Promise<void> | void> = new Set()
|
private callbacks: Set<(event: unknown) => Promise<void> | void> = new Set()
|
||||||
|
|
||||||
onLifecycle(cb: (event: any) => Promise<void> | void): void {
|
onLifecycle(cb: (event: unknown) => Promise<void> | void): void {
|
||||||
this.callbacks.add(cb)
|
this.callbacks.add(cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
offLifecycle(cb: (event: any) => Promise<void> | void): void {
|
offLifecycle(cb: (event: unknown) => Promise<void> | void): void {
|
||||||
this.callbacks.delete(cb)
|
this.callbacks.delete(cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
async emit(event: any): Promise<void> {
|
async emit(event: unknown): Promise<void> {
|
||||||
for (const cb of Array.from(this.callbacks)) {
|
for (const cb of Array.from(this.callbacks)) {
|
||||||
try {
|
try {
|
||||||
await cb(event)
|
await cb(event)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { expect, test } from '@playwright/test';
|
|||||||
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
|
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
|
||||||
import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager';
|
import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager';
|
||||||
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
|
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
|
||||||
|
import { fetchFeatureFlags, getEnabledFlags, isFeatureEnabled } from '../../shared/website/FeatureFlagHelpers';
|
||||||
|
|
||||||
const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
// Wait for API to be ready with seeded data before running tests
|
// Wait for API to be ready with seeded data before running tests
|
||||||
test.beforeAll(async ({ request }) => {
|
test.beforeAll(async ({ request }) => {
|
||||||
@@ -46,40 +48,18 @@ test.beforeAll(async ({ request }) => {
|
|||||||
* Helper to fetch feature flags from the API
|
* Helper to fetch feature flags from the API
|
||||||
* Uses Playwright request context for compatibility across environments
|
* Uses Playwright request context for compatibility across environments
|
||||||
*/
|
*/
|
||||||
async function fetchFeatureFlags(request: import('@playwright/test').APIRequestContext): Promise<{ features: Record<string, string>; timestamp: string }> {
|
async function fetchFeatureFlagsWrapper(request: import('@playwright/test').APIRequestContext) {
|
||||||
const apiBaseUrl = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
return fetchFeatureFlags(
|
||||||
const featuresUrl = `${apiBaseUrl}/features`;
|
async (url) => {
|
||||||
|
const response = await request.get(url);
|
||||||
try {
|
return {
|
||||||
const response = await request.get(featuresUrl);
|
ok: response.ok(),
|
||||||
expect(response.ok()).toBe(true);
|
json: () => response.json(),
|
||||||
|
status: response.status()
|
||||||
const data = await response.json();
|
};
|
||||||
return data;
|
},
|
||||||
} catch (error) {
|
API_BASE_URL
|
||||||
console.error(`[FEATURE FLAGS] Failed to fetch from ${featuresUrl}:`, error);
|
);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to compute enabled flags from feature config
|
|
||||||
*/
|
|
||||||
function getEnabledFlags(featureData: { features: Record<string, string> }): string[] {
|
|
||||||
if (!featureData.features || typeof featureData.features !== 'object') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(featureData.features)
|
|
||||||
.filter(([, value]) => value === 'enabled')
|
|
||||||
.map(([flag]) => flag);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to check if a specific flag is enabled
|
|
||||||
*/
|
|
||||||
function isFeatureEnabled(featureData: { features: Record<string, string> }, flag: string): boolean {
|
|
||||||
return featureData.features?.[flag] === 'enabled';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Website Pages - TypeORM Integration', () => {
|
test.describe('Website Pages - TypeORM Integration', () => {
|
||||||
@@ -707,7 +687,7 @@ test.describe('Website Pages - TypeORM Integration', () => {
|
|||||||
|
|
||||||
test('features endpoint returns valid contract and reachable from API', async ({ request }) => {
|
test('features endpoint returns valid contract and reachable from API', async ({ request }) => {
|
||||||
// Contract test: verify /features endpoint returns correct shape
|
// Contract test: verify /features endpoint returns correct shape
|
||||||
const featureData = await fetchFeatureFlags(request);
|
const featureData = await fetchFeatureFlagsWrapper(request);
|
||||||
|
|
||||||
// Verify contract: { features: object, timestamp: string }
|
// Verify contract: { features: object, timestamp: string }
|
||||||
expect(featureData).toHaveProperty('features');
|
expect(featureData).toHaveProperty('features');
|
||||||
@@ -736,7 +716,7 @@ test.describe('Website Pages - TypeORM Integration', () => {
|
|||||||
|
|
||||||
test('conditional UI rendering based on feature flags', async ({ page, request }) => {
|
test('conditional UI rendering based on feature flags', async ({ page, request }) => {
|
||||||
// Fetch current feature flags from API
|
// Fetch current feature flags from API
|
||||||
const featureData = await fetchFeatureFlags(request);
|
const featureData = await fetchFeatureFlagsWrapper(request);
|
||||||
const enabledFlags = getEnabledFlags(featureData);
|
const enabledFlags = getEnabledFlags(featureData);
|
||||||
|
|
||||||
console.log(`[FEATURE TEST] Enabled flags: ${enabledFlags.join(', ')}`);
|
console.log(`[FEATURE TEST] Enabled flags: ${enabledFlags.join(', ')}`);
|
||||||
@@ -785,7 +765,7 @@ test.describe('Website Pages - TypeORM Integration', () => {
|
|||||||
|
|
||||||
test('feature flag state drives UI behavior', async ({ page, request }) => {
|
test('feature flag state drives UI behavior', async ({ page, request }) => {
|
||||||
// This test validates that feature flags actually control UI visibility
|
// This test validates that feature flags actually control UI visibility
|
||||||
const featureData = await fetchFeatureFlags(request);
|
const featureData = await fetchFeatureFlagsWrapper(request);
|
||||||
|
|
||||||
// Test sponsor management feature
|
// Test sponsor management feature
|
||||||
const sponsorManagementEnabled = isFeatureEnabled(featureData, 'sponsors.management');
|
const sponsorManagementEnabled = isFeatureEnabled(featureData, 'sponsors.management');
|
||||||
@@ -818,7 +798,7 @@ test.describe('Website Pages - TypeORM Integration', () => {
|
|||||||
|
|
||||||
test('feature flags are consistent across environments', async ({ request }) => {
|
test('feature flags are consistent across environments', async ({ request }) => {
|
||||||
// This test validates that the same feature endpoint works in both local dev and docker e2e
|
// This test validates that the same feature endpoint works in both local dev and docker e2e
|
||||||
const featureData = await fetchFeatureFlags(request);
|
const featureData = await fetchFeatureFlagsWrapper(request);
|
||||||
|
|
||||||
// Verify the API base URL is correctly resolved
|
// Verify the API base URL is correctly resolved
|
||||||
const apiBaseUrl = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
const apiBaseUrl = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
||||||
@@ -10,11 +10,22 @@ export interface CapturedError {
|
|||||||
|
|
||||||
export class ConsoleErrorCapture {
|
export class ConsoleErrorCapture {
|
||||||
private errors: CapturedError[] = [];
|
private errors: CapturedError[] = [];
|
||||||
|
private allowlist: (string | RegExp)[] = [];
|
||||||
|
|
||||||
constructor(private page: Page) {
|
constructor(private page: Page) {
|
||||||
this.setupCapture();
|
this.setupCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setAllowlist(patterns: (string | RegExp)[]): void {
|
||||||
|
this.allowlist = patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAllowed(message: string): boolean {
|
||||||
|
return this.allowlist.some(pattern =>
|
||||||
|
typeof pattern === 'string' ? message.includes(pattern) : pattern.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private setupCapture(): void {
|
private setupCapture(): void {
|
||||||
this.page.on('console', (msg) => {
|
this.page.on('console', (msg) => {
|
||||||
if (msg.type() === 'error') {
|
if (msg.type() === 'error') {
|
||||||
@@ -40,10 +51,44 @@ export class ConsoleErrorCapture {
|
|||||||
return this.errors;
|
return this.errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getUnexpectedErrors(): CapturedError[] {
|
||||||
|
return this.errors.filter(e => !this.isAllowed(e.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
public format(): string {
|
||||||
|
if (this.errors.length === 0) return 'No console errors captured.';
|
||||||
|
|
||||||
|
const unexpected = this.getUnexpectedErrors();
|
||||||
|
const allowed = this.errors.filter(e => this.isAllowed(e.message));
|
||||||
|
|
||||||
|
let output = '--- Console Error Capture ---\n';
|
||||||
|
|
||||||
|
if (unexpected.length > 0) {
|
||||||
|
output += `UNEXPECTED ERRORS (${unexpected.length}):\n`;
|
||||||
|
unexpected.forEach((e, i) => {
|
||||||
|
output += `[${i + 1}] ${e.type.toUpperCase()}: ${e.message}\n`;
|
||||||
|
if (e.stack) output += `Stack: ${e.stack}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed.length > 0) {
|
||||||
|
output += `\nALLOWED ERRORS (${allowed.length}):\n`;
|
||||||
|
allowed.forEach((e, i) => {
|
||||||
|
output += `[${i + 1}] ${e.type.toUpperCase()}: ${e.message}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
public hasErrors(): boolean {
|
public hasErrors(): boolean {
|
||||||
return this.errors.length > 0;
|
return this.errors.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasUnexpectedErrors(): boolean {
|
||||||
|
return this.getUnexpectedErrors().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public clear(): void {
|
public clear(): void {
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
}
|
}
|
||||||
|
|||||||
52
tests/shared/website/FeatureFlagHelpers.ts
Normal file
52
tests/shared/website/FeatureFlagHelpers.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Feature flag helper functions for testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FeatureFlagData {
|
||||||
|
features: Record<string, string>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to compute enabled flags from feature config
|
||||||
|
*/
|
||||||
|
export function getEnabledFlags(featureData: FeatureFlagData): string[] {
|
||||||
|
if (!featureData.features || typeof featureData.features !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(featureData.features)
|
||||||
|
.filter(([, value]) => value === 'enabled')
|
||||||
|
.map(([flag]) => flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if a specific flag is enabled
|
||||||
|
*/
|
||||||
|
export function isFeatureEnabled(featureData: FeatureFlagData, flag: string): boolean {
|
||||||
|
return featureData.features?.[flag] === 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to fetch feature flags from the API
|
||||||
|
* Note: This is a pure function that takes the fetcher as an argument to avoid network side effects in unit tests
|
||||||
|
*/
|
||||||
|
export async function fetchFeatureFlags(
|
||||||
|
fetcher: (url: string) => Promise<{ ok: boolean; json: () => Promise<unknown>; status: number }>,
|
||||||
|
apiBaseUrl: string
|
||||||
|
): Promise<FeatureFlagData> {
|
||||||
|
const featuresUrl = `${apiBaseUrl}/features`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetcher(featuresUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch feature flags: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as FeatureFlagData;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[FEATURE FLAGS] Failed to fetch from ${featuresUrl}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
tests/shared/website/HttpDiagnostics.ts
Normal file
57
tests/shared/website/HttpDiagnostics.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface HttpFailureContext {
|
||||||
|
role?: string;
|
||||||
|
url: string;
|
||||||
|
status: number;
|
||||||
|
location?: string | null;
|
||||||
|
html?: string;
|
||||||
|
extra?: string;
|
||||||
|
serverLogs?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpDiagnostics {
|
||||||
|
static clipString(str: string, max = 1200): string {
|
||||||
|
if (str.length <= max) return str;
|
||||||
|
return str.substring(0, max) + `... [clipped ${str.length - max} chars]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static formatHttpFailure({ role, url, status, location, html, extra, serverLogs }: HttpFailureContext): string {
|
||||||
|
const lines = [
|
||||||
|
`HTTP Failure: ${status} for ${url}`,
|
||||||
|
role ? `Role: ${role}` : null,
|
||||||
|
location ? `Location: ${location}` : null,
|
||||||
|
extra ? `Extra: ${extra}` : null,
|
||||||
|
html ? `HTML Body (clipped):\n${this.clipString(html)}` : 'No HTML body provided',
|
||||||
|
serverLogs ? `\n--- Server Log Tail ---\n${serverLogs}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
static assertHtmlContains(html: string, mustContain: string | string[], context: HttpFailureContext): void {
|
||||||
|
const targets = Array.isArray(mustContain) ? mustContain : [mustContain];
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!html.includes(target)) {
|
||||||
|
const message = this.formatHttpFailure({
|
||||||
|
...context,
|
||||||
|
extra: `Expected HTML to contain: "${target}"`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static assertHtmlNotContains(html: string, mustNotContain: string | string[], context: HttpFailureContext): void {
|
||||||
|
const targets = Array.isArray(mustNotContain) ? mustNotContain : [mustNotContain];
|
||||||
|
for (const target of targets) {
|
||||||
|
if (html.includes(target)) {
|
||||||
|
const message = this.formatHttpFailure({
|
||||||
|
...context,
|
||||||
|
extra: `Expected HTML NOT to contain: "${target}"`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
tests/shared/website/RouteContractSpec.ts
Normal file
94
tests/shared/website/RouteContractSpec.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { WebsiteRouteManager, RouteAccess } from './WebsiteRouteManager';
|
||||||
|
import { routes } from '../../../apps/website/lib/routing/RouteConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected HTTP status or behavior for a route.
|
||||||
|
* - 'ok': 200 OK
|
||||||
|
* - 'redirect': 3xx redirect (usually to login)
|
||||||
|
* - 'notFoundAllowed': 404 is an acceptable/expected outcome (e.g. for edge cases)
|
||||||
|
* - 'errorRoute': The dedicated error pages themselves
|
||||||
|
*/
|
||||||
|
export type ExpectedStatus = 'ok' | 'redirect' | 'notFoundAllowed' | 'errorRoute';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RouteContract defines the "Single Source of Truth" for how a website route
|
||||||
|
* should behave during SSR and E2E testing.
|
||||||
|
*/
|
||||||
|
export interface RouteContract {
|
||||||
|
/** The fully resolved path (e.g. /leagues/123 instead of /leagues/[id]) */
|
||||||
|
path: string;
|
||||||
|
/** The required access level for this route */
|
||||||
|
accessLevel: RouteAccess;
|
||||||
|
/** What we expect when hitting this route unauthenticated */
|
||||||
|
expectedStatus: ExpectedStatus;
|
||||||
|
/** If expectedStatus is 'redirect', where should it go? (pathname only) */
|
||||||
|
expectedRedirectTo?: string;
|
||||||
|
/** Strings or Regex that MUST be present in the SSR HTML */
|
||||||
|
ssrMustContain?: Array<string | RegExp>;
|
||||||
|
/** Strings or Regex that MUST NOT be present in the SSR HTML (e.g. error markers) */
|
||||||
|
ssrMustNotContain?: Array<string | RegExp>;
|
||||||
|
/** Minimum expected length of the HTML response body */
|
||||||
|
minTextLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SSR_MUST_CONTAIN = ['<!DOCTYPE html>', '<body'];
|
||||||
|
const DEFAULT_SSR_MUST_NOT_CONTAIN = [
|
||||||
|
'__NEXT_ERROR__',
|
||||||
|
'Application error: a client-side exception has occurred',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the full list of route contracts by augmenting the base inventory
|
||||||
|
* with expected behaviors and sanity checks.
|
||||||
|
*/
|
||||||
|
export function getWebsiteRouteContracts(): RouteContract[] {
|
||||||
|
const manager = new WebsiteRouteManager();
|
||||||
|
const inventory = manager.getWebsiteRouteInventory();
|
||||||
|
|
||||||
|
// Per-route overrides for special cases where the group-based logic isn't enough
|
||||||
|
const overrides: Record<string, Partial<RouteContract>> = {
|
||||||
|
[routes.error.notFound]: {
|
||||||
|
expectedStatus: 'notFoundAllowed',
|
||||||
|
},
|
||||||
|
[routes.error.serverError]: {
|
||||||
|
expectedStatus: 'errorRoute',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return inventory.map((def) => {
|
||||||
|
const path = manager.resolvePathTemplate(def.pathTemplate, def.params);
|
||||||
|
|
||||||
|
// Default augmentation based on access level
|
||||||
|
let expectedStatus: ExpectedStatus = 'ok';
|
||||||
|
let expectedRedirectTo: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (def.access !== 'public') {
|
||||||
|
expectedStatus = 'redirect';
|
||||||
|
// Most protected routes redirect to login when unauthenticated
|
||||||
|
expectedRedirectTo = routes.auth.login;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the inventory explicitly allows 404 (e.g. for non-existent IDs in edge cases)
|
||||||
|
if (def.allowNotFound) {
|
||||||
|
expectedStatus = 'notFoundAllowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract: RouteContract = {
|
||||||
|
path,
|
||||||
|
accessLevel: def.access,
|
||||||
|
expectedStatus,
|
||||||
|
expectedRedirectTo,
|
||||||
|
ssrMustContain: [...DEFAULT_SSR_MUST_CONTAIN],
|
||||||
|
ssrMustNotContain: [...DEFAULT_SSR_MUST_NOT_CONTAIN],
|
||||||
|
minTextLength: 1000, // Reasonable minimum for a Next.js page
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply per-route overrides (matching by template or resolved path)
|
||||||
|
const override = overrides[def.pathTemplate] || overrides[path];
|
||||||
|
if (override) {
|
||||||
|
Object.assign(contract, override);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,7 +25,6 @@ export class WebsiteAuthManager {
|
|||||||
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3101';
|
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3101';
|
||||||
|
|
||||||
const role = (typeof requestOrRole === 'string' ? requestOrRole : maybeRole) as AuthRole;
|
const role = (typeof requestOrRole === 'string' ? requestOrRole : maybeRole) as AuthRole;
|
||||||
const request = typeof requestOrRole === 'string' ? null : requestOrRole;
|
|
||||||
|
|
||||||
// If using API login, create context with cookies pre-set
|
// If using API login, create context with cookies pre-set
|
||||||
if (typeof requestOrRole !== 'string') {
|
if (typeof requestOrRole !== 'string') {
|
||||||
|
|||||||
@@ -94,9 +94,13 @@ export class WebsiteRouteManager {
|
|||||||
public getAccessLevel(pathTemplate: string): RouteAccess {
|
public getAccessLevel(pathTemplate: string): RouteAccess {
|
||||||
// NOTE: `routeMatchers.isInGroup(path, 'public')` is prefix-based and will treat everything
|
// NOTE: `routeMatchers.isInGroup(path, 'public')` is prefix-based and will treat everything
|
||||||
// as public because the home route is `/`. Use `isPublic()` for correct classification.
|
// as public because the home route is `/`. Use `isPublic()` for correct classification.
|
||||||
|
|
||||||
|
// Check public first to ensure public routes nested under protected prefixes (e.g. /sponsor/signup)
|
||||||
|
// are correctly classified as public.
|
||||||
|
if (routeMatchers.isPublic(pathTemplate)) return 'public';
|
||||||
|
|
||||||
if (routeMatchers.isInGroup(pathTemplate, 'admin')) return 'admin';
|
if (routeMatchers.isInGroup(pathTemplate, 'admin')) return 'admin';
|
||||||
if (routeMatchers.isInGroup(pathTemplate, 'sponsor')) return 'sponsor';
|
if (routeMatchers.isInGroup(pathTemplate, 'sponsor')) return 'sponsor';
|
||||||
if (routeMatchers.isPublic(pathTemplate)) return 'public';
|
|
||||||
if (routeMatchers.requiresAuth(pathTemplate)) return 'auth';
|
if (routeMatchers.requiresAuth(pathTemplate)) return 'auth';
|
||||||
return 'public';
|
return 'public';
|
||||||
}
|
}
|
||||||
|
|||||||
114
tests/smoke/website-ssr.test.ts
Normal file
114
tests/smoke/website-ssr.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Website SSR Smoke Tests
|
||||||
|
*
|
||||||
|
* Run with: npx vitest run tests/smoke/website-ssr.test.ts --config vitest.smoke.config.ts
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { getWebsiteRouteContracts, RouteContract } from '../shared/website/RouteContractSpec';
|
||||||
|
import { WebsiteServerHarness } from '../integration/harness/WebsiteServerHarness';
|
||||||
|
|
||||||
|
const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
describe('Website SSR Contract Suite', () => {
|
||||||
|
const contracts = getWebsiteRouteContracts();
|
||||||
|
let harness: WebsiteServerHarness | null = null;
|
||||||
|
let errorCount500 = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Only start harness if WEBSITE_BASE_URL is localhost and not already reachable
|
||||||
|
if (WEBSITE_BASE_URL.includes('localhost')) {
|
||||||
|
try {
|
||||||
|
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
|
||||||
|
console.log(`Server already running at ${WEBSITE_BASE_URL}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Starting website server harness on ${WEBSITE_BASE_URL}...`);
|
||||||
|
harness = new WebsiteServerHarness({
|
||||||
|
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
|
||||||
|
});
|
||||||
|
await harness.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (harness) {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail suite on bursts of 500s (e.g. > 3)
|
||||||
|
if (errorCount500 > 3) {
|
||||||
|
throw new Error(`Suite failed due to high error rate: ${errorCount500} routes returned 500`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail on uncaught exceptions in logs
|
||||||
|
if (harness?.hasErrorPatterns()) {
|
||||||
|
console.error('Server logs contained error patterns:\n' + harness.getLogTail(50));
|
||||||
|
throw new Error('Suite failed due to error patterns in server logs');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(contracts)('Contract: $path', async (contract: RouteContract) => {
|
||||||
|
const url = `${WEBSITE_BASE_URL}${contract.path}`;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { redirect: 'manual' });
|
||||||
|
} catch (e) {
|
||||||
|
const logTail = harness ? `\nServer Log Tail:\n${harness.getLogTail(20)}` : '';
|
||||||
|
throw new Error(`Failed to fetch ${url}: ${e.message}${logTail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = response.status;
|
||||||
|
const text = await response.text();
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
|
||||||
|
if (status === 500 && contract.expectedStatus !== 'errorRoute') {
|
||||||
|
errorCount500++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureContext = `
|
||||||
|
Route: ${contract.path}
|
||||||
|
Status: ${status}
|
||||||
|
Location: ${location || 'N/A'}
|
||||||
|
HTML (clipped): ${text.slice(0, 500)}...
|
||||||
|
${harness ? `\nServer Log Tail:\n${harness.getLogTail(10)}` : ''}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
// 1. Status class matches expectedStatus
|
||||||
|
if (contract.expectedStatus === 'ok') {
|
||||||
|
expect(status, failureContext).toBe(200);
|
||||||
|
} else if (contract.expectedStatus === 'redirect') {
|
||||||
|
expect(status, failureContext).toBeGreaterThanOrEqual(300);
|
||||||
|
expect(status, failureContext).toBeLessThan(400);
|
||||||
|
} else if (contract.expectedStatus === 'notFoundAllowed') {
|
||||||
|
expect([200, 404], failureContext).toContain(status);
|
||||||
|
} else if (contract.expectedStatus === 'errorRoute') {
|
||||||
|
expect([200, 404, 500], failureContext).toContain(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Redirect location semantics
|
||||||
|
if (contract.expectedStatus === 'redirect' && contract.expectedRedirectTo) {
|
||||||
|
expect(location, failureContext).toContain(contract.expectedRedirectTo);
|
||||||
|
if (contract.accessLevel !== 'public' && contract.expectedRedirectTo.includes('/auth/login')) {
|
||||||
|
expect(location, failureContext).toContain('returnTo=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. HTML sanity checks
|
||||||
|
if (status === 200 || (status === 404 && contract.expectedStatus === 'notFoundAllowed')) {
|
||||||
|
if (contract.ssrMustContain) {
|
||||||
|
for (const pattern of contract.ssrMustContain) {
|
||||||
|
expect(text, failureContext).toMatch(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contract.ssrMustNotContain) {
|
||||||
|
for (const pattern of contract.ssrMustNotContain) {
|
||||||
|
expect(text, failureContext).not.toMatch(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contract.minTextLength) {
|
||||||
|
expect(text.length, failureContext).toBeGreaterThanOrEqual(contract.minTextLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/unit/website/FeatureFlagHelpers.test.ts
Normal file
66
tests/unit/website/FeatureFlagHelpers.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { getEnabledFlags, isFeatureEnabled, fetchFeatureFlags, FeatureFlagData } from '../../shared/website/FeatureFlagHelpers';
|
||||||
|
|
||||||
|
describe('FeatureFlagHelpers', () => {
|
||||||
|
const mockFeatureData: FeatureFlagData = {
|
||||||
|
features: {
|
||||||
|
'feature.a': 'enabled',
|
||||||
|
'feature.b': 'disabled',
|
||||||
|
'feature.c': 'coming_soon',
|
||||||
|
'feature.d': 'enabled',
|
||||||
|
},
|
||||||
|
timestamp: '2026-01-17T16:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getEnabledFlags()', () => {
|
||||||
|
it('should return only enabled flags', () => {
|
||||||
|
const enabled = getEnabledFlags(mockFeatureData);
|
||||||
|
expect(enabled).toEqual(['feature.a', 'feature.d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array if no features', () => {
|
||||||
|
expect(getEnabledFlags({ features: {}, timestamp: '' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined features', () => {
|
||||||
|
expect(getEnabledFlags({ features: null as unknown as Record<string, string>, timestamp: '' })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFeatureEnabled()', () => {
|
||||||
|
it('should return true for enabled features', () => {
|
||||||
|
expect(isFeatureEnabled(mockFeatureData, 'feature.a')).toBe(true);
|
||||||
|
expect(isFeatureEnabled(mockFeatureData, 'feature.d')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-enabled features', () => {
|
||||||
|
expect(isFeatureEnabled(mockFeatureData, 'feature.b')).toBe(false);
|
||||||
|
expect(isFeatureEnabled(mockFeatureData, 'feature.c')).toBe(false);
|
||||||
|
expect(isFeatureEnabled(mockFeatureData, 'non-existent')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchFeatureFlags()', () => {
|
||||||
|
it('should fetch and return feature flags', async () => {
|
||||||
|
const mockFetcher = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockFeatureData,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fetchFeatureFlags(mockFetcher, 'http://api.test');
|
||||||
|
|
||||||
|
expect(mockFetcher).toHaveBeenCalledWith('http://api.test/features');
|
||||||
|
expect(result).toEqual(mockFeatureData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if fetch fails', async () => {
|
||||||
|
const mockFetcher = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchFeatureFlags(mockFetcher, 'http://api.test')).rejects.toThrow('Failed to fetch feature flags: 500');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/unit/website/WebsiteRouteManager.test.ts
Normal file
73
tests/unit/website/WebsiteRouteManager.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
|
||||||
|
import { routes } from '../../../apps/website/lib/routing/RouteConfig';
|
||||||
|
|
||||||
|
describe('WebsiteRouteManager - Route Classification Contract', () => {
|
||||||
|
const routeManager = new WebsiteRouteManager();
|
||||||
|
|
||||||
|
describe('getAccessLevel()', () => {
|
||||||
|
it('should correctly classify public routes', () => {
|
||||||
|
expect(routeManager.getAccessLevel('/')).toBe('public');
|
||||||
|
expect(routeManager.getAccessLevel('/auth/login')).toBe('public');
|
||||||
|
expect(routeManager.getAccessLevel('/leagues')).toBe('public');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly classify dashboard routes as auth', () => {
|
||||||
|
expect(routeManager.getAccessLevel('/dashboard')).toBe('auth');
|
||||||
|
expect(routeManager.getAccessLevel('/profile')).toBe('auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly classify admin routes', () => {
|
||||||
|
expect(routeManager.getAccessLevel('/admin')).toBe('admin');
|
||||||
|
expect(routeManager.getAccessLevel('/admin/users')).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly classify sponsor routes', () => {
|
||||||
|
expect(routeManager.getAccessLevel('/sponsor')).toBe('sponsor');
|
||||||
|
expect(routeManager.getAccessLevel('/sponsor/dashboard')).toBe('sponsor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly classify dynamic route patterns', () => {
|
||||||
|
// League detail is public
|
||||||
|
expect(routeManager.getAccessLevel('/leagues/any-id')).toBe('public');
|
||||||
|
expect(routeManager.getAccessLevel('/races/any-id')).toBe('public');
|
||||||
|
|
||||||
|
// Nested protected routes
|
||||||
|
expect(routeManager.getAccessLevel('/leagues/any-id/settings')).toBe('auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RouteConfig Contract', () => {
|
||||||
|
it('should fail loudly if RouteConfig paths change unexpectedly', () => {
|
||||||
|
// These assertions act as a contract. If the paths change in RouteConfig,
|
||||||
|
// these tests will fail, forcing a conscious update of the contract.
|
||||||
|
expect(routes.public.home).toBe('/');
|
||||||
|
expect(routes.auth.login).toBe('/auth/login');
|
||||||
|
expect(routes.protected.dashboard).toBe('/dashboard');
|
||||||
|
expect(routes.admin.root).toBe('/admin');
|
||||||
|
expect(routes.sponsor.root).toBe('/sponsor');
|
||||||
|
|
||||||
|
// Dynamic patterns
|
||||||
|
expect(routes.league.detail('test-id')).toBe('/leagues/test-id');
|
||||||
|
expect(routes.league.scheduleAdmin('test-id')).toBe('/leagues/test-id/schedule/admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Representative Subset Verification', () => {
|
||||||
|
const testCases = [
|
||||||
|
{ path: '/', expected: 'public' },
|
||||||
|
{ path: '/auth/login', expected: 'public' },
|
||||||
|
{ path: '/dashboard', expected: 'auth' },
|
||||||
|
{ path: '/admin', expected: 'admin' },
|
||||||
|
{ path: '/sponsor', expected: 'sponsor' },
|
||||||
|
{ path: '/leagues/123', expected: 'public' },
|
||||||
|
{ path: '/races/456', expected: 'public' },
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ path, expected }) => {
|
||||||
|
it(`should classify ${path} as ${expected}`, () => {
|
||||||
|
expect(routeManager.getAccessLevel(path)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
'adapters/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
'adapters/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||||
'apps/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
'apps/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||||
'tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
'tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||||
|
'tests/unit/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: [
|
include: [
|
||||||
// Companion-related smoke tests are excluded
|
'tests/smoke/website-ssr.test.ts',
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
'**/companion/**',
|
'**/companion/**',
|
||||||
@@ -24,9 +24,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, './apps/website'),
|
||||||
'@/packages': path.resolve(__dirname, './packages'),
|
'@core': path.resolve(__dirname, './core'),
|
||||||
'@/apps': path.resolve(__dirname, './apps'),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
16
vitest.website-ssr.config.ts
Normal file
16
vitest.website-ssr.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/smoke/website-ssr.test.ts'],
|
||||||
|
testTimeout: 30000, // Increase timeout for network requests
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './apps/website'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
500
website_logs.txt
Normal file
500
website_logs.txt
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
ℹ️ [WEBSITE] INFO: [RouteGuard] Auth page detected
|
||||||
|
Timestamp: 2026-01-17T15:51:02.368Z
|
||||||
|
Source: website
|
||||||
|
[SESSION] Using server component cookies, length: 0
|
||||||
|
[SESSION] Cookie string:
|
||||||
|
[SESSION] No cookies found, returning null
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteGuard] No session, allowing access to auth page
|
||||||
|
Timestamp: 2026-01-17T15:51:02.377Z
|
||||||
|
Source: website
|
||||||
|
GET /auth/login?returnTo=%2Fdashboard 200 in 90ms
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ========== REQUEST START ==========
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.491Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Request details
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.491Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
pathname: [32m'/'[39m,
|
||||||
|
method: [32m'GET'[39m,
|
||||||
|
url: [32m'http://localhost:3000/'[39m,
|
||||||
|
cookieHeaderLength: [33m0[39m,
|
||||||
|
cookiePreview: [32m''[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Fetching session...
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.491Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
[SESSION] NextRequest cookie header length: 0
|
||||||
|
[SESSION] NextRequest cookie header:
|
||||||
|
[SESSION] Using provided cookie header, length: 0
|
||||||
|
[SESSION] Cookie string:
|
||||||
|
[SESSION] No cookies found, returning null
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Session fetched
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.491Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
userId: [90mundefined[39m,
|
||||||
|
role: [90mundefined[39m,
|
||||||
|
sessionData: [32m'null'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Auth session converted
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.491Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ authSession: [32m'null'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] isPublic check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.491Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path is public (exact match)
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] requiresRole check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path requires no specific role
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Route classification
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m, isPublic: [33mtrue[39m, requiresRole: [1mnull[22m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Calling handleAuthFlow...
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Called
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ hasSession: [33mfalse[39m, sessionRole: [90mundefined[39m, requestedPath: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] isPublic check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path is public (exact match)
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] requiresRole check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path requires no specific role
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] getAction called
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
requestedPath: [32m'/'[39m,
|
||||||
|
isPublic: [33mtrue[39m,
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
requiredRoles: [1mnull[22m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] Public route, showing page
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Action determined
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ actionType: [32m'SHOW_PAGE'[39m, action: [32m'{\n "type": "SHOW_PAGE"\n}'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Returning SHOW_PAGE
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] handleAuthFlow result
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
result: [32m'{\n "shouldRedirect": false,\n "shouldShowPage": true\n}'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Decision summary
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
pathname: [32m'/'[39m,
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
role: [90mundefined[39m,
|
||||||
|
shouldRedirect: [33mfalse[39m,
|
||||||
|
redirectUrl: [90mundefined[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ALLOWING ACCESS
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ pathname: [32m'/'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ========== REQUEST END (ALLOW) ==========
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.492Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: Global error handler skipped (server-side)
|
||||||
|
Timestamp: 2026-01-17T15:51:07.517Z
|
||||||
|
Source: website
|
||||||
|
🐛 [WEBSITE] DEBUG: API Request: GET http://api:3000/auth/session
|
||||||
|
Timestamp: 2026-01-17T15:51:07.519Z
|
||||||
|
Source: website
|
||||||
|
Context:
|
||||||
|
{
|
||||||
|
requestId: [32m'req_1768665067519_1'[39m,
|
||||||
|
timestamp: [32m'2026-01-17T15:51:07.519Z'[39m,
|
||||||
|
headers: { [32m'Content-Type'[39m: [32m'application/json'[39m },
|
||||||
|
body: [90mundefined[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: API Response: GET http://api:3000/auth/session
|
||||||
|
Timestamp: 2026-01-17T15:51:07.559Z
|
||||||
|
Source: website
|
||||||
|
Context:
|
||||||
|
{
|
||||||
|
requestId: [32m'req_1768665067519_1'[39m,
|
||||||
|
duration: [32m'39.00ms'[39m,
|
||||||
|
status: [32m'200 OK'[39m,
|
||||||
|
body: [1mnull[22m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ========== REQUEST START ==========
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Request details
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
pathname: [32m'/dashboard'[39m,
|
||||||
|
method: [32m'GET'[39m,
|
||||||
|
url: [32m'http://localhost:3000/dashboard'[39m,
|
||||||
|
cookieHeaderLength: [33m0[39m,
|
||||||
|
cookiePreview: [32m''[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Fetching session...
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
[SESSION] NextRequest cookie header length: 0
|
||||||
|
[SESSION] NextRequest cookie header:
|
||||||
|
[SESSION] Using provided cookie header, length: 0
|
||||||
|
[SESSION] Cookie string:
|
||||||
|
[SESSION] No cookies found, returning null
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Session fetched
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
userId: [90mundefined[39m,
|
||||||
|
role: [90mundefined[39m,
|
||||||
|
sessionData: [32m'null'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Auth session converted
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ authSession: [32m'null'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] isPublic check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path is NOT public
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] requiresRole check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path requires no specific role
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Route classification
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m, isPublic: [33mfalse[39m, requiresRole: [1mnull[22m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Calling handleAuthFlow...
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Called
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
sessionRole: [90mundefined[39m,
|
||||||
|
requestedPath: [32m'/dashboard'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] isPublic check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path is NOT public
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] requiresRole check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path requires no specific role
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/dashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] getAction called
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
requestedPath: [32m'/dashboard'[39m,
|
||||||
|
isPublic: [33mfalse[39m,
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
requiredRoles: [1mnull[22m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] No session, redirecting to login
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Action determined
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
actionType: [32m'REDIRECT_TO_LOGIN'[39m,
|
||||||
|
action: [32m'{\n "type": "REDIRECT_TO_LOGIN",\n "returnTo": "/dashboard"\n}'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] getAction called
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
requestedPath: [32m'/dashboard'[39m,
|
||||||
|
isPublic: [33mfalse[39m,
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
requiredRoles: [1mnull[22m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] No session, redirecting to login
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Returning REDIRECT_TO_LOGIN
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ loginUrl: [32m'/auth/login?returnTo=%2Fdashboard'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] handleAuthFlow result
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
result: [32m'{\n'[39m +
|
||||||
|
[32m' "shouldRedirect": true,\n'[39m +
|
||||||
|
[32m' "redirectUrl": "/auth/login?returnTo=%2Fdashboard"\n'[39m +
|
||||||
|
[32m'}'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Decision summary
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
pathname: [32m'/dashboard'[39m,
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
role: [90mundefined[39m,
|
||||||
|
shouldRedirect: [33mtrue[39m,
|
||||||
|
redirectUrl: [32m'/auth/login?returnTo=%2Fdashboard'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] REDIRECTING
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.596Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
from: [32m'/dashboard'[39m,
|
||||||
|
to: [32m'http://localhost:3000/auth/login?returnTo=%2Fdashboard'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ========== REQUEST END (REDIRECT) ==========
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.597Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
GET / 307 in 106ms
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ========== REQUEST START ==========
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Request details
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
pathname: [32m'/auth/login'[39m,
|
||||||
|
method: [32m'GET'[39m,
|
||||||
|
url: [32m'http://localhost:3000/auth/login?returnTo=%2Fdashboard'[39m,
|
||||||
|
cookieHeaderLength: [33m0[39m,
|
||||||
|
cookiePreview: [32m''[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Fetching session...
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
[SESSION] NextRequest cookie header length: 0
|
||||||
|
[SESSION] NextRequest cookie header:
|
||||||
|
[SESSION] Using provided cookie header, length: 0
|
||||||
|
[SESSION] Cookie string:
|
||||||
|
[SESSION] No cookies found, returning null
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Session fetched
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
userId: [90mundefined[39m,
|
||||||
|
role: [90mundefined[39m,
|
||||||
|
sessionData: [32m'null'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Auth session converted
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ authSession: [32m'null'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] isPublic check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path is public (exact match)
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] requiresRole check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path requires no specific role
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Route classification
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m, isPublic: [33mtrue[39m, requiresRole: [1mnull[22m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Calling handleAuthFlow...
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Called
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
sessionRole: [90mundefined[39m,
|
||||||
|
requestedPath: [32m'/auth/login'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] isPublic check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path is public (exact match)
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] requiresRole check
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.608Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteConfig] Path requires no specific role
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ path: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] getAction called
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
requestedPath: [32m'/auth/login'[39m,
|
||||||
|
isPublic: [33mtrue[39m,
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
requiredRoles: [1mnull[22m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [AuthFlowRouter] Public route, showing page
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Action determined
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ actionType: [32m'SHOW_PAGE'[39m, action: [32m'{\n "type": "SHOW_PAGE"\n}'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [handleAuthFlow] Returning SHOW_PAGE
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] handleAuthFlow result
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
result: [32m'{\n "shouldRedirect": false,\n "shouldShowPage": true\n}'[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] Decision summary
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{
|
||||||
|
pathname: [32m'/auth/login'[39m,
|
||||||
|
hasSession: [33mfalse[39m,
|
||||||
|
role: [90mundefined[39m,
|
||||||
|
shouldRedirect: [33mfalse[39m,
|
||||||
|
redirectUrl: [90mundefined[39m
|
||||||
|
}
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ALLOWING ACCESS
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
%cContext: color: #666; font-weight: bold;
|
||||||
|
{ pathname: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [MIDDLEWARE] ========== REQUEST END (ALLOW) ==========
|
||||||
|
%cTimestamp: color: #666; font-weight: bold; 2026-01-17T15:51:07.609Z
|
||||||
|
%cSource: color: #666; font-weight: bold; website
|
||||||
|
ℹ️ [WEBSITE] INFO: Global error handler skipped (server-side)
|
||||||
|
Timestamp: 2026-01-17T15:51:07.652Z
|
||||||
|
Source: website
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteGuard] enforce called
|
||||||
|
Timestamp: 2026-01-17T15:51:07.668Z
|
||||||
|
Source: website
|
||||||
|
Context:
|
||||||
|
{ pathname: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteGuard] logicalPathname
|
||||||
|
Timestamp: 2026-01-17T15:51:07.668Z
|
||||||
|
Source: website
|
||||||
|
Context:
|
||||||
|
{ logicalPathname: [32m'/auth/login'[39m }
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteGuard] Auth page detected
|
||||||
|
Timestamp: 2026-01-17T15:51:07.669Z
|
||||||
|
Source: website
|
||||||
|
[SESSION] Using server component cookies, length: 0
|
||||||
|
[SESSION] Cookie string:
|
||||||
|
[SESSION] No cookies found, returning null
|
||||||
|
ℹ️ [WEBSITE] INFO: [RouteGuard] No session, allowing access to auth page
|
||||||
|
Timestamp: 2026-01-17T15:51:07.737Z
|
||||||
|
Source: website
|
||||||
|
GET /auth/login?returnTo=%2Fdashboard 200 in 203ms
|
||||||
Reference in New Issue
Block a user