feat(companion): implement hosted session automation POC with TDD approach
This commit is contained in:
292
tests/unit/application/use-cases/StartAutomationSession.test.ts
Normal file
292
tests/unit/application/use-cases/StartAutomationSession.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { StartAutomationSessionUseCase } from '../../../../src/packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { IAutomationEngine } from '../../../../src/packages/application/ports/IAutomationEngine';
|
||||
import { IBrowserAutomation } from '../../../../src/packages/application/ports/IBrowserAutomation';
|
||||
import { ISessionRepository } from '../../../../src/packages/application/ports/ISessionRepository';
|
||||
import { AutomationSession } from '../../../../src/packages/domain/entities/AutomationSession';
|
||||
|
||||
describe('StartAutomationSessionUseCase', () => {
|
||||
let mockAutomationEngine: {
|
||||
executeStep: Mock;
|
||||
validateConfiguration: Mock;
|
||||
};
|
||||
let mockBrowserAutomation: {
|
||||
navigateToPage: Mock;
|
||||
fillFormField: Mock;
|
||||
clickElement: Mock;
|
||||
waitForElement: Mock;
|
||||
handleModal: Mock;
|
||||
};
|
||||
let mockSessionRepository: {
|
||||
save: Mock;
|
||||
findById: Mock;
|
||||
update: Mock;
|
||||
delete: Mock;
|
||||
};
|
||||
let useCase: StartAutomationSessionUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAutomationEngine = {
|
||||
executeStep: vi.fn(),
|
||||
validateConfiguration: vi.fn(),
|
||||
};
|
||||
|
||||
mockBrowserAutomation = {
|
||||
navigateToPage: vi.fn(),
|
||||
fillFormField: vi.fn(),
|
||||
clickElement: vi.fn(),
|
||||
waitForElement: vi.fn(),
|
||||
handleModal: vi.fn(),
|
||||
};
|
||||
|
||||
mockSessionRepository = {
|
||||
save: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new StartAutomationSessionUseCase(
|
||||
mockAutomationEngine as unknown as IAutomationEngine,
|
||||
mockBrowserAutomation as unknown as IBrowserAutomation,
|
||||
mockSessionRepository as unknown as ISessionRepository
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute - happy path', () => {
|
||||
it('should create and persist a new automation session', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.sessionId).toBeDefined();
|
||||
expect(result.state).toBe('PENDING');
|
||||
expect(result.currentStep).toBe(1);
|
||||
expect(mockAutomationEngine.validateConfiguration).toHaveBeenCalledWith(config);
|
||||
expect(mockSessionRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
currentStep: expect.objectContaining({ value: 1 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return session DTO with correct structure', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
sessionId: expect.any(String),
|
||||
state: 'PENDING',
|
||||
currentStep: 1,
|
||||
config: {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
},
|
||||
});
|
||||
expect(result.startedAt).toBeUndefined();
|
||||
expect(result.completedAt).toBeUndefined();
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate configuration before creating session', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
await useCase.execute(config);
|
||||
|
||||
expect(mockAutomationEngine.validateConfiguration).toHaveBeenCalledWith(config);
|
||||
expect(mockSessionRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - validation failures', () => {
|
||||
it('should throw error for empty session name', async () => {
|
||||
const config = {
|
||||
sessionName: '',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Session name cannot be empty');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for missing track ID', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: '',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Track ID is required');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for empty car list', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: [],
|
||||
};
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('At least one car must be selected');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when automation engine validation fails', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'invalid-track',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Invalid track ID: invalid-track',
|
||||
});
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Invalid track ID: invalid-track');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when automation engine validation rejects', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['invalid-car'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockRejectedValue(
|
||||
new Error('Validation service unavailable')
|
||||
);
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Validation service unavailable');
|
||||
expect(mockSessionRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - port interactions', () => {
|
||||
it('should call automation engine before saving session', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockImplementation(async () => {
|
||||
callOrder.push('validateConfiguration');
|
||||
return { isValid: true };
|
||||
});
|
||||
|
||||
mockSessionRepository.save.mockImplementation(async () => {
|
||||
callOrder.push('save');
|
||||
});
|
||||
|
||||
await useCase.execute(config);
|
||||
|
||||
expect(callOrder).toEqual(['validateConfiguration', 'save']);
|
||||
});
|
||||
|
||||
it('should persist session with domain entity', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
await useCase.execute(config);
|
||||
|
||||
expect(mockSessionRepository.save).toHaveBeenCalledWith(
|
||||
expect.any(AutomationSession)
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when repository save fails', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
await expect(useCase.execute(config)).rejects.toThrow('Database connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - edge cases', () => {
|
||||
it('should handle very long session names', async () => {
|
||||
const config = {
|
||||
sessionName: 'A'.repeat(200),
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.config.sessionName).toBe('A'.repeat(200));
|
||||
});
|
||||
|
||||
it('should handle multiple cars in configuration', async () => {
|
||||
const config = {
|
||||
sessionName: 'Multi-car Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3', 'porsche-911-gt3', 'bmw-m4-gt4'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.config.carIds).toEqual(['dallara-f3', 'porsche-911-gt3', 'bmw-m4-gt4']);
|
||||
});
|
||||
|
||||
it('should handle special characters in session name', async () => {
|
||||
const config = {
|
||||
sessionName: 'Test & Race #1 (2025)',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
mockAutomationEngine.validateConfiguration.mockResolvedValue({ isValid: true });
|
||||
mockSessionRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(config);
|
||||
|
||||
expect(result.config.sessionName).toBe('Test & Race #1 (2025)');
|
||||
});
|
||||
});
|
||||
});
|
||||
364
tests/unit/domain/entities/AutomationSession.test.ts
Normal file
364
tests/unit/domain/entities/AutomationSession.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutomationSession } from '../../../../src/packages/domain/entities/AutomationSession';
|
||||
import { StepId } from '../../../../src/packages/domain/value-objects/StepId';
|
||||
import { SessionState } from '../../../../src/packages/domain/value-objects/SessionState';
|
||||
|
||||
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 18 (safety checkpoint)', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
// Advance through all steps to 18
|
||||
for (let i = 2; i <= 18; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
expect(session.currentStep.value).toBe(18);
|
||||
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 18
|
||||
for (let i = 2; i <= 18; 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 18
|
||||
for (let i = 2; i <= 18; i++) {
|
||||
session.transitionToStep(StepId.create(i));
|
||||
}
|
||||
|
||||
const elapsed = session.getElapsedTime();
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
expect(session.state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
231
tests/unit/domain/services/StepTransitionValidator.test.ts
Normal file
231
tests/unit/domain/services/StepTransitionValidator.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepTransitionValidator } from '../../../../src/packages/domain/services/StepTransitionValidator';
|
||||
import { StepId } from '../../../../src/packages/domain/value-objects/StepId';
|
||||
import { SessionState } from '../../../../src/packages/domain/value-objects/SessionState';
|
||||
|
||||
describe('StepTransitionValidator Service', () => {
|
||||
describe('canTransition', () => {
|
||||
it('should allow sequential forward transition', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject transition when not IN_PROGRESS', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
const state = SessionState.create('PENDING');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Session must be in progress to transition steps');
|
||||
});
|
||||
|
||||
it('should reject skipping steps', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(3);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Cannot skip steps - must progress sequentially');
|
||||
});
|
||||
|
||||
it('should reject backward transitions', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(4);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Cannot move backward - steps must progress forward only');
|
||||
});
|
||||
|
||||
it('should reject same step transition', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(5);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Already at this step');
|
||||
});
|
||||
|
||||
it('should allow transition through modal steps', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from modal step to next', () => {
|
||||
const currentStep = StepId.create(6);
|
||||
const nextStep = StepId.create(7);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModalStepTransition', () => {
|
||||
it('should allow entering modal step 6', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow entering modal step 9', () => {
|
||||
const currentStep = StepId.create(8);
|
||||
const nextStep = StepId.create(9);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow entering modal step 12', () => {
|
||||
const currentStep = StepId.create(11);
|
||||
const nextStep = StepId.create(12);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow exiting modal step 6', () => {
|
||||
const currentStep = StepId.create(6);
|
||||
const nextStep = StepId.create(7);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow non-modal transitions', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldStopAtStep18', () => {
|
||||
it('should return true when transitioning to step 18', () => {
|
||||
const nextStep = StepId.create(18);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for steps before 18', () => {
|
||||
const nextStep = StepId.create(17);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for early steps', () => {
|
||||
const nextStep = StepId.create(1);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepDescription', () => {
|
||||
it('should return description for step 1', () => {
|
||||
const step = StepId.create(1);
|
||||
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
|
||||
expect(description).toBe('Navigate to Hosted Racing page');
|
||||
});
|
||||
|
||||
it('should return description for step 6 (modal)', () => {
|
||||
const step = StepId.create(6);
|
||||
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
|
||||
expect(description).toBe('Add Admin (Modal)');
|
||||
});
|
||||
|
||||
it('should return description for step 18 (final)', () => {
|
||||
const step = StepId.create(18);
|
||||
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
|
||||
expect(description).toBe('Track Conditions (STOP - Manual Submit Required)');
|
||||
});
|
||||
|
||||
it('should return descriptions for all modal steps', () => {
|
||||
const modalSteps = [6, 9, 12];
|
||||
|
||||
modalSteps.forEach(stepNum => {
|
||||
const step = StepId.create(stepNum);
|
||||
const description = StepTransitionValidator.getStepDescription(step);
|
||||
expect(description).toContain('(Modal)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid sequential transitions', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
let currentStep = StepId.create(1);
|
||||
|
||||
for (let i = 2; i <= 18; i++) {
|
||||
const nextStep = StepId.create(i);
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
currentStep = nextStep;
|
||||
}
|
||||
});
|
||||
|
||||
it('should prevent transitions from terminal states', () => {
|
||||
const terminalStates = ['COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18'] as const;
|
||||
|
||||
terminalStates.forEach(stateValue => {
|
||||
const currentStep = StepId.create(10);
|
||||
const nextStep = StepId.create(11);
|
||||
const state = SessionState.create(stateValue);
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow transition from PAUSED when resumed', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
187
tests/unit/domain/value-objects/SessionState.test.ts
Normal file
187
tests/unit/domain/value-objects/SessionState.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionState } from '../../../../src/packages/domain/value-objects/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 throw error for invalid state', () => {
|
||||
expect(() => SessionState.create('INVALID' as any)).toThrow('Invalid session state');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => SessionState.create('' as any)).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
tests/unit/domain/value-objects/StepId.test.ts
Normal file
104
tests/unit/domain/value-objects/StepId.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepId } from '../../../../src/packages/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 18', () => {
|
||||
const stepId = StepId.create(18);
|
||||
expect(stepId.value).toBe(18);
|
||||
});
|
||||
|
||||
it('should throw error for step 0 (below minimum)', () => {
|
||||
expect(() => StepId.create(0)).toThrow('StepId must be between 1 and 18');
|
||||
});
|
||||
|
||||
it('should throw error for step 19 (above maximum)', () => {
|
||||
expect(() => StepId.create(19)).toThrow('StepId must be between 1 and 18');
|
||||
});
|
||||
|
||||
it('should throw error for negative step', () => {
|
||||
expect(() => StepId.create(-1)).toThrow('StepId must be between 1 and 18');
|
||||
});
|
||||
|
||||
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 18', () => {
|
||||
const stepId = StepId.create(18);
|
||||
expect(stepId.isFinalStep()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for step 17', () => {
|
||||
const stepId = StepId.create(17);
|
||||
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 17', () => {
|
||||
const stepId = StepId.create(17);
|
||||
const nextStep = stepId.next();
|
||||
expect(nextStep.value).toBe(18);
|
||||
});
|
||||
|
||||
it('should throw error when calling next on step 18', () => {
|
||||
const stepId = StepId.create(18);
|
||||
expect(() => stepId.next()).toThrow('Cannot advance beyond final step');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user