wip
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Result } from '@/packages/shared/result/Result';
|
||||
import { CheckoutConfirmation } from '@/packages/automation-domain/value-objects/CheckoutConfirmation';
|
||||
import { CheckoutPrice } from '@/packages/automation-domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@/packages/automation-domain/value-objects/CheckoutState';
|
||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
||||
|
||||
/**
|
||||
* Contract tests for ICheckoutConfirmationPort
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { OverlayAction, ActionAck } from '../../../../packages/automation-application/ports/IOverlaySyncPort'
|
||||
import { IAutomationEventPublisher, AutomationEvent } from '../../../../packages/automation-application/ports/IAutomationEventPublisher'
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../../../packages/automation-infrastructure/adapters/IAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from '../../../../packages/automation-application/services/OverlaySyncService'
|
||||
import { OverlayAction, ActionAck } from '../../../../packages/automation/application/ports/IOverlaySyncPort'
|
||||
import { IAutomationEventPublisher, AutomationEvent } from '../../../../packages/automation/application/ports/IAutomationEventPublisher'
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../../../packages/automation/infrastructure/adapters/IAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from '../../../../packages/automation/application/services/OverlaySyncService'
|
||||
|
||||
class MockLifecycleEmitter implements IAutomationLifecycleEmitter {
|
||||
private callbacks: Set<LifecycleCallback> = new Set()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { OverlayAction } from '../../../../packages/automation-application/ports/IOverlaySyncPort'
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../../../packages/automation-infrastructure/adapters/IAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from '../../../../packages/automation-application/services/OverlaySyncService'
|
||||
import { OverlayAction } from '../../../../packages/automation/application/ports/IOverlaySyncPort'
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../../../packages/automation/infrastructure/adapters/IAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from '../../../../packages/automation/application/services/OverlaySyncService'
|
||||
|
||||
class MockLifecycleEmitter implements IAutomationLifecycleEmitter {
|
||||
private callbacks: Set<LifecycleCallback> = new Set()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { CheckAuthenticationUseCase } from '../../../../packages/automation-application/use-cases/CheckAuthenticationUseCase';
|
||||
import { AuthenticationState } from '../../../../packages/automation-domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../../../packages/automation-domain/value-objects/BrowserAuthenticationState';
|
||||
import { CheckAuthenticationUseCase } from '../../../../packages/automation/application/use-cases/CheckAuthenticationUseCase';
|
||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '../../../../packages/shared/result/Result';
|
||||
import type { IAuthenticationService } from '../../../../packages/automation-application/ports/IAuthenticationService';
|
||||
import type { IAuthenticationService } from '../../../../packages/automation/application/ports/IAuthenticationService';
|
||||
|
||||
interface ISessionValidator {
|
||||
validateSession(): Promise<Result<boolean>>;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CompleteRaceCreationUseCase } from '@/packages/automation-application/use-cases/CompleteRaceCreationUseCase';
|
||||
import { Result } from '@/packages/shared/result/Result';
|
||||
import { RaceCreationResult } from '@/packages/automation-domain/value-objects/RaceCreationResult';
|
||||
import { CheckoutPrice } from '@/packages/automation-domain/value-objects/CheckoutPrice';
|
||||
import type { ICheckoutService } from '@/packages/automation-application/ports/ICheckoutService';
|
||||
import { CheckoutState } from '@/packages/automation-domain/value-objects/CheckoutState';
|
||||
import { CompleteRaceCreationUseCase } from '../../../../packages/automation/application/use-cases/CompleteRaceCreationUseCase';
|
||||
import { Result } from '../../../../packages/shared/result/Result';
|
||||
import { RaceCreationResult } from '@gridpilot/automation/domain/value-objects/RaceCreationResult';
|
||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
||||
import type { ICheckoutService } from '../../../../packages/automation/application/ports/ICheckoutService';
|
||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
||||
|
||||
describe('CompleteRaceCreationUseCase', () => {
|
||||
let mockCheckoutService: ICheckoutService;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfirmCheckoutUseCase } from '@/packages/automation-application/use-cases/ConfirmCheckoutUseCase';
|
||||
import { ConfirmCheckoutUseCase } from '@/packages/automation/application/use-cases/ConfirmCheckoutUseCase';
|
||||
import { Result } from '@/packages/shared/result/Result';
|
||||
import { CheckoutPrice } from '@/packages/automation-domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@/packages/automation-domain/value-objects/CheckoutState';
|
||||
import { CheckoutConfirmation } from '@/packages/automation-domain/value-objects/CheckoutConfirmation';
|
||||
import type { ICheckoutService } from '@/packages/automation-application/ports/ICheckoutService';
|
||||
import type { ICheckoutConfirmationPort } from '@/packages/automation-application/ports/ICheckoutConfirmationPort';
|
||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
||||
import type { ICheckoutService } from '@/packages/automation/application/ports/ICheckoutService';
|
||||
import type { ICheckoutConfirmationPort } from '@/packages/automation/application/ports/ICheckoutConfirmationPort';
|
||||
|
||||
describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
let mockCheckoutService: ICheckoutService;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { Result } from '../../../../packages/shared/result/Result';
|
||||
import { ConfirmCheckoutUseCase } from '../../../../packages/automation-application/use-cases/ConfirmCheckoutUseCase';
|
||||
import { ICheckoutService, CheckoutInfo } from '../../../../packages/automation-application/ports/ICheckoutService';
|
||||
import { ICheckoutConfirmationPort } from '../../../../packages/automation-application/ports/ICheckoutConfirmationPort';
|
||||
import { CheckoutPrice } from '../../../../packages/automation-domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState, CheckoutStateEnum } from '../../../../packages/automation-domain/value-objects/CheckoutState';
|
||||
import { CheckoutConfirmation } from '../../../../packages/automation-domain/value-objects/CheckoutConfirmation';
|
||||
import { ConfirmCheckoutUseCase } from '../../../../packages/automation/application/use-cases/ConfirmCheckoutUseCase';
|
||||
import { ICheckoutService, CheckoutInfo } from '../../../../packages/automation/application/ports/ICheckoutService';
|
||||
import { ICheckoutConfirmationPort } from '../../../../packages/automation/application/ports/ICheckoutConfirmationPort';
|
||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState, CheckoutStateEnum } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
||||
|
||||
/**
|
||||
* ConfirmCheckoutUseCase - GREEN PHASE
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { StartAutomationSessionUseCase } from '../../../../packages/automation-application/use-cases/StartAutomationSessionUseCase';
|
||||
import { IAutomationEngine } from '../../../../packages/automation-application/ports/IAutomationEngine';
|
||||
import { IScreenAutomation } from '../../../../packages/automation-application/ports/IScreenAutomation';
|
||||
import { ISessionRepository } from '../../../../packages/automation-application/ports/ISessionRepository';
|
||||
import { AutomationSession } from '../../../../packages/automation-domain/entities/AutomationSession';
|
||||
import { StartAutomationSessionUseCase } from '../../../../packages/automation/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { IAutomationEngine } from '../../../../packages/automation/application/ports/IAutomationEngine';
|
||||
import { IScreenAutomation } from '../../../../packages/automation/application/ports/IScreenAutomation';
|
||||
import { ISessionRepository } from '../../../../packages/automation/application/ports/ISessionRepository';
|
||||
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
|
||||
|
||||
describe('StartAutomationSessionUseCase', () => {
|
||||
let mockAutomationEngine: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { VerifyAuthenticatedPageUseCase } from '../../../../packages/automation-application/use-cases/VerifyAuthenticatedPageUseCase';
|
||||
import { IAuthenticationService } from '../../../../packages/automation-application/ports/IAuthenticationService';
|
||||
import { VerifyAuthenticatedPageUseCase } from '../../../../packages/automation/application/use-cases/VerifyAuthenticatedPageUseCase';
|
||||
import { IAuthenticationService } from '../../../../packages/automation/application/ports/IAuthenticationService';
|
||||
import { Result } from '../../../../packages/shared/result/Result';
|
||||
import { BrowserAuthenticationState } from '../../../../packages/automation-domain/value-objects/BrowserAuthenticationState';
|
||||
import { AuthenticationState } from '../../../../packages/automation-domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
||||
|
||||
describe('VerifyAuthenticatedPageUseCase', () => {
|
||||
let useCase: VerifyAuthenticatedPageUseCase;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutomationSession } from '../../../../packages/automation-domain/entities/AutomationSession';
|
||||
import { StepId } from '../../../../packages/automation-domain/value-objects/StepId';
|
||||
import { SessionState } from '../../../../packages/automation-domain/value-objects/SessionState';
|
||||
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
|
||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
||||
import { SessionState } from '@gridpilot/automation/domain/value-objects/SessionState';
|
||||
|
||||
describe('AutomationSession Entity', () => {
|
||||
describe('create', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PageStateValidator } from '../../../../packages/automation-domain/services/PageStateValidator';
|
||||
import { PageStateValidator } from '@gridpilot/automation/domain/services/PageStateValidator';
|
||||
|
||||
describe('PageStateValidator', () => {
|
||||
const validator = new PageStateValidator();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepTransitionValidator } from '../../../../packages/automation-domain/services/StepTransitionValidator';
|
||||
import { StepId } from '../../../../packages/automation-domain/value-objects/StepId';
|
||||
import { SessionState } from '../../../../packages/automation-domain/value-objects/SessionState';
|
||||
import { StepTransitionValidator } from '@gridpilot/automation/domain/services/StepTransitionValidator';
|
||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
||||
import { SessionState } from '@gridpilot/automation/domain/value-objects/SessionState';
|
||||
|
||||
describe('StepTransitionValidator Service', () => {
|
||||
describe('canTransition', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { BrowserAuthenticationState } from '../../../../packages/automation-domain/value-objects/BrowserAuthenticationState';
|
||||
import { AuthenticationState } from '../../../../packages/automation-domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
||||
|
||||
describe('BrowserAuthenticationState', () => {
|
||||
describe('isFullyAuthenticated()', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutConfirmation } from '../../../../packages/automation-domain/value-objects/CheckoutConfirmation';
|
||||
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
|
||||
|
||||
describe('CheckoutConfirmation Value Object', () => {
|
||||
describe('create', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutPrice } from '../../../../packages/automation-domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
||||
|
||||
/**
|
||||
* CheckoutPrice Value Object - GREEN PHASE
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutState, CheckoutStateEnum } from '../../../../packages/automation-domain/value-objects/CheckoutState';
|
||||
import { CheckoutState, CheckoutStateEnum } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
||||
|
||||
/**
|
||||
* CheckoutState Value Object - GREEN PHASE
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { CookieConfiguration } from '../../../../packages/automation-domain/value-objects/CookieConfiguration';
|
||||
import { CookieConfiguration } from '@gridpilot/automation/domain/value-objects/CookieConfiguration';
|
||||
|
||||
describe('CookieConfiguration', () => {
|
||||
const validTargetUrl = 'https://members-ng.iracing.com/jjwtauth/success';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceCreationResult } from '../../../../packages/automation-domain/value-objects/RaceCreationResult';
|
||||
import { RaceCreationResult } from '@gridpilot/automation/domain/value-objects/RaceCreationResult';
|
||||
|
||||
describe('RaceCreationResult Value Object', () => {
|
||||
describe('create', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionLifetime } from '../../../../packages/automation-domain/value-objects/SessionLifetime';
|
||||
import { SessionLifetime } from '@gridpilot/automation/domain/value-objects/SessionLifetime';
|
||||
|
||||
describe('SessionLifetime Value Object', () => {
|
||||
describe('Construction', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionState } from '../../../../packages/automation-domain/value-objects/SessionState';
|
||||
import { SessionState } from '@gridpilot/automation/domain/value-objects/SessionState';
|
||||
|
||||
describe('SessionState Value Object', () => {
|
||||
describe('create', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepId } from '../../../../packages/automation-domain/value-objects/StepId';
|
||||
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
|
||||
|
||||
describe('StepId Value Object', () => {
|
||||
describe('create', () => {
|
||||
|
||||
34
tests/unit/identity/EmailValidation.test.ts
Normal file
34
tests/unit/identity/EmailValidation.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { validateEmail, isDisposableEmail } from '@gridpilot/identity/domain/value-objects/EmailAddress';
|
||||
|
||||
describe('identity-domain email validation', () => {
|
||||
it('accepts a valid email and normalizes it', () => {
|
||||
const result = validateEmail(' USER@example.com ');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.email).toBe('user@example.com');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an invalid email format', () => {
|
||||
const result = validateEmail('not-an-email');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.email).toBeUndefined();
|
||||
expect(result.error).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('rejects an email that is too short', () => {
|
||||
const result = validateEmail('a@b');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('too short');
|
||||
});
|
||||
|
||||
it('detects disposable email domains', () => {
|
||||
expect(isDisposableEmail('foo@tempmail.com')).toBe(true);
|
||||
expect(isDisposableEmail('bar@mailinator.com')).toBe(true);
|
||||
expect(isDisposableEmail('user@example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { loadAutomationConfig, getAutomationMode, AutomationMode } from '../../../packages/automation-infrastructure/config/AutomationConfig';
|
||||
import { loadAutomationConfig, getAutomationMode, AutomationMode } from '../../../packages/automation/infrastructure/config/AutomationConfig';
|
||||
|
||||
describe('AutomationConfig', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Page } from 'playwright';
|
||||
import { AuthenticationGuard } from 'packages/automation-infrastructure/adapters/automation/auth/AuthenticationGuard';
|
||||
import { AuthenticationGuard } from 'packages/automation/infrastructure/adapters/automation/auth/AuthenticationGuard';
|
||||
|
||||
describe('AuthenticationGuard', () => {
|
||||
let mockPage: Page;
|
||||
|
||||
@@ -9,9 +9,9 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { ElectronCheckoutConfirmationAdapter } from '@/packages/automation-infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
|
||||
import { CheckoutPrice } from '@/packages/automation-domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@/packages/automation-domain/value-objects/CheckoutState';
|
||||
import { ElectronCheckoutConfirmationAdapter } from '@/packages/automation/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
|
||||
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
describe('ElectronCheckoutConfirmationAdapter', () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Page, BrowserContext } from 'playwright';
|
||||
import { PlaywrightAuthSessionService } from '../../../../packages/automation-infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
|
||||
import type { PlaywrightBrowserSession } from '../../../../packages/automation-infrastructure/adapters/automation/core/PlaywrightBrowserSession';
|
||||
import type { SessionCookieStore } from '../../../../packages/automation-infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from '../../../../packages/automation-infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
|
||||
import type { ILogger } from '../../../../packages/automation-application/ports/ILogger';
|
||||
import { AuthenticationState } from '../../../../packages/automation-domain/value-objects/AuthenticationState';
|
||||
import { PlaywrightAuthSessionService } from '../../../../packages/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
|
||||
import type { PlaywrightBrowserSession } from '../../../../packages/automation/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
|
||||
import type { SessionCookieStore } from '../../../../packages/automation/infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from '../../../../packages/automation/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
|
||||
import type { ILogger } from '../../../../packages/automation/application/ports/ILogger';
|
||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
||||
import { Result } from '../../../../packages/shared/result/Result';
|
||||
|
||||
describe('PlaywrightAuthSessionService.initiateLogin browser mode behaviour', () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Page, Locator } from 'playwright';
|
||||
import { PlaywrightAuthSessionService } from '../../../../packages/automation-infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
|
||||
import { AuthenticationState } from '../../../../packages/automation-domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../../../packages/automation-domain/value-objects/BrowserAuthenticationState';
|
||||
import type { ILogger } from '../../../../packages/automation-application/ports/ILogger';
|
||||
import { PlaywrightAuthSessionService } from '../../../../packages/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
|
||||
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import type { ILogger } from '../../../../packages/automation/application/ports/ILogger';
|
||||
import type { Result } from '../../../../packages/shared/result/Result';
|
||||
import type { PlaywrightBrowserSession } from '../../../../packages/automation-infrastructure/adapters/automation/core/PlaywrightBrowserSession';
|
||||
import type { SessionCookieStore } from '../../../../packages/automation-infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from '../../../../packages/automation-infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
|
||||
import type { PlaywrightBrowserSession } from '../../../../packages/automation/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
|
||||
import type { SessionCookieStore } from '../../../../packages/automation/infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from '../../../../packages/automation/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
|
||||
|
||||
describe('PlaywrightAuthSessionService.verifyPageAuthentication', () => {
|
||||
function createService(deps: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeEach } from 'vitest';
|
||||
import { SessionCookieStore } from 'packages/automation-infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||
import { SessionCookieStore } from 'packages/automation/infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||
import type { Cookie } from 'playwright';
|
||||
|
||||
describe('SessionCookieStore - Cookie Validation', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { BrowserModeConfigLoader } from '../../../../packages/automation-infrastructure/config/BrowserModeConfig';
|
||||
import { BrowserModeConfigLoader } from '../../../../packages/automation/infrastructure/config/BrowserModeConfig';
|
||||
|
||||
/**
|
||||
* Unit tests for BrowserModeConfig - GREEN PHASE
|
||||
|
||||
125
tests/unit/racing-application/MembershipUseCases.test.ts
Normal file
125
tests/unit/racing-application/MembershipUseCases.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { JoinLeagueUseCase } from '@gridpilot/racing/application/use-cases/JoinLeagueUseCase';
|
||||
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type {
|
||||
LeagueMembership,
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
|
||||
class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
|
||||
private memberships: LeagueMembership[] = [];
|
||||
|
||||
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.leagueId === leagueId && m.driverId === driverId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveMembershipForDriver(driverId: string): Promise<LeagueMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.driverId === driverId && m.status === 'active',
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.leagueId === leagueId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async getTeamMembers(leagueId: string): Promise<LeagueMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.leagueId === leagueId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
|
||||
const existingIndex = this.memberships.findIndex(
|
||||
(m) => m.leagueId === membership.leagueId && m.driverId === membership.driverId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.memberships[existingIndex] = membership;
|
||||
} else {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(leagueId: string, driverId: string): Promise<void> {
|
||||
this.memberships = this.memberships.filter(
|
||||
(m) => !(m.leagueId === leagueId && m.driverId === driverId),
|
||||
);
|
||||
}
|
||||
|
||||
async getJoinRequests(): Promise<never> {
|
||||
throw new Error('Not implemented for this test');
|
||||
}
|
||||
|
||||
async saveJoinRequest(): Promise<never> {
|
||||
throw new Error('Not implemented for this test');
|
||||
}
|
||||
|
||||
async removeJoinRequest(): Promise<never> {
|
||||
throw new Error('Not implemented for this test');
|
||||
}
|
||||
|
||||
seedMembership(membership: LeagueMembership): void {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
|
||||
getAllMemberships(): LeagueMembership[] {
|
||||
return [...this.memberships];
|
||||
}
|
||||
}
|
||||
|
||||
describe('Membership use-cases', () => {
|
||||
describe('JoinLeagueUseCase', () => {
|
||||
let repository: InMemoryLeagueMembershipRepository;
|
||||
let useCase: JoinLeagueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryLeagueMembershipRepository();
|
||||
useCase = new JoinLeagueUseCase(repository);
|
||||
});
|
||||
|
||||
it('creates an active member when driver has no membership', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await useCase.execute({ leagueId, driverId });
|
||||
|
||||
const membership = await repository.getMembership(leagueId, driverId);
|
||||
expect(membership).not.toBeNull();
|
||||
expect(membership?.leagueId).toBe(leagueId);
|
||||
expect(membership?.driverId).toBe(driverId);
|
||||
expect(membership?.role as MembershipRole).toBe('member');
|
||||
expect(membership?.status as MembershipStatus).toBe('active');
|
||||
expect(membership?.joinedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('throws when driver already has membership for league', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
repository.seedMembership({
|
||||
leagueId,
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date('2024-01-01'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useCase.execute({ leagueId, driverId }),
|
||||
).rejects.toThrow('Already a member or have a pending request');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
|
||||
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
|
||||
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
|
||||
import type {
|
||||
LeagueMembership,
|
||||
MembershipStatus,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import type {
|
||||
Team,
|
||||
TeamMembership,
|
||||
TeamMembershipStatus,
|
||||
TeamRole,
|
||||
TeamJoinRequest,
|
||||
} from '@gridpilot/racing/domain/entities/Team';
|
||||
|
||||
import { RegisterForRaceUseCase } from '@gridpilot/racing/application/use-cases/RegisterForRaceUseCase';
|
||||
import { WithdrawFromRaceUseCase } from '@gridpilot/racing/application/use-cases/WithdrawFromRaceUseCase';
|
||||
import {
|
||||
IsDriverRegisteredForRaceQuery,
|
||||
GetRaceRegistrationsQuery,
|
||||
} from '@gridpilot/racing/application/use-cases/RaceRegistrationQueries';
|
||||
|
||||
import {
|
||||
CreateTeamUseCase,
|
||||
JoinTeamUseCase,
|
||||
LeaveTeamUseCase,
|
||||
ApproveTeamJoinRequestUseCase,
|
||||
RejectTeamJoinRequestUseCase,
|
||||
UpdateTeamUseCase,
|
||||
GetAllTeamsQuery,
|
||||
GetTeamDetailsQuery,
|
||||
GetTeamMembersQuery,
|
||||
GetTeamJoinRequestsQuery,
|
||||
GetDriverTeamQuery,
|
||||
} from '@gridpilot/racing/application/use-cases/TeamUseCases';
|
||||
|
||||
/**
|
||||
* Simple in-memory fakes mirroring current alpha behavior.
|
||||
*/
|
||||
|
||||
class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
|
||||
private registrations = new Map<string, Set<string>>(); // raceId -> driverIds
|
||||
|
||||
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
|
||||
const set = this.registrations.get(raceId);
|
||||
return set ? set.has(driverId) : false;
|
||||
}
|
||||
|
||||
async getRegisteredDrivers(raceId: string): Promise<string[]> {
|
||||
const set = this.registrations.get(raceId);
|
||||
return set ? Array.from(set) : [];
|
||||
}
|
||||
|
||||
async getRegistrationCount(raceId: string): Promise<number> {
|
||||
const set = this.registrations.get(raceId);
|
||||
return set ? set.size : 0;
|
||||
}
|
||||
|
||||
async register(registration: RaceRegistration): Promise<void> {
|
||||
if (!this.registrations.has(registration.raceId)) {
|
||||
this.registrations.set(registration.raceId, new Set());
|
||||
}
|
||||
this.registrations.get(registration.raceId)!.add(registration.driverId);
|
||||
}
|
||||
|
||||
async withdraw(raceId: string, driverId: string): Promise<void> {
|
||||
const set = this.registrations.get(raceId);
|
||||
if (!set || !set.has(driverId)) {
|
||||
throw new Error('Not registered for this race');
|
||||
}
|
||||
set.delete(driverId);
|
||||
if (set.size === 0) {
|
||||
this.registrations.delete(raceId);
|
||||
}
|
||||
}
|
||||
|
||||
async getDriverRegistrations(driverId: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const [raceId, set] of this.registrations.entries()) {
|
||||
if (set.has(driverId)) {
|
||||
result.push(raceId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async clearRaceRegistrations(raceId: string): Promise<void> {
|
||||
this.registrations.delete(raceId);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryLeagueMembershipRepositoryForRegistrations implements ILeagueMembershipRepository {
|
||||
private memberships: LeagueMembership[] = [];
|
||||
|
||||
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.leagueId === leagueId && m.driverId === driverId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.leagueId === leagueId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async getJoinRequests(): Promise<never> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
|
||||
this.memberships.push(membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(): Promise<void> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
async saveJoinRequest(): Promise<never> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
async removeJoinRequest(): Promise<never> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
seedActiveMembership(leagueId: string, driverId: string): void {
|
||||
this.memberships.push({
|
||||
leagueId,
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active' as MembershipStatus,
|
||||
joinedAt: new Date('2024-01-01'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryTeamRepository implements ITeamRepository {
|
||||
private teams: Team[] = [];
|
||||
|
||||
async findById(id: string): Promise<Team | null> {
|
||||
return this.teams.find((t) => t.id === id) || null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Team[]> {
|
||||
return [...this.teams];
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Team[]> {
|
||||
return this.teams.filter((t) => t.leagues.includes(leagueId));
|
||||
}
|
||||
|
||||
async create(team: Team): Promise<Team> {
|
||||
this.teams.push(team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async update(team: Team): Promise<Team> {
|
||||
const index = this.teams.findIndex((t) => t.id === team.id);
|
||||
if (index >= 0) {
|
||||
this.teams[index] = team;
|
||||
} else {
|
||||
this.teams.push(team);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.teams = this.teams.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.teams.some((t) => t.id === id);
|
||||
}
|
||||
|
||||
seedTeam(team: Team): void {
|
||||
this.teams.push(team);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryTeamMembershipRepository implements ITeamMembershipRepository {
|
||||
private memberships: TeamMembership[] = [];
|
||||
private joinRequests: TeamJoinRequest[] = [];
|
||||
|
||||
async getMembership(teamId: string, driverId: string): Promise<TeamMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.teamId === teamId && m.driverId === driverId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.driverId === driverId && m.status === 'active',
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: string): Promise<TeamMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.teamId === teamId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
|
||||
const index = this.memberships.findIndex(
|
||||
(m) => m.teamId === membership.teamId && m.driverId === membership.driverId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.memberships[index] = membership;
|
||||
} else {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(teamId: string, driverId: string): Promise<void> {
|
||||
this.memberships = this.memberships.filter(
|
||||
(m) => !(m.teamId === teamId && m.driverId === driverId),
|
||||
);
|
||||
}
|
||||
|
||||
async getJoinRequests(teamId: string): Promise<TeamJoinRequest[]> {
|
||||
// For these tests we ignore teamId and return all,
|
||||
// allowing use-cases to look up by request ID only.
|
||||
return [...this.joinRequests];
|
||||
}
|
||||
|
||||
async saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest> {
|
||||
const index = this.joinRequests.findIndex((r) => r.id === request.id);
|
||||
if (index >= 0) {
|
||||
this.joinRequests[index] = request;
|
||||
} else {
|
||||
this.joinRequests.push(request);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async removeJoinRequest(requestId: string): Promise<void> {
|
||||
this.joinRequests = this.joinRequests.filter((r) => r.id !== requestId);
|
||||
}
|
||||
|
||||
seedMembership(membership: TeamMembership): void {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
|
||||
seedJoinRequest(request: TeamJoinRequest): void {
|
||||
this.joinRequests.push(request);
|
||||
}
|
||||
|
||||
getAllMemberships(): TeamMembership[] {
|
||||
return [...this.memberships];
|
||||
}
|
||||
|
||||
getAllJoinRequests(): TeamJoinRequest[] {
|
||||
return [...this.joinRequests];
|
||||
}
|
||||
}
|
||||
|
||||
describe('Racing application use-cases - registrations', () => {
|
||||
let registrationRepo: InMemoryRaceRegistrationRepository;
|
||||
let membershipRepo: InMemoryLeagueMembershipRepositoryForRegistrations;
|
||||
let registerForRace: RegisterForRaceUseCase;
|
||||
let withdrawFromRace: WithdrawFromRaceUseCase;
|
||||
let isDriverRegistered: IsDriverRegisteredForRaceQuery;
|
||||
let getRaceRegistrations: GetRaceRegistrationsQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
registrationRepo = new InMemoryRaceRegistrationRepository();
|
||||
membershipRepo = new InMemoryLeagueMembershipRepositoryForRegistrations();
|
||||
|
||||
registerForRace = new RegisterForRaceUseCase(registrationRepo, membershipRepo);
|
||||
withdrawFromRace = new WithdrawFromRaceUseCase(registrationRepo);
|
||||
isDriverRegistered = new IsDriverRegisteredForRaceQuery(registrationRepo);
|
||||
getRaceRegistrations = new GetRaceRegistrationsQuery(registrationRepo);
|
||||
});
|
||||
|
||||
it('registers an active league member for a race and tracks registration', async () => {
|
||||
const raceId = 'race-1';
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
membershipRepo.seedActiveMembership(leagueId, driverId);
|
||||
|
||||
await registerForRace.execute({ raceId, leagueId, driverId });
|
||||
|
||||
expect(await isDriverRegistered.execute({ raceId, driverId })).toBe(true);
|
||||
|
||||
const registeredDrivers = await getRaceRegistrations.execute({ raceId });
|
||||
expect(registeredDrivers).toContain(driverId);
|
||||
});
|
||||
|
||||
it('throws when registering a non-member for a race', async () => {
|
||||
const raceId = 'race-1';
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await expect(
|
||||
registerForRace.execute({ raceId, leagueId, driverId }),
|
||||
).rejects.toThrow('Must be an active league member to register for races');
|
||||
});
|
||||
|
||||
it('withdraws a registration and reflects state in queries', async () => {
|
||||
const raceId = 'race-1';
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
membershipRepo.seedActiveMembership(leagueId, driverId);
|
||||
await registerForRace.execute({ raceId, leagueId, driverId });
|
||||
|
||||
await withdrawFromRace.execute({ raceId, driverId });
|
||||
|
||||
expect(await isDriverRegistered.execute({ raceId, driverId })).toBe(false);
|
||||
expect(await getRaceRegistrations.execute({ raceId })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Racing application use-cases - teams', () => {
|
||||
let teamRepo: InMemoryTeamRepository;
|
||||
let membershipRepo: InMemoryTeamMembershipRepository;
|
||||
|
||||
let createTeam: CreateTeamUseCase;
|
||||
let joinTeam: JoinTeamUseCase;
|
||||
let leaveTeam: LeaveTeamUseCase;
|
||||
let approveJoin: ApproveTeamJoinRequestUseCase;
|
||||
let rejectJoin: RejectTeamJoinRequestUseCase;
|
||||
let updateTeamUseCase: UpdateTeamUseCase;
|
||||
let getAllTeamsQuery: GetAllTeamsQuery;
|
||||
let getTeamDetailsQuery: GetTeamDetailsQuery;
|
||||
let getTeamMembersQuery: GetTeamMembersQuery;
|
||||
let getTeamJoinRequestsQuery: GetTeamJoinRequestsQuery;
|
||||
let getDriverTeamQuery: GetDriverTeamQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
teamRepo = new InMemoryTeamRepository();
|
||||
membershipRepo = new InMemoryTeamMembershipRepository();
|
||||
|
||||
createTeam = new CreateTeamUseCase(teamRepo, membershipRepo);
|
||||
joinTeam = new JoinTeamUseCase(teamRepo, membershipRepo);
|
||||
leaveTeam = new LeaveTeamUseCase(membershipRepo);
|
||||
approveJoin = new ApproveTeamJoinRequestUseCase(membershipRepo);
|
||||
rejectJoin = new RejectTeamJoinRequestUseCase(membershipRepo);
|
||||
updateTeamUseCase = new UpdateTeamUseCase(teamRepo, membershipRepo);
|
||||
getAllTeamsQuery = new GetAllTeamsQuery(teamRepo);
|
||||
getTeamDetailsQuery = new GetTeamDetailsQuery(teamRepo, membershipRepo);
|
||||
getTeamMembersQuery = new GetTeamMembersQuery(membershipRepo);
|
||||
getTeamJoinRequestsQuery = new GetTeamJoinRequestsQuery(membershipRepo);
|
||||
getDriverTeamQuery = new GetDriverTeamQuery(teamRepo, membershipRepo);
|
||||
});
|
||||
|
||||
it('creates a team and assigns creator as active owner', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
|
||||
const result = await createTeam.execute({
|
||||
name: 'Apex Racing',
|
||||
tag: 'APEX',
|
||||
description: 'Professional GT3 racing',
|
||||
ownerId,
|
||||
leagues: ['league-1'],
|
||||
});
|
||||
|
||||
expect(result.team.id).toBeDefined();
|
||||
expect(result.team.ownerId).toBe(ownerId);
|
||||
|
||||
const membership = await membershipRepo.getActiveMembershipForDriver(ownerId);
|
||||
expect(membership?.teamId).toBe(result.team.id);
|
||||
expect(membership?.role as TeamRole).toBe('owner');
|
||||
expect(membership?.status as TeamMembershipStatus).toBe('active');
|
||||
});
|
||||
|
||||
it('prevents driver from joining multiple teams and mirrors legacy error message', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
const otherTeamId = 'team-2';
|
||||
|
||||
// Seed an existing active membership
|
||||
membershipRepo.seedMembership({
|
||||
teamId: otherTeamId,
|
||||
driverId: ownerId,
|
||||
role: 'driver',
|
||||
status: 'active',
|
||||
joinedAt: new Date('2024-02-01'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
joinTeam.execute({ teamId: 'team-1', driverId: ownerId }),
|
||||
).rejects.toThrow('Driver already belongs to a team');
|
||||
});
|
||||
|
||||
it('approves a join request and moves it into active membership', async () => {
|
||||
const teamId = 'team-1';
|
||||
const driverId = 'driver-2';
|
||||
|
||||
const request: TeamJoinRequest = {
|
||||
id: 'req-1',
|
||||
teamId,
|
||||
driverId,
|
||||
requestedAt: new Date('2024-03-01'),
|
||||
message: 'Let me in',
|
||||
};
|
||||
membershipRepo.seedJoinRequest(request);
|
||||
|
||||
await approveJoin.execute({ requestId: request.id });
|
||||
|
||||
const membership = await membershipRepo.getMembership(teamId, driverId);
|
||||
expect(membership).not.toBeNull();
|
||||
expect(membership?.status as TeamMembershipStatus).toBe('active');
|
||||
|
||||
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
|
||||
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a join request and removes it', async () => {
|
||||
const teamId = 'team-1';
|
||||
const driverId = 'driver-2';
|
||||
|
||||
const request: TeamJoinRequest = {
|
||||
id: 'req-2',
|
||||
teamId,
|
||||
driverId,
|
||||
requestedAt: new Date('2024-03-02'),
|
||||
message: 'Please?',
|
||||
};
|
||||
membershipRepo.seedJoinRequest(request);
|
||||
|
||||
await rejectJoin.execute({ requestId: request.id });
|
||||
|
||||
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
|
||||
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates team details when performed by owner or manager and reflects in queries', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
const created = await createTeam.execute({
|
||||
name: 'Original Name',
|
||||
tag: 'ORIG',
|
||||
description: 'Original description',
|
||||
ownerId,
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
await updateTeamUseCase.execute({
|
||||
teamId: created.team.id,
|
||||
updates: { name: 'Updated Name', description: 'Updated description' },
|
||||
updatedBy: ownerId,
|
||||
});
|
||||
|
||||
const teamDetails = await getTeamDetailsQuery.execute({
|
||||
teamId: created.team.id,
|
||||
driverId: ownerId,
|
||||
});
|
||||
|
||||
expect(teamDetails.team.name).toBe('Updated Name');
|
||||
expect(teamDetails.team.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('returns driver team via query matching legacy getDriverTeam behavior', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
|
||||
const { team } = await createTeam.execute({
|
||||
name: 'Apex Racing',
|
||||
tag: 'APEX',
|
||||
description: 'Professional GT3 racing',
|
||||
ownerId,
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
const result = await getDriverTeamQuery.execute({ driverId: ownerId });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.team.id).toBe(team.id);
|
||||
expect(result?.membership.driverId).toBe(ownerId);
|
||||
});
|
||||
|
||||
it('lists all teams and members via queries after multiple operations', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
const otherDriverId = 'driver-2';
|
||||
|
||||
const { team } = await createTeam.execute({
|
||||
name: 'Apex Racing',
|
||||
tag: 'APEX',
|
||||
description: 'Professional GT3 racing',
|
||||
ownerId,
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
await joinTeam.execute({ teamId: team.id, driverId: otherDriverId });
|
||||
|
||||
const teams = await getAllTeamsQuery.execute();
|
||||
expect(teams.length).toBe(1);
|
||||
|
||||
const members = await getTeamMembersQuery.execute({ teamId: team.id });
|
||||
const memberIds = members.map((m) => m.driverId).sort();
|
||||
expect(memberIds).toEqual([ownerId, otherDriverId].sort());
|
||||
});
|
||||
});
|
||||
281
tests/unit/structure/packages/PackageDependencies.test.ts
Normal file
281
tests/unit/structure/packages/PackageDependencies.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '../../../..');
|
||||
const packagesRoot = path.join(repoRoot, 'packages');
|
||||
|
||||
type PackageKind =
|
||||
| 'racing-domain'
|
||||
| 'racing-application'
|
||||
| 'racing-infrastructure'
|
||||
| 'racing-demo-infrastructure'
|
||||
| 'other';
|
||||
|
||||
interface TsFile {
|
||||
filePath: string;
|
||||
kind: PackageKind;
|
||||
}
|
||||
|
||||
function classifyFile(filePath: string): PackageKind {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Bounded-context domain lives under packages/racing/domain
|
||||
if (normalized.includes('/packages/racing/domain/')) {
|
||||
return 'racing-domain';
|
||||
}
|
||||
if (normalized.includes('/packages/racing-application/')) {
|
||||
return 'racing-application';
|
||||
}
|
||||
if (normalized.includes('/packages/racing-infrastructure/')) {
|
||||
return 'racing-infrastructure';
|
||||
}
|
||||
if (normalized.includes('/packages/racing-demo-infrastructure/')) {
|
||||
return 'racing-demo-infrastructure';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function collectTsFiles(dir: string): TsFile[] {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const files: TsFile[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectTsFiles(fullPath));
|
||||
} else if (entry.isFile()) {
|
||||
if (
|
||||
entry.name.endsWith('.ts') ||
|
||||
entry.name.endsWith('.tsx')
|
||||
) {
|
||||
const kind = classifyFile(fullPath);
|
||||
if (kind !== 'other') {
|
||||
files.push({ filePath: fullPath, kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
interface ImportViolation {
|
||||
file: string;
|
||||
line: number;
|
||||
moduleSpecifier: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function extractImportModule(line: string): string | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('import')) return null;
|
||||
|
||||
// Handle: import ... from 'x';
|
||||
const fromMatch = trimmed.match(/from\s+['"](.*)['"]/);
|
||||
if (fromMatch) {
|
||||
return fromMatch[1];
|
||||
}
|
||||
|
||||
// Handle: import 'x';
|
||||
const sideEffectMatch = trimmed.match(/^import\s+['"](.*)['"]\s*;?$/);
|
||||
if (sideEffectMatch) {
|
||||
return sideEffectMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('Package dependency structure for racing slice', () => {
|
||||
const tsFiles = collectTsFiles(packagesRoot);
|
||||
|
||||
it('enforces import boundaries for racing-domain', () => {
|
||||
const violations: ImportViolation[] = [];
|
||||
|
||||
const forbiddenPrefixes = [
|
||||
'@gridpilot/racing-application',
|
||||
'@gridpilot/racing-infrastructure',
|
||||
'@gridpilot/racing-demo-infrastructure',
|
||||
'apps/',
|
||||
'@/',
|
||||
'react',
|
||||
'next',
|
||||
'electron',
|
||||
];
|
||||
|
||||
for (const { filePath, kind } of tsFiles) {
|
||||
if (kind !== 'racing-domain') continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const moduleSpecifier = extractImportModule(line);
|
||||
if (!moduleSpecifier) return;
|
||||
|
||||
for (const prefix of forbiddenPrefixes) {
|
||||
if (moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
line: index + 1,
|
||||
moduleSpecifier,
|
||||
reason: 'racing-domain must not depend on application, infrastructure, apps, or UI frameworks',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const message =
|
||||
'Found forbidden imports in racing domain layer (packages/racing/domain):\n' +
|
||||
violations
|
||||
.map(
|
||||
(v) =>
|
||||
`- ${v.file}:${v.line} :: import '${v.moduleSpecifier}' // ${v.reason}`,
|
||||
)
|
||||
.join('\n');
|
||||
expect(message).toBe('');
|
||||
} else {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces import boundaries for racing-application', () => {
|
||||
const violations: ImportViolation[] = [];
|
||||
|
||||
const forbiddenPrefixes = [
|
||||
'@gridpilot/racing-infrastructure',
|
||||
'@gridpilot/racing-demo-infrastructure',
|
||||
'apps/',
|
||||
'@/',
|
||||
];
|
||||
|
||||
const allowedPrefixes = [
|
||||
'@gridpilot/racing',
|
||||
'@gridpilot/shared-result',
|
||||
'@gridpilot/identity',
|
||||
];
|
||||
|
||||
for (const { filePath, kind } of tsFiles) {
|
||||
if (kind !== 'racing-application') continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const moduleSpecifier = extractImportModule(line);
|
||||
if (!moduleSpecifier) return;
|
||||
|
||||
for (const prefix of forbiddenPrefixes) {
|
||||
if (moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
line: index + 1,
|
||||
moduleSpecifier,
|
||||
reason: 'racing-application must not depend on infrastructure or apps',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleSpecifier.startsWith('@gridpilot/')) {
|
||||
const isAllowed = allowedPrefixes.some((prefix) =>
|
||||
moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix),
|
||||
);
|
||||
if (!isAllowed) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
line: index + 1,
|
||||
moduleSpecifier,
|
||||
reason: 'racing-application should only depend on domain, shared-result, or other domain packages',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const message =
|
||||
'Found forbidden imports in packages/racing-application:\n' +
|
||||
violations
|
||||
.map(
|
||||
(v) =>
|
||||
`- ${v.file}:${v.line} :: import '${v.moduleSpecifier}' // ${v.reason}`,
|
||||
)
|
||||
.join('\n');
|
||||
expect(message).toBe('');
|
||||
} else {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces import boundaries for racing infrastructure packages', () => {
|
||||
const violations: ImportViolation[] = [];
|
||||
|
||||
const forbiddenPrefixes = ['apps/', '@/'];
|
||||
|
||||
const allowedPrefixes = [
|
||||
'@gridpilot/racing',
|
||||
'@gridpilot/shared-result',
|
||||
'@gridpilot/demo-support',
|
||||
'@gridpilot/social',
|
||||
];
|
||||
|
||||
for (const { filePath, kind } of tsFiles) {
|
||||
if (
|
||||
kind !== 'racing-infrastructure' &&
|
||||
kind !== 'racing-demo-infrastructure'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const moduleSpecifier = extractImportModule(line);
|
||||
if (!moduleSpecifier) return;
|
||||
|
||||
for (const prefix of forbiddenPrefixes) {
|
||||
if (moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
line: index + 1,
|
||||
moduleSpecifier,
|
||||
reason: 'racing infrastructure must not depend on apps or @/ aliases',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleSpecifier.startsWith('@gridpilot/')) {
|
||||
const isAllowed = allowedPrefixes.some((prefix) =>
|
||||
moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix),
|
||||
);
|
||||
if (!isAllowed) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
line: index + 1,
|
||||
moduleSpecifier,
|
||||
reason: 'racing infrastructure should depend only on domain, shared-result, or demo-support',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const message =
|
||||
'Found forbidden imports in racing infrastructure packages:\n' +
|
||||
violations
|
||||
.map(
|
||||
(v) =>
|
||||
`- ${v.file}:${v.line} :: import '${v.moduleSpecifier}' // ${v.reason}`,
|
||||
)
|
||||
.join('\n');
|
||||
expect(message).toBe('');
|
||||
} else {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
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