move automation out of core

This commit is contained in:
2025-12-16 14:31:43 +01:00
parent 29dc11deb9
commit 29410708c8
145 changed files with 378 additions and 1532 deletions

View File

@@ -0,0 +1,363 @@
import { describe, it, expect } from 'vitest';
import { AutomationSession } from 'apps/companion/main/automation/domain/entities/AutomationSession';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
describe('AutomationSession Entity', () => {
describe('create', () => {
it('should create a new session with PENDING state', () => {
const config = {
sessionName: 'Test Race Session',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const session = AutomationSession.create(config);
expect(session.id).toBeDefined();
expect(session.currentStep.value).toBe(1);
expect(session.state.isPending()).toBe(true);
expect(session.config).toEqual(config);
});
it('should throw error for empty session name', () => {
const config = {
sessionName: '',
trackId: 'spa',
carIds: ['dallara-f3'],
};
expect(() => AutomationSession.create(config)).toThrow('Session name cannot be empty');
});
it('should throw error for missing track ID', () => {
const config = {
sessionName: 'Test Race',
trackId: '',
carIds: ['dallara-f3'],
};
expect(() => AutomationSession.create(config)).toThrow('Track ID is required');
});
it('should throw error for empty car list', () => {
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: [],
};
expect(() => AutomationSession.create(config)).toThrow('At least one car must be selected');
});
});
describe('start', () => {
it('should transition from PENDING to IN_PROGRESS', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
expect(session.state.isInProgress()).toBe(true);
expect(session.startedAt).toBeDefined();
});
it('should throw error when starting non-PENDING session', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
expect(() => session.start()).toThrow('Cannot start session that is not pending');
});
});
describe('transitionToStep', () => {
it('should advance to next step when in progress', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
session.transitionToStep(StepId.create(2));
expect(session.currentStep.value).toBe(2);
});
it('should throw error when transitioning while not in progress', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
expect(() => session.transitionToStep(StepId.create(2))).toThrow(
'Cannot transition steps when session is not in progress'
);
});
it('should throw error when skipping steps', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
expect(() => session.transitionToStep(StepId.create(3))).toThrow(
'Cannot skip steps - must transition sequentially'
);
});
it('should throw error when moving backward', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
session.transitionToStep(StepId.create(2));
expect(() => session.transitionToStep(StepId.create(1))).toThrow(
'Cannot move backward - steps must progress forward only'
);
});
it('should stop at step 17 (safety checkpoint)', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
// Advance through all steps to 17
for (let i = 2; i <= 17; i++) {
session.transitionToStep(StepId.create(i));
}
expect(session.currentStep.value).toBe(17);
expect(session.state.isStoppedAtStep18()).toBe(true);
expect(session.completedAt).toBeDefined();
});
});
describe('pause', () => {
it('should transition from IN_PROGRESS to PAUSED', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
session.pause();
expect(session.state.value).toBe('PAUSED');
});
it('should throw error when pausing non-in-progress session', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
expect(() => session.pause()).toThrow('Cannot pause session that is not in progress');
});
});
describe('resume', () => {
it('should transition from PAUSED to IN_PROGRESS', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
session.pause();
session.resume();
expect(session.state.isInProgress()).toBe(true);
});
it('should throw error when resuming non-paused session', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
expect(() => session.resume()).toThrow('Cannot resume session that is not paused');
});
});
describe('fail', () => {
it('should transition to FAILED state with error message', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
const errorMessage = 'Browser automation failed at step 5';
session.fail(errorMessage);
expect(session.state.isFailed()).toBe(true);
expect(session.errorMessage).toBe(errorMessage);
expect(session.completedAt).toBeDefined();
});
it('should allow failing from PENDING state', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.fail('Initialization failed');
expect(session.state.isFailed()).toBe(true);
});
it('should allow failing from PAUSED state', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
session.pause();
session.fail('Failed during pause');
expect(session.state.isFailed()).toBe(true);
});
it('should throw error when failing already completed session', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
// Advance to step 17
for (let i = 2; i <= 17; i++) {
session.transitionToStep(StepId.create(i));
}
expect(() => session.fail('Too late')).toThrow('Cannot fail terminal session');
});
});
describe('isAtModalStep', () => {
it('should return true when at step 6', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
for (let i = 2; i <= 6; i++) {
session.transitionToStep(StepId.create(i));
}
expect(session.isAtModalStep()).toBe(true);
});
it('should return true when at step 9', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
for (let i = 2; i <= 9; i++) {
session.transitionToStep(StepId.create(i));
}
expect(session.isAtModalStep()).toBe(true);
});
it('should return true when at step 12', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
for (let i = 2; i <= 12; i++) {
session.transitionToStep(StepId.create(i));
}
expect(session.isAtModalStep()).toBe(true);
});
it('should return false when at non-modal step', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
expect(session.isAtModalStep()).toBe(false);
});
});
describe('getElapsedTime', () => {
it('should return 0 for non-started session', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
expect(session.getElapsedTime()).toBe(0);
});
it('should return elapsed milliseconds for in-progress session', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
// Wait a bit (in real test, this would be mocked)
const elapsed = session.getElapsedTime();
expect(elapsed).toBeGreaterThan(0);
});
it('should return total duration for completed session', () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
// Advance to step 17
for (let i = 2; i <= 17; i++) {
session.transitionToStep(StepId.create(i));
}
const elapsed = session.getElapsedTime();
expect(elapsed).toBeGreaterThan(0);
expect(session.state.isStoppedAtStep18()).toBe(true);
});
});
});

View File

@@ -0,0 +1,146 @@
import { randomUUID } from 'crypto';
import type { IEntity } from '@core/shared/domain';
import { StepId } from '../value-objects/StepId';
import type { HostedSessionConfig } from '../types/HostedSessionConfig';
import { AutomationDomainError } from '../errors/AutomationDomainError';
import { SessionState } from '../value-objects/SessionState';
export class AutomationSession implements IEntity<string> {
private readonly _id: string;
private _currentStep: StepId;
private _state: SessionState;
private readonly _config: HostedSessionConfig;
private _startedAt?: Date;
private _completedAt?: Date;
private _errorMessage?: string;
private constructor(
id: string,
currentStep: StepId,
state: SessionState,
config: HostedSessionConfig
) {
this._id = id;
this._currentStep = currentStep;
this._state = state;
this._config = config;
}
static create(config: HostedSessionConfig): AutomationSession {
if (!config.sessionName || config.sessionName.trim() === '') {
throw new AutomationDomainError('Session name cannot be empty');
}
if (!config.trackId || config.trackId.trim() === '') {
throw new AutomationDomainError('Track ID is required');
}
if (!config.carIds || config.carIds.length === 0) {
throw new AutomationDomainError('At least one car must be selected');
}
return new AutomationSession(
randomUUID(),
StepId.create(1),
SessionState.create('PENDING'),
config
);
}
get id(): string {
return this._id;
}
get currentStep(): StepId {
return this._currentStep;
}
get state(): SessionState {
return this._state;
}
get config(): HostedSessionConfig {
return this._config;
}
get startedAt(): Date | undefined {
return this._startedAt;
}
get completedAt(): Date | undefined {
return this._completedAt;
}
get errorMessage(): string | undefined {
return this._errorMessage;
}
start(): void {
if (!this._state.isPending()) {
throw new AutomationDomainError('Cannot start session that is not pending');
}
this._state = SessionState.create('IN_PROGRESS');
this._startedAt = new Date();
}
transitionToStep(targetStep: StepId): void {
if (!this._state.isInProgress()) {
throw new AutomationDomainError('Cannot transition steps when session is not in progress');
}
if (this._currentStep.equals(targetStep)) {
throw new AutomationDomainError('Already at this step');
}
if (targetStep.value < this._currentStep.value) {
throw new AutomationDomainError('Cannot move backward - steps must progress forward only');
}
if (targetStep.value !== this._currentStep.value + 1) {
throw new AutomationDomainError('Cannot skip steps - must transition sequentially');
}
this._currentStep = targetStep;
if (this._currentStep.isFinalStep()) {
this._state = SessionState.create('STOPPED_AT_STEP_18');
this._completedAt = new Date();
}
}
pause(): void {
if (!this._state.isInProgress()) {
throw new AutomationDomainError('Cannot pause session that is not in progress');
}
this._state = SessionState.create('PAUSED');
}
resume(): void {
if (this._state.value !== 'PAUSED') {
throw new AutomationDomainError('Cannot resume session that is not paused');
}
this._state = SessionState.create('IN_PROGRESS');
}
fail(errorMessage: string): void {
if (this._state.isTerminal()) {
throw new AutomationDomainError('Cannot fail terminal session');
}
this._state = SessionState.create('FAILED');
this._errorMessage = errorMessage;
this._completedAt = new Date();
}
isAtModalStep(): boolean {
return this._currentStep.isModalStep();
}
getElapsedTime(): number {
if (!this._startedAt) {
return 0;
}
const endTime = this._completedAt || new Date();
const elapsed = endTime.getTime() - this._startedAt.getTime();
return elapsed > 0 ? elapsed : 1;
}
}

View File

