refactor
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import { vi } from 'vitest';
|
||||
import { ConsoleLogger } from '../../../logging/ConsoleLogger'; // Assuming ConsoleLogger is here
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import { ConsoleLogger } from './ConsoleLogger';
|
||||
|
||||
describe('ConsoleLogger', () => {
|
||||
let logger: ConsoleLogger;
|
||||
let consoleDebugSpy: vi.SpyInstance;
|
||||
let consoleInfoSpy: vi.SpyInstance;
|
||||
let consoleWarnSpy: vi.SpyInstance;
|
||||
let consoleErrorSpy: vi.SpyInstance;
|
||||
let consoleLogSpy: vi.SpyInstance;
|
||||
let consoleDebugSpy: Mock;
|
||||
let consoleInfoSpy: Mock;
|
||||
let consoleWarnSpy: Mock;
|
||||
let consoleErrorSpy: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new ConsoleLogger();
|
||||
@@ -15,7 +14,6 @@ describe('ConsoleLogger', () => {
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -23,7 +21,6 @@ describe('ConsoleLogger', () => {
|
||||
consoleInfoSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call console.debug with the correct arguments when debug is called', () => {
|
||||
@@ -59,11 +56,4 @@ describe('ConsoleLogger', () => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(message, error, context);
|
||||
});
|
||||
|
||||
it('should call console.log with the correct arguments when log is called', () => {
|
||||
const message = 'Log message';
|
||||
const context = { key: 'value' };
|
||||
logger.log(message, context);
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(message, context);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@core/*": ["../core/*"],
|
||||
@@ -16,5 +17,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -5,19 +5,8 @@
|
||||
"module": "commonjs",
|
||||
"lib": ["es2022", "dom"],
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitThis": false,
|
||||
"strictNullChecks": false,
|
||||
"alwaysStrict": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"assumeChangesOnlyAffectDirectDependencies": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
@@ -26,8 +15,7 @@
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "express", "jest"],
|
||||
"strictPropertyInitialization": false,
|
||||
"types": ["node", "express", "vitest/globals"],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@core/*": ["../../core/*"],
|
||||
@@ -36,5 +24,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.mock.ts", "**/*.spec.ts", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist", "**/*.mock.ts"]
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
export class Result<T, E = Error> {
|
||||
private constructor(
|
||||
private readonly _value?: T,
|
||||
private readonly _error?: E,
|
||||
private readonly _isSuccess: boolean = true
|
||||
) {}
|
||||
|
||||
static ok<T, E = Error>(value: T): Result<T, E> {
|
||||
return new Result<T, E>(value, undefined, true);
|
||||
}
|
||||
|
||||
static err<T, E = Error>(error: E): Result<T, E> {
|
||||
return new Result<T, E>(undefined, error, false);
|
||||
}
|
||||
|
||||
isOk(): boolean {
|
||||
return this._isSuccess;
|
||||
}
|
||||
|
||||
isErr(): boolean {
|
||||
return !this._isSuccess;
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
if (!this._isSuccess) {
|
||||
throw new Error('Called unwrap on an error result');
|
||||
}
|
||||
return this._value!;
|
||||
}
|
||||
|
||||
unwrapOr(defaultValue: T): T {
|
||||
return this._isSuccess ? this._value! : defaultValue;
|
||||
}
|
||||
|
||||
unwrapErr(): E {
|
||||
if (this._isSuccess) {
|
||||
throw new Error('Called unwrapErr on a success result');
|
||||
}
|
||||
return this._error!;
|
||||
}
|
||||
|
||||
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||
if (this._isSuccess) {
|
||||
return Result.ok(fn(this._value!));
|
||||
}
|
||||
return Result.err(this._error!);
|
||||
}
|
||||
|
||||
mapErr<F>(fn: (error: E) => F): Result<T, F> {
|
||||
if (!this._isSuccess) {
|
||||
return Result.err(fn(this._error!));
|
||||
}
|
||||
return Result.ok(this._value!);
|
||||
}
|
||||
|
||||
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
|
||||
if (this._isSuccess) {
|
||||
return fn(this._value!);
|
||||
}
|
||||
return Result.err(this._error!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct access to the value (for testing convenience).
|
||||
* Prefer using unwrap() in production code.
|
||||
*/
|
||||
get value(): T | undefined {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct access to the error (for testing convenience).
|
||||
* Prefer using unwrapErr() in production code.
|
||||
*/
|
||||
get error(): E | undefined {
|
||||
return this._error;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@core/*": ["./*"],
|
||||
@@ -16,5 +17,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
useRouter: () => ({
|
||||
push: () => {},
|
||||
replace: () => {},
|
||||
prefetch: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
const ActualLink = ({ href, children, ...rest }: any) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
return { default: ActualLink };
|
||||
});
|
||||
|
||||
vi.mock('../../../apps/website/components/profile/UserPill', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: function MockUserPill() {
|
||||
return (
|
||||
<div>
|
||||
<a href="/auth/login">Sign In</a>
|
||||
<a href="/auth/signup">Get Started</a>
|
||||
<button type="button">Logout</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../apps/website/lib/auth/AuthContext', () => {
|
||||
const React = require('react');
|
||||
const AuthContext = React.createContext({
|
||||
session: null,
|
||||
loading: false,
|
||||
login: () => {},
|
||||
logout: async () => {},
|
||||
refreshSession: async () => {},
|
||||
});
|
||||
|
||||
const AuthProvider = ({ initialSession, children }: { initialSession?: any; children: React.ReactNode }) => (
|
||||
<AuthContext.Provider value={{ session: initialSession, loading: false, login: () => {}, logout: async () => {}, refreshSession: async () => {} }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
|
||||
const useAuth = () => React.useContext(AuthContext);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
AuthProvider,
|
||||
useAuth,
|
||||
};
|
||||
});
|
||||
|
||||
import { AuthProvider } from '../../../apps/website/lib/auth/AuthContext';
|
||||
import { AlphaNav } from '../../../apps/website/components/alpha/AlphaNav';
|
||||
|
||||
describe('AlphaNav', () => {
|
||||
it('hides Dashboard link and uses Home when unauthenticated', () => {
|
||||
render(
|
||||
<AuthProvider
|
||||
initialSession={null}
|
||||
>
|
||||
<AlphaNav />
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
const dashboardLinks = screen.queryAllByText('Dashboard');
|
||||
expect(dashboardLinks.length).toBe(0);
|
||||
|
||||
const homeLink = screen.getByText('Home');
|
||||
expect(homeLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Dashboard link and hides Home when authenticated', () => {
|
||||
render(
|
||||
<AuthProvider
|
||||
initialSession={{
|
||||
user: { id: 'user-1', displayName: 'Test User' },
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 3600000,
|
||||
token: 'fake-token',
|
||||
}}
|
||||
>
|
||||
<AlphaNav />
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
const dashboard = screen.getByText('Dashboard');
|
||||
expect(dashboard).toBeInTheDocument();
|
||||
expect((dashboard as HTMLAnchorElement).getAttribute('href')).toBe('/dashboard');
|
||||
|
||||
const homeLink = screen.queryByText('Home');
|
||||
expect(homeLink).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
// Mock() Button component
|
||||
vi.mock('../../../apps/website/components/ui/Button', () => ({
|
||||
default: ({ onClick, children, className, title, variant }: any) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
title={title}
|
||||
data-variant={variant}
|
||||
data-testid="penalty-button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import InlinePenaltyButton from '../../../apps/website/components/races/InlinePenaltyButton';
|
||||
|
||||
describe('InlinePenaltyButton', () => {
|
||||
const mockDriver = { id: 'driver-1', name: 'Test Driver' };
|
||||
const mockOnPenaltyClick = vi.fn();
|
||||
|
||||
it('should not render when user is not admin', () => {
|
||||
render(
|
||||
<InlinePenaltyButton
|
||||
driver={mockDriver}
|
||||
onPenaltyClick={mockOnPenaltyClick}
|
||||
isAdmin={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.queryByTestId('penalty-button');
|
||||
expect(button).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render when user is admin', () => {
|
||||
render(
|
||||
<InlinePenaltyButton
|
||||
driver={mockDriver}
|
||||
onPenaltyClick={mockOnPenaltyClick}
|
||||
isAdmin={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('penalty-button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('title', 'Issue penalty to Test Driver');
|
||||
expect(button).toHaveAttribute('data-variant', 'danger');
|
||||
});
|
||||
|
||||
it('should call onPenaltyClick when button is clicked', () => {
|
||||
render(
|
||||
<InlinePenaltyButton
|
||||
driver={mockDriver}
|
||||
onPenaltyClick={mockOnPenaltyClick}
|
||||
isAdmin={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('penalty-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnPenaltyClick).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnPenaltyClick).toHaveBeenCalledWith(mockDriver);
|
||||
});
|
||||
|
||||
it('should not crash when onPenaltyClick is not provided', () => {
|
||||
render(
|
||||
<InlinePenaltyButton
|
||||
driver={mockDriver}
|
||||
isAdmin={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('penalty-button');
|
||||
|
||||
// Should not crash when clicked without onPenaltyClick
|
||||
expect(() => fireEvent.click(button)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have proper button styling for spacing', () => {
|
||||
render(
|
||||
<InlinePenaltyButton
|
||||
driver={mockDriver}
|
||||
onPenaltyClick={mockOnPenaltyClick}
|
||||
isAdmin={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('penalty-button');
|
||||
|
||||
// Check that button has proper spacing classes
|
||||
expect(button).toHaveClass('p-1.5');
|
||||
expect(button).toHaveClass('min-h-[32px]');
|
||||
expect(button).toHaveClass('w-8');
|
||||
expect(button).toHaveClass('h-8');
|
||||
expect(button).toHaveClass('rounded-full');
|
||||
expect(button).toHaveClass('flex');
|
||||
expect(button).toHaveClass('items-center');
|
||||
expect(button).toHaveClass('justify-center');
|
||||
});
|
||||
|
||||
it('should render AlertTriangle icon with proper sizing', () => {
|
||||
render(
|
||||
<InlinePenaltyButton
|
||||
driver={mockDriver}
|
||||
onPenaltyClick={mockOnPenaltyClick}
|
||||
isAdmin={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('penalty-button');
|
||||
const icon = button.querySelector('svg');
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass('w-4');
|
||||
expect(icon).toHaveClass('h-4');
|
||||
expect(icon).toHaveClass('flex-shrink-0');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Auth + caching behavior for RootLayout and Dashboard.
|
||||
*
|
||||
* These tests assert that:
|
||||
* - RootLayout is marked dynamic so it re-evaluates cookies per request.
|
||||
* - DashboardPage is also dynamic (no static caching of auth state).
|
||||
*/
|
||||
|
||||
describe('RootLayout auth caching behavior', () => {
|
||||
it('is configured as dynamic to avoid static auth caching', async () => {
|
||||
const layoutModule = (await import(
|
||||
'../../../../apps/website/app/layout',
|
||||
)) as { dynamic?: string };
|
||||
|
||||
// Next.js dynamic routing flag
|
||||
const dynamic = layoutModule.dynamic;
|
||||
|
||||
expect(dynamic).toBe('force-dynamic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard auth caching behavior', () => {
|
||||
it('is configured as dynamic to evaluate auth per request', async () => {
|
||||
const dashboardModule = (await import(
|
||||
'../../../../apps/website/app/dashboard/page',
|
||||
)) as { dynamic?: string };
|
||||
|
||||
const dynamic = dashboardModule.dynamic;
|
||||
|
||||
expect(dynamic).toBe('force-dynamic');
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
// --- Mocks for Next.js navigation ---
|
||||
|
||||
const useSearchParamsMock = vi.fn();
|
||||
const useRouterMock = vi.fn();
|
||||
|
||||
const routerInstance = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('next/navigation', () => {
|
||||
return {
|
||||
useSearchParams: () => useSearchParamsMock(),
|
||||
useRouter: () => {
|
||||
return useRouterMock() ?? routerInstance;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Minimal next/link mock to keep existing patterns consistent
|
||||
vi.mock('next/link', () => {
|
||||
const ActualLink = ({ href, children, ...rest }: any) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
return { default: ActualLink };
|
||||
});
|
||||
|
||||
import CreateLeaguePage from '../../../../apps/website/app/leagues/create/page';
|
||||
|
||||
// Helper to build a searchParams-like object
|
||||
function createSearchParams(stepValue: string | null) {
|
||||
return {
|
||||
get: (key: string) => {
|
||||
if (key === 'step') {
|
||||
return stepValue;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as URLSearchParams;
|
||||
}
|
||||
|
||||
describe('CreateLeaguePage - URL-bound wizard steps', () => {
|
||||
beforeEach(() => {
|
||||
useSearchParamsMock.mockReset();
|
||||
useRouterMock.mockReset();
|
||||
routerInstance.push.mockReset();
|
||||
routerInstance.replace.mockReset();
|
||||
});
|
||||
|
||||
it('defaults to basics step when step param is missing', () => {
|
||||
useSearchParamsMock.mockReturnValue(createSearchParams(null));
|
||||
|
||||
render(<CreateLeaguePage />);
|
||||
|
||||
// Basics step title from the wizard
|
||||
expect(screen.getByText('Name your league')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats invalid step value as basics', () => {
|
||||
useSearchParamsMock.mockReturnValue(createSearchParams('invalid-step'));
|
||||
|
||||
render(<CreateLeaguePage />);
|
||||
|
||||
expect(screen.getByText('Name your league')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mounts directly on scoring step when step=scoring', () => {
|
||||
useSearchParamsMock.mockReturnValue(createSearchParams('scoring'));
|
||||
|
||||
render(<CreateLeaguePage />);
|
||||
|
||||
// Step 4 title in the wizard
|
||||
expect(screen.getByText('Scoring & championships')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a Continue button on the basics step that can trigger navigation when the form is valid', () => {
|
||||
useSearchParamsMock.mockReturnValue(createSearchParams(null));
|
||||
useRouterMock.mockReturnValue(routerInstance);
|
||||
|
||||
render(<CreateLeaguePage />);
|
||||
|
||||
const continueButton = screen.getByRole('button', { name: /continue/i });
|
||||
// The underlying wizard only enables this button when the form is valid.
|
||||
// This smoke-test just confirms the button is present and clickable without asserting navigation,
|
||||
// leaving detailed navigation behavior to more focused integration tests.
|
||||
fireEvent.click(continueButton);
|
||||
});
|
||||
|
||||
it('clicking Back from schedule navigates to step=structure via router', () => {
|
||||
useSearchParamsMock.mockReturnValue(createSearchParams('schedule'));
|
||||
useRouterMock.mockReturnValue(routerInstance);
|
||||
|
||||
render(<CreateLeaguePage />);
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(routerInstance.push).toHaveBeenCalledTimes(1);
|
||||
const call = routerInstance.push.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const callArg = (call as [string])[0];
|
||||
expect(callArg).toContain('/leagues/create');
|
||||
expect(callArg).toContain('step=structure');
|
||||
});
|
||||
|
||||
it('derives current step solely from URL so a "reload" keeps the same step', () => {
|
||||
useSearchParamsMock.mockReturnValueOnce(createSearchParams('scoring'));
|
||||
useSearchParamsMock.mockReturnValueOnce(createSearchParams('scoring'));
|
||||
|
||||
render(<CreateLeaguePage />);
|
||||
expect(screen.getAllByText('Scoring & championships').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Simulate a logical reload by re-rendering with the same URL state
|
||||
render(<CreateLeaguePage />);
|
||||
expect(screen.getAllByText('Scoring & championships').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const alphaDir = path.resolve(__dirname, '../../../../apps/website/components/alpha');
|
||||
|
||||
const metaAllowlist = new Set([
|
||||
'FeatureLimitationTooltip.tsx',
|
||||
'CompanionInstructions.tsx',
|
||||
'CompanionStatus.tsx',
|
||||
'AlphaBanner.tsx',
|
||||
'AlphaFooter.tsx',
|
||||
'AlphaNav.tsx',
|
||||
// Temporary passthrough wrapper that re-exports the real schedule form
|
||||
'ScheduleRaceForm.tsx',
|
||||
]);
|
||||
|
||||
describe('Alpha components structure', () => {
|
||||
it('contains only alpha chrome and meta components', () => {
|
||||
const entries = fs.readdirSync(alphaDir);
|
||||
const tsxFiles = entries.filter((file) => file.endsWith('.tsx'));
|
||||
|
||||
const violations = tsxFiles.filter((file) => {
|
||||
if (metaAllowlist.has(file)) {
|
||||
return false;
|
||||
}
|
||||
return !file.startsWith('Alpha');
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const websiteRoot = path.resolve(__dirname, '../../../../apps/website');
|
||||
|
||||
const forbiddenImportPrefixes = [
|
||||
"@/lib/demo-data",
|
||||
"@/lib/inmemory",
|
||||
"@/lib/social",
|
||||
"@/lib/email-validation",
|
||||
"@/lib/membership-data",
|
||||
"@/lib/registration-data",
|
||||
"@/lib/team-data",
|
||||
];
|
||||
|
||||
function collectTsFiles(dir: string): string[] {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Skip Next.js build output if present
|
||||
if (entry.name === '.next') continue;
|
||||
files.push(...collectTsFiles(fullPath));
|
||||
} else if (entry.isFile()) {
|
||||
if (
|
||||
entry.name.endsWith('.ts') ||
|
||||
entry.name.endsWith('.tsx')
|
||||
) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('Website import boundaries', () => {
|
||||
it('does not import forbidden website lib modules directly', () => {
|
||||
const files = collectTsFiles(websiteRoot);
|
||||
|
||||
const violations: { file: string; line: number; content: string }[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('import')) return;
|
||||
|
||||
for (const prefix of forbiddenImportPrefixes) {
|
||||
if (trimmed.includes(`"${prefix}`) || trimmed.includes(`'${prefix}`)) {
|
||||
violations.push({
|
||||
file,
|
||||
line: index + 1,
|
||||
content: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const message =
|
||||
'Found forbidden imports in apps/website:\n' +
|
||||
violations
|
||||
.map(
|
||||
(v) =>
|
||||
`- ${v.file}:${v.line} :: ${v.content.trim()}`,
|
||||
)
|
||||
.join('\n');
|
||||
// Fail with detailed message so we can iterate while RED
|
||||
expect(message).toBe(''); // Intentionally impossible when violations exist
|
||||
} else {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const cookieStore = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: () => cookieStore,
|
||||
}));
|
||||
|
||||
import { InMemoryAuthService } from '../../../../apps/website/lib/auth/InMemoryAuthService';
|
||||
|
||||
describe('InMemoryAuthService', () => {
|
||||
beforeEach(() => {
|
||||
cookieStore.get.mockReset();
|
||||
cookieStore.set.mockReset();
|
||||
cookieStore.delete.mockReset();
|
||||
});
|
||||
|
||||
it('startIracingAuthRedirect returns redirectUrl with returnTo and state without touching cookies', async () => {
|
||||
const service = new InMemoryAuthService();
|
||||
|
||||
const { redirectUrl, state } = await service.startIracingAuthRedirect('some');
|
||||
|
||||
expect(typeof state).toBe('string');
|
||||
expect(state.length).toBeGreaterThan(0);
|
||||
|
||||
const url = new URL(redirectUrl, 'http://localhost');
|
||||
expect(url.pathname).toBe('/auth/iracing/callback');
|
||||
expect(url.searchParams.get('returnTo')).toBe('some');
|
||||
expect(url.searchParams.get('state')).toBe(state);
|
||||
expect(url.searchParams.get('code')).toBeTruthy();
|
||||
|
||||
expect(cookieStore.get).not.toHaveBeenCalled();
|
||||
expect(cookieStore.set).not.toHaveBeenCalled();
|
||||
expect(cookieStore.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loginWithIracingCallback returns deterministic demo session', async () => {
|
||||
const service = new InMemoryAuthService();
|
||||
|
||||
const session = await service.loginWithIracingCallback({
|
||||
code: 'dummy-code',
|
||||
state: 'any-state',
|
||||
});
|
||||
|
||||
expect(session.user.id).toBe('demo-user');
|
||||
expect(session.user.primaryDriverId).toBeDefined();
|
||||
expect(session.user.primaryDriverId).not.toBe('');
|
||||
});
|
||||
|
||||
it('logout clears the demo session cookie via adapter', async () => {
|
||||
const service = new InMemoryAuthService();
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(cookieStore.get).not.toHaveBeenCalled();
|
||||
expect(cookieStore.set).not.toHaveBeenCalled();
|
||||
expect(cookieStore.delete).toHaveBeenCalledWith('gp_demo_session');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('IracingAuthPage imports', () => {
|
||||
it('does not import cookies or getAuthService', () => {
|
||||
const filePath = path.resolve(
|
||||
__dirname,
|
||||
'../../../../apps/website/app/auth/iracing/page.tsx',
|
||||
);
|
||||
const source = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
expect(source.includes("from 'next/headers'")).toBe(false);
|
||||
expect(source.includes('cookies(')).toBe(false);
|
||||
expect(source.includes('getAuthService')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const cookieStore = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('next/headers', () => {
|
||||
return {
|
||||
cookies: () => cookieStore,
|
||||
};
|
||||
});
|
||||
|
||||
import { GET as startGet } from '../../../../apps/website/app/auth/iracing/start/route';
|
||||
import { GET as callbackGet } from '../../../../apps/website/app/auth/iracing/callback/route';
|
||||
import { POST as logoutPost } from '../../../../apps/website/app/auth/logout/route';
|
||||
|
||||
describe('iRacing auth route handlers', () => {
|
||||
beforeEach(() => {
|
||||
cookieStore.get.mockReset();
|
||||
cookieStore.set.mockReset();
|
||||
cookieStore.delete.mockReset();
|
||||
});
|
||||
|
||||
it('start route redirects to auth URL and sets state cookie', async () => {
|
||||
const req = new Request('http://localhost/auth/iracing/start?returnTo=/dashboard');
|
||||
|
||||
const res = await startGet(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get('location') ?? '';
|
||||
expect(location).toContain('/auth/iracing/callback');
|
||||
expect(location).toContain('returnTo=%2Fdashboard');
|
||||
expect(location).toMatch(/state=/);
|
||||
|
||||
expect(cookieStore.set).toHaveBeenCalled();
|
||||
const call = cookieStore.set.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const [name] = call as [string, string];
|
||||
expect(name).toBe('gp_demo_auth_state');
|
||||
});
|
||||
|
||||
it('callback route creates session cookie and redirects to returnTo', async () => {
|
||||
cookieStore.get.mockImplementation((name: string) => {
|
||||
if (name === 'gp_demo_auth_state') {
|
||||
return { value: 'valid-state' };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const req = new Request(
|
||||
'http://localhost/auth/iracing/callback?code=demo-code&state=valid-state&returnTo=/dashboard',
|
||||
);
|
||||
|
||||
const res = await callbackGet(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get('location');
|
||||
expect(location).toBe('http://localhost/dashboard');
|
||||
|
||||
expect(cookieStore.set).toHaveBeenCalled();
|
||||
const call = cookieStore.set.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const [sessionName, sessionValue] = call as [string, string];
|
||||
expect(sessionName).toBe('gp_demo_session');
|
||||
expect(typeof sessionValue).toBe('string');
|
||||
|
||||
expect(cookieStore.delete).toHaveBeenCalledWith('gp_demo_auth_state');
|
||||
});
|
||||
|
||||
it('logout route deletes session cookie and redirects home using request origin', async () => {
|
||||
const req = new Request('http://example.com/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const res = await logoutPost(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get('location');
|
||||
expect(location).toBe('http://example.com/');
|
||||
|
||||
expect(cookieStore.delete).toHaveBeenCalledWith('gp_demo_session');
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { getAppMode, AppMode } from '../../../apps/website/lib/mode';
|
||||
|
||||
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
|
||||
|
||||
describe('getAppMode', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
(process.env as any).NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
(process.env as any).NODE_ENV = ORIGINAL_NODE_ENV;
|
||||
});
|
||||
|
||||
it('returns "pre-launch" when NEXT_PUBLIC_GRIDPILOT_MODE is undefined', () => {
|
||||
delete process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
|
||||
|
||||
const mode = getAppMode();
|
||||
|
||||
expect(mode).toBe<AppMode>('pre-launch');
|
||||
});
|
||||
|
||||
it('returns "pre-launch" when NEXT_PUBLIC_GRIDPILOT_MODE is explicitly set to "pre-launch"', () => {
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'pre-launch';
|
||||
|
||||
const mode = getAppMode();
|
||||
|
||||
expect(mode).toBe<AppMode>('pre-launch');
|
||||
});
|
||||
|
||||
it('returns "alpha" when NEXT_PUBLIC_GRIDPILOT_MODE is set to "alpha"', () => {
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'alpha';
|
||||
|
||||
const mode = getAppMode();
|
||||
|
||||
expect(mode).toBe<AppMode>('alpha');
|
||||
});
|
||||
|
||||
it('falls back to "pre-launch" and logs when NEXT_PUBLIC_GRIDPILOT_MODE is invalid in production', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'invalid-mode' as any;
|
||||
|
||||
const mode = getAppMode();
|
||||
|
||||
expect(mode).toBe<AppMode>('pre-launch');
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('throws in development when NEXT_PUBLIC_GRIDPILOT_MODE is invalid', () => {
|
||||
(process.env as any).NODE_ENV = 'development';
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'invalid-mode' as any;
|
||||
|
||||
expect(() => getAppMode()).toThrowError(/Invalid NEXT_PUBLIC_GRIDPILOT_MODE/);
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const mockCheckRateLimit = vi.fn(() => Promise.resolve({ allowed: true, remaining: 4, resetAt: 0 }));
|
||||
const mockGetClientIp = vi.fn(() => '127.0.0.1');
|
||||
|
||||
vi.mock('../../../apps/website/lib/rate-limit', () => ({
|
||||
checkRateLimit: mockCheckRateLimit,
|
||||
getClientIp: mockGetClientIp,
|
||||
}));
|
||||
|
||||
async function getPostHandler() {
|
||||
const routeModule = (await import(
|
||||
'../../../apps/website/app/api/signup/route'
|
||||
)) as { POST: (request: Request) => Promise<Response> };
|
||||
return routeModule.POST;
|
||||
}
|
||||
|
||||
function createJsonRequest(body: unknown): Request {
|
||||
return new Request('http://localhost/api/signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('/api/signup POST', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockCheckRateLimit.mockReset();
|
||||
mockGetClientIp.mockReset();
|
||||
|
||||
mockGetClientIp.mockReturnValue('127.0.0.1');
|
||||
mockCheckRateLimit.mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a valid email within rate limits and returns success payload', async () => {
|
||||
const POST = await getPostHandler();
|
||||
|
||||
const response = await POST(
|
||||
createJsonRequest({
|
||||
email: 'user@example.com',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(200);
|
||||
expect(response.status).toBeLessThan(300);
|
||||
|
||||
const data = (await response.json()) as { message: unknown; ok: unknown };
|
||||
|
||||
expect(data).toHaveProperty('message');
|
||||
expect(typeof data.message).toBe('string');
|
||||
expect(data).toHaveProperty('ok', true);
|
||||
});
|
||||
|
||||
it('rejects an invalid email with 400 and error message', async () => {
|
||||
const POST = await getPostHandler();
|
||||
|
||||
const response = await POST(
|
||||
createJsonRequest({
|
||||
email: 'not-an-email',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const data = (await response.json()) as { error: unknown };
|
||||
expect(typeof data.error).toBe('string');
|
||||
expect(typeof data.error === 'string' && data.error.toLowerCase()).toContain('email');
|
||||
});
|
||||
|
||||
it('rejects disposable email domains with 400 and error message', async () => {
|
||||
const POST = await getPostHandler();
|
||||
|
||||
const response = await POST(
|
||||
createJsonRequest({
|
||||
email: 'foo@mailinator.com',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const data = (await response.json()) as { error: unknown };
|
||||
expect(typeof data.error).toBe('string');
|
||||
});
|
||||
|
||||
it('returns 409 and friendly message when email is already subscribed', async () => {
|
||||
const POST = await getPostHandler();
|
||||
|
||||
const email = 'duplicate@example.com';
|
||||
|
||||
const first = await POST(createJsonRequest({ email }));
|
||||
expect(first.status).toBeGreaterThanOrEqual(200);
|
||||
expect(first.status).toBeLessThan(300);
|
||||
|
||||
const second = await POST(createJsonRequest({ email }));
|
||||
|
||||
expect(second.status).toBe(409);
|
||||
|
||||
const data = (await second.json()) as { error: unknown };
|
||||
expect(typeof data.error).toBe('string');
|
||||
expect(typeof data.error === 'string' && data.error.toLowerCase()).toContain('already');
|
||||
});
|
||||
|
||||
it('returns 429 with retryAfter when rate limit is exceeded', async () => {
|
||||
mockCheckRateLimit.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const POST = await getPostHandler();
|
||||
|
||||
const response = await POST(
|
||||
createJsonRequest({
|
||||
email: 'limited@example.com',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
|
||||
const data = (await response.json()) as { error: unknown; retryAfter?: unknown };
|
||||
expect(typeof data.error).toBe('string');
|
||||
expect(data).toHaveProperty('retryAfter');
|
||||
});
|
||||
});
|
||||
@@ -30,5 +30,5 @@
|
||||
"@testing/*": ["./testing/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"core/**/*",
|
||||
"apps/**/*",
|
||||
"tests/**/*"
|
||||
],
|
||||
, "adapters/logging/ConsoleLogger.test.ts" ],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
|
||||
@@ -7,7 +7,12 @@ export default defineConfig({
|
||||
watch: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['tests/setup/vitest.setup.ts'],
|
||||
include: ['tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', 'core/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
|
||||
include: [
|
||||
'tests/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||
'core/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||
'adapters/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||
'apps/**/*.{test,spec}.?(c|m)[jt]s?(x)',
|
||||
],
|
||||
exclude: [
|
||||
'node_modules/**',
|
||||
'tests/smoke/website-pages.spec.ts',
|
||||
|
||||
Reference in New Issue
Block a user