move automation out of core
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
88
apps/companion/main/automation/domain/types/ScreenRegion.ts
Normal file
88
apps/companion/main/automation/domain/types/ScreenRegion.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user