wip
This commit is contained in:
53
tests/unit/website/AlphaNav.test.tsx
Normal file
53
tests/unit/website/AlphaNav.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
const ActualLink = ({ href, children, ...rest }: any) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
return { default: ActualLink };
|
||||
});
|
||||
|
||||
import { AlphaNav } from '../../../apps/website/components/alpha/AlphaNav';
|
||||
|
||||
describe('AlphaNav', () => {
|
||||
it('hides Dashboard link and shows login when unauthenticated', () => {
|
||||
render(<AlphaNav isAuthenticated={false} />);
|
||||
|
||||
const dashboardLinks = screen.queryAllByText('Dashboard');
|
||||
expect(dashboardLinks.length).toBe(0);
|
||||
|
||||
const homeLink = screen.getByText('Home');
|
||||
expect(homeLink).toBeInTheDocument();
|
||||
|
||||
const login = screen.getByText('Authenticate with iRacing');
|
||||
expect(login).toBeInTheDocument();
|
||||
expect((login as HTMLAnchorElement).getAttribute('href')).toContain(
|
||||
'/auth/iracing/start?returnTo=/dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Dashboard link, hides Home, and logout control when authenticated', () => {
|
||||
render(<AlphaNav isAuthenticated />);
|
||||
|
||||
const dashboard = screen.getByText('Dashboard');
|
||||
expect(dashboard).toBeInTheDocument();
|
||||
expect((dashboard as HTMLAnchorElement).getAttribute('href')).toBe('/dashboard');
|
||||
|
||||
const homeLink = screen.queryByText('Home');
|
||||
expect(homeLink).toBeNull();
|
||||
|
||||
const login = screen.queryByText('Authenticate with iRacing');
|
||||
expect(login).toBeNull();
|
||||
|
||||
const logout = screen.getByText('Logout');
|
||||
expect(logout).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
30
tests/unit/website/auth/DashboardAndLayoutAuth.test.tsx
Normal file
30
tests/unit/website/auth/DashboardAndLayoutAuth.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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');
|
||||
|
||||
// Next.js dynamic routing flag
|
||||
const dynamic = (layoutModule as any).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');
|
||||
|
||||
const dynamic = (dashboardModule as any).dynamic;
|
||||
|
||||
expect(dynamic).toBe('force-dynamic');
|
||||
});
|
||||
});
|
||||
63
tests/unit/website/auth/InMemoryAuthService.test.ts
Normal file
63
tests/unit/website/auth/InMemoryAuthService.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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 does not attempt to modify cookies directly', async () => {
|
||||
const service = new InMemoryAuthService();
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(cookieStore.get).not.toHaveBeenCalled();
|
||||
expect(cookieStore.set).not.toHaveBeenCalled();
|
||||
expect(cookieStore.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
17
tests/unit/website/auth/IracingAuthPageImports.test.ts
Normal file
17
tests/unit/website/auth/IracingAuthPageImports.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
81
tests/unit/website/auth/IracingRoutes.test.ts
Normal file
81
tests/unit/website/auth/IracingRoutes.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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 as any);
|
||||
|
||||
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 [name] = cookieStore.set.mock.calls[0];
|
||||
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 as any);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get('location');
|
||||
expect(location).toBe('http://localhost/dashboard');
|
||||
|
||||
expect(cookieStore.set).toHaveBeenCalled();
|
||||
const [sessionName, sessionValue] = cookieStore.set.mock.calls[0];
|
||||
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 as any);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get('location');
|
||||
expect(location).toBe('http://example.com/');
|
||||
|
||||
expect(cookieStore.delete).toHaveBeenCalledWith('gp_demo_session');
|
||||
});
|
||||
});
|
||||
30
tests/unit/website/structure/AlphaComponents.test.ts
Normal file
30
tests/unit/website/structure/AlphaComponents.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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',
|
||||
]);
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
81
tests/unit/website/structure/ImportBoundaries.test.ts
Normal file
81
tests/unit/website/structure/ImportBoundaries.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user