This commit is contained in:
2025-12-16 12:14:06 +01:00
parent 9a891ac8b3
commit 7d3393e1b9
90 changed files with 20 additions and 974 deletions

View File

@@ -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);
});
});

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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;
}
}

View File

@@ -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"]
}

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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([]);
});
});

View File

@@ -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([]);
}
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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/);
});
});

View File

@@ -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');
});
});

View File

@@ -30,5 +30,5 @@
"@testing/*": ["./testing/*"]
}
},
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
"exclude": ["node_modules", "dist"]
}

View File

@@ -12,7 +12,7 @@
"core/**/*",
"apps/**/*",
"tests/**/*"
],
, "adapters/logging/ConsoleLogger.test.ts" ],
"exclude": [
"node_modules",
"dist",

View File

@@ -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',