This commit is contained in:
2025-12-04 11:54:42 +01:00
parent 9d5caa87f3
commit b7d5551ea7
223 changed files with 5473 additions and 885 deletions

View File

@@ -0,0 +1,98 @@
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
import { SessionLifetime } from '@gridpilot/automation/domain/value-objects/SessionLifetime';
/**
* Port for optional server-side session validation.
*/
export interface ISessionValidator {
validateSession(): Promise<Result<boolean>>;
}
/**
* Use case for checking if the user has a valid iRacing session.
*
* This validates the session before automation starts, allowing
* the system to prompt for re-authentication if needed.
*
* Implements hybrid validation strategy:
* - File-based validation (fast, always executed)
* - Optional server-side validation (slow, requires network)
*/
export class CheckAuthenticationUseCase {
constructor(
private readonly authService: IAuthenticationService,
private readonly sessionValidator?: ISessionValidator
) {}
/**
* Execute the authentication check.
*
* @param options Optional configuration for validation
* @returns Result containing the current AuthenticationState
*/
async execute(options?: {
requireServerValidation?: boolean;
verifyPageContent?: boolean;
}): Promise<Result<AuthenticationState>> {
// Step 1: File-based validation (fast)
const fileResult = await this.authService.checkSession();
if (fileResult.isErr()) {
return fileResult;
}
const fileState = fileResult.unwrap();
// Step 2: Check session expiry if authenticated
if (fileState === AuthenticationState.AUTHENTICATED) {
const expiryResult = await this.authService.getSessionExpiry();
if (expiryResult.isErr()) {
// Don't fail completely if we can't get expiry, use file-based state
return Result.ok(fileState);
}
const expiry = expiryResult.unwrap();
if (expiry !== null) {
try {
const sessionLifetime = new SessionLifetime(expiry);
if (sessionLifetime.isExpired()) {
return Result.ok(AuthenticationState.EXPIRED);
}
} catch {
// Invalid expiry date, treat as expired for safety
return Result.ok(AuthenticationState.EXPIRED);
}
}
}
// Step 3: Optional page content verification
if (options?.verifyPageContent && fileState === AuthenticationState.AUTHENTICATED) {
const pageResult = await this.authService.verifyPageAuthentication();
if (pageResult.isOk()) {
const browserState = pageResult.unwrap();
// If cookies valid but page shows login UI, session is expired
if (!browserState.isFullyAuthenticated()) {
return Result.ok(AuthenticationState.EXPIRED);
}
}
// Don't block on page verification errors, continue with file-based state
}
// Step 4: Optional server-side validation
if (this.sessionValidator && fileState === AuthenticationState.AUTHENTICATED) {
const serverResult = await this.sessionValidator.validateSession();
// Don't block on server validation errors
if (serverResult.isOk()) {
const isValid = serverResult.unwrap();
if (!isValid) {
return Result.ok(AuthenticationState.EXPIRED);
}
}
}
return Result.ok(fileState);
}
}

View File

@@ -0,0 +1,21 @@
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
/**
* Use case for clearing the user's session (logout).
*
* Removes stored browser context and cookies, effectively logging
* the user out. The next automation attempt will require re-authentication.
*/
export class ClearSessionUseCase {
constructor(private readonly authService: IAuthenticationService) {}
/**
* Execute the session clearing.
*
* @returns Result indicating success or failure
*/
async execute(): Promise<Result<void>> {
return this.authService.clearSession();
}
}

View File

@@ -0,0 +1,38 @@
import { Result } from '../../shared/result/Result';
import { RaceCreationResult } from '@gridpilot/automation/domain/value-objects/RaceCreationResult';
import type { ICheckoutService } from '../ports/ICheckoutService';
export class CompleteRaceCreationUseCase {
constructor(private readonly checkoutService: ICheckoutService) {}
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
if (!sessionId || sessionId.trim() === '') {
return Result.err(new Error('Session ID is required'));
}
const infoResult = await this.checkoutService.extractCheckoutInfo();
if (infoResult.isErr()) {
return Result.err(infoResult.unwrapErr());
}
const info = infoResult.unwrap();
if (!info.price) {
return Result.err(new Error('Could not extract price from checkout page'));
}
try {
const raceCreationResult = RaceCreationResult.create({
sessionId,
price: info.price.toDisplayString(),
timestamp: new Date(),
});
return Result.ok(raceCreationResult);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
return Result.err(err);
}
}
}

View File

