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