@@ -0,0 +1,15 @@
import type { StepId } from '../value-objects/StepId';
/**
* Domain Type: StepExecution
*
* Represents execution metadata for a single automation step.
* This is a pure data shape (DTO-like), not an entity or value object.
*/
export interface StepExecution {
stepId: StepId;
startedAt: Date;
completedAt?: Date;
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,19 @@
import type { IDomainError } from '@core/shared/errors';
/**
* Domain Error: AutomationDomainError
*
* Implements the shared IDomainError contract for automation domain failures.
*/
export class AutomationDomainError extends Error implements IDomainError {
readonly name = 'AutomationDomainError';
readonly type = 'domain' as const;
readonly context = 'automation';
readonly kind: string;
constructor(message: string, kind: string = 'validation') {
super(message);
this.kind = kind;
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import { PageStateValidator } from 'apps/companion/main/automation/domain/services/PageStateValidator';
describe('PageStateValidator', () => {
const validator = new PageStateValidator();
describe('validateState', () => {
it('should return valid when all required selectors are present', () => {
// Arrange
const actualState = (selector: string) => {
return ['#add-car-button', '#cars-list'].includes(selector);
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button', '#cars-list']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(true);
expect(value.expectedStep).toBe('cars');
expect(value.message).toContain('Page state valid');
});
it('should return invalid when required selectors are missing', () => {
// Arrange
const actualState = (selector: string) => {
return selector === '#add-car-button'; // Only one of two selectors present
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button', '#cars-list']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.expectedStep).toBe('cars');
expect(value.missingSelectors).toEqual(['#cars-list']);
expect(value.message).toContain('missing required elements');
});
it('should return invalid when forbidden selectors are present', () => {
// Arrange
const actualState = (selector: string) => {
return ['#add-car-button', '#set-track'].includes(selector);
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button'],
forbiddenSelectors: ['#set-track'] // Should NOT be on track page yet
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.expectedStep).toBe('cars');
expect(value.unexpectedSelectors).toEqual(['#set-track']);
expect(value.message).toContain('unexpected elements');
});
it('should handle empty forbidden selectors array', () => {
// Arrange
const actualState = (selector: string) => {
return selector === '#add-car-button';
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button'],
forbiddenSelectors: []
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(true);
});
it('should handle undefined forbidden selectors', () => {
// Arrange
const actualState = (selector: string) => {
return selector === '#add-car-button';
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button']
// forbiddenSelectors is undefined
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(true);
});
it('should return error result when actualState function throws', () => {
// Arrange
const actualState = () => {
throw new Error('Selector evaluation failed');
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button']
});
// Assert
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.message).toContain('Selector evaluation failed');
});
it('should provide clear error messages for missing selectors', () => {
// Arrange
const actualState = () => false; // Nothing present
// Act
const result = validator.validateState(actualState, {
expectedStep: 'track',
requiredSelectors: ['#set-track', '#track-search']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.message).toBe('Page state mismatch: Expected to be on "track" page but missing required elements');
expect(value.missingSelectors).toEqual(['#set-track', '#track-search']);
});
it('should validate complex state with both required and forbidden selectors', () => {
// Arrange - Simulate being on Cars page but Track page elements leaked through
const actualState = (selector: string) => {
const presentSelectors = ['#add-car-button', '#cars-list', '#set-track'];
return presentSelectors.includes(selector);
};
// Act
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#add-car-button', '#cars-list'],
forbiddenSelectors: ['#set-track', '#track-search']
});
// Assert
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false); // Invalid due to forbidden selector
expect(value.unexpectedSelectors).toEqual(['#set-track']);
expect(value.message).toContain('unexpected elements');
});
});
});

View File

@@ -0,0 +1,242 @@
import type { IDomainValidationService } from '@core/shared/domain';
import { Result } from '@gridpilot/shared/result/Result';
/**
* Configuration for page state validation.
* Defines expected and forbidden elements on the current page.
*/
export interface PageStateValidation {
/** Expected wizard step name (e.g., 'cars', 'track') */
expectedStep: string;
/** Selectors that MUST be present on the page */
requiredSelectors: string[];
/** Selectors that MUST NOT be present on the page */
forbiddenSelectors?: string[];
}
/**
* Result of page state validation.
*/
export interface PageStateValidationResult {
isValid: boolean;
message: string;
expectedStep: string;
missingSelectors?: string[];
unexpectedSelectors?: string[];
}
export interface PageStateValidationInput {
actualState: (_selector: string) => boolean;
validation: PageStateValidation;
realMode?: boolean;
}
/**
* Domain service for validating page state during wizard navigation.
*
* Purpose: Prevent navigation bugs by ensuring each step executes on the correct page.
*
* Clean Architecture: This is pure domain logic with no infrastructure dependencies.
* It validates state based on selector presence/absence without knowing HOW to check them.
*/
export class PageStateValidator
implements
IDomainValidationService<PageStateValidationInput, PageStateValidationResult, Error>
{
validate(input: PageStateValidationInput): Result<PageStateValidationResult, Error> {
const { actualState, validation, realMode } = input;
if (typeof realMode === 'boolean') {
return this.validateStateEnhanced(actualState, validation, realMode);
}
return this.validateState(actualState, validation);
}
/**
* Validate that the page state matches expected conditions.
*
* @param actualState Function that checks if selectors exist on the page
* @param validation Expected page state configuration
* @returns Result with validation outcome
*/
validateState(
actualState: (_selector: string) => boolean,
validation: PageStateValidation
): Result<PageStateValidationResult, Error> {
try {
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
// Check required selectors are present
const missingSelectors = requiredSelectors.filter(_selector => !actualState(_selector));
if (missingSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
expectedStep,
missingSelectors
};
return Result.ok(result);
}
// Check forbidden selectors are absent
const unexpectedSelectors = forbiddenSelectors.filter(_selector => actualState(_selector));
if (unexpectedSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
expectedStep,
unexpectedSelectors
};
return Result.ok(result);
}
// All checks passed
const result: PageStateValidationResult = {
isValid: true,
message: `Page state valid for "${expectedStep}"`,
expectedStep
};
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
);
}
}
/**
* Enhanced validation that tries multiple selector strategies for real iRacing HTML.
* This handles the mismatch between test expectations (data-indicator attributes)
* and real HTML structure (Chakra UI components).
*
* @param actualState Function that checks if selectors exist on the page
* @param validation Expected page state configuration
* @param realMode Whether we're in real mode (using real HTML dumps) or mock mode
* @returns Result with validation outcome
*/
validateStateEnhanced(
actualState: (_selector: string) => boolean,
validation: PageStateValidation,
realMode: boolean = false
): Result<PageStateValidationResult, Error> {
try {
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
// In real mode, try to match the actual HTML structure with fallbacks
let selectorsToCheck = [...requiredSelectors];
if (realMode) {
// Add fallback selectors for real iRacing HTML (Chakra UI structure)
const fallbackMap: Record<string, string[]> = {
cars: [
'#set-cars',
'[id*="cars"]',
'.wizard-step[id*="cars"]',
'.cars-panel',
// Real iRacing fallbacks - use step container IDs
'[data-testid*="set-cars"]',
'.chakra-stack:has([data-testid*="cars"])',
],
track: [
'#set-track',
'[id*="track"]',
'.wizard-step[id*="track"]',
'.track-panel',
// Real iRacing fallbacks
'[data-testid*="set-track"]',
'.chakra-stack:has([data-testid*="track"])',
],
'add-car': [
'a.btn:has-text("Add a Car")',
'.btn:has-text("Add a Car")',
'[data-testid*="add-car"]',
// Real iRacing button selectors
'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Car")',
],
};
// For each required selector, add fallbacks
const enhancedSelectors: string[] = [];
for (const selector of requiredSelectors) {
enhancedSelectors.push(selector);
// Add step-specific fallbacks
const lowerStep = expectedStep.toLowerCase();
if (fallbackMap[lowerStep]) {
enhancedSelectors.push(...fallbackMap[lowerStep]);
}
// Generic Chakra UI fallbacks for wizard steps
if (selector.includes('data-indicator')) {
enhancedSelectors.push(
`[id*="${expectedStep}"]`,
`[data-testid*="${expectedStep}"]`,
`.wizard-step:has([data-testid*="${expectedStep}"])`,
);
}
}
selectorsToCheck = enhancedSelectors;
}
// Check required selectors are present (with fallbacks for real mode)
const missingSelectors = requiredSelectors.filter(selector => {
if (realMode) {
const relatedSelectors = selectorsToCheck.filter(s =>
s.includes(expectedStep) ||
s.includes(
selector
.replace(/[\[\]"']/g, '')
.replace('data-indicator=', ''),
),
);
if (relatedSelectors.length === 0) {
return !actualState(selector);
}
return !relatedSelectors.some(s => actualState(s));
}
return !actualState(selector);
});
if (missingSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
expectedStep,
missingSelectors
};
return Result.ok(result);
}
// Check forbidden selectors are absent
const unexpectedSelectors = forbiddenSelectors.filter(_selector => actualState(_selector));
if (unexpectedSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
expectedStep,
unexpectedSelectors
};
return Result.ok(result);
}
// All checks passed
const result: PageStateValidationResult = {
isValid: true,
message: `Page state valid for "${expectedStep}"`,
expectedStep
};
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
);
}
}
}

View File