@@ -0,0 +1,65 @@
import { Result } from '../../shared/result/Result';
import { ICheckoutService } from '../ports/ICheckoutService';
import { ICheckoutConfirmationPort } from '../ports/ICheckoutConfirmationPort';
import { CheckoutStateEnum } from '@gridpilot/automation/domain/value-objects/CheckoutState';
interface SessionMetadata {
sessionName: string;
trackId: string;
carIds: string[];
}
export class ConfirmCheckoutUseCase {
private static readonly DEFAULT_TIMEOUT_MS = 30000;
constructor(
private readonly checkoutService: ICheckoutService,
private readonly confirmationPort: ICheckoutConfirmationPort
) {}
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
const infoResult = await this.checkoutService.extractCheckoutInfo();
if (infoResult.isErr()) {
return Result.err(infoResult.unwrapErr());
}
const info = infoResult.unwrap();
if (info.state.getValue() === CheckoutStateEnum.INSUFFICIENT_FUNDS) {
return Result.err(new Error('Insufficient funds to complete checkout'));
}
if (!info.price) {
return Result.err(new Error('Could not extract price from checkout page'));
}
// Request confirmation via port with full checkout context
const confirmationResult = await this.confirmationPort.requestCheckoutConfirmation({
price: info.price,
state: info.state,
sessionMetadata: sessionMetadata || {
sessionName: 'Unknown Session',
trackId: 'unknown',
carIds: [],
},
timeoutMs: ConfirmCheckoutUseCase.DEFAULT_TIMEOUT_MS,
});
if (confirmationResult.isErr()) {
return Result.err(confirmationResult.unwrapErr());
}
const confirmation = confirmationResult.unwrap();
if (confirmation.isCancelled()) {
return Result.err(new Error('Checkout cancelled by user'));
}
if (confirmation.isTimeout()) {
return Result.err(new Error('Checkout confirmation timeout'));
}
return await this.checkoutService.proceedWithCheckout();
}
}

View File

@@ -0,0 +1,23 @@
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
/**
* Use case for initiating the manual login flow.
*
* Opens a visible browser window where the user can log into iRacing directly.
* GridPilot never sees the credentials - it only waits for the URL to change
* indicating successful login.
*/
export class InitiateLoginUseCase {
constructor(private readonly authService: IAuthenticationService) {}
/**
* Execute the login flow.
* Opens browser and waits for user to complete manual login.
*
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
*/
async execute(): Promise<Result<void>> {
return this.authService.initiateLogin();
}
}

View File

@@ -0,0 +1,44 @@
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
import { IAutomationEngine } from '../ports/IAutomationEngine';
import type { IBrowserAutomation } from '../ports/IScreenAutomation';
import { ISessionRepository } from '../ports/ISessionRepository';
export interface SessionDTO {
sessionId: string;
state: string;
currentStep: number;
config: HostedSessionConfig;
startedAt?: Date;
completedAt?: Date;
errorMessage?: string;
}
export class StartAutomationSessionUseCase {
constructor(
private readonly automationEngine: IAutomationEngine,
private readonly browserAutomation: IBrowserAutomation,
private readonly sessionRepository: ISessionRepository
) {}
async execute(config: HostedSessionConfig): Promise<SessionDTO> {
const session = AutomationSession.create(config);
const validationResult = await this.automationEngine.validateConfiguration(config);
if (!validationResult.isValid) {
throw new Error(validationResult.error);
}
await this.sessionRepository.save(session);
return {
sessionId: session.id,
state: session.state.value,
currentStep: session.currentStep.value,
config: session.config,
startedAt: session.startedAt,
completedAt: session.completedAt,
errorMessage: session.errorMessage,
};
}
}

View File

@@ -0,0 +1,30 @@
import { IAuthenticationService } from '../ports/IAuthenticationService';
import { Result } from '../../shared/result/Result';
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
/**
* Use case for verifying browser shows authenticated page state.
* Combines cookie validation with page content verification.
*/
export class VerifyAuthenticatedPageUseCase {
constructor(
private readonly authService: IAuthenticationService
) {}
async execute(): Promise<Result<BrowserAuthenticationState>> {
try {
const result = await this.authService.verifyPageAuthentication();
if (result.isErr()) {
const error = result.error ?? new Error('Page verification failed');
return Result.err<BrowserAuthenticationState>(error);
}
const browserState = result.unwrap();
return Result.ok<BrowserAuthenticationState>(browserState);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
}
}
}