@@ -0,0 +1,105 @@
import { StepId } from '../value-objects/StepId';
import type { IDomainValidationService } from '@core/shared/domain';
import { Result } from '@gridpilot/shared/result/Result';
import { SessionState } from '../value-objects/SessionState';
export interface ValidationResult {
isValid: boolean;
error?: string;
}
export interface StepTransitionValidationInput {
currentStep: StepId;
nextStep: StepId;
state: SessionState;
}
export interface StepTransitionValidationResult extends ValidationResult {}
const STEP_DESCRIPTIONS: Record<number, string> = {
1: 'Navigate to Hosted Racing page',
2: 'Click Create a Race',
3: 'Fill Race Information',
4: 'Configure Server Details',
5: 'Set Admins',
6: 'Add Admin (Modal)',
7: 'Set Time Limits',
8: 'Set Cars',
9: 'Add a Car (Modal)',
10: 'Set Car Classes',
11: 'Set Track',
12: 'Add a Track (Modal)',
13: 'Configure Track Options',
14: 'Set Time of Day',
15: 'Configure Weather',
16: 'Set Race Options',
17: 'Track Conditions (STOP - Manual Submit Required)',
};
export class StepTransitionValidator
implements
IDomainValidationService<StepTransitionValidationInput, StepTransitionValidationResult, Error>
{
validate(input: StepTransitionValidationInput): Result<StepTransitionValidationResult, Error> {
try {
const { currentStep, nextStep, state } = input;
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Step transition validation failed: ${String(error)}`),
);
}
}
static canTransition(
currentStep: StepId,
nextStep: StepId,
state: SessionState
): ValidationResult {
if (!state.isInProgress()) {
return {
isValid: false,
error: 'Session must be in progress to transition steps',
};
}
if (currentStep.equals(nextStep)) {
return {
isValid: false,
error: 'Already at this step',
};
}
if (nextStep.value < currentStep.value) {
return {
isValid: false,
error: 'Cannot move backward - steps must progress forward only',
};
}
if (nextStep.value !== currentStep.value + 1) {
return {
isValid: false,
error: 'Cannot skip steps - must progress sequentially',
};
}
return { isValid: true };
}
static validateModalStepTransition(
): ValidationResult {
return { isValid: true };
}
static shouldStopAtStep18(nextStep: StepId): boolean {
return nextStep.isFinalStep();
}
static getStepDescription(step: StepId): string {
return STEP_DESCRIPTIONS[step.value] || `Step ${step.value}`;
}
}

View File

@@ -0,0 +1,34 @@
/**
* Domain Type: HostedSessionConfig
*
* Pure configuration shape for an iRacing hosted session.
* This is a DTO-like domain type, not a value object or entity.
*/
export interface HostedSessionConfig {
sessionName: string;
trackId: string;
carIds: string[];
// Optional fields for extended configuration.
serverName?: string;
password?: string;
adminPassword?: string;
maxDrivers?: number;
/** Search term for car selection (alternative to carIds) */
carSearch?: string;
/** Search term for track selection (alternative to trackId) */
trackSearch?: string;
weatherType?: 'static' | 'dynamic';
timeOfDay?: 'morning' | 'afternoon' | 'evening' | 'night';
sessionDuration?: number;
practiceLength?: number;
qualifyingLength?: number;
warmupLength?: number;
raceLength?: number;
startType?: 'standing' | 'rolling';
restarts?: 'single-file' | 'double-file';
damageModel?: 'off' | 'limited' | 'realistic';
trackState?: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum';
}

View File

@@ -0,0 +1,88 @@
/**
* Domain Types: ScreenRegion, Point, ElementLocation, LoginDetectionResult
*
* These are pure data shapes and helpers used across automation.
*/
export interface ScreenRegion {
x: number;
y: number;
width: number;
height: number;
}
/**
* Represents a point on the screen with x,y coordinates.
*/
export interface Point {
x: number;
y: number;
}
/**
* Represents the location of a detected UI element on screen.
* Contains the center point, bounding box, and confidence score.
*/
export interface ElementLocation {
center: Point;
bounds: ScreenRegion;
confidence: number;
}
/**
* Result of login state detection via screen recognition.
*/
export interface LoginDetectionResult {
isLoggedIn: boolean;
confidence: number;
detectedIndicators: string[];
error?: string;
}
/**
* Create a ScreenRegion from coordinates.
*/
export function createScreenRegion(x: number, y: number, width: number, height: number): ScreenRegion {
return { x, y, width, height };
}
/**
* Create a Point from coordinates.
*/
export function createPoint(x: number, y: number): Point {
return { x, y };
}
/**
* Calculate the center point of a ScreenRegion.
*/
export function getRegionCenter(region: ScreenRegion): Point {
return {
x: region.x + Math.floor(region.width / 2),
y: region.y + Math.floor(region.height / 2),
};
}
/**
* Check if a point is within a screen region.
*/
export function isPointInRegion(point: Point, region: ScreenRegion): boolean {
return (
point.x >= region.x &&
point.x <= region.x + region.width &&
point.y >= region.y &&
point.y <= region.y + region.height
);
}
/**
* Check if two screen regions overlap.
*/
export function regionsOverlap(a: ScreenRegion, b: ScreenRegion): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
);
}

View File

@@ -0,0 +1,18 @@
/**
* Value object representing the user's authentication state with iRacing.
*
* This is used to track whether the user has a valid session for automation
* without GridPilot ever seeing or storing credentials (zero-knowledge design).
*/
export const AuthenticationState = {
/** Authentication status has not yet been checked */
UNKNOWN: 'UNKNOWN',
/** Valid session exists and is ready for automation */
AUTHENTICATED: 'AUTHENTICATED',
/** Session was valid but has expired, re-authentication required */
EXPIRED: 'EXPIRED',
/** User explicitly logged out, clearing the session */
LOGGED_OUT: 'LOGGED_OUT',
} as const;
export type AuthenticationState = typeof AuthenticationState[keyof typeof AuthenticationState];

View File

@@ -0,0 +1,111 @@
import { describe, test, expect } from 'vitest';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';
describe('BrowserAuthenticationState', () => {
describe('isFullyAuthenticated()', () => {
test('should return true when both cookies and page authenticated', () => {
const state = new BrowserAuthenticationState(true, true);
expect(state.isFullyAuthenticated()).toBe(true);
});
test('should return false when cookies valid but page unauthenticated', () => {
const state = new BrowserAuthenticationState(true, false);
expect(state.isFullyAuthenticated()).toBe(false);
});
test('should return false when cookies invalid but page authenticated', () => {
const state = new BrowserAuthenticationState(false, true);
expect(state.isFullyAuthenticated()).toBe(false);
});
test('should return false when both cookies and page unauthenticated', () => {
const state = new BrowserAuthenticationState(false, false);
expect(state.isFullyAuthenticated()).toBe(false);
});
});
describe('getAuthenticationState()', () => {
test('should return AUTHENTICATED when both cookies and page authenticated', () => {
const state = new BrowserAuthenticationState(true, true);
expect(state.getAuthenticationState()).toBe(AuthenticationState.AUTHENTICATED);
});
test('should return EXPIRED when cookies valid but page unauthenticated', () => {
const state = new BrowserAuthenticationState(true, false);
expect(state.getAuthenticationState()).toBe(AuthenticationState.EXPIRED);
});
test('should return UNKNOWN when cookies invalid', () => {
const state = new BrowserAuthenticationState(false, false);
expect(state.getAuthenticationState()).toBe(AuthenticationState.UNKNOWN);
});
test('should return UNKNOWN when cookies invalid regardless of page state', () => {
const state = new BrowserAuthenticationState(false, true);
expect(state.getAuthenticationState()).toBe(AuthenticationState.UNKNOWN);
});
});
describe('requiresReauthentication()', () => {
test('should return false when fully authenticated', () => {
const state = new BrowserAuthenticationState(true, true);
expect(state.requiresReauthentication()).toBe(false);
});
test('should return true when cookies valid but page unauthenticated', () => {
const state = new BrowserAuthenticationState(true, false);
expect(state.requiresReauthentication()).toBe(true);
});
test('should return true when cookies invalid', () => {
const state = new BrowserAuthenticationState(false, false);
expect(state.requiresReauthentication()).toBe(true);
});
test('should return true when cookies invalid but page authenticated', () => {
const state = new BrowserAuthenticationState(false, true);
expect(state.requiresReauthentication()).toBe(true);
});
});
describe('getCookieValidity()', () => {
test('should return true when cookies are valid', () => {
const state = new BrowserAuthenticationState(true, true);
expect(state.getCookieValidity()).toBe(true);
});
test('should return false when cookies are invalid', () => {
const state = new BrowserAuthenticationState(false, false);
expect(state.getCookieValidity()).toBe(false);
});
});
describe('getPageAuthenticationStatus()', () => {
test('should return true when page is authenticated', () => {
const state = new BrowserAuthenticationState(true, true);
expect(state.getPageAuthenticationStatus()).toBe(true);
});
test('should return false when page is unauthenticated', () => {
const state = new BrowserAuthenticationState(true, false);
expect(state.getPageAuthenticationStatus()).toBe(false);
});
});
});

View File

@@ -0,0 +1,58 @@
import { AuthenticationState } from './AuthenticationState';
import type { IValueObject } from '@core/shared/domain';
export interface BrowserAuthenticationStateProps {
cookiesValid: boolean;
pageAuthenticated: boolean;
}
export class BrowserAuthenticationState implements IValueObject<BrowserAuthenticationStateProps> {
private readonly cookiesValid: boolean;
private readonly pageAuthenticated: boolean;
constructor(cookiesValid: boolean, pageAuthenticated: boolean) {
this.cookiesValid = cookiesValid;
this.pageAuthenticated = pageAuthenticated;
}
isFullyAuthenticated(): boolean {
return this.cookiesValid && this.pageAuthenticated;
}
getAuthenticationState(): AuthenticationState {
if (!this.cookiesValid) {
return AuthenticationState.UNKNOWN;
}
if (!this.pageAuthenticated) {
return AuthenticationState.EXPIRED;
}
return AuthenticationState.AUTHENTICATED;
}
requiresReauthentication(): boolean {
return !this.isFullyAuthenticated();
}
getCookieValidity(): boolean {
return this.cookiesValid;
}
getPageAuthenticationStatus(): boolean {
return this.pageAuthenticated;
}
get props(): BrowserAuthenticationStateProps {
return {
cookiesValid: this.cookiesValid,
pageAuthenticated: this.pageAuthenticated,
};
}
equals(other: IValueObject<BrowserAuthenticationStateProps>): boolean {
const a = this.props;
const b = other.props;
return a.cookiesValid === b.cookiesValid && a.pageAuthenticated === b.pageAuthenticated;
}
}

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { CheckoutConfirmation } from 'apps/companion/main/automation/domain/value-objects/CheckoutConfirmation';
describe('CheckoutConfirmation Value Object', () => {
describe('create', () => {
it('should create confirmed decision', () => {
const confirmation = CheckoutConfirmation.create('confirmed');
expect(confirmation.value).toBe('confirmed');
});
it('should create cancelled decision', () => {
const confirmation = CheckoutConfirmation.create('cancelled');
expect(confirmation.value).toBe('cancelled');
});
it('should create timeout decision', () => {
const confirmation = CheckoutConfirmation.create('timeout');
expect(confirmation.value).toBe('timeout');
});
it('should throw error for invalid decision', () => {
// @ts-expect-error Testing invalid input
expect(() => CheckoutConfirmation.create('invalid')).toThrow(
'Invalid checkout confirmation decision',
);
});
});
describe('isConfirmed', () => {
it('should return true for confirmed decision', () => {
const confirmation = CheckoutConfirmation.create('confirmed');
expect(confirmation.isConfirmed()).toBe(true);
});
it('should return false for cancelled decision', () => {
const confirmation = CheckoutConfirmation.create('cancelled');
expect(confirmation.isConfirmed()).toBe(false);
});
it('should return false for timeout decision', () => {
const confirmation = CheckoutConfirmation.create('timeout');
expect(confirmation.isConfirmed()).toBe(false);
});
});
describe('isCancelled', () => {
it('should return true for cancelled decision', () => {
const confirmation = CheckoutConfirmation.create('cancelled');
expect(confirmation.isCancelled()).toBe(true);
});
it('should return false for confirmed decision', () => {
const confirmation = CheckoutConfirmation.create('confirmed');
expect(confirmation.isCancelled()).toBe(false);
});
it('should return false for timeout decision', () => {
const confirmation = CheckoutConfirmation.create('timeout');
expect(confirmation.isCancelled()).toBe(false);
});
});
describe('isTimeout', () => {
it('should return true for timeout decision', () => {
const confirmation = CheckoutConfirmation.create('timeout');
expect(confirmation.isTimeout()).toBe(true);
});
it('should return false for confirmed decision', () => {
const confirmation = CheckoutConfirmation.create('confirmed');
expect(confirmation.isTimeout()).toBe(false);
});
it('should return false for cancelled decision', () => {
const confirmation = CheckoutConfirmation.create('cancelled');
expect(confirmation.isTimeout()).toBe(false);
});
});
describe('equals', () => {
it('should return true for equal confirmations', () => {
const confirmation1 = CheckoutConfirmation.create('confirmed');
const confirmation2 = CheckoutConfirmation.create('confirmed');
expect(confirmation1.equals(confirmation2)).toBe(true);
});
it('should return false for different confirmations', () => {
const confirmation1 = CheckoutConfirmation.create('confirmed');
const confirmation2 = CheckoutConfirmation.create('cancelled');
expect(confirmation1.equals(confirmation2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,54 @@
export type CheckoutConfirmationDecision = 'confirmed' | 'cancelled' | 'timeout';
const VALID_DECISIONS: CheckoutConfirmationDecision[] = [
'confirmed',
'cancelled',
'timeout',
];
export class CheckoutConfirmation {
private readonly _value: CheckoutConfirmationDecision;
private constructor(value: CheckoutConfirmationDecision) {
this._value = value;
}
static create(value: CheckoutConfirmationDecision): CheckoutConfirmation {
if (!VALID_DECISIONS.includes(value)) {
throw new Error('Invalid checkout confirmation decision');
}
return new CheckoutConfirmation(value);
}
static confirmed(): CheckoutConfirmation {
return CheckoutConfirmation.create('confirmed');
}
static cancelled(): CheckoutConfirmation {
return CheckoutConfirmation.create('cancelled');
}
static timeout(): CheckoutConfirmation {
return CheckoutConfirmation.create('timeout');
}
get value(): CheckoutConfirmationDecision {
return this._value;
}
equals(other: CheckoutConfirmation): boolean {
return this._value === other._value;
}
isConfirmed(): boolean {
return this._value === 'confirmed';
}
isCancelled(): boolean {
return this._value === 'cancelled';
}
isTimeout(): boolean {
return this._value === 'timeout';
}
}

View File

@@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import { CheckoutPrice } from 'apps/companion/main/automation/domain/value-objects/CheckoutPrice';
/**
* CheckoutPrice Value Object - GREEN PHASE
*
* Tests for price validation, parsing, and formatting.
*/
describe('CheckoutPrice Value Object', () => {
describe('Construction', () => {
it('should create with valid price $0.50', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(0.50)).not.toThrow();
});
it('should create with valid price $10.00', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(10.00)).not.toThrow();
});
it('should create with valid price $100.00', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(100.00)).not.toThrow();
});
it('should reject negative prices', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(-0.50)).toThrow(/negative/i);
});
it('should reject excessive prices over $10,000', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(10000.01)).toThrow(/excessive|maximum/i);
});
it('should accept exactly $10,000', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(10000.00)).not.toThrow();
});
it('should accept $0.00 (zero price)', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(0.00)).not.toThrow();
});
});
describe('fromString() parsing', () => {
it('should extract $0.50 from string', () => {
const price = CheckoutPrice.fromString('$0.50');
expect(price.getAmount()).toBe(0.50);
});
it('should extract $10.00 from string', () => {
const price = CheckoutPrice.fromString('$10.00');
expect(price.getAmount()).toBe(10.00);
});
it('should extract $100.00 from string', () => {
const price = CheckoutPrice.fromString('$100.00');
expect(price.getAmount()).toBe(100.00);
});
it('should reject string without dollar sign', () => {
expect(() => CheckoutPrice.fromString('10.00')).toThrow(/invalid.*format/i);
});
it('should reject string with multiple dollar signs', () => {
expect(() => CheckoutPrice.fromString('$$10.00')).toThrow(/invalid.*format/i);
});
it('should reject non-numeric values', () => {
expect(() => CheckoutPrice.fromString('$abc')).toThrow(/invalid.*format/i);
});
it('should reject empty string', () => {
expect(() => CheckoutPrice.fromString('')).toThrow(/invalid.*format/i);
});
it('should handle prices with commas $1,000.00', () => {
const price = CheckoutPrice.fromString('$1,000.00');
expect(price.getAmount()).toBe(1000.00);
});
it('should handle whitespace around price', () => {
const price = CheckoutPrice.fromString(' $5.00 ');
expect(price.getAmount()).toBe(5.00);
});
});
describe('Display formatting', () => {
it('should format $0.50 as "$0.50"', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(0.50);
expect(price.toDisplayString()).toBe('$0.50');
});
it('should format $10.00 as "$10.00"', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(10.00);
expect(price.toDisplayString()).toBe('$10.00');
});
it('should format $100.00 as "$100.00"', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(100.00);
expect(price.toDisplayString()).toBe('$100.00');
});
it('should always show two decimal places', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(5);
expect(price.toDisplayString()).toBe('$5.00');
});
it('should round to two decimal places', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(5.129);
expect(price.toDisplayString()).toBe('$5.13');
});
});
describe('Zero check', () => {
it('should detect $0.00 correctly', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(0.00);
expect(price.isZero()).toBe(true);
});
it('should return false for non-zero prices', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(0.50);
expect(price.isZero()).toBe(false);
});
it('should handle floating point precision for zero', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(0.0000001);
expect(price.isZero()).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle very small prices $0.01', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(0.01);
expect(price.toDisplayString()).toBe('$0.01');
});
it('should handle large prices $9,999.99', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(9999.99);
expect(price.toDisplayString()).toBe('$9999.99');
});
it('should be immutable after creation', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(5.00);
const amount = price.getAmount();
expect(amount).toBe(5.00);
// Verify no setters exist
const mutablePrice = price as unknown as { setAmount?: unknown };
expect(typeof mutablePrice.setAmount).toBe('undefined');
});
});
describe('BDD Scenarios', () => {
it('Given price string "$0.50", When parsing, Then amount is 0.50', () => {
const price = CheckoutPrice.fromString('$0.50');
expect(price.getAmount()).toBe(0.50);
});
it('Given amount 10.00, When formatting, Then display is "$10.00"', () => {
// @ts-expect-error Testing private constructor invariants
const price = new CheckoutPrice(10.00);
expect(price.toDisplayString()).toBe('$10.00');
});
it('Given negative amount, When constructing, Then error is thrown', () => {
// @ts-expect-error Testing private constructor invariants
expect(() => new CheckoutPrice(-5.00)).toThrow();
});
});
});

View File

@@ -0,0 +1,73 @@
import type { IValueObject } from '@core/shared/domain';
export interface CheckoutPriceProps {
amountUsd: number;
}
export class CheckoutPrice implements IValueObject<CheckoutPriceProps> {
private constructor(private readonly amountUsd: number) {
if (amountUsd < 0) {
throw new Error('Price cannot be negative');
}
if (amountUsd > 10000) {
throw new Error('Price exceeds maximum of $10,000');
}
}
static fromString(priceStr: string): CheckoutPrice {
const trimmed = priceStr.trim();
if (!trimmed.startsWith('$')) {
throw new Error('Invalid price format: missing dollar sign');
}
const dollarSignCount = (trimmed.match(/\$/g) || []).length;
if (dollarSignCount > 1) {
throw new Error('Invalid price format: multiple dollar signs');
}
const numericPart = trimmed.substring(1).replace(/,/g, '');
if (numericPart === '') {
throw new Error('Invalid price format: no numeric value');
}
const amount = parseFloat(numericPart);
if (isNaN(amount)) {
throw new Error('Invalid price format: not a valid number');
}
return new CheckoutPrice(amount);
}
/**
* Factory for a neutral/zero checkout price.
* Used when no explicit price can be extracted from the DOM.
*/
static zero(): CheckoutPrice {
return new CheckoutPrice(0);
}
toDisplayString(): string {
return `$${this.amountUsd.toFixed(2)}`;
}
getAmount(): number {
return this.amountUsd;
}
isZero(): boolean {
return this.amountUsd < 0.001;
}
get props(): CheckoutPriceProps {
return {
amountUsd: this.amountUsd,
};
}
equals(other: IValueObject<CheckoutPriceProps>): boolean {
return this.props.amountUsd === other.props.amountUsd;
}
}

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { CheckoutState, CheckoutStateEnum } from './CheckoutState';
/**
* CheckoutState Value Object - GREEN PHASE
*
* Tests for checkout button state detection.
*/
describe('CheckoutState Value Object', () => {
describe('READY state', () => {
it('should create READY state from btn-success class', () => {
const state = CheckoutState.fromButtonClasses('btn btn-success');
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should detect ready state correctly', () => {
const state = CheckoutState.fromButtonClasses('btn btn-success');
expect(state.isReady()).toBe(true);
expect(state.hasInsufficientFunds()).toBe(false);
});
it('should handle additional classes with btn-success', () => {
const state = CheckoutState.fromButtonClasses('btn btn-lg btn-success pull-right');
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should be case-insensitive for btn-success', () => {
const state = CheckoutState.fromButtonClasses('btn BTN-SUCCESS');
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
});
});
describe('INSUFFICIENT_FUNDS state', () => {
it('should create INSUFFICIENT_FUNDS from btn-default without btn-success', () => {
const state = CheckoutState.fromButtonClasses('btn btn-default');
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
it('should detect insufficient funds correctly', () => {
const state = CheckoutState.fromButtonClasses('btn btn-default');
expect(state.isReady()).toBe(false);
expect(state.hasInsufficientFunds()).toBe(true);
});
it('should handle btn-primary as insufficient funds', () => {
const state = CheckoutState.fromButtonClasses('btn btn-primary');
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
it('should handle btn-warning as insufficient funds', () => {
const state = CheckoutState.fromButtonClasses('btn btn-warning');
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
it('should handle disabled button as insufficient funds', () => {
const state = CheckoutState.fromButtonClasses('btn btn-default disabled');
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
});
describe('UNKNOWN state', () => {
it('should create UNKNOWN when no btn class exists', () => {
const state = CheckoutState.fromButtonClasses('some-other-class');
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
});
it('should create UNKNOWN from empty string', () => {
const state = CheckoutState.fromButtonClasses('');
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
});
it('should detect unknown state correctly', () => {
const state = CheckoutState.fromButtonClasses('');
expect(state.isReady()).toBe(false);
expect(state.hasInsufficientFunds()).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle whitespace in class names', () => {
const state = CheckoutState.fromButtonClasses(' btn btn-success ');
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should handle multiple spaces between classes', () => {
const state = CheckoutState.fromButtonClasses('btn btn-success');
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should be immutable after creation', () => {
const state = CheckoutState.fromButtonClasses('btn btn-success');
const originalState = state.getValue();
expect(originalState).toBe(CheckoutStateEnum.READY);
// Verify no setters exist
const mutableState = state as unknown as { setState?: unknown };
expect(typeof mutableState.setState).toBe('undefined');
});
});
describe('BDD Scenarios', () => {
it('Given button with btn-success, When checking state, Then state is READY', () => {
const state = CheckoutState.fromButtonClasses('btn btn-success');
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('Given button without btn-success, When checking state, Then state is INSUFFICIENT_FUNDS', () => {
const state = CheckoutState.fromButtonClasses('btn btn-default');
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
it('Given no button classes, When checking state, Then state is UNKNOWN', () => {
const state = CheckoutState.fromButtonClasses('');
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
});
it('Given READY state, When checking isReady, Then returns true', () => {
const state = CheckoutState.fromButtonClasses('btn btn-success');
expect(state.isReady()).toBe(true);
});
it('Given INSUFFICIENT_FUNDS state, When checking hasInsufficientFunds, Then returns true', () => {
const state = CheckoutState.fromButtonClasses('btn btn-default');
expect(state.hasInsufficientFunds()).toBe(true);
});
});
});

View File

@@ -0,0 +1,51 @@
export enum CheckoutStateEnum {
READY = 'READY',
INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS',
UNKNOWN = 'UNKNOWN'
}
export class CheckoutState {
private constructor(private readonly state: CheckoutStateEnum) {}
static ready(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.READY);
}
static insufficientFunds(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.INSUFFICIENT_FUNDS);
}
static unknown(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.UNKNOWN);
}
static fromButtonClasses(classes: string): CheckoutState {
const normalized = classes.toLowerCase().trim();
if (normalized.includes('btn-success')) {
return CheckoutState.ready();
}
if (normalized.includes('btn')) {
return CheckoutState.insufficientFunds();
}
return CheckoutState.unknown();
}
isReady(): boolean {
return this.state === CheckoutStateEnum.READY;
}
hasInsufficientFunds(): boolean {
return this.state === CheckoutStateEnum.INSUFFICIENT_FUNDS;
}
isUnknown(): boolean {
return this.state === CheckoutStateEnum.UNKNOWN;
}
getValue(): CheckoutStateEnum {
return this.state;
}
}

View File

@@ -0,0 +1,288 @@
import { describe, test, expect } from 'vitest';
import { CookieConfiguration } from 'apps/companion/main/automation/domain/value-objects/CookieConfiguration';
describe('CookieConfiguration', () => {
const validTargetUrl = 'https://members-ng.iracing.com/jjwtauth/success';
describe('domain validation', () => {
test('should accept exact domain match', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/',
};
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
});
test('should accept wildcard domain for subdomain match', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: '.iracing.com',
path: '/',
};
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
});
test('should accept wildcard domain for base domain match', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: '.iracing.com',
path: '/',
};
const baseUrl = 'https://iracing.com/';
expect(() => new CookieConfiguration(config, baseUrl)).not.toThrow();
});
test('should match wildcard domain with multiple subdomain levels', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: '.iracing.com',
path: '/',
};
const deepUrl = 'https://api.members-ng.iracing.com/endpoint';
expect(() => new CookieConfiguration(config, deepUrl)).not.toThrow();
});
test('should throw error when domain does not match target', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'example.com',
path: '/',
};
expect(() => new CookieConfiguration(config, validTargetUrl))
.toThrow(/domain mismatch/i);
});
test('should throw error when wildcard domain does not match target', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: '.example.com',
path: '/',
};
expect(() => new CookieConfiguration(config, validTargetUrl))
.toThrow(/domain mismatch/i);
});
test('should throw error when subdomain does not match wildcard', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: '.racing.com',
path: '/',
};
expect(() => new CookieConfiguration(config, validTargetUrl))
.toThrow(/domain mismatch/i);
});
test('should accept cookies from related subdomains with same base domain', () => {
const cookie = {
name: 'XSESSIONID',
value: 'session_value',
domain: 'members.iracing.com',
path: '/',
};
// Should work: members.iracing.com → members-ng.iracing.com
// Both share base domain "iracing.com"
expect(() =>
new CookieConfiguration(cookie, 'https://members-ng.iracing.com/web/racing')
).not.toThrow();
const config = new CookieConfiguration(cookie, 'https://members-ng.iracing.com/web/racing');
expect(config.getValidatedCookie().name).toBe('XSESSIONID');
});
test('should reject cookies from different base domains', () => {
const cookie = {
name: 'SESSION',
value: 'session_value',
domain: 'example.com',
path: '/',
};
// Should fail: example.com ≠ iracing.com
expect(() =>
new CookieConfiguration(cookie, 'https://members.iracing.com/web/racing')
).toThrow(/domain mismatch/i);
});
test('should accept cookies from exact subdomain match', () => {
const cookie = {
name: 'SESSION',
value: 'session_value',
domain: 'members-ng.iracing.com',
path: '/',
};
// Exact match should always work
expect(() =>
new CookieConfiguration(cookie, 'https://members-ng.iracing.com/web/racing')
).not.toThrow();
});
test('should accept cookies between different subdomains of same base domain', () => {
const cookie = {
name: 'AUTH_TOKEN',
value: 'token_value',
domain: 'api.iracing.com',
path: '/',
};
// Should work: api.iracing.com → members-ng.iracing.com
expect(() =>
new CookieConfiguration(cookie, 'https://members-ng.iracing.com/api')
).not.toThrow();
});
test('should reject subdomain cookies when base domain has insufficient parts', () => {
const cookie = {
name: 'TEST',
value: 'test_value',
domain: 'localhost',
path: '/',
};
// Single-part domain should not match different single-part domain
expect(() =>
new CookieConfiguration(cookie, 'https://example/path')
).toThrow(/domain mismatch/i);
});
});
describe('path validation', () => {
test('should accept root path for any target path', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/',
};
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
});
test('should accept path that is prefix of target path', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/jjwtauth',
};
expect(() => new CookieConfiguration(config, validTargetUrl)).not.toThrow();
});
test('should throw error when path is not prefix of target path', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/other/path',
};
expect(() => new CookieConfiguration(config, validTargetUrl))
.toThrow(/path.*not valid/i);
});
test('should throw error when path is longer than target path', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/jjwtauth/success/extra',
};
expect(() => new CookieConfiguration(config, validTargetUrl))
.toThrow(/path.*not valid/i);
});
});
describe('getValidatedCookie()', () => {
test('should return cookie with validated domain and path', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/',
};
const cookieConfig = new CookieConfiguration(config, validTargetUrl);
const cookie = cookieConfig.getValidatedCookie();
expect(cookie.name).toBe('test_cookie');
expect(cookie.value).toBe('test_value');
expect(cookie.domain).toBe('members-ng.iracing.com');
expect(cookie.path).toBe('/');
});
test('should preserve all cookie properties', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Lax' as const,
};
const cookieConfig = new CookieConfiguration(config, validTargetUrl);
const cookie = cookieConfig.getValidatedCookie();
expect(cookie.secure).toBe(true);
expect(cookie.httpOnly).toBe(true);
expect(cookie.sameSite).toBe('Lax');
});
});
describe('edge cases', () => {
test('should handle empty domain', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: '',
path: '/',
};
expect(() => new CookieConfiguration(config, validTargetUrl))
.toThrow(/domain mismatch/i);
});
test('should handle empty path', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '',
};
expect(() => new CookieConfiguration(config, validTargetUrl))
.toThrow(/path.*not valid/i);
});
test('should handle malformed target URL', () => {
const config = {
name: 'test_cookie',
value: 'test_value',
domain: 'members-ng.iracing.com',
path: '/',
};
expect(() => new CookieConfiguration(config, 'not-a-valid-url'))
.toThrow();
});
});
});

View File

@@ -0,0 +1,104 @@
interface Cookie {
name: string;
value: string;
domain: string;
path: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: 'Strict' | 'Lax' | 'None';
}
export class CookieConfiguration {
private readonly cookie: Cookie;
private readonly targetUrl: URL;
constructor(cookie: Cookie, targetUrl: string) {
this.cookie = cookie;
try {
this.targetUrl = new URL(targetUrl);
} catch {
throw new Error(`Invalid target URL: ${targetUrl}`);
}
this.validate();
}
private validate(): void {
if (!this.isValidDomain()) {
throw new Error(
`Domain mismatch: Cookie domain "${this.cookie.domain}" is invalid for target "${this.targetUrl.hostname}"`
);
}
if (!this.isValidPath()) {
throw new Error(
`Path not valid: Cookie path "${this.cookie.path}" is invalid for target path "${this.targetUrl.pathname}"`
);
}
}
private isValidDomain(): boolean {
const targetHost = this.targetUrl.hostname;
const cookieDomain = this.cookie.domain;
// Empty domain is invalid
if (!cookieDomain) {
return false;
}
// Exact match
if (cookieDomain === targetHost) {
return true;
}
// Wildcard domain (e.g., ".iracing.com" matches "members-ng.iracing.com")
if (cookieDomain.startsWith('.')) {
const domainWithoutDot = cookieDomain.slice(1);
return targetHost === domainWithoutDot || targetHost.endsWith('.' + domainWithoutDot);
}
// Subdomain compatibility: Allow cookies from related subdomains if they share the same base domain
// Example: "members.iracing.com" → "members-ng.iracing.com" (both share "iracing.com")
if (this.isSameBaseDomain(cookieDomain, targetHost)) {
return true;
}
return false;
}
/**
* Check if two domains share the same base domain (last 2 parts)
* @example
* isSameBaseDomain('members.iracing.com', 'members-ng.iracing.com') // true
* isSameBaseDomain('example.com', 'iracing.com') // false
*/
private isSameBaseDomain(domain1: string, domain2: string): boolean {
const parts1 = domain1.split('.');
const parts2 = domain2.split('.');
// Need at least 2 parts (domain.tld) for valid comparison
if (parts1.length < 2 || parts2.length < 2) {
return false;
}
// Compare last 2 parts (e.g., "iracing.com")
const base1 = parts1.slice(-2).join('.');
const base2 = parts2.slice(-2).join('.');
return base1 === base2;
}
private isValidPath(): boolean {
// Empty path is invalid
if (!this.cookie.path) {
return false;
}
// Path must be prefix of target pathname
return this.targetUrl.pathname.startsWith(this.cookie.path);
}
getValidatedCookie(): Cookie {
return { ...this.cookie };
}
}

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { RaceCreationResult } from 'apps/companion/main/automation/domain/value-objects/RaceCreationResult';
describe('RaceCreationResult Value Object', () => {
describe('create', () => {
it('should create race creation result with all fields', () => {
const result = RaceCreationResult.create({
sessionId: 'test-session-123',
price: '$10.00',
timestamp: new Date('2025-11-25T12:00:00Z'),
});
expect(result.sessionId).toBe('test-session-123');
expect(result.price).toBe('$10.00');
expect(result.timestamp).toEqual(new Date('2025-11-25T12:00:00Z'));
});
it('should throw error for empty session ID', () => {
expect(() =>
RaceCreationResult.create({
sessionId: '',
price: '$10.00',
timestamp: new Date(),
})
).toThrow('Session ID cannot be empty');
});
it('should throw error for empty price', () => {
expect(() =>
RaceCreationResult.create({
sessionId: 'test-session-123',
price: '',
timestamp: new Date(),
})
).toThrow('Price cannot be empty');
});
});
describe('equals', () => {
it('should return true for equal results', () => {
const timestamp = new Date('2025-11-25T12:00:00Z');
const result1 = RaceCreationResult.create({
sessionId: 'test-session-123',
price: '$10.00',
timestamp,
});
const result2 = RaceCreationResult.create({
sessionId: 'test-session-123',
price: '$10.00',
timestamp,
});
expect(result1.equals(result2)).toBe(true);
});
it('should return false for different session IDs', () => {
const timestamp = new Date('2025-11-25T12:00:00Z');
const result1 = RaceCreationResult.create({
sessionId: 'test-session-123',
price: '$10.00',
timestamp,
});
const result2 = RaceCreationResult.create({
sessionId: 'test-session-456',
price: '$10.00',
timestamp,
});
expect(result1.equals(result2)).toBe(false);
});
it('should return false for different prices', () => {
const timestamp = new Date('2025-11-25T12:00:00Z');
const result1 = RaceCreationResult.create({
sessionId: 'test-session-123',
price: '$10.00',
timestamp,
});
const result2 = RaceCreationResult.create({
sessionId: 'test-session-123',
price: '$20.00',
timestamp,
});
expect(result1.equals(result2)).toBe(false);
});
});
describe('toJSON', () => {
it('should serialize to JSON correctly', () => {
const timestamp = new Date('2025-11-25T12:00:00Z');
const result = RaceCreationResult.create({
sessionId: 'test-session-123',
price: '$10.00',
timestamp,
});
const json = result.toJSON();
expect(json).toEqual({
sessionId: 'test-session-123',
price: '$10.00',
timestamp: timestamp.toISOString(),
});
});
});
});

View File

@@ -0,0 +1,55 @@
export interface RaceCreationResultData {
sessionId: string;
price: string;
timestamp: Date;
}
export class RaceCreationResult {
private readonly _sessionId: string;
private readonly _price: string;
private readonly _timestamp: Date;
private constructor(data: RaceCreationResultData) {
this._sessionId = data.sessionId;
this._price = data.price;
this._timestamp = data.timestamp;
}
static create(data: RaceCreationResultData): RaceCreationResult {
if (!data.sessionId || data.sessionId.trim() === '') {
throw new Error('Session ID cannot be empty');
}
if (!data.price || data.price.trim() === '') {
throw new Error('Price cannot be empty');
}
return new RaceCreationResult(data);
}
get sessionId(): string {
return this._sessionId;
}
get price(): string {
return this._price;
}
get timestamp(): Date {
return this._timestamp;
}
equals(other: RaceCreationResult): boolean {
return (
this._sessionId === other._sessionId &&
this._price === other._price &&
this._timestamp.getTime() === other._timestamp.getTime()
);
}
toJSON(): { sessionId: string; price: string; timestamp: string } {
return {
sessionId: this._sessionId,
price: this._price,
timestamp: this._timestamp.toISOString(),
};
}
}

View File

@@ -0,0 +1,86 @@
/**
* Represents a rectangular region on the screen.
* Used for targeted screen capture and element location.
*/
export interface ScreenRegion {
x: number;
y: number;
width: number;
height: number;
}
/**
* Represents a point on the screen with x,y coordinates.
*/
export interface Point {
x: number;
y: number;
}
/**
* Represents the location of a detected UI element on screen.
* Contains the center point, bounding box, and confidence score.
*/
export interface ElementLocation {
center: Point;
bounds: ScreenRegion;
confidence: number;
}
/**
* Result of login state detection via screen recognition.
*/
export interface LoginDetectionResult {
isLoggedIn: boolean;
confidence: number;
detectedIndicators: string[];
error?: string;
}
/**
* Create a ScreenRegion from coordinates.
*/
export function createScreenRegion(x: number, y: number, width: number, height: number): ScreenRegion {
return { x, y, width, height };
}
/**
* Create a Point from coordinates.
*/
export function createPoint(x: number, y: number): Point {
return { x, y };
}
/**
* Calculate the center point of a ScreenRegion.
*/
export function getRegionCenter(region: ScreenRegion): Point {
return {
x: region.x + Math.floor(region.width / 2),
y: region.y + Math.floor(region.height / 2),
};
}
/**
* Check if a point is within a screen region.
*/
export function isPointInRegion(point: Point, region: ScreenRegion): boolean {
return (
point.x >= region.x &&
point.x <= region.x + region.width &&
point.y >= region.y &&
point.y <= region.y + region.height
);
}
/**
* Check if two screen regions overlap.
*/
export function regionsOverlap(a: ScreenRegion, b: ScreenRegion): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
);
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { SessionLifetime } from 'apps/companion/main/automation/domain/value-objects/SessionLifetime';
describe('SessionLifetime Value Object', () => {
describe('Construction', () => {
it('should create with valid expiry date', () => {
const futureDate = new Date(Date.now() + 3600000);
expect(() => new SessionLifetime(futureDate)).not.toThrow();
});
it('should create with null expiry (no expiration)', () => {
expect(() => new SessionLifetime(null)).not.toThrow();
});
it('should reject invalid dates', () => {
const invalidDate = new Date('invalid');
expect(() => new SessionLifetime(invalidDate)).toThrow();
});
it('should reject dates in the past', () => {
const pastDate = new Date(Date.now() - 3600000);
expect(() => new SessionLifetime(pastDate)).toThrow();
});
});
describe('isExpired()', () => {
it('should return true for expired date', () => {
const pastDate = new Date(Date.now() - 1000);
const lifetime = new SessionLifetime(pastDate);
expect(lifetime.isExpired()).toBe(true);
});
it('should return false for valid future date', () => {
const futureDate = new Date(Date.now() + 3600000);
const lifetime = new SessionLifetime(futureDate);
expect(lifetime.isExpired()).toBe(false);
});
it('should return false for null expiry (never expires)', () => {
const lifetime = new SessionLifetime(null);
expect(lifetime.isExpired()).toBe(false);
});
it('should consider buffer time (5 minutes)', () => {
const nearExpiryDate = new Date(Date.now() + 240000);
const lifetime = new SessionLifetime(nearExpiryDate);
expect(lifetime.isExpired()).toBe(true);
});
it('should not consider expired when beyond buffer', () => {
const safeDate = new Date(Date.now() + 360000);
const lifetime = new SessionLifetime(safeDate);
expect(lifetime.isExpired()).toBe(false);
});
});
describe('isExpiringSoon()', () => {
it('should return true for date within buffer window', () => {
const soonDate = new Date(Date.now() + 240000);
const lifetime = new SessionLifetime(soonDate);
expect(lifetime.isExpiringSoon()).toBe(true);
});
it('should return false for date far in future', () => {
const farDate = new Date(Date.now() + 3600000);
const lifetime = new SessionLifetime(farDate);
expect(lifetime.isExpiringSoon()).toBe(false);
});
it('should return false for null expiry', () => {
const lifetime = new SessionLifetime(null);
expect(lifetime.isExpiringSoon()).toBe(false);
});
it('should return true exactly at buffer boundary (5 minutes)', () => {
const boundaryDate = new Date(Date.now() + 300000);
const lifetime = new SessionLifetime(boundaryDate);
expect(lifetime.isExpiringSoon()).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle timezone correctly', () => {
const utcDate = new Date('2025-12-31T23:59:59Z');
const lifetime = new SessionLifetime(utcDate);
expect(lifetime.getExpiry()).toEqual(utcDate);
});
it('should handle millisecond precision', () => {
const preciseDate = new Date(Date.now() + 299999);
const lifetime = new SessionLifetime(preciseDate);
expect(lifetime.isExpiringSoon()).toBe(true);
});
it('should provide remaining time', () => {
const futureDate = new Date(Date.now() + 3600000);
const lifetime = new SessionLifetime(futureDate);
const remaining = lifetime.getRemainingTime();
expect(remaining).toBeGreaterThan(3000000);
expect(remaining).toBeLessThanOrEqual(3600000);
});
});
});

View File

@@ -0,0 +1,107 @@
/**
* SessionLifetime Value Object
*
* Represents the lifetime of an authentication session with expiry tracking.
* Handles validation of session expiry dates with a configurable buffer window.
*/
import type { IValueObject } from '@core/shared/domain';
export interface SessionLifetimeProps {
expiry: Date | null;
bufferMinutes: number;
}
export class SessionLifetime implements IValueObject<SessionLifetimeProps> {
private readonly expiry: Date | null;
private readonly bufferMinutes: number;
constructor(expiry: Date | null, bufferMinutes: number = 5) {
if (expiry !== null) {
if (isNaN(expiry.getTime())) {
throw new Error('Invalid expiry date provided');
}
// Allow dates within buffer window to support checking expiry of recently expired sessions
const bufferMs = bufferMinutes * 60 * 1000;
const expiryWithBuffer = expiry.getTime() + bufferMs;
if (expiryWithBuffer < Date.now()) {
throw new Error('Expiry date cannot be in the past');
}
}
this.expiry = expiry;
this.bufferMinutes = bufferMinutes;
}
/**
* Check if the session is expired.
* Considers the buffer time - sessions within the buffer window are treated as expired.
*
* @returns true if expired or expiring soon (within buffer), false otherwise
*/
isExpired(): boolean {
if (this.expiry === null) {
return false;
}
const bufferMs = this.bufferMinutes * 60 * 1000;
const expiryWithBuffer = this.expiry.getTime() - bufferMs;
return Date.now() >= expiryWithBuffer;
}
/**
* Check if the session is expiring soon (within buffer window).
*
* @returns true if expiring within buffer window, false otherwise
*/
isExpiringSoon(): boolean {
if (this.expiry === null) {
return false;
}
const bufferMs = this.bufferMinutes * 60 * 1000;
const now = Date.now();
const expiryTime = this.expiry.getTime();
const expiryWithBuffer = expiryTime - bufferMs;
return now >= expiryWithBuffer && now < expiryTime;
}
/**
* Get the expiry date.
*
* @returns The expiry date or null if no expiration
*/
getExpiry(): Date | null {
return this.expiry;
}
/**
* Get remaining time until expiry in milliseconds.
*
* @returns Milliseconds until expiry, or Infinity if no expiration
*/
getRemainingTime(): number {
if (this.expiry === null) {
return Infinity;
}
const remaining = this.expiry.getTime() - Date.now();
return Math.max(0, remaining);
}
get props(): SessionLifetimeProps {
return {
expiry: this.expiry,
bufferMinutes: this.bufferMinutes,
};
}
equals(other: IValueObject<SessionLifetimeProps>): boolean {
const a = this.props;
const b = other.props;
const aExpiry = a.expiry?.getTime() ?? null;
const bExpiry = b.expiry?.getTime() ?? null;
return aExpiry === bExpiry && a.bufferMinutes === b.bufferMinutes;
}
}

View File

@@ -0,0 +1,256 @@
import { describe, it, expect } from 'vitest';
import { SessionState } from './SessionState';
import type { SessionStateValue } from './SessionState';
describe('SessionState Value Object', () => {
describe('create', () => {
it('should create PENDING state', () => {
const state = SessionState.create('PENDING');
expect(state.value).toBe('PENDING');
});
it('should create IN_PROGRESS state', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.value).toBe('IN_PROGRESS');
});
it('should create PAUSED state', () => {
const state = SessionState.create('PAUSED');
expect(state.value).toBe('PAUSED');
});
it('should create COMPLETED state', () => {
const state = SessionState.create('COMPLETED');
expect(state.value).toBe('COMPLETED');
});
it('should create FAILED state', () => {
const state = SessionState.create('FAILED');
expect(state.value).toBe('FAILED');
});
it('should create STOPPED_AT_STEP_18 state', () => {
const state = SessionState.create('STOPPED_AT_STEP_18');
expect(state.value).toBe('STOPPED_AT_STEP_18');
});
it('should create AWAITING_CHECKOUT_CONFIRMATION state', () => {
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
expect(state.value).toBe('AWAITING_CHECKOUT_CONFIRMATION');
});
it('should create CANCELLED state', () => {
const state = SessionState.create('CANCELLED');
expect(state.value).toBe('CANCELLED');
});
it('should throw error for invalid state', () => {
expect(() => SessionState.create('INVALID' as SessionStateValue)).toThrow('Invalid session state');
});
it('should throw error for empty string', () => {
expect(() => SessionState.create('' as SessionStateValue)).toThrow('Invalid session state');
});
});
describe('equals', () => {
it('should return true for equal states', () => {
const state1 = SessionState.create('PENDING');
const state2 = SessionState.create('PENDING');
expect(state1.equals(state2)).toBe(true);
});
it('should return false for different states', () => {
const state1 = SessionState.create('PENDING');
const state2 = SessionState.create('IN_PROGRESS');
expect(state1.equals(state2)).toBe(false);
});
});
describe('isPending', () => {
it('should return true for PENDING state', () => {
const state = SessionState.create('PENDING');
expect(state.isPending()).toBe(true);
});
it('should return false for IN_PROGRESS state', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.isPending()).toBe(false);
});
});
describe('isInProgress', () => {
it('should return true for IN_PROGRESS state', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.isInProgress()).toBe(true);
});
it('should return false for PENDING state', () => {
const state = SessionState.create('PENDING');
expect(state.isInProgress()).toBe(false);
});
});
describe('isCompleted', () => {
it('should return true for COMPLETED state', () => {
const state = SessionState.create('COMPLETED');
expect(state.isCompleted()).toBe(true);
});
it('should return false for IN_PROGRESS state', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.isCompleted()).toBe(false);
});
});
describe('isFailed', () => {
it('should return true for FAILED state', () => {
const state = SessionState.create('FAILED');
expect(state.isFailed()).toBe(true);
});
it('should return false for COMPLETED state', () => {
const state = SessionState.create('COMPLETED');
expect(state.isFailed()).toBe(false);
});
});
describe('isStoppedAtStep18', () => {
it('should return true for STOPPED_AT_STEP_18 state', () => {
const state = SessionState.create('STOPPED_AT_STEP_18');
expect(state.isStoppedAtStep18()).toBe(true);
});
it('should return false for COMPLETED state', () => {
const state = SessionState.create('COMPLETED');
expect(state.isStoppedAtStep18()).toBe(false);
});
});
describe('canTransitionTo', () => {
it('should allow transition from PENDING to IN_PROGRESS', () => {
const state = SessionState.create('PENDING');
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(true);
});
it('should allow transition from IN_PROGRESS to PAUSED', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.canTransitionTo(SessionState.create('PAUSED'))).toBe(true);
});
it('should allow transition from IN_PROGRESS to STOPPED_AT_STEP_18', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.canTransitionTo(SessionState.create('STOPPED_AT_STEP_18'))).toBe(true);
});
it('should allow transition from PAUSED to IN_PROGRESS', () => {
const state = SessionState.create('PAUSED');
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(true);
});
it('should not allow transition from COMPLETED to IN_PROGRESS', () => {
const state = SessionState.create('COMPLETED');
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
});
it('should not allow transition from FAILED to IN_PROGRESS', () => {
const state = SessionState.create('FAILED');
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
});
it('should not allow transition from STOPPED_AT_STEP_18 to IN_PROGRESS', () => {
const state = SessionState.create('STOPPED_AT_STEP_18');
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
});
});
describe('isTerminal', () => {
it('should return true for COMPLETED state', () => {
const state = SessionState.create('COMPLETED');
expect(state.isTerminal()).toBe(true);
});
it('should return true for FAILED state', () => {
const state = SessionState.create('FAILED');
expect(state.isTerminal()).toBe(true);
});
it('should return true for STOPPED_AT_STEP_18 state', () => {
const state = SessionState.create('STOPPED_AT_STEP_18');
expect(state.isTerminal()).toBe(true);
});
it('should return false for PENDING state', () => {
const state = SessionState.create('PENDING');
expect(state.isTerminal()).toBe(false);
});
it('should return false for IN_PROGRESS state', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.isTerminal()).toBe(false);
});
it('should return false for PAUSED state', () => {
const state = SessionState.create('PAUSED');
expect(state.isTerminal()).toBe(false);
});
it('should return false for AWAITING_CHECKOUT_CONFIRMATION state', () => {
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
expect(state.isTerminal()).toBe(false);
});
it('should return true for CANCELLED state', () => {
const state = SessionState.create('CANCELLED');
expect(state.isTerminal()).toBe(true);
});
});
describe('state transitions with new states', () => {
it('should allow transition from IN_PROGRESS to AWAITING_CHECKOUT_CONFIRMATION', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.canTransitionTo(SessionState.create('AWAITING_CHECKOUT_CONFIRMATION'))).toBe(true);
});
it('should allow transition from AWAITING_CHECKOUT_CONFIRMATION to COMPLETED', () => {
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
expect(state.canTransitionTo(SessionState.create('COMPLETED'))).toBe(true);
});
it('should allow transition from AWAITING_CHECKOUT_CONFIRMATION to CANCELLED', () => {
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
expect(state.canTransitionTo(SessionState.create('CANCELLED'))).toBe(true);
});
it('should not allow transition from CANCELLED to any other state', () => {
const state = SessionState.create('CANCELLED');
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
expect(state.canTransitionTo(SessionState.create('COMPLETED'))).toBe(false);
});
});
describe('isAwaitingCheckoutConfirmation', () => {
it('should return true for AWAITING_CHECKOUT_CONFIRMATION state', () => {
const state = SessionState.create('AWAITING_CHECKOUT_CONFIRMATION');
expect(state.isAwaitingCheckoutConfirmation()).toBe(true);
});
it('should return false for IN_PROGRESS state', () => {
const state = SessionState.create('IN_PROGRESS');
expect(state.isAwaitingCheckoutConfirmation()).toBe(false);
});
});
describe('isCancelled', () => {
it('should return true for CANCELLED state', () => {
const state = SessionState.create('CANCELLED');
expect(state.isCancelled()).toBe(true);
});
it('should return false for COMPLETED state', () => {
const state = SessionState.create('COMPLETED');
expect(state.isCancelled()).toBe(false);
});
});
});

View File

@@ -0,0 +1,106 @@
import type { IValueObject } from '@core/shared/domain';
export type SessionStateValue =
| 'PENDING'
| 'IN_PROGRESS'
| 'PAUSED'
| 'COMPLETED'
| 'FAILED'
| 'STOPPED_AT_STEP_18'
| 'AWAITING_CHECKOUT_CONFIRMATION'
| 'CANCELLED';
const VALID_STATES: SessionStateValue[] = [
'PENDING',
'IN_PROGRESS',
'PAUSED',
'COMPLETED',
'FAILED',
'STOPPED_AT_STEP_18',
'AWAITING_CHECKOUT_CONFIRMATION',
'CANCELLED',
];
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
PENDING: ['IN_PROGRESS', 'FAILED'],
IN_PROGRESS: ['PAUSED', 'COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18', 'AWAITING_CHECKOUT_CONFIRMATION'],
PAUSED: ['IN_PROGRESS', 'FAILED'],
COMPLETED: [],
FAILED: [],
STOPPED_AT_STEP_18: [],
AWAITING_CHECKOUT_CONFIRMATION: ['COMPLETED', 'CANCELLED', 'FAILED'],
CANCELLED: [],
};
export interface SessionStateProps {
value: SessionStateValue;
}
export class SessionState implements IValueObject<SessionStateProps> {
private readonly _value: SessionStateValue;
private constructor(value: SessionStateValue) {
this._value = value;
}
static create(value: SessionStateValue): SessionState {
if (!VALID_STATES.includes(value)) {
throw new Error('Invalid session state');
}
return new SessionState(value);
}
get value(): SessionStateValue {
return this._value;
}
isPending(): boolean {
return this._value === 'PENDING';
}
isInProgress(): boolean {
return this._value === 'IN_PROGRESS';
}
isCompleted(): boolean {
return this._value === 'COMPLETED';
}
isFailed(): boolean {
return this._value === 'FAILED';
}
isStoppedAtStep18(): boolean {
return this._value === 'STOPPED_AT_STEP_18';
}
isAwaitingCheckoutConfirmation(): boolean {
return this._value === 'AWAITING_CHECKOUT_CONFIRMATION';
}
isCancelled(): boolean {
return this._value === 'CANCELLED';
}
canTransitionTo(targetState: SessionState): boolean {
const allowedTransitions = VALID_TRANSITIONS[this._value];
return allowedTransitions.includes(targetState._value);
}
isTerminal(): boolean {
return (
this._value === 'COMPLETED' ||
this._value === 'FAILED' ||
this._value === 'STOPPED_AT_STEP_18' ||
this._value === 'CANCELLED'
);
}
get props(): SessionStateProps {
return { value: this._value };
}
equals(other: IValueObject<SessionStateProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
describe('StepId Value Object', () => {
describe('create', () => {
it('should create a valid StepId for step 1', () => {
const stepId = StepId.create(1);
expect(stepId.value).toBe(1);
});
it('should create a valid StepId for step 17', () => {
const stepId = StepId.create(17);
expect(stepId.value).toBe(17);
});
it('should throw error for step 0 (below minimum)', () => {
expect(() => StepId.create(0)).toThrow('StepId must be between 1 and 17');
});
it('should throw error for step 18 (above maximum)', () => {
expect(() => StepId.create(18)).toThrow('StepId must be between 1 and 17');
});
it('should throw error for negative step', () => {
expect(() => StepId.create(-1)).toThrow('StepId must be between 1 and 17');
});
it('should throw error for non-integer step', () => {
expect(() => StepId.create(5.5)).toThrow('StepId must be an integer');
});
});
describe('equals', () => {
it('should return true for equal StepIds', () => {
const stepId1 = StepId.create(5);
const stepId2 = StepId.create(5);
expect(stepId1.equals(stepId2)).toBe(true);
});
it('should return false for different StepIds', () => {
const stepId1 = StepId.create(5);
const stepId2 = StepId.create(6);
expect(stepId1.equals(stepId2)).toBe(false);
});
});
describe('isModalStep', () => {
it('should return true for step 6 (add admin modal)', () => {
const stepId = StepId.create(6);
expect(stepId.isModalStep()).toBe(true);
});
it('should return true for step 9 (add car modal)', () => {
const stepId = StepId.create(9);
expect(stepId.isModalStep()).toBe(true);
});
it('should return true for step 12 (add track modal)', () => {
const stepId = StepId.create(12);
expect(stepId.isModalStep()).toBe(true);
});
it('should return false for non-modal step', () => {
const stepId = StepId.create(1);
expect(stepId.isModalStep()).toBe(false);
});
});
describe('isFinalStep', () => {
it('should return true for step 17', () => {
const stepId = StepId.create(17);
expect(stepId.isFinalStep()).toBe(true);
});
it('should return false for step 16', () => {
const stepId = StepId.create(16);
expect(stepId.isFinalStep()).toBe(false);
});
it('should return false for step 1', () => {
const stepId = StepId.create(1);
expect(stepId.isFinalStep()).toBe(false);
});
});
describe('next', () => {
it('should return next step for step 1', () => {
const stepId = StepId.create(1);
const nextStep = stepId.next();
expect(nextStep.value).toBe(2);
});
it('should return next step for step 16', () => {
const stepId = StepId.create(16);
const nextStep = stepId.next();
expect(nextStep.value).toBe(17);
});
it('should throw error when calling next on step 17', () => {
const stepId = StepId.create(17);
expect(() => stepId.next()).toThrow('Cannot advance beyond final step');
});
});
});

View File

@@ -0,0 +1,50 @@
import type { IValueObject } from '@core/shared/domain';
export interface StepIdProps {
value: number;
}
export class StepId implements IValueObject<StepIdProps> {
private readonly _value: number;
private constructor(value: number) {
this._value = value;
}
static create(value: number): StepId {
if (!Number.isInteger(value)) {
throw new Error('StepId must be an integer');
}
if (value < 1 || value > 17) {
throw new Error('StepId must be between 1 and 17');
}
return new StepId(value);
}
get value(): number {
return this._value;
}
isModalStep(): boolean {
return this._value === 6 || this._value === 9 || this._value === 12;
}
isFinalStep(): boolean {
return this._value === 17;
}
next(): StepId {
if (this.isFinalStep()) {
throw new Error('Cannot advance beyond final step');
}
return StepId.create(this._value + 1);
}
get props(): StepIdProps {
return { value: this._value };
}
equals(other: IValueObject<StepIdProps>): boolean {
return this.props.value === other.props.value;
}
}