move automation out of core
This commit is contained in:
@@ -1,400 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { CheckAuthenticationUseCase } from '@core/automation/application/use-cases/CheckAuthenticationUseCase';
|
||||
import { AuthenticationState } from '@core/automation/domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '@core/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '@core/shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '@core/automation/application/ports/AuthenticationServicePort';
|
||||
|
||||
interface ISessionValidator {
|
||||
validateSession(): Promise<Result<boolean>>;
|
||||
}
|
||||
|
||||
describe('CheckAuthenticationUseCase', () => {
|
||||
let mockAuthService: {
|
||||
checkSession: Mock;
|
||||
initiateLogin: Mock;
|
||||
clearSession: Mock;
|
||||
getState: Mock;
|
||||
validateServerSide: Mock;
|
||||
refreshSession: Mock;
|
||||
getSessionExpiry: Mock;
|
||||
verifyPageAuthentication: Mock;
|
||||
};
|
||||
let mockSessionValidator: {
|
||||
validateSession: Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthService = {
|
||||
checkSession: vi.fn(),
|
||||
initiateLogin: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
validateServerSide: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
getSessionExpiry: vi.fn(),
|
||||
verifyPageAuthentication: vi.fn(),
|
||||
};
|
||||
|
||||
mockSessionValidator = {
|
||||
validateSession: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('File-based validation only', () => {
|
||||
it('should return AUTHENTICATED when cookies are valid', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
expect(mockAuthService.checkSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return EXPIRED when cookies are expired', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.EXPIRED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() - 3600000))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN when no session exists', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.UNKNOWN)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(null)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server-side validation enabled', () => {
|
||||
it('should confirm AUTHENTICATED when file and server both validate', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort,
|
||||
mockSessionValidator as unknown as ISessionValidator
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockSessionValidator.validateSession.mockResolvedValue(
|
||||
Result.ok(true)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
expect(mockSessionValidator.validateSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return EXPIRED when file says valid but server rejects', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort,
|
||||
mockSessionValidator as unknown as ISessionValidator
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockSessionValidator.validateSession.mockResolvedValue(
|
||||
Result.ok(false)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('should work without ISessionValidator injected (optional dependency)', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should not block file-based result if server validation fails', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort,
|
||||
mockSessionValidator as unknown as ISessionValidator
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockSessionValidator.validateSession.mockResolvedValue(
|
||||
Result.err('Network error')
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
it('should handle authentication service errors gracefully', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.err('File read error')
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr()).toContain('File read error');
|
||||
});
|
||||
|
||||
it('should handle session expiry check errors gracefully', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.err('Invalid session format')
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
// Should not block on expiry check errors, return file-based state
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Page content verification', () => {
|
||||
it('should call verifyPageAuthentication when verifyPageContent is true', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockAuthService.verifyPageAuthentication = vi.fn().mockResolvedValue(
|
||||
Result.ok(new BrowserAuthenticationState(true, true))
|
||||
);
|
||||
|
||||
await useCase.execute({ verifyPageContent: true });
|
||||
|
||||
expect(mockAuthService.verifyPageAuthentication).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return EXPIRED when cookies valid but page shows login UI', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockAuthService.verifyPageAuthentication = vi.fn().mockResolvedValue(
|
||||
Result.ok(new BrowserAuthenticationState(true, false))
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ verifyPageContent: true });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('should return AUTHENTICATED when both cookies AND page authenticated', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockAuthService.verifyPageAuthentication = vi.fn().mockResolvedValue(
|
||||
Result.ok(new BrowserAuthenticationState(true, true))
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ verifyPageContent: true });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
it('should default verifyPageContent to false (backward compatible)', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockAuthService.verifyPageAuthentication = vi.fn();
|
||||
|
||||
await useCase.execute();
|
||||
|
||||
expect(mockAuthService.verifyPageAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle verifyPageAuthentication errors gracefully', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockAuthService.verifyPageAuthentication = vi.fn().mockResolvedValue(
|
||||
Result.err('Page navigation failed')
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ verifyPageContent: true });
|
||||
|
||||
// Should not block on page verification errors, return cookie-based state
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BDD Scenarios', () => {
|
||||
it('Given valid session cookies, When checking auth, Then return AUTHENTICATED', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 7200000))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.unwrap()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
it('Given expired session cookies, When checking auth, Then return EXPIRED', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.EXPIRED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() - 1000))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.unwrap()).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('Given no session file, When checking auth, Then return UNKNOWN', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.UNKNOWN)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(null)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.unwrap()).toBe(AuthenticationState.UNKNOWN);
|
||||
});
|
||||
|
||||
it('Given valid cookies but page shows login, When verifying page content, Then return EXPIRED', async () => {
|
||||
const useCase = new CheckAuthenticationUseCase(
|
||||
mockAuthService as unknown as AuthenticationServicePort
|
||||
);
|
||||
|
||||
mockAuthService.checkSession.mockResolvedValue(
|
||||
Result.ok(AuthenticationState.AUTHENTICATED)
|
||||
);
|
||||
mockAuthService.getSessionExpiry.mockResolvedValue(
|
||||
Result.ok(new Date(Date.now() + 3600000))
|
||||
);
|
||||
mockAuthService.verifyPageAuthentication = vi.fn().mockResolvedValue(
|
||||
Result.ok(new BrowserAuthenticationState(true, false))
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ verifyPageContent: true });
|
||||
|
||||
expect(result.unwrap()).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
|
||||
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
import { CompleteOnboardingPresenter } from '@apps/api/src/modules/driver/presenters/CompleteOnboardingPresenter';
|
||||
|
||||
describe('CompleteDriverOnboardingUseCase', () => {
|
||||
let useCase: CompleteDriverOnboardingUseCase;
|
||||
let driverRepository: { findById: () => Promise<any>; save: () => Promise<void> };
|
||||
|
||||
beforeEach(() => {
|
||||
driverRepository = {
|
||||
findById: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
useCase = new CompleteDriverOnboardingUseCase(driverRepository as IDriverRepository);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create a new driver and return success', async () => {
|
||||
const input = {
|
||||
userId: 'user-123',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
displayName: 'John Doe',
|
||||
country: 'US',
|
||||
timezone: 'America/New_York',
|
||||
bio: 'Racing enthusiast',
|
||||
};
|
||||
|
||||
driverRepository.findById.mockResolvedValue(null); // Driver doesn't exist
|
||||
driverRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const presenter = new CompleteOnboardingPresenter();
|
||||
|
||||
await useCase.execute(input, presenter);
|
||||
|
||||
expect(driverRepository.findById).toHaveBeenCalledWith('user-123');
|
||||
expect(driverRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'user-123',
|
||||
iracingId: 'user-123',
|
||||
name: 'John Doe',
|
||||
country: 'US',
|
||||
bio: 'Racing enthusiast',
|
||||
})
|
||||
);
|
||||
expect(presenter.viewModel).toEqual({
|
||||
success: true,
|
||||
driverId: 'user-123',
|
||||
errorMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if driver already exists', async () => {
|
||||
const input = {
|
||||
userId: 'user-123',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
displayName: 'John Doe',
|
||||
country: 'US',
|
||||
};
|
||||
|
||||
const existingDriver = {
|
||||
id: 'user-123',
|
||||
name: 'Existing Driver',
|
||||
};
|
||||
|
||||
driverRepository.findById.mockResolvedValue(existingDriver);
|
||||
|
||||
const presenter = new CompleteOnboardingPresenter();
|
||||
|
||||
await useCase.execute(input, presenter);
|
||||
|
||||
expect(driverRepository.findById).toHaveBeenCalledWith('user-123');
|
||||
expect(driverRepository.save).not.toHaveBeenCalled();
|
||||
expect(presenter.viewModel).toEqual({
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Driver already exists',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle domain validation errors', async () => {
|
||||
const input = {
|
||||
userId: 'user-123',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
displayName: 'John Doe',
|
||||
country: 'INVALID', // Invalid country code
|
||||
};
|
||||
|
||||
driverRepository.findById.mockResolvedValue(null);
|
||||
|
||||
const presenter = new CompleteOnboardingPresenter();
|
||||
|
||||
await useCase.execute(input, presenter);
|
||||
|
||||
expect(presenter.viewModel.success).toBe(false);
|
||||
expect(presenter.viewModel.errorMessage).toContain('Country must be a valid ISO code');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CompleteRaceCreationUseCase } from '@core/automation/application/use-cases/CompleteRaceCreationUseCase';
|
||||
import { Result } from '@core/shared/result/Result';
|
||||
import { RaceCreationResult } from '@core/automation/domain/value-objects/RaceCreationResult';
|
||||
import { CheckoutPrice } from '@core/automation/domain/value-objects/CheckoutPrice';
|
||||
import type { CheckoutServicePort } from '@core/automation/application/ports/CheckoutServicePort';
|
||||
import { CheckoutState } from '@core/automation/domain/value-objects/CheckoutState';
|
||||
|
||||
describe('CompleteRaceCreationUseCase', () => {
|
||||
let mockCheckoutService: CheckoutServicePort;
|
||||
let useCase: CompleteRaceCreationUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckoutService = {
|
||||
extractCheckoutInfo: vi.fn(),
|
||||
proceedWithCheckout: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new CompleteRaceCreationUseCase(mockCheckoutService);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should extract checkout price and create RaceCreationResult', async () => {
|
||||
const price = CheckoutPrice.fromString('$25.50');
|
||||
const state = CheckoutState.ready();
|
||||
const sessionId = 'test-session-123';
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '<a>$25.50</a>' })
|
||||
);
|
||||
|
||||
const result = await useCase.execute(sessionId);
|
||||
|
||||
expect(mockCheckoutService.extractCheckoutInfo).toHaveBeenCalled();
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const raceCreationResult = result.unwrap();
|
||||
expect(raceCreationResult).toBeInstanceOf(RaceCreationResult);
|
||||
expect(raceCreationResult.sessionId).toBe(sessionId);
|
||||
expect(raceCreationResult.price).toBe('$25.50');
|
||||
expect(raceCreationResult.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return error if checkout info extraction fails', async () => {
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.err(new Error('Failed to extract checkout info'))
|
||||
);
|
||||
|
||||
const result = await useCase.execute('test-session-123');
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('Failed to extract checkout info');
|
||||
});
|
||||
|
||||
it('should return error if price is missing', async () => {
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price: null, state, buttonHtml: '<a>n/a</a>' })
|
||||
);
|
||||
|
||||
const result = await useCase.execute('test-session-123');
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('Could not extract price');
|
||||
});
|
||||
|
||||
it('should validate session ID is provided', async () => {
|
||||
const result = await useCase.execute('');
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('Session ID is required');
|
||||
});
|
||||
|
||||
it('should format different price values correctly', async () => {
|
||||
const testCases = [
|
||||
{ input: '$10.00', expected: '$10.00' },
|
||||
{ input: '$100.50', expected: '$100.50' },
|
||||
{ input: '$0.99', expected: '$0.99' },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const price = CheckoutPrice.fromString(testCase.input);
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: `<a>${testCase.input}</a>` })
|
||||
);
|
||||
|
||||
const result = await useCase.execute('test-session');
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const raceCreationResult = result.unwrap();
|
||||
expect(raceCreationResult.price).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should capture current timestamp when creating result', async () => {
|
||||
const price = CheckoutPrice.fromString('$25.50');
|
||||
const state = CheckoutState.ready();
|
||||
const beforeExecution = new Date();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '<a>$25.50</a>' })
|
||||
);
|
||||
|
||||
const result = await useCase.execute('test-session');
|
||||
const afterExecution = new Date();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const raceCreationResult = result.unwrap();
|
||||
|
||||
expect(raceCreationResult.timestamp.getTime()).toBeGreaterThanOrEqual(
|
||||
beforeExecution.getTime()
|
||||
);
|
||||
expect(raceCreationResult.timestamp.getTime()).toBeLessThanOrEqual(
|
||||
afterExecution.getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfirmCheckoutUseCase } from '@core/automation/application/use-cases/ConfirmCheckoutUseCase';
|
||||
import { Result } from '@core/shared/result/Result';
|
||||
import { CheckoutPrice } from '@core/automation/domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@core/automation/domain/value-objects/CheckoutState';
|
||||
import { CheckoutConfirmation } from '@core/automation/domain/value-objects/CheckoutConfirmation';
|
||||
import type { CheckoutServicePort } from '@core/automation/application/ports/CheckoutServicePort';
|
||||
import type { CheckoutConfirmationPort } from '@core/automation/application/ports/CheckoutConfirmationPort';
|
||||
|
||||
describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
let mockCheckoutService: CheckoutServicePort;
|
||||
let mockConfirmationPort: CheckoutConfirmationPort;
|
||||
let useCase: ConfirmCheckoutUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckoutService = {
|
||||
extractCheckoutInfo: vi.fn(),
|
||||
proceedWithCheckout: vi.fn(),
|
||||
};
|
||||
|
||||
mockConfirmationPort = {
|
||||
requestCheckoutConfirmation: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new ConfirmCheckoutUseCase(mockCheckoutService, mockConfirmationPort);
|
||||
});
|
||||
|
||||
describe('with new confirmation flow', () => {
|
||||
it('should extract price, request confirmation via port, then proceed', async () => {
|
||||
const price = CheckoutPrice.fromString('$25.50');
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
|
||||
vi.mocked(mockCheckoutService.proceedWithCheckout).mockResolvedValue(
|
||||
Result.ok(undefined)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockCheckoutService.extractCheckoutInfo).toHaveBeenCalled();
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
price: expect.any(CheckoutPrice),
|
||||
state: expect.any(CheckoutState),
|
||||
})
|
||||
);
|
||||
expect(mockCheckoutService.proceedWithCheckout).toHaveBeenCalled();
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not proceed if user cancels confirmation', async () => {
|
||||
const price = CheckoutPrice.fromString('$10.00');
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('cancelled'))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalled();
|
||||
expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled();
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('cancelled');
|
||||
});
|
||||
|
||||
it('should not proceed if confirmation times out', async () => {
|
||||
const price = CheckoutPrice.fromString('$10.00');
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('timeout'))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalled();
|
||||
expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled();
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('timeout');
|
||||
});
|
||||
|
||||
it('should fail if confirmation port returns error', async () => {
|
||||
const price = CheckoutPrice.fromString('$10.00');
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
Result.err(new Error('IPC communication failed'))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('IPC communication failed');
|
||||
});
|
||||
|
||||
it('should still reject insufficient funds before confirmation', async () => {
|
||||
const price = CheckoutPrice.fromString('$10.00');
|
||||
const state = CheckoutState.insufficientFunds();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).not.toHaveBeenCalled();
|
||||
expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled();
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('Insufficient funds');
|
||||
});
|
||||
|
||||
it('should pass session metadata to confirmation port', async () => {
|
||||
const price = CheckoutPrice.fromString('$25.50');
|
||||
const state = CheckoutState.ready();
|
||||
const sessionMetadata = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1', 'car2'],
|
||||
};
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
|
||||
vi.mocked(mockCheckoutService.proceedWithCheckout).mockResolvedValue(
|
||||
Result.ok(undefined)
|
||||
);
|
||||
|
||||
const result = await useCase.execute(sessionMetadata);
|
||||
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionMetadata,
|
||||
timeoutMs: expect.any(Number),
|
||||
})
|
||||
);
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,405 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { Result } from '@core/shared/result/Result';
|
||||
import { ConfirmCheckoutUseCase } from '@core/automation/application/use-cases/ConfirmCheckoutUseCase';
|
||||
import type { CheckoutServicePort } from '@core/automation/application/ports/CheckoutServicePort';
|
||||
import type { CheckoutConfirmationPort } from '@core/automation/application/ports/CheckoutConfirmationPort';
|
||||
import type { CheckoutInfoDTO } from '@core/automation/application/dto/CheckoutInfoDTO';
|
||||
import { CheckoutPrice } from '@core/automation/domain/value-objects/CheckoutPrice';
|
||||
|
||||
import { CheckoutConfirmation } from '@core/automation/domain/value-objects/CheckoutConfirmation';
|
||||
|
||||
/**
|
||||
* ConfirmCheckoutUseCase - GREEN PHASE
|
||||
*
|
||||
* Tests for checkout confirmation flow including price extraction,
|
||||
* insufficient funds detection, and user confirmation.
|
||||
*/
|
||||
|
||||
describe('ConfirmCheckoutUseCase', () => {
|
||||
let mockCheckoutService: {
|
||||
extractCheckoutInfo: Mock;
|
||||
proceedWithCheckout: Mock;
|
||||
};
|
||||
let mockConfirmationPort: {
|
||||
requestCheckoutConfirmation: Mock;
|
||||
};
|
||||
let mockPrice: CheckoutPrice;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckoutService = {
|
||||
extractCheckoutInfo: vi.fn(),
|
||||
proceedWithCheckout: vi.fn(),
|
||||
};
|
||||
|
||||
mockConfirmationPort = {
|
||||
requestCheckoutConfirmation: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrice = {
|
||||
getAmount: vi.fn(() => 0.50),
|
||||
toDisplayString: vi.fn(() => '$0.50'),
|
||||
isZero: vi.fn(() => false),
|
||||
} as unknown as CheckoutPrice;
|
||||
});
|
||||
|
||||
describe('Success flow', () => {
|
||||
it('should extract price, get user confirmation, and proceed with checkout', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockCheckoutService.extractCheckoutInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledTimes(1);
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ price: mockPrice })
|
||||
);
|
||||
expect(mockCheckoutService.proceedWithCheckout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should include price in confirmation message', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
await useCase.execute();
|
||||
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ price: mockPrice })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User cancellation', () => {
|
||||
it('should abort checkout when user cancels confirmation', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('cancelled'))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/cancel/i);
|
||||
expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not proceed with checkout after cancellation', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('cancelled'))
|
||||
);
|
||||
|
||||
await useCase.execute();
|
||||
|
||||
expect(mockCheckoutService.proceedWithCheckout).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Insufficient funds detection', () => {
|
||||
it('should return error when checkout state is INSUFFICIENT_FUNDS', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.insufficientFunds(),
|
||||
buttonHtml: '<a class="btn btn-default"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/insufficient.*funds/i);
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).not.toHaveBeenCalled();
|
||||
expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not ask for confirmation when funds are insufficient', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.insufficientFunds(),
|
||||
buttonHtml: '<a class="btn btn-default"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
|
||||
await useCase.execute();
|
||||
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Price extraction failure', () => {
|
||||
it('should return error when price cannot be extracted', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: null,
|
||||
state: CheckoutState.unknown(),
|
||||
buttonHtml: '',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/extract|price|not found/i);
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).not.toHaveBeenCalled();
|
||||
expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when extraction service fails', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.err('Button not found')
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zero price warning', () => {
|
||||
it('should still require confirmation for $0.00 price', async () => {
|
||||
const zeroPriceMock = {
|
||||
getAmount: vi.fn(() => 0.00),
|
||||
toDisplayString: vi.fn(() => '$0.00'),
|
||||
isZero: vi.fn(() => true),
|
||||
} as unknown as CheckoutPrice;
|
||||
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: zeroPriceMock,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.00</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledTimes(1);
|
||||
expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ price: zeroPriceMock })
|
||||
);
|
||||
});
|
||||
|
||||
it('should proceed with checkout for zero price after confirmation', async () => {
|
||||
const zeroPriceMock = {
|
||||
getAmount: vi.fn(() => 0.00),
|
||||
toDisplayString: vi.fn(() => '$0.00'),
|
||||
isZero: vi.fn(() => true),
|
||||
} as unknown as CheckoutPrice;
|
||||
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: zeroPriceMock,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.00</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
await useCase.execute();
|
||||
|
||||
expect(mockCheckoutService.proceedWithCheckout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Checkout execution failure', () => {
|
||||
it('should return error when proceedWithCheckout fails', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
mockCheckoutService.proceedWithCheckout.mockResolvedValue(
|
||||
Result.err('Network error')
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr()).toContain('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BDD Scenarios', () => {
|
||||
it('Given checkout price $0.50 and READY state, When user confirms, Then checkout proceeds', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('confirmed'))
|
||||
);
|
||||
mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('Given checkout price $0.50, When user cancels, Then checkout is aborted', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.ready(),
|
||||
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue(
|
||||
Result.ok(CheckoutConfirmation.create('cancelled'))
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Given INSUFFICIENT_FUNDS state, When executing, Then error is returned', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.ok({
|
||||
price: mockPrice,
|
||||
state: CheckoutState.insufficientFunds(),
|
||||
buttonHtml: '<a class="btn btn-default"><span>$0.50</span></a>',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
});
|
||||
|
||||
it('Given price extraction failure, When executing, Then error is returned', async () => {
|
||||
const useCase = new ConfirmCheckoutUseCase(
|
||||
mockCheckoutService as unknown as CheckoutServicePort,
|
||||
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
||||
);
|
||||
|
||||
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
||||
Result.err('Button not found')
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
|
||||
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
import { DriverStatsPresenter } from '@apps/api/src/modules/driver/presenters/DriverStatsPresenter';
|
||||
|
||||
describe('GetTotalDriversUseCase', () => {
|
||||
let useCase: GetTotalDriversUseCase;
|
||||
let driverRepository: { findAll: any };
|
||||
|
||||
beforeEach(() => {
|
||||
driverRepository = {
|
||||
findAll: vi.fn(),
|
||||
};
|
||||
useCase = new GetTotalDriversUseCase(driverRepository as IDriverRepository);
|
||||
});
|
||||
|
||||
it('should return total drivers count', async () => {
|
||||
// Arrange
|
||||
const mockDrivers = [
|
||||
{ id: '1', name: 'Driver 1' },
|
||||
{ id: '2', name: 'Driver 2' },
|
||||
];
|
||||
driverRepository.findAll.mockResolvedValue(mockDrivers);
|
||||
const presenter = new DriverStatsPresenter();
|
||||
|
||||
// Act
|
||||
await useCase.execute(undefined, presenter);
|
||||
|
||||
// Assert
|
||||
expect(driverRepository.findAll).toHaveBeenCalled();
|
||||
expect(presenter.viewModel).toEqual({ totalDrivers: 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Result } from '@core/shared/result/Result';
|
||||
import { CheckoutConfirmation } from '@core/automation/domain/value-objects/CheckoutConfirmation';
|
||||
import { CheckoutPrice } from '@core/automation/domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@core/automation/domain/value-objects/CheckoutState';
|
||||
|
||||
/**
|
||||
* Contract tests for ICheckoutConfirmationPort
|
||||
*
|
||||
* Any implementation must:
|
||||
* 1. Accept CheckoutConfirmationRequest with price, state, sessionMetadata, timeoutMs
|
||||
* 2. Return Result<CheckoutConfirmation> with decision: confirmed, cancelled, or timeout
|
||||
* 3. Handle timeout gracefully by returning timeout decision
|
||||
* 4. Validate request parameters before processing
|
||||
*/
|
||||
|
||||
export interface CheckoutConfirmationRequest {
|
||||
price: CheckoutPrice;
|
||||
state: CheckoutState;
|
||||
sessionMetadata: {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
};
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ICheckoutConfirmationPort {
|
||||
requestCheckoutConfirmation(
|
||||
request: CheckoutConfirmationRequest
|
||||
): Promise<Result<CheckoutConfirmation>>;
|
||||
}
|
||||
|
||||
describe('ICheckoutConfirmationPort contract', () => {
|
||||
it('should define the required interface structure', () => {
|
||||
// This test verifies the port interface contract exists
|
||||
const mockPort: ICheckoutConfirmationPort = {
|
||||
requestCheckoutConfirmation: async (__request: CheckoutConfirmationRequest) => {
|
||||
return Result.ok(CheckoutConfirmation.create('confirmed'));
|
||||
},
|
||||
};
|
||||
|
||||
expect(mockPort.requestCheckoutConfirmation).toBeDefined();
|
||||
expect(typeof mockPort.requestCheckoutConfirmation).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept valid CheckoutConfirmationRequest', async () => {
|
||||
const mockPort: ICheckoutConfirmationPort = {
|
||||
requestCheckoutConfirmation: async (request: CheckoutConfirmationRequest) => {
|
||||
expect(request.price).toBeInstanceOf(CheckoutPrice);
|
||||
expect(request.state).toBeInstanceOf(CheckoutState);
|
||||
expect(request.sessionMetadata).toBeDefined();
|
||||
expect(request.sessionMetadata.sessionName).toBeTruthy();
|
||||
expect(request.sessionMetadata.trackId).toBeTruthy();
|
||||
expect(Array.isArray(request.sessionMetadata.carIds)).toBe(true);
|
||||
expect(request.timeoutMs).toBeGreaterThan(0);
|
||||
return Result.ok(CheckoutConfirmation.create('confirmed'));
|
||||
},
|
||||
};
|
||||
|
||||
const request: CheckoutConfirmationRequest = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1', 'car2'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
const result = await mockPort.requestCheckoutConfirmation(request);
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return Result with CheckoutConfirmation on success', async () => {
|
||||
const mockPort: ICheckoutConfirmationPort = {
|
||||
requestCheckoutConfirmation: async () => {
|
||||
return Result.ok(CheckoutConfirmation.create('confirmed'));
|
||||
},
|
||||
};
|
||||
|
||||
const request: CheckoutConfirmationRequest = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
const result = await mockPort.requestCheckoutConfirmation(request);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const confirmation = result.unwrap();
|
||||
expect(confirmation).toBeInstanceOf(CheckoutConfirmation);
|
||||
expect(confirmation.isConfirmed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should support cancelled decision', async () => {
|
||||
const mockPort: ICheckoutConfirmationPort = {
|
||||
requestCheckoutConfirmation: async () => {
|
||||
return Result.ok(CheckoutConfirmation.create('cancelled'));
|
||||
},
|
||||
};
|
||||
|
||||
const request: CheckoutConfirmationRequest = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
const result = await mockPort.requestCheckoutConfirmation(request);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const confirmation = result.unwrap();
|
||||
expect(confirmation.isCancelled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should support timeout decision', async () => {
|
||||
const mockPort: ICheckoutConfirmationPort = {
|
||||
requestCheckoutConfirmation: async () => {
|
||||
return Result.ok(CheckoutConfirmation.create('timeout'));
|
||||
},
|
||||
};
|
||||
|
||||
const request: CheckoutConfirmationRequest = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 1000,
|
||||
};
|
||||
|
||||
const result = await mockPort.requestCheckoutConfirmation(request);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const confirmation = result.unwrap();
|
||||
expect(confirmation.isTimeout()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error Result for invalid requests', async () => {
|
||||
const mockPort: ICheckoutConfirmationPort = {
|
||||
requestCheckoutConfirmation: async (request: CheckoutConfirmationRequest) => {
|
||||
if (request.timeoutMs <= 0) {
|
||||
return Result.err(new Error('Timeout must be positive'));
|
||||
}
|
||||
if (!request.sessionMetadata.sessionName) {
|
||||
return Result.err(new Error('Session name is required'));
|
||||
}
|
||||
return Result.ok(CheckoutConfirmation.create('confirmed'));
|
||||
},
|
||||
};
|
||||
|
||||
const invalidRequest: CheckoutConfirmationRequest = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: '',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
const result = await mockPort.requestCheckoutConfirmation(invalidRequest);
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('Session name');
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { OverlayAction } from '@core/automation/application/ports/IOverlaySyncPort'
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '@core/automation/infrastructure//IAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from '@core/automation/application/services/OverlaySyncService'
|
||||
|
||||
class MockLifecycleEmitter implements IAutomationLifecycleEmitter {
|
||||
private callbacks: Set<LifecycleCallback> = new Set()
|
||||
onLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.add(cb)
|
||||
}
|
||||
offLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.delete(cb)
|
||||
}
|
||||
async emit(event: AutomationEvent) {
|
||||
for (const cb of Array.from(this.callbacks)) {
|
||||
// fire without awaiting to simulate async emitter
|
||||
cb(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('OverlaySyncService (unit)', () => {
|
||||
test('startAction resolves as confirmed only after action-started event is emitted', async () => {
|
||||
const emitter = new MockLifecycleEmitter()
|
||||
// create service wiring: pass emitter as dependency (constructor shape expected)
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter,
|
||||
logger: console as unknown,
|
||||
publisher: { publish: async () => {} },
|
||||
})
|
||||
|
||||
const action: OverlayAction = { id: 'add-car', label: 'Adding...' }
|
||||
|
||||
// start the action but don't emit event yet
|
||||
const promise = svc.startAction(action)
|
||||
|
||||
// wait a small tick to ensure promise hasn't resolved prematurely
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
let resolved = false
|
||||
promise.then(() => (resolved = true))
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// now emit action-started
|
||||
await emitter.emit({ type: 'action-started', actionId: 'add-car', timestamp: Date.now() })
|
||||
|
||||
const ack = await promise
|
||||
expect(ack.status).toBe('confirmed')
|
||||
expect(ack.id).toBe('add-car')
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { OverlayAction } from '@core/automation/application/ports/OverlaySyncPort'
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '@core/automation/infrastructure//IAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from '@core/automation/application/services/OverlaySyncService'
|
||||
|
||||
class MockLifecycleEmitter implements IAutomationLifecycleEmitter {
|
||||
private callbacks: Set<LifecycleCallback> = new Set()
|
||||
onLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.add(cb)
|
||||
}
|
||||
offLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.delete(cb)
|
||||
}
|
||||
async emit(event: { type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'; actionId: string; timestamp: number }) {
|
||||
for (const cb of Array.from(this.callbacks)) {
|
||||
cb(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('OverlaySyncService timeout (unit)', () => {
|
||||
test('startAction with short timeout resolves as tentative when no events', async () => {
|
||||
const emitter = new MockLifecycleEmitter()
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter,
|
||||
logger: console as unknown,
|
||||
publisher: { publish: async () => {} },
|
||||
})
|
||||
|
||||
const action: OverlayAction = { id: 'add-car', label: 'Adding...', timeoutMs: 50 }
|
||||
|
||||
const start = Date.now()
|
||||
const ack = await svc.startAction(action)
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
expect(ack.status).toBe('tentative')
|
||||
expect(ack.id).toBe('add-car')
|
||||
expect(elapsed).toBeGreaterThanOrEqual(40)
|
||||
})
|
||||
})
|
||||
@@ -1,549 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { RecalculateChampionshipStandingsUseCase } from '@core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase';
|
||||
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
|
||||
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
|
||||
import type { IChampionshipStandingRepository } from '@core/racing/domain/repositories/IChampionshipStandingRepository';
|
||||
|
||||
import { Season } from '@core/racing/domain/entities/Season';
|
||||
import type { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { Result } from '@core/racing/domain/entities/Result';
|
||||
import type { Penalty } from '@core/racing/domain/entities/Penalty';
|
||||
import type { ChampionshipStanding } from '@core/racing/domain/entities/ChampionshipStanding';
|
||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||
import { EventScoringService } from '@core/racing/domain/services/EventScoringService';
|
||||
import { DropScoreApplier } from '@core/racing/domain/services/DropScoreApplier';
|
||||
import { ChampionshipAggregator } from '@core/racing/domain/services/ChampionshipAggregator';
|
||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
||||
import type { BonusRule } from '@core/racing/domain/types/BonusRule';
|
||||
import type { DropScorePolicy } from '@core/racing/domain/types/DropScorePolicy';
|
||||
|
||||
class InMemorySeasonRepository implements ISeasonRepository {
|
||||
private seasons: Season[] = [];
|
||||
|
||||
async findById(id: string): Promise<Season | null> {
|
||||
return this.seasons.find((s) => s.id === id) || null;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Season[]> {
|
||||
return this.seasons.filter((s) => s.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async create(season: Season): Promise<Season> {
|
||||
this.seasons.push(season);
|
||||
return season;
|
||||
}
|
||||
|
||||
async add(season: Season): Promise<void> {
|
||||
this.seasons.push(season);
|
||||
}
|
||||
|
||||
async update(season: Season): Promise<void> {
|
||||
const index = this.seasons.findIndex((s) => s.id === season.id);
|
||||
if (index >= 0) {
|
||||
this.seasons[index] = season;
|
||||
}
|
||||
}
|
||||
|
||||
async listByLeague(leagueId: string): Promise<Season[]> {
|
||||
return this.seasons.filter((s) => s.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async listActiveByLeague(leagueId: string): Promise<Season[]> {
|
||||
return this.seasons.filter((s) => s.leagueId === leagueId && s.status === 'active');
|
||||
}
|
||||
|
||||
seedSeason(season: Season): void {
|
||||
this.seasons.push(season);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryLeagueScoringConfigRepository implements ILeagueScoringConfigRepository {
|
||||
private configs: LeagueScoringConfig[] = [];
|
||||
|
||||
async findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null> {
|
||||
return this.configs.find((c) => c.seasonId === seasonId) || null;
|
||||
}
|
||||
|
||||
async save(config: LeagueScoringConfig): Promise<LeagueScoringConfig> {
|
||||
const index = this.configs.findIndex((c) => c.id === config.id);
|
||||
if (index >= 0) {
|
||||
this.configs[index] = config;
|
||||
} else {
|
||||
this.configs.push(config);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
seedConfig(config: LeagueScoringConfig): void {
|
||||
this.configs.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRaceRepository implements IRaceRepository {
|
||||
private races: Race[] = [];
|
||||
|
||||
async findById(id: string): Promise<Race | null> {
|
||||
return this.races.find((r) => r.id === id) || null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Race[]> {
|
||||
return [...this.races];
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
return this.races.filter((r) => r.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async findUpcomingByLeagueId(): Promise<Race[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async findCompletedByLeagueId(): Promise<Race[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async findByStatus(): Promise<Race[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async findByDateRange(): Promise<Race[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async create(race: Race): Promise<Race> {
|
||||
this.races.push(race);
|
||||
return race;
|
||||
}
|
||||
|
||||
async update(race: Race): Promise<Race> {
|
||||
const index = this.races.findIndex((r) => r.id === race.id);
|
||||
if (index >= 0) {
|
||||
this.races[index] = race;
|
||||
} else {
|
||||
this.races.push(race);
|
||||
}
|
||||
return race;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.races = this.races.filter((r) => r.id !== id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.races.some((r) => r.id === id);
|
||||
}
|
||||
|
||||
seedRace(race: Race): void {
|
||||
this.races.push(race);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryResultRepository implements IResultRepository {
|
||||
private results: Result[] = [];
|
||||
|
||||
async findById(id: string): Promise<Result | null> {
|
||||
return this.results.find((r) => r.id === id) || null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Result[]> {
|
||||
return [...this.results];
|
||||
}
|
||||
|
||||
async findByRaceId(raceId: string): Promise<Result[]> {
|
||||
return this.results.filter((r) => r.raceId === raceId);
|
||||
}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<Result[]> {
|
||||
return this.results.filter((r) => r.driverId === driverId);
|
||||
}
|
||||
|
||||
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]> {
|
||||
return this.results.filter((r) => r.driverId === driverId && r.raceId.startsWith(leagueId));
|
||||
}
|
||||
|
||||
async create(result: Result): Promise<Result> {
|
||||
this.results.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async createMany(results: Result[]): Promise<Result[]> {
|
||||
this.results.push(...results);
|
||||
return results;
|
||||
}
|
||||
|
||||
async update(result: Result): Promise<Result> {
|
||||
const index = this.results.findIndex((r) => r.id === result.id);
|
||||
if (index >= 0) {
|
||||
this.results[index] = result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.results = this.results.filter((r) => r.id !== id);
|
||||
}
|
||||
|
||||
async deleteByRaceId(raceId: string): Promise<void> {
|
||||
this.results = this.results.filter((r) => r.raceId !== raceId);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.results.some((r) => r.id === id);
|
||||
}
|
||||
|
||||
async existsByRaceId(raceId: string): Promise<boolean> {
|
||||
return this.results.some((r) => r.raceId === raceId);
|
||||
}
|
||||
|
||||
seedResult(result: Result): void {
|
||||
this.results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryPenaltyRepository implements IPenaltyRepository {
|
||||
private penalties: Penalty[] = [];
|
||||
|
||||
async findByRaceId(raceId: string): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.raceId === raceId);
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async findByLeagueIdAndDriverId(
|
||||
leagueId: string,
|
||||
driverId: string,
|
||||
): Promise<Penalty[]> {
|
||||
return this.penalties.filter(
|
||||
(p) => p.leagueId === leagueId && p.driverId === driverId,
|
||||
);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Penalty[]> {
|
||||
return [...this.penalties];
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Penalty | null> {
|
||||
return this.penalties.find((p) => p.id === id) || null;
|
||||
}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.driverId === driverId);
|
||||
}
|
||||
|
||||
async findByProtestId(protestId: string): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.protestId === protestId);
|
||||
}
|
||||
|
||||
async findPending(): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.status === 'pending');
|
||||
}
|
||||
|
||||
async findIssuedBy(stewardId: string): Promise<Penalty[]> {
|
||||
return this.penalties.filter((p) => p.issuedBy === stewardId);
|
||||
}
|
||||
|
||||
async create(penalty: Penalty): Promise<void> {
|
||||
this.penalties.push(penalty);
|
||||
}
|
||||
|
||||
async update(penalty: Penalty): Promise<void> {
|
||||
const index = this.penalties.findIndex((p) => p.id === penalty.id);
|
||||
if (index >= 0) {
|
||||
this.penalties[index] = penalty;
|
||||
}
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.penalties.some((p) => p.id === id);
|
||||
}
|
||||
|
||||
seedPenalty(penalty: Penalty): void {
|
||||
this.penalties.push(penalty);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryChampionshipStandingRepository implements IChampionshipStandingRepository {
|
||||
private standings: ChampionshipStanding[] = [];
|
||||
|
||||
async findBySeasonAndChampionship(
|
||||
seasonId: string,
|
||||
championshipId: string,
|
||||
): Promise<ChampionshipStanding[]> {
|
||||
return this.standings.filter(
|
||||
(s) => s.seasonId === seasonId && s.championshipId === championshipId,
|
||||
);
|
||||
}
|
||||
|
||||
async saveAll(standings: ChampionshipStanding[]): Promise<void> {
|
||||
this.standings = standings;
|
||||
}
|
||||
|
||||
getAll(): ChampionshipStanding[] {
|
||||
return [...this.standings];
|
||||
}
|
||||
}
|
||||
|
||||
function makePointsTable(points: number[]): PointsTable {
|
||||
const byPos: Record<number, number> = {};
|
||||
points.forEach((p, idx) => {
|
||||
byPos[idx + 1] = p;
|
||||
});
|
||||
return new PointsTable(byPos);
|
||||
}
|
||||
|
||||
function makeChampionshipConfig(): ChampionshipConfig {
|
||||
const mainPoints = makePointsTable([25, 18, 15, 12, 10, 8, 6, 4, 2, 1]);
|
||||
const sprintPoints = makePointsTable([8, 7, 6, 5, 4, 3, 2, 1]);
|
||||
|
||||
const fastestLapBonus: BonusRule = {
|
||||
id: 'fastest-lap-main',
|
||||
type: 'fastestLap',
|
||||
points: 1,
|
||||
requiresFinishInTopN: 10,
|
||||
};
|
||||
|
||||
const sessionTypes: SessionType[] = ['sprint', 'main'];
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||
sprint: sprintPoints,
|
||||
main: mainPoints,
|
||||
practice: new PointsTable({}),
|
||||
qualifying: new PointsTable({}),
|
||||
q1: new PointsTable({}),
|
||||
q2: new PointsTable({}),
|
||||
q3: new PointsTable({}),
|
||||
timeTrial: new PointsTable({}),
|
||||
};
|
||||
|
||||
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
|
||||
sprint: [],
|
||||
main: [fastestLapBonus],
|
||||
practice: [],
|
||||
qualifying: [],
|
||||
q1: [],
|
||||
q2: [],
|
||||
q3: [],
|
||||
timeTrial: [],
|
||||
};
|
||||
|
||||
const dropScorePolicy: DropScorePolicy = {
|
||||
strategy: 'bestNResults',
|
||||
count: 6,
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'driver-champ',
|
||||
name: 'Driver Championship',
|
||||
type: 'driver',
|
||||
sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
bonusRulesBySessionType,
|
||||
dropScorePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RecalculateChampionshipStandingsUseCase', () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
const championshipId = 'driver-champ';
|
||||
|
||||
let seasonRepository: InMemorySeasonRepository;
|
||||
let leagueScoringConfigRepository: InMemoryLeagueScoringConfigRepository;
|
||||
let raceRepository: InMemoryRaceRepository;
|
||||
let resultRepository: InMemoryResultRepository;
|
||||
let penaltyRepository: InMemoryPenaltyRepository;
|
||||
let championshipStandingRepository: InMemoryChampionshipStandingRepository;
|
||||
|
||||
let useCase: RecalculateChampionshipStandingsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
seasonRepository = new InMemorySeasonRepository();
|
||||
leagueScoringConfigRepository = new InMemoryLeagueScoringConfigRepository();
|
||||
raceRepository = new InMemoryRaceRepository();
|
||||
resultRepository = new InMemoryResultRepository();
|
||||
penaltyRepository = new InMemoryPenaltyRepository();
|
||||
championshipStandingRepository = new InMemoryChampionshipStandingRepository();
|
||||
|
||||
const eventScoringService = new EventScoringService();
|
||||
const dropScoreApplier = new DropScoreApplier();
|
||||
const championshipAggregator = new ChampionshipAggregator(dropScoreApplier);
|
||||
|
||||
useCase = new RecalculateChampionshipStandingsUseCase(
|
||||
seasonRepository,
|
||||
leagueScoringConfigRepository,
|
||||
raceRepository,
|
||||
resultRepository,
|
||||
penaltyRepository,
|
||||
championshipStandingRepository,
|
||||
eventScoringService,
|
||||
championshipAggregator,
|
||||
);
|
||||
|
||||
const season = Season.create({
|
||||
id: seasonId,
|
||||
leagueId,
|
||||
gameId: 'iracing',
|
||||
name: 'Demo Season',
|
||||
status: 'active',
|
||||
year: 2025,
|
||||
order: 1,
|
||||
startDate: new Date('2025-01-01'),
|
||||
endDate: new Date('2025-12-31'),
|
||||
});
|
||||
|
||||
seasonRepository.seedSeason(season);
|
||||
|
||||
const championship = makeChampionshipConfig();
|
||||
|
||||
const leagueScoringConfig: LeagueScoringConfig = {
|
||||
id: 'lsc-1',
|
||||
seasonId,
|
||||
championships: [championship],
|
||||
};
|
||||
|
||||
leagueScoringConfigRepository.seedConfig(leagueScoringConfig);
|
||||
|
||||
const races: Race[] = [
|
||||
Race.create({
|
||||
id: 'race-1-sprint',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-02-01'),
|
||||
track: 'Track 1',
|
||||
car: 'Car A',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-1-main',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-02-01'),
|
||||
track: 'Track 1',
|
||||
car: 'Car A',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-2-sprint',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-03-01'),
|
||||
track: 'Track 2',
|
||||
car: 'Car A',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-2-main',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-03-01'),
|
||||
track: 'Track 2',
|
||||
car: 'Car A',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-3-sprint',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-04-01'),
|
||||
track: 'Track 3',
|
||||
car: 'Car A',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-3-main',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-04-01'),
|
||||
track: 'Track 3',
|
||||
car: 'Car A',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
}),
|
||||
];
|
||||
|
||||
races.forEach((race) => raceRepository.seedRace(race));
|
||||
|
||||
const _drivers = ['driver-1', 'driver-2', 'driver-3'];
|
||||
|
||||
const resultsData: Array<{
|
||||
raceId: string;
|
||||
finishingOrder: string[];
|
||||
fastestLapDriverId: string;
|
||||
}> = [
|
||||
{
|
||||
raceId: 'race-1-sprint',
|
||||
finishingOrder: ['driver-1', 'driver-2', 'driver-3'],
|
||||
fastestLapDriverId: 'driver-2',
|
||||
},
|
||||
{
|
||||
raceId: 'race-1-main',
|
||||
finishingOrder: ['driver-2', 'driver-1', 'driver-3'],
|
||||
fastestLapDriverId: 'driver-1',
|
||||
},
|
||||
{
|
||||
raceId: 'race-2-sprint',
|
||||
finishingOrder: ['driver-1', 'driver-3', 'driver-2'],
|
||||
fastestLapDriverId: 'driver-1',
|
||||
},
|
||||
{
|
||||
raceId: 'race-2-main',
|
||||
finishingOrder: ['driver-1', 'driver-2', 'driver-3'],
|
||||
fastestLapDriverId: 'driver-1',
|
||||
},
|
||||
{
|
||||
raceId: 'race-3-sprint',
|
||||
finishingOrder: ['driver-2', 'driver-1', 'driver-3'],
|
||||
fastestLapDriverId: 'driver-2',
|
||||
},
|
||||
{
|
||||
raceId: 'race-3-main',
|
||||
finishingOrder: ['driver-3', 'driver-1', 'driver-2'],
|
||||
fastestLapDriverId: 'driver-3',
|
||||
},
|
||||
];
|
||||
|
||||
let resultIdCounter = 1;
|
||||
for (const raceData of resultsData) {
|
||||
raceData.finishingOrder.forEach((driverId, index) => {
|
||||
const result = Result.create({
|
||||
id: `result-${resultIdCounter++}`,
|
||||
raceId: raceData.raceId,
|
||||
driverId,
|
||||
position: index + 1,
|
||||
fastestLap: driverId === raceData.fastestLapDriverId ? 90000 : 91000 + index * 100,
|
||||
incidents: 0,
|
||||
startPosition: index + 1,
|
||||
});
|
||||
resultRepository.seedResult(result);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('recalculates standings for a driver championship with sprint and main races', async () => {
|
||||
const dto = await useCase.execute({
|
||||
seasonId,
|
||||
championshipId,
|
||||
});
|
||||
|
||||
expect(dto.seasonId).toBe(seasonId);
|
||||
expect(dto.championshipId).toBe(championshipId);
|
||||
expect(dto.championshipName).toBe('Driver Championship');
|
||||
expect(dto.rows.length).toBeGreaterThan(0);
|
||||
|
||||
const rows = dto.rows;
|
||||
const sorted = [...rows].sort((a, b) => b.totalPoints - a.totalPoints);
|
||||
expect(rows.map((r) => r.participant.id)).toEqual(
|
||||
sorted.map((r) => r.participant.id),
|
||||
);
|
||||
|
||||
const leader = rows[0]!;
|
||||
expect(leader.resultsCounted).toBeLessThanOrEqual(6);
|
||||
expect(leader.resultsDropped).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -1,293 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { StartAutomationSessionUseCase } from '@core/automation/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { AutomationEnginePort as IAutomationEngine } from '@core/automation/application/ports/AutomationEnginePort';
|
||||
import { IBrowserAutomation as IScreenAutomation } from '@core/automation/application/ports/ScreenAutomationPort';
|
||||
import { SessionRepositoryPort as ISessionRepository } from '@core/automation/application/ports/SessionRepositoryPort';
|
||||
import { AutomationSession } from '@core/automation/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 IScreenAutomation,
|
||||
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)');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { VerifyAuthenticatedPageUseCase } from '@core/automation/application/use-cases/VerifyAuthenticatedPageUseCase';
|
||||
import { AuthenticationServicePort as IAuthenticationService } from '@core/automation/application/ports/AuthenticationServicePort';
|
||||
import { Result } from '@core/shared/result/Result';
|
||||
import { BrowserAuthenticationState } from '@core/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import { AuthenticationState } from '@core/automation/domain/value-objects/AuthenticationState';
|
||||
|
||||
describe('VerifyAuthenticatedPageUseCase', () => {
|
||||
let useCase: VerifyAuthenticatedPageUseCase;
|
||||
let mockAuthService: {
|
||||
checkSession: ReturnType<typeof vi.fn>;
|
||||
verifyPageAuthentication: ReturnType<typeof vi.fn>;
|
||||
initiateLogin: ReturnType<typeof vi.fn>;
|
||||
clearSession: ReturnType<typeof vi.fn>;
|
||||
getState: ReturnType<typeof vi.fn>;
|
||||
validateServerSide: ReturnType<typeof vi.fn>;
|
||||
refreshSession: ReturnType<typeof vi.fn>;
|
||||
getSessionExpiry: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthService = {
|
||||
checkSession: vi.fn(),
|
||||
verifyPageAuthentication: vi.fn(),
|
||||
initiateLogin: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
validateServerSide: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
getSessionExpiry: vi.fn(),
|
||||
};
|
||||
useCase = new VerifyAuthenticatedPageUseCase(
|
||||
mockAuthService as unknown as IAuthenticationService
|
||||
);
|
||||
});
|
||||
|
||||
it('should return fully authenticated browser state', async () => {
|
||||
const mockBrowserState = new BrowserAuthenticationState(true, true);
|
||||
mockAuthService.verifyPageAuthentication.mockResolvedValue(
|
||||
Result.ok(mockBrowserState)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const browserState = result.unwrap();
|
||||
expect(browserState.isFullyAuthenticated()).toBe(true);
|
||||
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
it('should return unauthenticated state when page not authenticated', async () => {
|
||||
const mockBrowserState = new BrowserAuthenticationState(true, false);
|
||||
mockAuthService.verifyPageAuthentication.mockResolvedValue(
|
||||
Result.ok(mockBrowserState)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const browserState = result.unwrap();
|
||||
expect(browserState.isFullyAuthenticated()).toBe(false);
|
||||
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('should return requires reauth state when cookies invalid', async () => {
|
||||
const mockBrowserState = new BrowserAuthenticationState(false, false);
|
||||
mockAuthService.verifyPageAuthentication.mockResolvedValue(
|
||||
Result.ok(mockBrowserState)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const browserState = result.unwrap();
|
||||
expect(browserState.requiresReauthentication()).toBe(true);
|
||||
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should propagate errors from verifyPageAuthentication', async () => {
|
||||
const error = new Error('Verification failed');
|
||||
mockAuthService.verifyPageAuthentication.mockResolvedValue(
|
||||
Result.err(error)
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error?.message).toBe('Verification failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle unexpected errors', async () => {
|
||||
mockAuthService.verifyPageAuthentication.mockRejectedValue(
|
||||
new Error('Unexpected error')
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error?.message).toBe('Page verification failed: Unexpected error');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface AutomationEngineValidationResultDTO {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface AutomationResultDTO {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
export interface CheckoutConfirmationRequestDTO {
|
||||
price: CheckoutPrice;
|
||||
state: CheckoutState;
|
||||
sessionMetadata: {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
};
|
||||
timeoutMs: number;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
export interface CheckoutInfoDTO {
|
||||
price: CheckoutPrice | null;
|
||||
state: CheckoutState;
|
||||
buttonHtml: string;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface ClickResultDTO extends AutomationResultDTO {
|
||||
target: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface FormFillResultDTO extends AutomationResultDTO {
|
||||
fieldName: string;
|
||||
valueSet: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface ModalResultDTO extends AutomationResultDTO {
|
||||
stepId: number;
|
||||
action: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface NavigationResultDTO extends AutomationResultDTO {
|
||||
url: string;
|
||||
loadTime: number;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
|
||||
export interface SessionDTO {
|
||||
sessionId: string;
|
||||
state: string;
|
||||
currentStep: number;
|
||||
config: HostedSessionConfig;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface WaitResultDTO extends AutomationResultDTO {
|
||||
target: string;
|
||||
waitedMs: number;
|
||||
found: boolean;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
/**
|
||||
* Port for authentication services implementing zero-knowledge login.
|
||||
*
|
||||
* GridPilot never sees, stores, or transmits user credentials.
|
||||
* Authentication is handled by opening a visible browser window where
|
||||
* the user logs in directly with iRacing. GridPilot only observes
|
||||
* URL changes to detect successful authentication.
|
||||
*/
|
||||
export interface AuthenticationServicePort {
|
||||
/**
|
||||
* Check if user has a valid session without prompting login.
|
||||
* Navigates to a protected iRacing page and checks for login redirects.
|
||||
*
|
||||
* @returns Result containing the current authentication state
|
||||
*/
|
||||
checkSession(): Promise<Result<AuthenticationState>>;
|
||||
|
||||
/**
|
||||
* Open browser for user to login manually.
|
||||
* The browser window is visible so user can verify they're on the real iRacing site.
|
||||
* GridPilot waits for URL change indicating successful login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
initiateLogin(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Clear the persistent session (logout).
|
||||
* Removes stored browser context and cookies.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
clearSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get current authentication state.
|
||||
* Returns cached state without making network requests.
|
||||
*
|
||||
* @returns The current AuthenticationState
|
||||
*/
|
||||
getState(): AuthenticationState;
|
||||
|
||||
/**
|
||||
* Validate session with server-side check.
|
||||
* Makes a lightweight HTTP request to verify cookies are still valid on the server.
|
||||
*
|
||||
* @returns Result containing true if server confirms validity, false otherwise
|
||||
*/
|
||||
validateServerSide(): Promise<Result<boolean>>;
|
||||
|
||||
/**
|
||||
* Refresh session state from cookie store.
|
||||
* Re-reads cookies and updates internal state without server validation.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
refreshSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get session expiry date.
|
||||
* Returns the expiry time extracted from session cookies.
|
||||
*
|
||||
* @returns Result containing the expiry Date or null if no expiration
|
||||
*/
|
||||
getSessionExpiry(): Promise<Result<Date | null>>;
|
||||
|
||||
/**
|
||||
* Verify browser page shows authenticated state.
|
||||
* Checks page content for authentication indicators.
|
||||
*/
|
||||
verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>>;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO';
|
||||
import type { IBrowserAutomation } from './ScreenAutomationPort';
|
||||
|
||||
export interface AutomationEnginePort {
|
||||
validateConfiguration(config: HostedSessionConfig): Promise<AutomationEngineValidationResultDTO>;
|
||||
executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void>;
|
||||
stopAutomation(): void;
|
||||
readonly browserAutomation: IBrowserAutomation;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export type AutomationEvent = {
|
||||
actionId?: string
|
||||
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
|
||||
timestamp: number
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export interface AutomationEventPublisherPort {
|
||||
publish(event: AutomationEvent): Promise<void>
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { AutomationEvent } from './AutomationEventPublisherPort';
|
||||
|
||||
export type LifecycleCallback = (event: AutomationEvent) => Promise<void> | void;
|
||||
|
||||
export interface AutomationLifecycleEmitterPort {
|
||||
onLifecycle(cb: LifecycleCallback): void;
|
||||
offLifecycle(cb: LifecycleCallback): void;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface AutomationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation';
|
||||
import type { CheckoutConfirmationRequestDTO } from '../dto/CheckoutConfirmationRequestDTO';
|
||||
|
||||
export interface CheckoutConfirmationPort {
|
||||
requestCheckoutConfirmation(
|
||||
request: CheckoutConfirmationRequestDTO
|
||||
): Promise<Result<CheckoutConfirmation>>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO';
|
||||
|
||||
export interface CheckoutServicePort {
|
||||
extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>>;
|
||||
proceedWithCheckout(): Promise<Result<void>>;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export type AutomationEvent = {
|
||||
actionId?: string
|
||||
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
|
||||
timestamp: number
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export interface IAutomationEventPublisher {
|
||||
publish(event: AutomationEvent): Promise<void>
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface Logger {
|
||||
debug(message: string, context?: Record<string, any>): void;
|
||||
info(message: string, context?: Record<string, any>): void;
|
||||
warn(message: string, context?: Record<string, any>): void;
|
||||
error(message: string, error?: Error, context?: Record<string, any>): void;
|
||||
verbose?(message: string, context?: Record<string, any>): void;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
|
||||
export type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
|
||||
|
||||
export interface IOverlaySyncPort {
|
||||
startAction(action: OverlayAction): Promise<ActionAck>
|
||||
cancelAction(actionId: string): Promise<void>
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Contextual metadata attached to log entries
|
||||
*/
|
||||
export interface LogContext {
|
||||
/** Unique session identifier for correlation */
|
||||
sessionId?: string;
|
||||
/** Current automation step (1-18) */
|
||||
stepId?: number;
|
||||
/** Step name for human readability */
|
||||
stepName?: string;
|
||||
/** Adapter or component name */
|
||||
adapter?: string;
|
||||
/** Operation duration in milliseconds */
|
||||
durationMs?: number;
|
||||
/** Additional arbitrary metadata */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/**
|
||||
* Log levels in order of severity (lowest to highest)
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { LogContext } from './LoggerContext';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
/**
|
||||
* LoggerPort - Port interface for application-layer logging.
|
||||
*/
|
||||
export interface LoggerPort extends Logger {
|
||||
debug(message: string, context?: LogContext): void;
|
||||
info(message: string, context?: LogContext): void;
|
||||
warn(message: string, context?: LogContext): void;
|
||||
error(message: string, error?: Error, context?: LogContext): void;
|
||||
fatal(message: string, error?: Error, context?: LogContext): void;
|
||||
child(context: LogContext): LoggerPort;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
|
||||
export type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
|
||||
|
||||
export interface OverlaySyncPort {
|
||||
startAction(action: OverlayAction): Promise<ActionAck>
|
||||
cancelAction(actionId: string): Promise<void>
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { NavigationResultDTO } from '../dto/NavigationResultDTO';
|
||||
import type { ClickResultDTO } from '../dto/ClickResultDTO';
|
||||
import type { WaitResultDTO } from '../dto/WaitResultDTO';
|
||||
import type { ModalResultDTO } from '../dto/ModalResultDTO';
|
||||
import type { AutomationResultDTO } from '../dto/AutomationResultDTO';
|
||||
import type { FormFillResultDTO } from '../dto/FormFillResultDTO';
|
||||
|
||||
/**
|
||||
* Browser automation interface for Playwright-based automation.
|
||||
*
|
||||
* This interface defines the contract for browser automation using
|
||||
* standard DOM manipulation via Playwright. All automation is done
|
||||
* through browser DevTools protocol - no OS-level automation.
|
||||
*/
|
||||
export interface IBrowserAutomation {
|
||||
/**
|
||||
* Navigate to a URL.
|
||||
*/
|
||||
navigateToPage(url: string): Promise<NavigationResultDTO>;
|
||||
|
||||
/**
|
||||
* Fill a form field by name or selector.
|
||||
*/
|
||||
fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO>;
|
||||
|
||||
/**
|
||||
* Click an element by selector or action name.
|
||||
*/
|
||||
clickElement(target: string): Promise<ClickResultDTO>;
|
||||
|
||||
/**
|
||||
* Wait for an element to appear.
|
||||
*/
|
||||
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO>;
|
||||
|
||||
/**
|
||||
* Handle modal dialogs.
|
||||
*/
|
||||
handleModal(stepId: StepId, action: string): Promise<ModalResultDTO>;
|
||||
|
||||
/**
|
||||
* Execute a complete workflow step.
|
||||
*/
|
||||
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO>;
|
||||
|
||||
/**
|
||||
* Initialize the browser connection.
|
||||
* Returns an AutomationResult indicating success or failure.
|
||||
*/
|
||||
connect?(): Promise<AutomationResultDTO>;
|
||||
|
||||
/**
|
||||
* Clean up browser resources.
|
||||
*/
|
||||
disconnect?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if browser is connected and ready.
|
||||
*/
|
||||
isConnected?(): boolean;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
|
||||
|
||||
export interface SessionRepositoryPort {
|
||||
save(session: AutomationSession): Promise<void>;
|
||||
findById(id: string): Promise<AutomationSession | null>;
|
||||
update(session: AutomationSession): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
findAll(): Promise<AutomationSession[]>;
|
||||
findByState(state: SessionStateValue): Promise<AutomationSession[]>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { Result } from '../../../shared/result/Result';
|
||||
|
||||
export interface SessionValidatorPort {
|
||||
validateSession(): Promise<Result<boolean>>;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface IUserConfirmationPort {
|
||||
confirm(message: string): Promise<boolean>;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
|
||||
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
|
||||
import { AutomationLifecycleEmitterPort, LifecycleCallback } from '../ports/AutomationLifecycleEmitterPort';
|
||||
import { LoggerPort } from '../ports/LoggerPort';
|
||||
import type { IAsyncApplicationService } from '@core/shared/application';
|
||||
|
||||
type ConstructorArgs = {
|
||||
lifecycleEmitter: AutomationLifecycleEmitterPort
|
||||
publisher: AutomationEventPublisherPort
|
||||
logger: LoggerPort
|
||||
initialPanelWaitMs?: number
|
||||
maxPanelRetries?: number
|
||||
backoffFactor?: number
|
||||
defaultTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class OverlaySyncService
|
||||
implements OverlaySyncPort, IAsyncApplicationService<OverlayAction, ActionAck>
|
||||
{
|
||||
private lifecycleEmitter: AutomationLifecycleEmitterPort
|
||||
private publisher: AutomationEventPublisherPort
|
||||
private logger: LoggerPort
|
||||
private initialPanelWaitMs: number
|
||||
private maxPanelRetries: number
|
||||
private backoffFactor: number
|
||||
private defaultTimeoutMs: number
|
||||
|
||||
constructor(args: ConstructorArgs) {
|
||||
this.lifecycleEmitter = args.lifecycleEmitter
|
||||
this.publisher = args.publisher
|
||||
this.logger = args.logger
|
||||
this.initialPanelWaitMs = args.initialPanelWaitMs ?? 500
|
||||
this.maxPanelRetries = args.maxPanelRetries ?? 3
|
||||
this.backoffFactor = args.backoffFactor ?? 2
|
||||
this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000
|
||||
}
|
||||
|
||||
async execute(action: OverlayAction): Promise<ActionAck> {
|
||||
return this.startAction(action)
|
||||
}
|
||||
|
||||
async startAction(action: OverlayAction): Promise<ActionAck> {
|
||||
const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs
|
||||
const seenEvents: AutomationEvent[] = []
|
||||
let settled = false
|
||||
|
||||
const cb: LifecycleCallback = async (ev) => {
|
||||
seenEvents.push(ev)
|
||||
if (ev.type === 'action-started' && ev.actionId === action.id) {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
resolveAck({ id: action.id, status: 'confirmed' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
this.lifecycleEmitter.offLifecycle(cb)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let resolveAck: (ack: ActionAck) => void = () => {}
|
||||
const promise = new Promise<ActionAck>((resolve) => {
|
||||
resolveAck = resolve
|
||||
try {
|
||||
this.lifecycleEmitter.onLifecycle(cb)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, {
|
||||
actionId: action.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
void this.publisher.publish({
|
||||
type: 'modal-opened',
|
||||
timestamp: Date.now(),
|
||||
payload: { actionId: action.id, label: action.label },
|
||||
actionId: action.id,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', {
|
||||
actionId: action.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise<ActionAck>((res) => {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', {
|
||||
actionId: action.id,
|
||||
timeoutMs,
|
||||
})
|
||||
const lastEvents = seenEvents.slice(-10)
|
||||
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', {
|
||||
actionId: action.id,
|
||||
events: lastEvents,
|
||||
})
|
||||
res({ id: action.id, status: 'tentative', reason: 'timeout' })
|
||||
}
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeoutPromise])
|
||||
}
|
||||
|
||||
async cancelAction(actionId: string): Promise<void> {
|
||||
try {
|
||||
await this.publisher.publish({
|
||||
type: 'panel-missing',
|
||||
timestamp: Date.now(),
|
||||
actionId,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', {
|
||||
actionId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import { SessionLifetime } from '../../domain/value-objects/SessionLifetime';
|
||||
import type { SessionValidatorPort } from '../ports/SessionValidatorPort';
|
||||
|
||||
/**
|
||||
* Use case for checking if the user has a valid iRacing session.
|
||||
*
|
||||
* This validates the session before automation starts, allowing
|
||||
* the system to prompt for re-authentication if needed.
|
||||
*
|
||||
* Implements hybrid validation strategy:
|
||||
* - File-based validation (fast, always executed)
|
||||
* - Optional server-side validation (slow, requires network)
|
||||
*/
|
||||
export class CheckAuthenticationUseCase {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly sessionValidator?: SessionValidatorPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the authentication check.
|
||||
*
|
||||
* @param options Optional configuration for validation
|
||||
* @returns Result containing the current AuthenticationState
|
||||
*/
|
||||
async execute(options?: {
|
||||
requireServerValidation?: boolean;
|
||||
verifyPageContent?: boolean;
|
||||
}): Promise<Result<AuthenticationState>> {
|
||||
this.logger.debug('Executing CheckAuthenticationUseCase', { options });
|
||||
try {
|
||||
// Step 1: File-based validation (fast)
|
||||
this.logger.debug('Performing file-based authentication check.');
|
||||
const fileResult = await this.authService.checkSession();
|
||||
if (fileResult.isErr()) {
|
||||
this.logger.error('File-based authentication check failed.', { error: fileResult.unwrapErr() });
|
||||
return fileResult;
|
||||
}
|
||||
this.logger.info('File-based authentication check succeeded.');
|
||||
|
||||
const fileState = fileResult.unwrap();
|
||||
this.logger.debug(`File-based authentication state: ${fileState}`);
|
||||
|
||||
// Step 2: Check session expiry if authenticated
|
||||
if (fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Session is authenticated, checking expiry.');
|
||||
const expiryResult = await this.authService.getSessionExpiry();
|
||||
if (expiryResult.isErr()) {
|
||||
this.logger.warn('Could not retrieve session expiry, proceeding with file-based state.', { error: expiryResult.unwrapErr() });
|
||||
// Don't fail completely if we can't get expiry, use file-based state
|
||||
return Result.ok(fileState);
|
||||
}
|
||||
|
||||
const expiry = expiryResult.unwrap();
|
||||
if (expiry !== null) {
|
||||
try {
|
||||
const sessionLifetime = new SessionLifetime(expiry);
|
||||
if (sessionLifetime.isExpired()) {
|
||||
this.logger.info('Session has expired based on lifetime.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.debug('Session is not expired.');
|
||||
} catch (error) {
|
||||
this.logger.error('Invalid expiry date encountered, treating session as expired.', { expiry, error });
|
||||
// Invalid expiry date, treat as expired for safety
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Optional page content verification
|
||||
if (options?.verifyPageContent && fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Performing optional page content verification.');
|
||||
const pageResult = await this.authService.verifyPageAuthentication();
|
||||
|
||||
if (pageResult.isOk()) {
|
||||
const browserState = pageResult.unwrap();
|
||||
// If cookies valid but page shows login UI, session is expired
|
||||
if (!browserState.isFullyAuthenticated()) {
|
||||
this.logger.info('Page content verification indicated session expired.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.info('Page content verification succeeded.');
|
||||
} else {
|
||||
this.logger.warn('Page content verification failed, proceeding with file-based state.', { error: pageResult.unwrapErr() });
|
||||
}
|
||||
// Don't block on page verification errors, continue with file-based state
|
||||
}
|
||||
|
||||
// Step 4: Optional server-side validation
|
||||
if (this.sessionValidator && fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Performing optional server-side validation.');
|
||||
const serverResult = await this.sessionValidator.validateSession();
|
||||
|
||||
// Don't block on server validation errors
|
||||
if (serverResult.isOk()) {
|
||||
const isValid = serverResult.unwrap();
|
||||
if (!isValid) {
|
||||
this.logger.info('Server-side validation indicated session expired.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.info('Server-side validation succeeded.');
|
||||
} else {
|
||||
this.logger.warn('Server-side validation failed, proceeding with file-based state.', { error: serverResult.unwrapErr() });
|
||||
}
|
||||
}
|
||||
this.logger.info(`CheckAuthenticationUseCase completed successfully with state: ${fileState}`);
|
||||
return Result.ok(fileState);
|
||||
} catch (error) {
|
||||
this.logger.error('An unexpected error occurred during authentication check.', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
/**
|
||||
* Use case for clearing the user's session (logout).
|
||||
*
|
||||
* Removes stored browser context and cookies, effectively logging
|
||||
* the user out. The next automation attempt will require re-authentication.
|
||||
*/
|
||||
export class ClearSessionUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: Logger, // Inject Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the session clearing.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
this.logger.debug('Attempting to clear user session.', {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
try {
|
||||
const result = await this.authService.clearSession();
|
||||
|
||||
if (result.isSuccess) {
|
||||
this.logger.info('User session cleared successfully.', {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('Failed to clear user session.', {
|
||||
useCase: 'ClearSessionUseCase',
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('Error clearing user session.', error, {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
return Result.fail(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { RaceCreationResult } from '../../domain/value-objects/RaceCreationResult';
|
||||
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class CompleteRaceCreationUseCase {
|
||||
constructor(private readonly checkoutService: CheckoutServicePort, private readonly logger: Logger) {}
|
||||
|
||||
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
|
||||
this.logger.debug(`Attempting to complete race creation for session ID: ${sessionId}`);
|
||||
if (!sessionId || sessionId.trim() === '') {
|
||||
this.logger.error('Session ID is required for completing race creation.');
|
||||
return Result.err(new Error('Session ID is required'));
|
||||
}
|
||||
|
||||
const infoResult = await this.checkoutService.extractCheckoutInfo();
|
||||
|
||||
if (infoResult.isErr()) {
|
||||
this.logger.error(`Failed to extract checkout info: ${infoResult.unwrapErr().message}`);
|
||||
return Result.err(infoResult.unwrapErr());
|
||||
}
|
||||
|
||||
const info = infoResult.unwrap();
|
||||
this.logger.debug(`Extracted checkout information: ${JSON.stringify(info)}`);
|
||||
|
||||
if (!info.price) {
|
||||
this.logger.error('Could not extract price from checkout page.');
|
||||
return Result.err(new Error('Could not extract price from checkout page'));
|
||||
}
|
||||
|
||||
try {
|
||||
const raceCreationResult = RaceCreationResult.create({
|
||||
sessionId,
|
||||
price: info.price.toDisplayString(),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
this.logger.info(`Race creation completed successfully for session ID: ${sessionId}`);
|
||||
return Result.ok(raceCreationResult);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
this.logger.error(`Error completing race creation for session ID ${sessionId}: ${err.message}`);
|
||||
return Result.err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||
import type { CheckoutConfirmationPort } from '../ports/CheckoutConfirmationPort';
|
||||
|
||||
|
||||
interface SessionMetadata {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
}
|
||||
|
||||
export class ConfirmCheckoutUseCase {
|
||||
private static readonly DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
constructor(
|
||||
private readonly checkoutService: CheckoutServicePort,
|
||||
private readonly confirmationPort: CheckoutConfirmationPort,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
|
||||
this.logger.debug('Executing ConfirmCheckoutUseCase', { sessionMetadata });
|
||||
|
||||
const infoResult = await this.checkoutService.extractCheckoutInfo();
|
||||
|
||||
if (infoResult.isErr()) {
|
||||
this.logger.error('Failed to extract checkout info', { error: infoResult.unwrapErr() });
|
||||
return Result.err(infoResult.unwrapErr());
|
||||
}
|
||||
|
||||
const info = infoResult.unwrap();
|
||||
this.logger.info('Extracted checkout info', { state: info.state.getValue(), price: info.price });
|
||||
|
||||
|
||||
if (info.state.getValue() === CheckoutStateEnum.INSUFFICIENT_FUNDS) {
|
||||
this.logger.error('Insufficient funds to complete checkout');
|
||||
return Result.err(new Error('Insufficient funds to complete checkout'));
|
||||
}
|
||||
|
||||
if (!info.price) {
|
||||
this.logger.error('Could not extract price from checkout page');
|
||||
return Result.err(new Error('Could not extract price from checkout page'));
|
||||
}
|
||||
|
||||
this.logger.debug('Requesting checkout confirmation', { price: info.price, state: info.state.getValue(), sessionMetadata });
|
||||
|
||||
// Request confirmation via port with full checkout context
|
||||
const confirmationResult = await this.confirmationPort.requestCheckoutConfirmation({
|
||||
price: info.price,
|
||||
state: info.state,
|
||||
sessionMetadata: sessionMetadata || {
|
||||
sessionName: 'Unknown Session',
|
||||
trackId: 'unknown',
|
||||
carIds: [],
|
||||
},
|
||||
timeoutMs: ConfirmCheckoutUseCase.DEFAULT_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (confirmationResult.isErr()) {
|
||||
this.logger.error('Checkout confirmation failed', { error: confirmationResult.unwrapErr() });
|
||||
return Result.err(confirmationResult.unwrapErr());
|
||||
}
|
||||
|
||||
const confirmation = confirmationResult.unwrap();
|
||||
this.logger.info('Checkout confirmation received', { confirmation });
|
||||
|
||||
if (confirmation.isCancelled()) {
|
||||
this.logger.error('Checkout cancelled by user');
|
||||
return Result.err(new Error('Checkout cancelled by user'));
|
||||
}
|
||||
|
||||
if (confirmation.isTimeout()) {
|
||||
this.logger.error('Checkout confirmation timeout');
|
||||
return Result.err(new Error('Checkout confirmation timeout'));
|
||||
}
|
||||
|
||||
this.logger.info('Proceeding with checkout');
|
||||
const checkoutResult = await this.checkoutService.proceedWithCheckout();
|
||||
|
||||
if (checkoutResult.isOk()) {
|
||||
this.logger.info('Checkout process completed successfully.');
|
||||
} else {
|
||||
this.logger.error('Checkout process failed', { error: checkoutResult.unwrapErr() });
|
||||
}
|
||||
return checkoutResult;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
/**
|
||||
* Use case for initiating the manual login flow.
|
||||
*
|
||||
* Opens a visible browser window where the user can log into iRacing directly.
|
||||
* GridPilot never sees the credentials - it only waits for the URL to change
|
||||
* indicating successful login.
|
||||
*/
|
||||
export class InitiateLoginUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the login flow.
|
||||
* Opens browser and waits for user to complete manual login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
this.logger.debug('Initiating login flow...');
|
||||
try {
|
||||
const result = await this.authService.initiateLogin();
|
||||
if (result.isOk()) {
|
||||
this.logger.info('Login flow initiated successfully.');
|
||||
} else {
|
||||
this.logger.warn('Login flow initiation failed.', { error: result.error });
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('Error initiating login flow.', error);
|
||||
return Result.fail(error.message || 'Unknown error during login initiation.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { AsyncUseCase } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { AutomationEnginePort } from '../ports/AutomationEnginePort';
|
||||
import type { IBrowserAutomation } from '../ports/ScreenAutomationPort';
|
||||
import { SessionRepositoryPort } from '../ports/SessionRepositoryPort';
|
||||
import type { SessionDTO } from '../dto/SessionDTO';
|
||||
|
||||
export class StartAutomationSessionUseCase
|
||||
implements AsyncUseCase<HostedSessionConfig, SessionDTO> {
|
||||
constructor(
|
||||
private readonly automationEngine: AutomationEnginePort,
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: SessionRepositoryPort,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(config: HostedSessionConfig): Promise<SessionDTO> {
|
||||
this.logger.debug('Starting automation session execution', { config });
|
||||
|
||||
const session = AutomationSession.create(config);
|
||||
this.logger.info(`Automation session created with ID: ${session.id}`);
|
||||
|
||||
const validationResult = await this.automationEngine.validateConfiguration(config);
|
||||
if (!validationResult.isValid) {
|
||||
this.logger.warn('Automation session configuration validation failed', { config, error: validationResult.error });
|
||||
this.logger.error('Automation session configuration validation failed', { config, error: validationResult.error });
|
||||
throw new Error(validationResult.error);
|
||||
}
|
||||
this.logger.debug('Automation session configuration validated successfully.');
|
||||
|
||||
await this.sessionRepository.save(session);
|
||||
this.logger.info(`Automation session with ID: ${session.id} saved to repository.`);
|
||||
|
||||
const dto: SessionDTO = {
|
||||
sessionId: session.id,
|
||||
state: session.state.value,
|
||||
currentStep: session.currentStep.value,
|
||||
config: session.config,
|
||||
...(session.startedAt ? { startedAt: session.startedAt } : {}),
|
||||
...(session.completedAt ? { completedAt: session.completedAt } : {}),
|
||||
...(session.errorMessage ? { errorMessage: session.errorMessage } : {}),
|
||||
};
|
||||
|
||||
this.logger.debug('Automation session executed successfully, returning DTO.', { dto });
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
/**
|
||||
* Use case for verifying browser shows authenticated page state.
|
||||
* Combines cookie validation with page content verification.
|
||||
*/
|
||||
export class VerifyAuthenticatedPageUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<BrowserAuthenticationState>> {
|
||||
this.logger.debug('Executing VerifyAuthenticatedPageUseCase');
|
||||
try {
|
||||
const result = await this.authService.verifyPageAuthentication();
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.error ?? new Error('Page verification failed');
|
||||
this.logger.error(`Page verification failed: ${error.message}`, error);
|
||||
return Result.err<BrowserAuthenticationState>(error);
|
||||
}
|
||||
|
||||
const browserState = result.unwrap();
|
||||
this.logger.info('Successfully verified authenticated page state.');
|
||||
return Result.ok<BrowserAuthenticationState>(browserState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Page verification failed unexpectedly: ${message}`, error);
|
||||
return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutomationSession } from '@core/automation/domain/entities/AutomationSession';
|
||||
import { StepId } from '@core/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PageStateValidator } from '@core/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 = (_selector: string) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
import type { IDomainValidationService } from '@core/shared/domain';
|
||||
import { Result } from '../../../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)}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepTransitionValidator } from '@core/automation/domain/services/StepTransitionValidator';
|
||||
import { StepId } from '@core/automation/domain/value-objects/StepId';
|
||||
|
||||
|
||||
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 17 (final step)', () => {
|
||||
const nextStep = StepId.create(17);
|
||||
|
||||
const shouldStop = StepTransitionValidator.shouldStopAtStep18(nextStep);
|
||||
|
||||
expect(shouldStop).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for steps before 17', () => {
|
||||
const nextStep = StepId.create(16);
|
||||
|
||||
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 17 (final)', () => {
|
||||
const step = StepId.create(17);
|
||||
|
||||
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 <= 17; 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
|
||||
import type { IDomainValidationService } from '@core/shared/domain';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
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(
|
||||
currentStep: StepId,
|
||||
nextStep: StepId
|
||||
): 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}`;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 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];
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { BrowserAuthenticationState } from '@core/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import { AuthenticationState } from '@core/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutConfirmation } from '@core/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', () => {
|
||||
expect(() => CheckoutConfirmation.create('invalid' as unknown)).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
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(__reason?: string): 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';
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutPrice } from '@core/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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { CookieConfiguration } from '@core/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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
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 (error) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceCreationResult } from '@core/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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionLifetime } from '@core/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
|
||||
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 unknown)).toThrow('Invalid session state');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => SessionState.create('' as unknown)).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepId } from '@core/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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export * from './domain/value-objects/StepId';
|
||||
export * from './domain/value-objects/CheckoutState';
|
||||
export * from './domain/value-objects/RaceCreationResult';
|
||||
export * from './domain/value-objects/CheckoutPrice';
|
||||
export * from './domain/value-objects/CheckoutConfirmation';
|
||||
export * from './domain/value-objects/AuthenticationState';
|
||||
export * from './domain/value-objects/BrowserAuthenticationState';
|
||||
export * from './domain/value-objects/CookieConfiguration';
|
||||
export * from './domain/value-objects/ScreenRegion';
|
||||
export * from './domain/value-objects/SessionLifetime';
|
||||
export * from './domain/value-objects/SessionState';
|
||||
|
||||
export * from './domain/entities/AutomationSession';
|
||||
export * from './domain/types/HostedSessionConfig';
|
||||
export * from './domain/entities/StepExecution';
|
||||
|
||||
export * from './domain/services/PageStateValidator';
|
||||
export * from './domain/services/StepTransitionValidator';
|
||||
@@ -1,262 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
|
||||
describe('AutomationConfig', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset environment before each test
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('getAutomationMode', () => {
|
||||
describe('NODE_ENV-based mode detection', () => {
|
||||
it('should return production mode when NODE_ENV=production', () => {
|
||||
(process.env as unknown).NODE_ENV = 'production';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('production');
|
||||
});
|
||||
|
||||
it('should return test mode when NODE_ENV=test', () => {
|
||||
(process.env as unknown).NODE_ENV = 'test';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return test mode when NODE_ENV is not set', () => {
|
||||
delete (process.env as unknown).NODE_ENV;
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return test mode for unknown NODE_ENV values', () => {
|
||||
(process.env as unknown).NODE_ENV = 'staging';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return development mode when NODE_ENV=development', () => {
|
||||
(process.env as unknown).NODE_ENV = 'development';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('development');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy AUTOMATION_MODE support', () => {
|
||||
it('should map legacy dev mode to test with deprecation warning', () => {
|
||||
process.env.AUTOMATION_MODE = 'dev';
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('test');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED] AUTOMATION_MODE')
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should map legacy mock mode to test with deprecation warning', () => {
|
||||
process.env.AUTOMATION_MODE = 'mock';
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('test');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED] AUTOMATION_MODE')
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should map legacy production mode to production with deprecation warning', () => {
|
||||
process.env.AUTOMATION_MODE = 'production';
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('production');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED] AUTOMATION_MODE')
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should ignore invalid AUTOMATION_MODE and use NODE_ENV', () => {
|
||||
process.env.AUTOMATION_MODE = 'invalid-mode';
|
||||
(process.env as unknown).NODE_ENV = 'production';
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('production');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAutomationConfig', () => {
|
||||
describe('default configuration', () => {
|
||||
it('should return test mode when NODE_ENV is not set', () => {
|
||||
delete (process.env as unknown).NODE_ENV;
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return default nutJs configuration', () => {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.nutJs?.windowTitle).toBe('iRacing');
|
||||
expect(config.nutJs?.templatePath).toBe('./resources/templates/iracing');
|
||||
expect(config.nutJs?.confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it('should return default shared settings', () => {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.defaultTimeout).toBe(30000);
|
||||
expect(config.retryAttempts).toBe(3);
|
||||
expect(config.screenshotOnError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('production mode configuration', () => {
|
||||
it('should return production mode when NODE_ENV=production', () => {
|
||||
(process.env as unknown).NODE_ENV = 'production';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.mode).toBe('production');
|
||||
});
|
||||
|
||||
it('should parse IRACING_WINDOW_TITLE', () => {
|
||||
process.env.IRACING_WINDOW_TITLE = 'iRacing Simulator';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.nutJs?.windowTitle).toBe('iRacing Simulator');
|
||||
});
|
||||
|
||||
it('should parse TEMPLATE_PATH', () => {
|
||||
process.env.TEMPLATE_PATH = '/custom/templates';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.nutJs?.templatePath).toBe('/custom/templates');
|
||||
});
|
||||
|
||||
it('should parse OCR_CONFIDENCE', () => {
|
||||
process.env.OCR_CONFIDENCE = '0.85';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.nutJs?.confidence).toBe(0.85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable parsing', () => {
|
||||
it('should parse AUTOMATION_TIMEOUT', () => {
|
||||
process.env.AUTOMATION_TIMEOUT = '60000';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.defaultTimeout).toBe(60000);
|
||||
});
|
||||
|
||||
it('should parse RETRY_ATTEMPTS', () => {
|
||||
process.env.RETRY_ATTEMPTS = '5';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.retryAttempts).toBe(5);
|
||||
});
|
||||
|
||||
it('should parse SCREENSHOT_ON_ERROR=false', () => {
|
||||
process.env.SCREENSHOT_ON_ERROR = 'false';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.screenshotOnError).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse SCREENSHOT_ON_ERROR=true', () => {
|
||||
process.env.SCREENSHOT_ON_ERROR = 'true';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.screenshotOnError).toBe(true);
|
||||
});
|
||||
|
||||
it('should fallback to defaults for invalid integer values', () => {
|
||||
process.env.AUTOMATION_TIMEOUT = 'not-a-number';
|
||||
process.env.RETRY_ATTEMPTS = '';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.defaultTimeout).toBe(30000);
|
||||
expect(config.retryAttempts).toBe(3);
|
||||
});
|
||||
|
||||
it('should fallback to defaults for invalid float values', () => {
|
||||
process.env.OCR_CONFIDENCE = 'invalid';
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.nutJs?.confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it('should fallback to test mode for invalid NODE_ENV', () => {
|
||||
(process.env as unknown).NODE_ENV = 'invalid-env';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.mode).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full configuration scenario', () => {
|
||||
it('should load complete test environment configuration', () => {
|
||||
(process.env as unknown).NODE_ENV = 'test';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.mode).toBe('test');
|
||||
expect(config.nutJs).toBeDefined();
|
||||
});
|
||||
|
||||
it('should load complete production environment configuration', () => {
|
||||
(process.env as unknown).NODE_ENV = 'production';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.mode).toBe('production');
|
||||
expect(config.nutJs).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { BrowserModeConfigLoader } from '@core/automation/infrastructure/config/BrowserModeConfig';
|
||||
|
||||
/**
|
||||
* Unit tests for BrowserModeConfig - GREEN PHASE
|
||||
*
|
||||
* Tests for browser mode configuration with runtime control in development mode.
|
||||
*/
|
||||
|
||||
describe('BrowserModeConfig - GREEN Phase', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete (process.env as unknown).NODE_ENV;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('Development Mode with Runtime Control', () => {
|
||||
it('should default to headless in development mode', () => {
|
||||
(process.env as unknown).NODE_ENV = 'development';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless'); // Changed from 'headed'
|
||||
expect(config.source).toBe('GUI');
|
||||
});
|
||||
|
||||
it('should allow runtime switch to headless mode in development', () => {
|
||||
(process.env as unknown).NODE_ENV = 'development';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
loader.setDevelopmentMode('headless');
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('GUI');
|
||||
});
|
||||
|
||||
it('should allow runtime switch to headed mode in development', () => {
|
||||
(process.env as unknown).NODE_ENV = 'development';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
loader.setDevelopmentMode('headed');
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headed');
|
||||
expect(config.source).toBe('GUI');
|
||||
});
|
||||
|
||||
it('should persist runtime setting across multiple load() calls', () => {
|
||||
(process.env as unknown).NODE_ENV = 'development';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
loader.setDevelopmentMode('headless');
|
||||
|
||||
const config1 = loader.load();
|
||||
const config2 = loader.load();
|
||||
|
||||
expect(config1.mode).toBe('headless');
|
||||
expect(config2.mode).toBe('headless');
|
||||
});
|
||||
|
||||
it('should return current development mode via getter', () => {
|
||||
(process.env as unknown).NODE_ENV = 'development';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
expect(loader.getDevelopmentMode()).toBe('headless');
|
||||
|
||||
loader.setDevelopmentMode('headless');
|
||||
expect(loader.getDevelopmentMode()).toBe('headless');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Production Mode', () => {
|
||||
it('should use headless mode when NODE_ENV=production', () => {
|
||||
(process.env as unknown).NODE_ENV = 'production';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
|
||||
it('should ignore setDevelopmentMode in production', () => {
|
||||
(process.env as unknown).NODE_ENV = 'production';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
loader.setDevelopmentMode('headed');
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Mode', () => {
|
||||
it('should use headless mode when NODE_ENV=test', () => {
|
||||
(process.env as unknown).NODE_ENV = 'test';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
|
||||
it('should ignore setDevelopmentMode in test mode', () => {
|
||||
(process.env as unknown).NODE_ENV = 'test';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
loader.setDevelopmentMode('headed');
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Mode', () => {
|
||||
it('should default to headless mode when NODE_ENV is not set', () => {
|
||||
delete (process.env as unknown).NODE_ENV;
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
|
||||
it('should use headless mode for any non-development NODE_ENV value', () => {
|
||||
(process.env as unknown).NODE_ENV = 'staging';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source Tracking', () => {
|
||||
it('should report GUI as source in development mode', () => {
|
||||
(process.env as unknown).NODE_ENV = 'development';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.source).toBe('GUI');
|
||||
});
|
||||
|
||||
it('should report NODE_ENV as source in production mode', () => {
|
||||
(process.env as unknown).NODE_ENV = 'production';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
|
||||
it('should report NODE_ENV as source in test mode', () => {
|
||||
(process.env as unknown).NODE_ENV = 'test';
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
|
||||
it('should report NODE_ENV as source when NODE_ENV is not set', () => {
|
||||
delete (process.env as unknown).NODE_ENV;
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { DemoImageServiceAdapter } from '@core/testing-support';
|
||||
|
||||
describe('DemoImageServiceAdapter - driver avatars', () => {
|
||||
it('returns male default avatar for a demo driver treated as male (odd id suffix)', () => {
|
||||
// Given a demo driver id that maps to a male profile
|
||||
const adapter = new DemoImageServiceAdapter();
|
||||
|
||||
// When resolving the driver avatar
|
||||
const src = adapter.getDriverAvatar('driver-1');
|
||||
|
||||
// Then it should use the male default avatar asset
|
||||
expect(src).toBe('/images/avatars/male-default-avatar.jpg');
|
||||
});
|
||||
|
||||
it('returns female default avatar for a demo driver treated as female (even id suffix)', () => {
|
||||
// Given a demo driver id that maps to a female profile
|
||||
const adapter = new DemoImageServiceAdapter();
|
||||
|
||||
// When resolving the driver avatar
|
||||
const src = adapter.getDriverAvatar('driver-2');
|
||||
|
||||
// Then it should use the female default avatar asset
|
||||
expect(src).toBe('/images/avatars/female-default-avatar.jpeg');
|
||||
});
|
||||
|
||||
it('falls back to a sensible default avatar when driver id has no numeric suffix', () => {
|
||||
// Given a demo driver id without a numeric suffix
|
||||
const adapter = new DemoImageServiceAdapter();
|
||||
|
||||
// When resolving the driver avatar
|
||||
const src = adapter.getDriverAvatar('demo-driver');
|
||||
|
||||
// Then it should still resolve to one of the default avatar assets
|
||||
expect(['/images/avatars/male-default-avatar.jpg', '/images/avatars/female-default-avatar.jpeg']).toContain(
|
||||
src,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
// Mock electron module with factory function
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
on: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { ElectronCheckoutConfirmationAdapter } from '@core/automation/infrastructure//ipc/ElectronCheckoutConfirmationAdapter';
|
||||
import { CheckoutPrice } from '@core/automation/domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '@core/automation/domain/value-objects/CheckoutState';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
describe('ElectronCheckoutConfirmationAdapter', () => {
|
||||
let mockWindow: BrowserWindow;
|
||||
let adapter: ElectronCheckoutConfirmationAdapter;
|
||||
type IpcEventLike = { sender?: unknown };
|
||||
let ipcMainOnCallback: ((event: IpcEventLike, decision: 'confirmed' | 'cancelled' | 'timeout') => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainOnCallback = null;
|
||||
|
||||
// Capture the IPC handler callback
|
||||
vi.mocked(ipcMain.on).mockImplementation((channel, callback) => {
|
||||
if (channel === 'checkout:confirm') {
|
||||
ipcMainOnCallback = callback as (event: IpcEventLike, decision: 'confirmed' | 'cancelled' | 'timeout') => void;
|
||||
}
|
||||
return ipcMain;
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
},
|
||||
} as unknown as BrowserWindow;
|
||||
|
||||
adapter = new ElectronCheckoutConfirmationAdapter(mockWindow);
|
||||
});
|
||||
|
||||
describe('requestCheckoutConfirmation', () => {
|
||||
it('should send IPC message to renderer with request details', async () => {
|
||||
const request = {
|
||||
price: CheckoutPrice.fromString('$25.50'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1', 'car2'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
// Simulate immediate confirmation via IPC
|
||||
setTimeout(() => {
|
||||
if (ipcMainOnCallback) {
|
||||
ipcMainOnCallback({} as IpcEventLike, 'confirmed');
|
||||
}
|
||||
}, 10);
|
||||
|
||||
const result = await adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'checkout:request-confirmation',
|
||||
expect.objectContaining({
|
||||
price: '$25.50',
|
||||
sessionMetadata: request.sessionMetadata,
|
||||
timeoutMs: 30000,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const confirmation = result.unwrap();
|
||||
expect(confirmation.isConfirmed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle user confirmation', async () => {
|
||||
const request = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (ipcMainOnCallback) {
|
||||
ipcMainOnCallback({} as IpcEventLike, 'confirmed');
|
||||
}
|
||||
}, 10);
|
||||
|
||||
const result = await adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const confirmation = result.unwrap();
|
||||
expect(confirmation.isConfirmed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle user cancellation', async () => {
|
||||
const request = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (ipcMainOnCallback) {
|
||||
ipcMainOnCallback({} as IpcEventLike, 'cancelled');
|
||||
}
|
||||
}, 10);
|
||||
|
||||
const result = await adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const confirmation = result.unwrap();
|
||||
expect(confirmation.isCancelled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should timeout when no response received', async () => {
|
||||
const request = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 100,
|
||||
};
|
||||
|
||||
const result = await adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const confirmation = result.unwrap();
|
||||
expect(confirmation.isTimeout()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject when already pending', async () => {
|
||||
const request = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
// Start first request
|
||||
const promise1 = adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
// Try to start second request immediately (should fail)
|
||||
const result2 = await adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
expect(result2.isErr()).toBe(true);
|
||||
expect(result2.unwrapErr().message).toContain('already pending');
|
||||
|
||||
// Confirm first request to clean up
|
||||
if (ipcMainOnCallback) {
|
||||
ipcMainOnCallback({} as IpcEventLike, 'confirmed');
|
||||
}
|
||||
|
||||
await promise1;
|
||||
});
|
||||
|
||||
it('should send correct state to renderer', async () => {
|
||||
const request = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.ready(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 100,
|
||||
};
|
||||
|
||||
await adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'checkout:request-confirmation',
|
||||
expect.objectContaining({
|
||||
state: 'ready',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle insufficient funds state', async () => {
|
||||
const request = {
|
||||
price: CheckoutPrice.fromString('$10.00'),
|
||||
state: CheckoutState.insufficientFunds(),
|
||||
sessionMetadata: {
|
||||
sessionName: 'Test',
|
||||
trackId: 'spa',
|
||||
carIds: ['car1'],
|
||||
},
|
||||
timeoutMs: 100,
|
||||
};
|
||||
|
||||
await adapter.requestCheckoutConfirmation(request);
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'checkout:request-confirmation',
|
||||
expect.objectContaining({
|
||||
state: 'insufficient_funds',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
describe('Wizard Dismissal Detection', () => {
|
||||
let mockPage: Page;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPage = {
|
||||
locator: vi.fn(),
|
||||
waitForTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Page;
|
||||
});
|
||||
|
||||
describe('isWizardModalDismissed', () => {
|
||||
test('should return FALSE when modal is transitioning between steps (temporarily hidden)', async () => {
|
||||
const modalSelector = '.modal.fade.in';
|
||||
|
||||
// Simulate step transition: modal not visible initially, then reappears after 500ms
|
||||
let checkCount = 0;
|
||||
const mockLocator = {
|
||||
isVisible: vi.fn().mockImplementation(() => {
|
||||
checkCount++;
|
||||
// First check: modal not visible (transitioning)
|
||||
if (checkCount === 1) return Promise.resolve(false);
|
||||
// Second check after 500ms delay: modal reappears (transition complete)
|
||||
if (checkCount === 2) return Promise.resolve(true);
|
||||
return Promise.resolve(false);
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
// Simulate the isWizardModalDismissed logic
|
||||
const isWizardModalDismissed = async (): Promise<boolean> => {
|
||||
const modalVisible = await mockPage.locator(modalSelector).isVisible().catch(() => false);
|
||||
|
||||
if (modalVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait 500ms to distinguish between transition and dismissal
|
||||
await mockPage.waitForTimeout(500);
|
||||
|
||||
// Check again after delay
|
||||
const stillNotVisible = !await mockPage.locator(modalSelector).isVisible().catch(() => false);
|
||||
|
||||
return stillNotVisible;
|
||||
};
|
||||
|
||||
const result = await isWizardModalDismissed();
|
||||
|
||||
// Should be FALSE because modal reappeared after transition
|
||||
expect(result).toBe(false);
|
||||
expect(mockPage.waitForTimeout).toHaveBeenCalledWith(500);
|
||||
expect(mockLocator.isVisible).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should return TRUE when modal is permanently dismissed by user', async () => {
|
||||
const modalSelector = '.modal.fade.in';
|
||||
|
||||
// Simulate user dismissal: modal not visible and stays not visible
|
||||
const mockLocator = {
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const isWizardModalDismissed = async (): Promise<boolean> => {
|
||||
const modalVisible = await mockPage.locator(modalSelector).isVisible().catch(() => false);
|
||||
|
||||
if (modalVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await mockPage.waitForTimeout(500);
|
||||
|
||||
const stillNotVisible = !await mockPage.locator(modalSelector).isVisible().catch(() => false);
|
||||
|
||||
return stillNotVisible;
|
||||
};
|
||||
|
||||
const result = await isWizardModalDismissed();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockLocator.isVisible).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should return FALSE when modal is visible (user did not dismiss)', async () => {
|
||||
const modalSelector = '.modal.fade.in';
|
||||
|
||||
const mockLocator = {
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const isWizardModalDismissed = async (): Promise<boolean> => {
|
||||
const modalVisible = await mockPage.locator(modalSelector).isVisible().catch(() => false);
|
||||
|
||||
if (modalVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await mockPage.waitForTimeout(500);
|
||||
|
||||
const stillNotVisible = !await mockPage.locator(modalSelector).isVisible().catch(() => false);
|
||||
|
||||
return stillNotVisible;
|
||||
};
|
||||
|
||||
const result = await isWizardModalDismissed();
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should not wait or check again if modal is visible
|
||||
expect(mockPage.waitForTimeout).not.toHaveBeenCalled();
|
||||
expect(mockLocator.isVisible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { AutomationEvent } from '@core/automation/application/ports/AutomationEventPublisherPort';
|
||||
|
||||
export type LifecycleCallback = (event: AutomationEvent) => Promise<void> | void;
|
||||
|
||||
export interface IAutomationLifecycleEmitter {
|
||||
onLifecycle(cb: LifecycleCallback): void;
|
||||
offLifecycle(cb: LifecycleCallback): void;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Result } from '../../../../shared/result/Result';
|
||||
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
|
||||
import type { CheckoutInfoDTO } from '../../../application/dto/CheckoutInfoDTO';
|
||||
import { IRACING_SELECTORS } from './dom/IRacingSelectors';
|
||||
|
||||
interface Page {
|
||||
locator(_selector: string): Locator;
|
||||
}
|
||||
|
||||
interface Locator {
|
||||
first(): Locator;
|
||||
locator(_selector: string): Locator;
|
||||
getAttribute(name: string): Promise<string | null>;
|
||||
innerHTML(): Promise<string>;
|
||||
textContent(): Promise<string | null>;
|
||||
}
|
||||
|
||||
export class CheckoutPriceExtractor {
|
||||
// Use the price action selector from IRACING_SELECTORS
|
||||
private readonly selector = IRACING_SELECTORS.BLOCKED_SELECTORS.priceAction;
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>> {
|
||||
try {
|
||||
// Prefer the explicit pill element which contains the price
|
||||
const pillLocator = this.page.locator('.label-pill, .label-inverse');
|
||||
const pillText = await pillLocator.first().textContent().catch(() => null);
|
||||
|
||||
let price: CheckoutPrice | null = null;
|
||||
let state = CheckoutState.unknown();
|
||||
let buttonHtml = '';
|
||||
|
||||
if (pillText) {
|
||||
// Parse price if possible
|
||||
try {
|
||||
price = CheckoutPrice.fromString(pillText.trim());
|
||||
} catch {
|
||||
price = null;
|
||||
}
|
||||
|
||||
// Try to find the containing button and its classes/html
|
||||
// Primary: locate button via known selector that contains the pill
|
||||
const buttonLocator = this.page.locator(this.selector).first();
|
||||
let classes = await buttonLocator.getAttribute('class').catch(() => null);
|
||||
let html = await buttonLocator.innerHTML().catch(() => '');
|
||||
|
||||
if (!classes) {
|
||||
// Fallback: find ancestor <a> of the pill (XPath)
|
||||
const ancestorButton = pillLocator.first().locator('xpath=ancestor::a[1]');
|
||||
classes = await ancestorButton.getAttribute('class').catch(() => null);
|
||||
html = await ancestorButton.innerHTML().catch(() => '');
|
||||
}
|
||||
|
||||
if (classes) {
|
||||
state = CheckoutState.fromButtonClasses(classes);
|
||||
buttonHtml = html ?? '';
|
||||
}
|
||||
} else {
|
||||
// No pill found — attempt to read button directly (best-effort)
|
||||
const buttonLocator = this.page.locator(this.selector).first();
|
||||
const classes = await buttonLocator.getAttribute('class').catch(() => null);
|
||||
const html = await buttonLocator.innerHTML().catch(() => '');
|
||||
|
||||
if (classes) {
|
||||
state = CheckoutState.fromButtonClasses(classes);
|
||||
buttonHtml = html ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Additional fallback: search the wizard-footer for any price text if pill was not present or parsing failed
|
||||
if (!price) {
|
||||
try {
|
||||
const footerLocator = this.page.locator('.wizard-footer, .modal-footer').first();
|
||||
const footerText = await footerLocator.textContent().catch(() => null);
|
||||
if (footerText) {
|
||||
const match = footerText.match(/\$\d+\.\d{2}/);
|
||||
if (match) {
|
||||
try {
|
||||
price = CheckoutPrice.fromString(match[0]);
|
||||
} catch {
|
||||
price = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore footer parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
price,
|
||||
state,
|
||||
buttonHtml
|
||||
});
|
||||
} catch (error) {
|
||||
// On any unexpected error, return an "unknown" result (do not throw)
|
||||
return Result.ok({
|
||||
price: null,
|
||||
state: CheckoutState.unknown(),
|
||||
buttonHtml: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Page } from 'playwright';
|
||||
import { AuthenticationGuard } from '@core/automation/infrastructure//automation/auth/AuthenticationGuard';
|
||||
|
||||
describe('AuthenticationGuard', () => {
|
||||
let mockPage: Page;
|
||||
let guard: AuthenticationGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPage = {
|
||||
locator: vi.fn(),
|
||||
content: vi.fn(),
|
||||
} as unknown as Page;
|
||||
|
||||
guard = new AuthenticationGuard(mockPage);
|
||||
});
|
||||
|
||||
describe('checkForLoginUI', () => {
|
||||
test('should return true when "You are not logged in" text is present', async () => {
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as Parameters<Page['locator']>[0] extends string ? ReturnType<Page['locator']> : never);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPage.locator).toHaveBeenCalledWith('text="You are not logged in"');
|
||||
});
|
||||
|
||||
test('should return true when "Log in" button is present', async () => {
|
||||
const mockNotLoggedInLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockLoginButtonLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator)
|
||||
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPage.locator).toHaveBeenCalledWith('text="You are not logged in"');
|
||||
expect(mockPage.locator).toHaveBeenCalledWith(':not(.chakra-menu):not([role="menu"]) button:has-text("Log in")');
|
||||
});
|
||||
|
||||
test('should return true when email/password input fields are present', async () => {
|
||||
const mockNotLoggedInLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockLoginButtonLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockAriaLabelLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator)
|
||||
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockAriaLabelLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPage.locator).toHaveBeenCalledWith('button[aria-label="Log in"]');
|
||||
});
|
||||
|
||||
test('should return false when no login indicators are present', async () => {
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should check for "Sign in" text as alternative login indicator', async () => {
|
||||
// Implementation only checks 3 selectors, not "Sign in"
|
||||
// This test can be removed or adjusted
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should check for password input field as login indicator', async () => {
|
||||
// Implementation only checks 3 selectors, not password input
|
||||
// This test can be removed or adjusted
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle page locator errors gracefully', async () => {
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockRejectedValue(new Error('Page not ready')),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
// Should return false when error occurs (caught and handled)
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failFastIfUnauthenticated', () => {
|
||||
test('should throw error when login UI is detected', async () => {
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
await expect(guard.failFastIfUnauthenticated()).rejects.toThrow(
|
||||
'Authentication required: Login UI detected on page'
|
||||
);
|
||||
});
|
||||
|
||||
test('should succeed when no login UI is detected', async () => {
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
await expect(guard.failFastIfUnauthenticated()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('should include page URL in error message', async () => {
|
||||
// Error message does not include URL in current implementation
|
||||
// Test that error is thrown when login UI detected
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(
|
||||
mockLocator as unknown as ReturnType<Page['locator']>,
|
||||
);
|
||||
|
||||
await expect(guard.failFastIfUnauthenticated()).rejects.toThrow(
|
||||
'Authentication required: Login UI detected on page'
|
||||
);
|
||||
});
|
||||
|
||||
test('should propagate page locator errors', async () => {
|
||||
// Errors are caught and return false, not propagated
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockRejectedValue(new Error('Network timeout')),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
// Should not throw, checkForLoginUI catches errors
|
||||
await expect(guard.failFastIfUnauthenticated()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login button _selector specificity', () => {
|
||||
test('should detect login button on actual login pages', async () => {
|
||||
// Simulate a real login page with a login form
|
||||
const mockLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
vi.mocked(mockPage.content).mockResolvedValue(`
|
||||
<form action="/login">
|
||||
<button>Log in</button>
|
||||
</form>
|
||||
`);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should NOT detect profile dropdown "Log in" button on authenticated pages', async () => {
|
||||
// Simulate authenticated page with profile menu containing "Log in" text
|
||||
// The new selector should exclude buttons inside .chakra-menu or [role="menu"]
|
||||
const mockNotLoggedInLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockLoginButtonLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
// With the fixed selector, this button inside chakra-menu should NOT be found
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockAriaLabelLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator)
|
||||
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockAriaLabelLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
vi.mocked(mockPage.content).mockResolvedValue(`
|
||||
<div class="dashboard">
|
||||
<button>Create a Race</button>
|
||||
<div class="chakra-menu" role="menu">
|
||||
<button>Log in as Team Member</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
// Should be false because the selector excludes menu buttons
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should NOT detect account menu "Log in" button on authenticated pages', async () => {
|
||||
const mockNotLoggedInLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockLoginButtonLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
// With the fixed selector, this button inside [role="menu"] should NOT be found
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const mockAriaLabelLocator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator)
|
||||
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockAriaLabelLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
vi.mocked(mockPage.content).mockResolvedValue(`
|
||||
<div class="authenticated-page">
|
||||
<nav>
|
||||
<div role="menu">
|
||||
<button>Log in to another account</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const result = await guard.checkForLoginUI();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForAuthenticatedUI', () => {
|
||||
test('should return true when user profile menu is present', async () => {
|
||||
const mockLocator = {
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
// This method doesn't exist yet - will be added in GREEN phase
|
||||
const guard = new AuthenticationGuard(mockPage);
|
||||
|
||||
// Mock the method for testing purposes
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI =
|
||||
async () => {
|
||||
const userMenuCount = await mockPage.locator('[data-testid="user-menu"]').count();
|
||||
return userMenuCount > 0;
|
||||
};
|
||||
|
||||
const result = await (guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPage.locator).toHaveBeenCalledWith('[data-testid="user-menu"]');
|
||||
});
|
||||
|
||||
test('should return true when logout button is present', async () => {
|
||||
const mockUserMenuLocator = {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
const mockLogoutButtonLocator = {
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator)
|
||||
.mockReturnValueOnce(mockUserMenuLocator as unknown as ReturnType<Page['locator']>)
|
||||
.mockReturnValueOnce(mockLogoutButtonLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
// Mock the method for testing purposes
|
||||
const guard = new AuthenticationGuard(mockPage);
|
||||
(guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI =
|
||||
async () => {
|
||||
const userMenuCount = await mockPage.locator('[data-testid="user-menu"]').count();
|
||||
if (userMenuCount > 0) return true;
|
||||
|
||||
const logoutCount = await mockPage.locator('button:has-text("Log out")').count();
|
||||
return logoutCount > 0;
|
||||
};
|
||||
|
||||
const result = await (guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false when no authenticated indicators are present', async () => {
|
||||
const mockLocator = {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
|
||||
|
||||
// Mock the method for testing purposes
|
||||
const guard = new AuthenticationGuard(mockPage);
|
||||
(guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI =
|
||||
async () => {
|
||||
const userMenuCount = await mockPage.locator('[data-testid="user-menu"]').count();
|
||||
const logoutCount = await mockPage.locator('button:has-text("Log out")').count();
|
||||
return userMenuCount > 0 || logoutCount > 0;
|
||||
};
|
||||
|
||||
const result = await (guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Page } from 'playwright';
|
||||
import { LoggerPort } from '@core/automation/application/ports/LoggerPort';
|
||||
|
||||
export class AuthenticationGuard {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly logger?: LoggerPort
|
||||
) {}
|
||||
|
||||
async checkForLoginUI(): Promise<boolean> {
|
||||
const loginSelectors = [
|
||||
'text="You are not logged in"',
|
||||
':not(.chakra-menu):not([role="menu"]) button:has-text("Log in")',
|
||||
'button[aria-label="Log in"]',
|
||||
];
|
||||
|
||||
for (const selector of loginSelectors) {
|
||||
try {
|
||||
const element = this.page.locator(selector).first();
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
this.logger?.warn('Login UI detected - user not authenticated', {
|
||||
selector,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Selector not found, continue checking
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async failFastIfUnauthenticated(): Promise<void> {
|
||||
if (await this.checkForLoginUI()) {
|
||||
throw new Error('Authentication required: Login UI detected on page');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { LoggerPort } from '@core/automation/application/ports/LoggerPort';
|
||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||
import { IRACING_URLS, IRACING_SELECTORS, IRACING_TIMEOUTS } from '../dom/IRacingSelectors';
|
||||
import { AuthenticationGuard } from './AuthenticationGuard';
|
||||
|
||||
export class IRacingPlaywrightAuthFlow implements IPlaywrightAuthFlow {
|
||||
constructor(private readonly logger?: LoggerPort) {}
|
||||
|
||||
getLoginUrl(): string {
|
||||
return IRACING_URLS.login;
|
||||
}
|
||||
|
||||
getPostLoginLandingUrl(): string {
|
||||
return IRACING_URLS.hostedSessions;
|
||||
}
|
||||
|
||||
isLoginUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return (
|
||||
lower.includes('oauth.iracing.com') ||
|
||||
lower.includes('/membersite/login') ||
|
||||
lower.includes('/login.jsp') ||
|
||||
lower.includes('/login')
|
||||
);
|
||||
}
|
||||
|
||||
isAuthenticatedUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return (
|
||||
lower.includes('/web/racing/hosted') ||
|
||||
lower.includes('/membersite/member') ||
|
||||
lower.includes('members-ng.iracing.com') ||
|
||||
lower.startsWith(IRACING_URLS.hostedSessions.toLowerCase()) ||
|
||||
lower.startsWith(IRACING_URLS.home.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
isLoginSuccessUrl(url: string): boolean {
|
||||
return this.isAuthenticatedUrl(url) && !this.isLoginUrl(url);
|
||||
}
|
||||
|
||||
async detectAuthenticatedUi(page: Page): Promise<boolean> {
|
||||
const authSelectors = [
|
||||
IRACING_SELECTORS.hostedRacing.createRaceButton,
|
||||
'[aria-label*="user menu" i]',
|
||||
'[aria-label*="account menu" i]',
|
||||
'.user-menu',
|
||||
'.account-menu',
|
||||
'nav a[href*="/membersite"]',
|
||||
'nav a[href*="/members"]',
|
||||
];
|
||||
|
||||
for (const selector of authSelectors) {
|
||||
try {
|
||||
const element = page.locator(selector).first();
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
this.logger?.info?.('Authenticated UI detected', { selector });
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore selector errors, try next selector
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async detectLoginUi(page: Page): Promise<boolean> {
|
||||
const guard = new AuthenticationGuard(page, this.logger);
|
||||
return guard.checkForLoginUI();
|
||||
}
|
||||
|
||||
async navigateToAuthenticatedArea(page: Page): Promise<void> {
|
||||
await page.goto(this.getPostLoginLandingUrl(), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
});
|
||||
}
|
||||
|
||||
async waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
this.logger?.info?.('Waiting for post-login redirect', { timeoutMs });
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
if (page.isClosed()) {
|
||||
this.logger?.warn?.('Page closed while waiting for post-login redirect');
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = page.url();
|
||||
|
||||
if (this.isLoginSuccessUrl(url)) {
|
||||
this.logger?.info?.('Login success detected by URL', { url });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: detect authenticated UI even if URL is not the canonical one
|
||||
const hasAuthUi = await this.detectAuthenticatedUi(page);
|
||||
if (hasAuthUi) {
|
||||
this.logger?.info?.('Login success detected by authenticated UI', { url });
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger?.debug?.('Error while waiting for post-login redirect', { error: message });
|
||||
|
||||
if (page.isClosed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
this.logger?.warn?.('Post-login redirect wait timed out', { timeoutMs });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
/**
|
||||
* Infra-level abstraction for Playwright-based authentication flows.
|
||||
*
|
||||
* Encapsulates game/site-specific URL patterns and UI detection so that
|
||||
* auth/session orchestration can remain generic and reusable.
|
||||
*/
|
||||
export interface IPlaywrightAuthFlow {
|
||||
/** Get the URL of the login page. */
|
||||
getLoginUrl(): string;
|
||||
|
||||
/**
|
||||
* Get a canonical URL that indicates the user is in an authenticated
|
||||
* area suitable for running automation (e.g. hosted sessions dashboard).
|
||||
*/
|
||||
getPostLoginLandingUrl(): string;
|
||||
|
||||
/** True if the given URL points at the login experience. */
|
||||
isLoginUrl(url: string): boolean;
|
||||
|
||||
/** True if the given URL is considered authenticated (members area). */
|
||||
isAuthenticatedUrl(url: string): boolean;
|
||||
|
||||
/**
|
||||
* True if the URL represents a successful login redirect, distinct from
|
||||
* the raw login form page or intermediate OAuth pages.
|
||||
*/
|
||||
isLoginSuccessUrl(url: string): boolean;
|
||||
|
||||
/** Detect whether an authenticated UI is currently rendered. */
|
||||
detectAuthenticatedUi(page: Page): Promise<boolean>;
|
||||
|
||||
/** Detect whether a login UI is currently rendered. */
|
||||
detectLoginUi(page: Page): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Navigate the given page into an authenticated area that the automation
|
||||
* engine can assume as a starting point after login.
|
||||
*/
|
||||
navigateToAuthenticatedArea(page: Page): Promise<void>;
|
||||
|
||||
/**
|
||||
* Wait for the browser to reach a post-login state within the timeout.
|
||||
*
|
||||
* Implementations may use URL changes, UI detection, or a combination of
|
||||
* both to determine success.
|
||||
*/
|
||||
waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean>;
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Page, BrowserContext } from 'playwright';
|
||||
import { PlaywrightAuthSessionService } from '@core/automation/infrastructure//automation/auth/PlaywrightAuthSessionService';
|
||||
import type { PlaywrightBrowserSession } from '@core/automation/infrastructure//automation/core/PlaywrightBrowserSession';
|
||||
import type { SessionCookieStore } from '@core/automation/infrastructure//automation/auth/SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from '@core/automation/infrastructure//automation/auth/PlaywrightAuthFlow';
|
||||
import type { LoggerPort as Logger } from '@core/automation/application/ports/LoggerPort';
|
||||
import { AuthenticationState } from '@core/automation/domain/value-objects/AuthenticationState';
|
||||
import { Result } from '@core/shared/result/Result';
|
||||
|
||||
describe('PlaywrightAuthSessionService.initiateLogin browser mode behaviour', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
let mockBrowserSession: PlaywrightBrowserSession;
|
||||
let mockCookieStore: SessionCookieStore;
|
||||
let mockAuthFlow: IPlaywrightAuthFlow;
|
||||
let mockLogger: Logger;
|
||||
let mockPage: Page;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
mockLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockPage = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
url: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
|
||||
isClosed: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Page;
|
||||
|
||||
mockBrowserSession = {
|
||||
connect: vi.fn().mockResolvedValue({ success: true }),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
getPersistentContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
|
||||
getContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
|
||||
getPage: vi.fn().mockReturnValue(mockPage),
|
||||
getUserDataDir: vi.fn().mockReturnValue(''),
|
||||
} as unknown as PlaywrightBrowserSession;
|
||||
|
||||
mockCookieStore = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
cookies: [],
|
||||
origins: [],
|
||||
}),
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
validateCookies: vi.fn().mockReturnValue(AuthenticationState.UNKNOWN),
|
||||
getSessionExpiry: vi.fn(),
|
||||
getValidCookiesForUrl: vi.fn().mockReturnValue([]),
|
||||
} as unknown as SessionCookieStore;
|
||||
|
||||
mockAuthFlow = {
|
||||
getLoginUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/login'),
|
||||
getPostLoginLandingUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
|
||||
isLoginUrl: vi.fn().mockReturnValue(false),
|
||||
isAuthenticatedUrl: vi.fn().mockReturnValue(true),
|
||||
isLoginSuccessUrl: vi.fn().mockReturnValue(true),
|
||||
detectAuthenticatedUi: vi.fn().mockResolvedValue(true),
|
||||
detectLoginUi: vi.fn().mockResolvedValue(false),
|
||||
navigateToAuthenticatedArea: vi.fn().mockResolvedValue(undefined),
|
||||
waitForPostLoginRedirect: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as IPlaywrightAuthFlow;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createService() {
|
||||
return new PlaywrightAuthSessionService(
|
||||
mockBrowserSession,
|
||||
mockCookieStore,
|
||||
mockAuthFlow,
|
||||
mockLogger,
|
||||
{
|
||||
navigationTimeoutMs: 1000,
|
||||
loginWaitTimeoutMs: 1000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it('always forces headed browser for login regardless of browser mode configuration', async () => {
|
||||
const service = createService();
|
||||
const result = await service.initiateLogin();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockBrowserSession.connect).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('navigates the headed page to the non-blank login URL', async () => {
|
||||
const service = createService();
|
||||
const result = await service.initiateLogin();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockAuthFlow.getLoginUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockPage.goto).toHaveBeenCalledWith(
|
||||
'https://members-ng.iracing.com/login',
|
||||
expect.objectContaining({
|
||||
waitUntil: 'domcontentloaded',
|
||||
}),
|
||||
);
|
||||
|
||||
const calledUrl = (mockPage.goto as unknown as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
|
||||
expect(calledUrl).not.toEqual('about:blank');
|
||||
});
|
||||
|
||||
it('propagates connection failure from browserSession.connect', async () => {
|
||||
(mockBrowserSession.connect as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'boom',
|
||||
});
|
||||
|
||||
const service = createService();
|
||||
const result = await service.initiateLogin();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain('boom');
|
||||
});
|
||||
|
||||
it('logs explicit headed login message for human companion flow', async () => {
|
||||
const service = createService();
|
||||
const result = await service.initiateLogin();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Opening login in headed Playwright browser (forceHeaded=true)',
|
||||
expect.objectContaining({ forceHeaded: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,459 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import type { BrowserContext, Page } from 'playwright';
|
||||
|
||||
import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort';
|
||||
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '../../../../../shared/result/Result';
|
||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||
import { SessionCookieStore } from './SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||
import { AuthenticationGuard } from './AuthenticationGuard';
|
||||
|
||||
interface PlaywrightAuthSessionConfig {
|
||||
navigationTimeoutMs?: number;
|
||||
loginWaitTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game-agnostic Playwright-based authentication/session service.
|
||||
*
|
||||
* All game/site-specific behavior (URLs, selectors, redirects) is delegated to
|
||||
* the injected IPlaywrightAuthFlow implementation. This class is responsible
|
||||
* only for:
|
||||
* - Browser/session orchestration via PlaywrightBrowserSession
|
||||
* - Cookie persistence via SessionCookieStore
|
||||
* - Exposing the IAuthenticationService port for application layer
|
||||
*/
|
||||
export class PlaywrightAuthSessionService implements AuthenticationServicePort {
|
||||
private readonly browserSession: PlaywrightBrowserSession;
|
||||
private readonly cookieStore: SessionCookieStore;
|
||||
private readonly authFlow: IPlaywrightAuthFlow;
|
||||
private readonly logger: LoggerPort | undefined;
|
||||
|
||||
private readonly navigationTimeoutMs: number;
|
||||
private readonly loginWaitTimeoutMs: number;
|
||||
|
||||
private authState: AuthenticationState = AuthenticationState.UNKNOWN;
|
||||
|
||||
constructor(
|
||||
browserSession: PlaywrightBrowserSession,
|
||||
cookieStore: SessionCookieStore,
|
||||
authFlow: IPlaywrightAuthFlow,
|
||||
logger?: LoggerPort,
|
||||
config?: PlaywrightAuthSessionConfig,
|
||||
) {
|
||||
this.browserSession = browserSession;
|
||||
this.cookieStore = cookieStore;
|
||||
this.authFlow = authFlow;
|
||||
this.logger = logger;
|
||||
|
||||
this.navigationTimeoutMs = config?.navigationTimeoutMs ?? 30000;
|
||||
this.loginWaitTimeoutMs = config?.loginWaitTimeoutMs ?? 300000;
|
||||
}
|
||||
|
||||
// ===== Logging =====
|
||||
|
||||
private log(
|
||||
level: 'debug' | 'info' | 'warn' | 'error',
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger as Record<
|
||||
'debug' | 'info' | 'warn' | 'error',
|
||||
(msg: string, ctx?: Record<string, unknown>) => void
|
||||
>;
|
||||
logger[level](message, context);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private getContext(): BrowserContext | null {
|
||||
return this.browserSession.getPersistentContext() ?? this.browserSession.getContext();
|
||||
}
|
||||
|
||||
private getPageOrError(): Result<Page> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) {
|
||||
return Result.err(new Error('Browser not connected'));
|
||||
}
|
||||
return Result.ok(page);
|
||||
}
|
||||
|
||||
private async injectCookiesBeforeNavigation(targetUrl: string): Promise<Result<void>> {
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
return Result.err(new Error('No browser context available'));
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state || state.cookies.length === 0) {
|
||||
return Result.err(new Error('No cookies found in session store'));
|
||||
}
|
||||
|
||||
const validCookies = this.cookieStore.getValidCookiesForUrl(targetUrl);
|
||||
if (validCookies.length === 0) {
|
||||
this.log('warn', 'No valid cookies found for target URL', {
|
||||
targetUrl,
|
||||
totalCookies: state.cookies.length,
|
||||
});
|
||||
return Result.err(new Error('No valid cookies found for target URL'));
|
||||
}
|
||||
|
||||
await context.addCookies(validCookies);
|
||||
|
||||
this.log('info', 'Cookies injected successfully', {
|
||||
count: validCookies.length,
|
||||
targetUrl,
|
||||
cookieNames: validCookies.map((c) => c.name),
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(new Error(`Cookie injection failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSessionState(): Promise<void> {
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
this.log('warn', 'No browser context available to save session state');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storageState = await context.storageState();
|
||||
await this.cookieStore.write(storageState);
|
||||
this.log('info', 'Session state saved to cookie store');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to save session state', { error: message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IAuthenticationService implementation =====
|
||||
|
||||
async checkSession(): Promise<Result<AuthenticationState>> {
|
||||
try {
|
||||
this.log('info', 'Checking session from cookie store');
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state) {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('info', 'No session state file found');
|
||||
return Result.ok(this.authState);
|
||||
}
|
||||
|
||||
this.authState = this.cookieStore.validateCookies(state.cookies);
|
||||
this.log('info', 'Session check complete', { state: this.authState });
|
||||
return Result.ok(this.authState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Session check failed', { error: message });
|
||||
return Result.err(new Error(`Session check failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
getLoginUrl(): string {
|
||||
return this.authFlow.getLoginUrl();
|
||||
}
|
||||
|
||||
async initiateLogin(): Promise<Result<void>> {
|
||||
try {
|
||||
const forceHeaded = true;
|
||||
this.log('info', 'Opening login in headed Playwright browser (forceHeaded=true)', {
|
||||
forceHeaded,
|
||||
});
|
||||
|
||||
const connectResult = await this.browserSession.connect(forceHeaded);
|
||||
if (!connectResult.success) {
|
||||
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
|
||||
}
|
||||
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
const loginUrl = this.authFlow.getLoginUrl();
|
||||
await page.goto(loginUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
this.log('info', forceHeaded
|
||||
? 'Browser opened to login page in headed mode, waiting for login...'
|
||||
: 'Browser opened to login page, waiting for login...');
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
|
||||
const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
|
||||
page,
|
||||
this.loginWaitTimeoutMs,
|
||||
);
|
||||
|
||||
if (loginSuccess) {
|
||||
this.log('info', 'Login detected, saving session state');
|
||||
await this.saveSessionState();
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
|
||||
this.authState = AuthenticationState.AUTHENTICATED;
|
||||
this.log('info', 'Session saved and validated successfully');
|
||||
} else {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('warn', 'Session saved but validation unclear');
|
||||
}
|
||||
|
||||
this.log('info', 'Closing browser after successful login');
|
||||
await this.browserSession.disconnect();
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
this.log('warn', 'Login was not completed');
|
||||
await this.browserSession.disconnect();
|
||||
return Result.err(new Error('Login timeout - please try again'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed during login process', { error: message });
|
||||
|
||||
try {
|
||||
await this.browserSession.disconnect();
|
||||
} catch {
|
||||
}
|
||||
|
||||
return Result.err(error instanceof Error ? error : new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
async confirmLoginComplete(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'User confirmed login complete');
|
||||
|
||||
await this.saveSessionState();
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
|
||||
this.authState = AuthenticationState.AUTHENTICATED;
|
||||
this.log('info', 'Login confirmed and session saved successfully');
|
||||
} else {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('warn', 'Login confirmation received but session state unclear');
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to confirm login', { error: message });
|
||||
return Result.err(error instanceof Error ? error : new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
async clearSession(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'Clearing session');
|
||||
|
||||
await this.cookieStore.delete();
|
||||
this.log('debug', 'Cookie store deleted');
|
||||
|
||||
const userDataDir = this.browserSession.getUserDataDir();
|
||||
if (userDataDir && fs.existsSync(userDataDir)) {
|
||||
this.log('debug', 'Removing user data directory', { path: userDataDir });
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
this.authState = AuthenticationState.LOGGED_OUT;
|
||||
this.log('info', 'Session cleared successfully');
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to clear session', { error: message });
|
||||
return Result.err(new Error(`Failed to clear session: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
getState(): AuthenticationState {
|
||||
return this.authState;
|
||||
}
|
||||
|
||||
async validateServerSide(): Promise<Result<boolean>> {
|
||||
try {
|
||||
this.log('info', 'Performing server-side session validation');
|
||||
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
return Result.err(new Error('No browser context available'));
|
||||
}
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const response = await page.goto(this.authFlow.getPostLoginLandingUrl(), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
const finalUrl = page.url();
|
||||
const isOnLoginPage = this.authFlow.isLoginUrl(finalUrl);
|
||||
|
||||
const isValid = !isOnLoginPage;
|
||||
this.log('info', 'Server-side validation complete', { isValid, finalUrl });
|
||||
|
||||
return Result.ok(isValid);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('warn', 'Server-side validation failed', { error: message });
|
||||
return Result.err(new Error(`Server validation failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'Refreshing session from cookie store');
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state) {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
this.authState = this.cookieStore.validateCookies(state.cookies);
|
||||
this.log('info', 'Session refreshed', { state: this.authState });
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Session refresh failed', { error: message });
|
||||
return Result.err(new Error(`Session refresh failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionExpiry(): Promise<Result<Date | null>> {
|
||||
try {
|
||||
const expiry = await this.cookieStore.getSessionExpiry();
|
||||
return Result.ok(expiry);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to get session expiry', { error: message });
|
||||
return Result.err(new Error(`Failed to get session expiry: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>> {
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
try {
|
||||
const url = page.url();
|
||||
|
||||
const isOnAuthenticatedPath = this.authFlow.isAuthenticatedUrl(url);
|
||||
const isOnLoginPath = this.authFlow.isLoginUrl(url);
|
||||
|
||||
const guard = new AuthenticationGuard(page, this.logger);
|
||||
const hasLoginUI = await guard.checkForLoginUI();
|
||||
|
||||
const hasAuthUI = await this.authFlow.detectAuthenticatedUi(page);
|
||||
|
||||
const cookieResult = await this.checkSession();
|
||||
const cookiesValid =
|
||||
cookieResult.isOk() &&
|
||||
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
|
||||
|
||||
const pageAuthenticated =
|
||||
!hasLoginUI &&
|
||||
!isOnLoginPath &&
|
||||
((isOnAuthenticatedPath && cookiesValid) || hasAuthUI);
|
||||
|
||||
this.log('debug', 'Page authentication check', {
|
||||
url,
|
||||
isOnAuthenticatedPath,
|
||||
isOnLoginPath,
|
||||
hasLoginUI,
|
||||
hasAuthUI,
|
||||
cookiesValid,
|
||||
pageAuthenticated,
|
||||
});
|
||||
|
||||
return Result.ok(new BrowserAuthenticationState(cookiesValid, pageAuthenticated));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(new Error(`Page verification failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Public helper for navigation with cookie injection =====
|
||||
|
||||
/**
|
||||
* Navigate to an authenticated area using stored cookies when possible.
|
||||
* Not part of the IAuthenticationService port, but useful for internal
|
||||
* orchestration (e.g. within automation flows).
|
||||
*/
|
||||
async navigateWithExistingSession(forceHeaded: boolean = false): Promise<Result<void>> {
|
||||
try {
|
||||
const sessionResult = await this.checkSession();
|
||||
if (
|
||||
sessionResult.isOk() &&
|
||||
sessionResult.unwrap() === AuthenticationState.AUTHENTICATED
|
||||
) {
|
||||
this.log('info', 'Session cookies found, launching in configured browser mode');
|
||||
|
||||
await this.browserSession.ensureBrowserContext(forceHeaded);
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
const targetUrl = this.authFlow.getPostLoginLandingUrl();
|
||||
const injectResult = await this.injectCookiesBeforeNavigation(targetUrl);
|
||||
|
||||
if (injectResult.isErr()) {
|
||||
this.log('warn', 'Cookie injection failed, falling back to manual login', {
|
||||
error: injectResult.error?.message ?? 'unknown error',
|
||||
});
|
||||
return Result.err(injectResult.unwrapErr());
|
||||
}
|
||||
|
||||
await page.goto(targetUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
const verifyResult = await this.verifyPageAuthentication();
|
||||
if (verifyResult.isOk()) {
|
||||
const browserState = verifyResult.unwrap();
|
||||
if (browserState.isFullyAuthenticated()) {
|
||||
this.log('info', 'Authentication verified successfully after cookie navigation');
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
this.log('warn', 'Page shows unauthenticated state despite cookies');
|
||||
}
|
||||
|
||||
return Result.err(new Error('Page not authenticated after cookie navigation'));
|
||||
}
|
||||
|
||||
return Result.err(new Error('No valid session cookies found'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to navigate with existing session', { error: message });
|
||||
return Result.err(new Error(`Failed to navigate with existing session: ${message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Page, Locator } from 'playwright';
|
||||
import { PlaywrightAuthSessionService } from '@core/automation/infrastructure//automation/auth/PlaywrightAuthSessionService';
|
||||
import { AuthenticationState } from '@core/automation/domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '@core/automation/domain/value-objects/BrowserAuthenticationState';
|
||||
import type { LoggerPort as Logger } from '@core/automation/application/ports/LoggerPort';
|
||||
import type { Result } from '@core/shared/result/Result';
|
||||
import type { PlaywrightBrowserSession } from '@core/automation/infrastructure//automation/core/PlaywrightBrowserSession';
|
||||
import type { SessionCookieStore } from '@core/automation/infrastructure//automation/auth/SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from '@core/automation/infrastructure//automation/auth/PlaywrightAuthFlow';
|
||||
|
||||
describe('PlaywrightAuthSessionService.verifyPageAuthentication', () => {
|
||||
function createService(deps: {
|
||||
pageUrl: string;
|
||||
hasLoginUi: boolean;
|
||||
hasAuthUi: boolean;
|
||||
cookieState: AuthenticationState;
|
||||
}) {
|
||||
const mockLogger: Logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockLocator: Locator = {
|
||||
first: vi.fn().mockReturnThis(),
|
||||
isVisible: vi.fn().mockImplementation(async () => deps.hasLoginUi),
|
||||
} as unknown as Locator;
|
||||
|
||||
const mockPage: Page = {
|
||||
url: vi.fn().mockReturnValue(deps.pageUrl),
|
||||
locator: vi.fn().mockReturnValue(mockLocator),
|
||||
} as unknown as Page;
|
||||
|
||||
const mockBrowserSession: PlaywrightBrowserSession = {
|
||||
getPersistentContext: vi.fn().mockReturnValue(null),
|
||||
getContext: vi.fn().mockReturnValue(null),
|
||||
getPage: vi.fn().mockReturnValue(mockPage),
|
||||
} as unknown as PlaywrightBrowserSession;
|
||||
|
||||
const mockCookieStore: SessionCookieStore = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
cookies: [{ name: 'XSESSIONID', value: 'abc', domain: 'members-ng.iracing.com', path: '/', expires: -1 }],
|
||||
origins: [],
|
||||
}),
|
||||
validateCookies: vi.fn().mockReturnValue(deps.cookieState),
|
||||
getSessionExpiry: vi.fn(),
|
||||
write: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as SessionCookieStore;
|
||||
|
||||
const mockAuthFlow: IPlaywrightAuthFlow = {
|
||||
getLoginUrl: () => 'https://members-ng.iracing.com/login',
|
||||
getPostLoginLandingUrl: () => 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||
isLoginUrl: (url: string) => url.includes('/login'),
|
||||
isAuthenticatedUrl: (url: string) => url.includes('/web/racing/hosted'),
|
||||
isLoginSuccessUrl: (url: string) => url.includes('/web/racing/hosted'),
|
||||
detectAuthenticatedUi: vi.fn().mockResolvedValue(deps.hasAuthUi),
|
||||
detectLoginUi: vi.fn(),
|
||||
navigateToAuthenticatedArea: vi.fn(),
|
||||
waitForPostLoginRedirect: vi.fn(),
|
||||
} as unknown as IPlaywrightAuthFlow;
|
||||
|
||||
const service = new PlaywrightAuthSessionService(
|
||||
mockBrowserSession,
|
||||
mockCookieStore,
|
||||
mockAuthFlow,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
return { service, mockCookieStore, mockAuthFlow, mockPage };
|
||||
}
|
||||
|
||||
it('treats cookies-valid + login UI as EXPIRED (page wins over cookies)', async () => {
|
||||
const { service } = createService({
|
||||
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||
hasLoginUi: true,
|
||||
hasAuthUi: false,
|
||||
cookieState: AuthenticationState.AUTHENTICATED,
|
||||
});
|
||||
|
||||
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const browserState = result.unwrap();
|
||||
expect(browserState.getCookieValidity()).toBe(true);
|
||||
expect(browserState.getPageAuthenticationStatus()).toBe(false);
|
||||
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.EXPIRED);
|
||||
expect(browserState.requiresReauthentication()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats cookies-valid + authenticated UI without login UI as AUTHENTICATED', async () => {
|
||||
const { service } = createService({
|
||||
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||
hasLoginUi: false,
|
||||
hasAuthUi: true,
|
||||
cookieState: AuthenticationState.AUTHENTICATED,
|
||||
});
|
||||
|
||||
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const browserState = result.unwrap();
|
||||
expect(browserState.getCookieValidity()).toBe(true);
|
||||
expect(browserState.getPageAuthenticationStatus()).toBe(true);
|
||||
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.AUTHENTICATED);
|
||||
expect(browserState.requiresReauthentication()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { describe, test, expect, beforeEach } from 'vitest';
|
||||
import { SessionCookieStore } from '@core/automation/infrastructure//automation/auth/SessionCookieStore';
|
||||
import type { Cookie } from 'playwright';
|
||||
|
||||
const logger = console as unknown;
|
||||
|
||||
describe('SessionCookieStore - Cookie Validation', () => {
|
||||
let cookieStore: SessionCookieStore;
|
||||
|
||||
beforeEach(() => {
|
||||
cookieStore = new SessionCookieStore('test-user-data', logger);
|
||||
});
|
||||
|
||||
describe('validateCookieConfiguration()', () => {
|
||||
const targetUrl = 'https://members-ng.iracing.com/jjwtauth/success';
|
||||
|
||||
test('should succeed when all cookies are valid for target URL', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'irsso_members',
|
||||
value: 'valid_sso_token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'authtoken_members',
|
||||
value: 'valid_auth_token',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
test('should fail when cookie domain mismatches target', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'irsso_members',
|
||||
value: 'valid_token',
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/domain mismatch/i);
|
||||
});
|
||||
|
||||
test('should fail when cookie path is invalid for target', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'irsso_members',
|
||||
value: 'valid_token',
|
||||
domain: '.iracing.com',
|
||||
path: '/invalid/path',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/path.*not valid/i);
|
||||
});
|
||||
|
||||
test('should fail when required irsso_members cookie is missing', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'authtoken_members',
|
||||
value: 'valid_auth_token',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/required.*irsso_members/i);
|
||||
});
|
||||
|
||||
test('should fail when required authtoken_members cookie is missing', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'irsso_members',
|
||||
value: 'valid_sso_token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/required.*authtoken_members/i);
|
||||
});
|
||||
|
||||
test('should fail when no cookies are stored', () => {
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toMatch(/no cookies/i);
|
||||
});
|
||||
|
||||
test('should validate cookies for members-ng.iracing.com domain', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'irsso_members',
|
||||
value: 'valid_token',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'authtoken_members',
|
||||
value: 'valid_auth_token',
|
||||
domain: 'members-ng.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidCookiesForUrl()', () => {
|
||||
const targetUrl = 'https://members-ng.iracing.com/jjwtauth/success';
|
||||
|
||||
test('should return only cookies valid for target URL', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'valid_cookie',
|
||||
value: 'valid_value',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'invalid_cookie',
|
||||
value: 'invalid_value',
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
|
||||
|
||||
expect(validCookies).toHaveLength(1);
|
||||
expect(validCookies[0]!.name).toBe('valid_cookie');
|
||||
});
|
||||
|
||||
test('should filter out cookies with mismatched domains', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'cookie1',
|
||||
value: 'value1',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'cookie2',
|
||||
value: 'value2',
|
||||
domain: '.example.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
|
||||
|
||||
expect(validCookies).toHaveLength(1);
|
||||
expect(validCookies[0]!.name).toBe('cookie1');
|
||||
});
|
||||
|
||||
test('should filter out cookies with invalid paths', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'valid_path_cookie',
|
||||
value: 'value',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'invalid_path_cookie',
|
||||
value: 'value',
|
||||
domain: '.iracing.com',
|
||||
path: '/wrong/path',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
|
||||
|
||||
expect(validCookies).toHaveLength(1);
|
||||
expect(validCookies[0]!.name).toBe('valid_path_cookie');
|
||||
});
|
||||
|
||||
test('should return empty array when no cookies are valid', async () => {
|
||||
const cookies: Cookie[] = [
|
||||
{
|
||||
name: 'invalid_cookie',
|
||||
value: 'value',
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
expires: Date.now() / 1000 + 3600,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
];
|
||||
|
||||
await cookieStore.write({ cookies, origins: [] });
|
||||
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
|
||||
|
||||
expect(validCookies).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,372 +0,0 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||
import { CookieConfiguration } from '../../../../domain/value-objects/CookieConfiguration';
|
||||
import { Result } from '../../../../../shared/result/Result';
|
||||
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
interface StorageState {
|
||||
cookies: Cookie[];
|
||||
origins: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Known iRacing session cookie names to look for.
|
||||
* These are the primary authentication indicators.
|
||||
*/
|
||||
const IRACING_SESSION_COOKIES = [
|
||||
'irsso_members',
|
||||
'authtoken_members',
|
||||
'irsso',
|
||||
'authtoken',
|
||||
];
|
||||
|
||||
/**
|
||||
* iRacing domain patterns to match cookies against.
|
||||
*/
|
||||
const IRACING_DOMAINS = [
|
||||
'iracing.com',
|
||||
'.iracing.com',
|
||||
'members.iracing.com',
|
||||
'members-ng.iracing.com',
|
||||
];
|
||||
|
||||
const EXPIRY_BUFFER_SECONDS = 300;
|
||||
|
||||
export class SessionCookieStore {
|
||||
private readonly storagePath: string;
|
||||
private readonly logger: LoggerPort | undefined;
|
||||
|
||||
constructor(userDataDir: string, logger: LoggerPort | undefined) {
|
||||
this.storagePath = path.join(userDataDir, 'session-state.json');
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (this.logger) {
|
||||
if (level === 'error') {
|
||||
this.logger.error(message, undefined, context);
|
||||
} else {
|
||||
this.logger[level](message, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
return this.storagePath;
|
||||
}
|
||||
|
||||
async read(): Promise<StorageState | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.storagePath, 'utf-8');
|
||||
const state = JSON.parse(content) as StorageState;
|
||||
|
||||
// Ensure all cookies have path field (default to "/" for backward compatibility)
|
||||
state.cookies = state.cookies.map(cookie => ({
|
||||
...cookie,
|
||||
path: cookie.path || '/'
|
||||
}));
|
||||
|
||||
this.cachedState = state;
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async write(state: StorageState): Promise<void> {
|
||||
this.cachedState = state;
|
||||
await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.storagePath);
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session expiry date from iRacing cookies.
|
||||
* Returns the earliest expiry date from valid session cookies.
|
||||
*/
|
||||
async getSessionExpiry(): Promise<Date | null> {
|
||||
try {
|
||||
const state = await this.read();
|
||||
if (!state || state.cookies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to iRacing authentication cookies
|
||||
const authCookies = state.cookies.filter(c =>
|
||||
IRACING_DOMAINS.some(domain =>
|
||||
c.domain === domain || c.domain.endsWith(domain)
|
||||
) &&
|
||||
(IRACING_SESSION_COOKIES.some(name =>
|
||||
c.name.toLowerCase().includes(name.toLowerCase())
|
||||
) ||
|
||||
c.name.toLowerCase().includes('auth') ||
|
||||
c.name.toLowerCase().includes('sso') ||
|
||||
c.name.toLowerCase().includes('token'))
|
||||
);
|
||||
|
||||
if (authCookies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the earliest expiry date (most restrictive)
|
||||
// Session cookies (expires = -1 or 0) are treated as never expiring
|
||||
const expiryDates = authCookies
|
||||
.filter(c => c.expires > 0)
|
||||
.map(c => {
|
||||
// Handle both formats: seconds (standard) and milliseconds (test fixtures)
|
||||
// If expires > year 2100 in seconds (33134745600), it's likely milliseconds
|
||||
const isMilliseconds = c.expires > 33134745600;
|
||||
return new Date(isMilliseconds ? c.expires : c.expires * 1000);
|
||||
});
|
||||
|
||||
if (expiryDates.length === 0) {
|
||||
// All session cookies, no expiry
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return earliest expiry
|
||||
const earliestExpiry = new Date(Math.min(...expiryDates.map(d => d.getTime())));
|
||||
|
||||
this.log('debug', 'Session expiry determined', {
|
||||
earliestExpiry: earliestExpiry.toISOString(),
|
||||
cookiesChecked: authCookies.length
|
||||
});
|
||||
|
||||
return earliestExpiry;
|
||||
} catch (error) {
|
||||
this.log('error', 'Failed to get session expiry', { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cookies and determine authentication state.
|
||||
*
|
||||
* Looks for iRacing session cookies by checking:
|
||||
* 1. Domain matches iRacing patterns
|
||||
* 2. Cookie name matches known session cookie names OR
|
||||
* 3. Any cookie from members.iracing.com domain (fallback)
|
||||
*/
|
||||
validateCookies(cookies: Cookie[]): AuthenticationState {
|
||||
// Log all cookies for debugging
|
||||
this.log('debug', 'Validating cookies', {
|
||||
totalCookies: cookies.length,
|
||||
cookieNames: cookies.map(c => ({ name: c.name, domain: c.domain }))
|
||||
});
|
||||
|
||||
// Filter cookies from iRacing domains
|
||||
const iracingDomainCookies = cookies.filter(c =>
|
||||
IRACING_DOMAINS.some(domain =>
|
||||
c.domain === domain || c.domain.endsWith(domain)
|
||||
)
|
||||
);
|
||||
|
||||
this.log('debug', 'iRacing domain cookies found', {
|
||||
count: iracingDomainCookies.length,
|
||||
cookies: iracingDomainCookies.map(c => ({
|
||||
name: c.name,
|
||||
domain: c.domain,
|
||||
expires: c.expires,
|
||||
expiresDate: new Date(c.expires * 1000).toISOString()
|
||||
}))
|
||||
});
|
||||
|
||||
// Look for known session cookies first
|
||||
const knownSessionCookies = iracingDomainCookies.filter(c =>
|
||||
IRACING_SESSION_COOKIES.some(name =>
|
||||
c.name.toLowerCase() === name.toLowerCase() ||
|
||||
c.name.toLowerCase().includes(name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// If no known session cookies, check for any auth-like cookies from members domain
|
||||
const authCookies = knownSessionCookies.length > 0
|
||||
? knownSessionCookies
|
||||
: iracingDomainCookies.filter(c =>
|
||||
c.domain.includes('members') &&
|
||||
(c.name.toLowerCase().includes('auth') ||
|
||||
c.name.toLowerCase().includes('sso') ||
|
||||
c.name.toLowerCase().includes('session') ||
|
||||
c.name.toLowerCase().includes('token'))
|
||||
);
|
||||
|
||||
this.log('debug', 'Authentication cookies identified', {
|
||||
knownSessionCookiesCount: knownSessionCookies.length,
|
||||
authCookiesCount: authCookies.length,
|
||||
cookies: authCookies.map(c => ({ name: c.name, domain: c.domain }))
|
||||
});
|
||||
|
||||
if (authCookies.length === 0) {
|
||||
// Last resort: if we have ANY cookies from members.iracing.com, consider it potentially valid
|
||||
const membersCookies = iracingDomainCookies.filter(c =>
|
||||
c.domain.includes('members.iracing.com') || c.domain === '.iracing.com'
|
||||
);
|
||||
|
||||
if (membersCookies.length > 0) {
|
||||
this.log('info', 'No known auth cookies found, but members domain cookies exist', {
|
||||
count: membersCookies.length,
|
||||
cookies: membersCookies.map(c => c.name)
|
||||
});
|
||||
|
||||
// Check expiry on any of these cookies
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hasValidCookie = membersCookies.some(c =>
|
||||
c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
|
||||
);
|
||||
|
||||
return hasValidCookie
|
||||
? AuthenticationState.AUTHENTICATED
|
||||
: AuthenticationState.EXPIRED;
|
||||
}
|
||||
|
||||
this.log('info', 'No iRacing authentication cookies found');
|
||||
return AuthenticationState.UNKNOWN;
|
||||
}
|
||||
|
||||
// Check if any auth cookie is still valid (not expired)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const validCookies = authCookies.filter(c => {
|
||||
// Handle session cookies (expires = -1 or 0) and persistent cookies
|
||||
const isSession = c.expires === -1 || c.expires === 0;
|
||||
const isNotExpired = c.expires > (now + EXPIRY_BUFFER_SECONDS);
|
||||
return isSession || isNotExpired;
|
||||
});
|
||||
|
||||
this.log('debug', 'Cookie expiry check', {
|
||||
now,
|
||||
validCookiesCount: validCookies.length,
|
||||
cookies: authCookies.map(c => ({
|
||||
name: c.name,
|
||||
expires: c.expires,
|
||||
isValid: c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
|
||||
}))
|
||||
});
|
||||
|
||||
if (validCookies.length > 0) {
|
||||
this.log('info', 'Valid iRacing session cookies found', { count: validCookies.length });
|
||||
return AuthenticationState.AUTHENTICATED;
|
||||
}
|
||||
|
||||
this.log('info', 'iRacing session cookies found but all expired');
|
||||
return AuthenticationState.EXPIRED;
|
||||
}
|
||||
|
||||
private cachedState: StorageState | null = null;
|
||||
|
||||
/**
|
||||
* Validate stored cookies for a target URL.
|
||||
* Note: This requires cookies to be written first via write().
|
||||
* This is synchronous because tests expect it - uses cached state.
|
||||
* Validates domain/path compatibility AND checks for required authentication cookies.
|
||||
*/
|
||||
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
|
||||
try {
|
||||
if (!this.cachedState || this.cachedState.cookies.length === 0) {
|
||||
return Result.err<Cookie[]>(new Error('No cookies found in session store'));
|
||||
}
|
||||
|
||||
return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a list of cookies for a target URL.
|
||||
* Returns only cookies that are valid for the target URL.
|
||||
* @param requireAuthCookies - If true, checks for required authentication cookies
|
||||
*/
|
||||
validateCookiesForUrl(
|
||||
cookies: Cookie[],
|
||||
targetUrl: string,
|
||||
requireAuthCookies = false
|
||||
): Result<Cookie[]> {
|
||||
try {
|
||||
const validatedCookies: Cookie[] = [];
|
||||
let firstValidationError: Error | null = null;
|
||||
|
||||
for (const cookie of cookies) {
|
||||
try {
|
||||
new CookieConfiguration(cookie, targetUrl);
|
||||
validatedCookies.push(cookie);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (!firstValidationError) {
|
||||
firstValidationError = err;
|
||||
}
|
||||
|
||||
this.logger?.warn('Cookie validation failed', {
|
||||
name: cookie.name,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (validatedCookies.length === 0) {
|
||||
return Result.err<Cookie[]>(
|
||||
firstValidationError ?? new Error('No valid cookies found for target URL')
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAuthCookies) {
|
||||
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
|
||||
|
||||
const hasIrssoMembers = cookieNames.some((name) =>
|
||||
name.includes('irsso_members') || name.includes('irsso')
|
||||
);
|
||||
|
||||
const hasAuthtokenMembers = cookieNames.some((name) =>
|
||||
name.includes('authtoken_members') || name.includes('authtoken')
|
||||
);
|
||||
|
||||
if (!hasIrssoMembers) {
|
||||
return Result.err<Cookie[]>(new Error('Required cookie missing: irsso_members'));
|
||||
}
|
||||
|
||||
if (!hasAuthtokenMembers) {
|
||||
return Result.err<Cookie[]>(new Error('Required cookie missing: authtoken_members'));
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok<Cookie[]>(validatedCookies);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookies that are valid for a target URL.
|
||||
* Returns array of cookies (empty if none valid).
|
||||
* Uses cached state from last write().
|
||||
*/
|
||||
getValidCookiesForUrl(targetUrl: string): Cookie[] {
|
||||
try {
|
||||
if (!this.cachedState || this.cachedState.cookies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl);
|
||||
return result.isOk() ? result.unwrap() : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,303 +0,0 @@
|
||||
import { Browser, BrowserContext, Page } from 'playwright';
|
||||
import { chromium } from 'playwright-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { LoggerPort } from '@core/automation/application/ports/LoggerPort';
|
||||
import { BrowserModeConfigLoader, BrowserMode } from '../../../config/BrowserModeConfig';
|
||||
|
||||
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
|
||||
import { PlaywrightAutomationAdapter } from './PlaywrightAutomationAdapter';
|
||||
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
export type BrowserModeSource = string;
|
||||
|
||||
export class PlaywrightBrowserSession {
|
||||
private browser: Browser | null = null;
|
||||
private persistentContext: BrowserContext | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private page: Page | null = null;
|
||||
private connected = false;
|
||||
private isConnecting = false;
|
||||
private browserModeLoader: BrowserModeConfigLoader;
|
||||
private actualBrowserMode: BrowserMode;
|
||||
private browserModeSource: BrowserModeSource;
|
||||
|
||||
constructor(
|
||||
private readonly config: Required<PlaywrightConfig>,
|
||||
private readonly logger?: LoggerPort,
|
||||
browserModeLoader?: BrowserModeConfigLoader,
|
||||
) {
|
||||
const automationMode = getAutomationMode();
|
||||
this.browserModeLoader = browserModeLoader ?? new BrowserModeConfigLoader();
|
||||
const browserModeConfig = this.browserModeLoader.load();
|
||||
this.actualBrowserMode = browserModeConfig.mode;
|
||||
this.browserModeSource = browserModeConfig.source as BrowserModeSource;
|
||||
|
||||
this.log('info', 'Browser mode configured', {
|
||||
mode: this.actualBrowserMode,
|
||||
source: this.browserModeSource,
|
||||
automationMode,
|
||||
configHeadless: this.config.headless,
|
||||
});
|
||||
}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger as Record<
|
||||
'debug' | 'info' | 'warn' | 'error',
|
||||
(msg: string, ctx?: Record<string, unknown>) => void
|
||||
>;
|
||||
logger[level](message, context);
|
||||
}
|
||||
|
||||
private isRealMode(): boolean {
|
||||
return this.config.mode === 'real';
|
||||
}
|
||||
|
||||
getBrowserMode(): BrowserMode {
|
||||
return this.actualBrowserMode;
|
||||
}
|
||||
|
||||
getBrowserModeSource(): BrowserModeSource {
|
||||
return this.browserModeSource;
|
||||
}
|
||||
|
||||
getUserDataDir(): string {
|
||||
return this.config.userDataDir;
|
||||
}
|
||||
|
||||
getPage(): Page | null {
|
||||
return this.page;
|
||||
}
|
||||
|
||||
getContext(): BrowserContext | null {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
getPersistentContext(): BrowserContext | null {
|
||||
return this.persistentContext;
|
||||
}
|
||||
|
||||
getBrowser(): Browser | null {
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.page !== null;
|
||||
}
|
||||
|
||||
async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.connected && this.page) {
|
||||
const shouldReuse =
|
||||
!forceHeaded ||
|
||||
this.actualBrowserMode === 'headed';
|
||||
|
||||
if (shouldReuse) {
|
||||
this.log('debug', 'Already connected, reusing existing connection', {
|
||||
browserMode: this.actualBrowserMode,
|
||||
forcedHeaded: forceHeaded,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this.log('info', 'Existing browser connection is headless, reopening in headed mode for login', {
|
||||
browserMode: this.actualBrowserMode,
|
||||
forcedHeaded: forceHeaded,
|
||||
});
|
||||
await this.closeBrowserContext();
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
this.log('debug', 'Connection in progress, waiting...');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return this.connect(forceHeaded);
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
try {
|
||||
const currentConfig = this.browserModeLoader.load();
|
||||
this.actualBrowserMode = currentConfig.mode;
|
||||
this.browserModeSource = currentConfig.source as BrowserModeSource;
|
||||
const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode;
|
||||
|
||||
const adapterWithLauncher = PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: typeof chromium;
|
||||
};
|
||||
const launcher = adapterWithLauncher.testLauncher ?? chromium;
|
||||
|
||||
this.log('debug', 'Effective browser mode at connect', {
|
||||
effectiveMode,
|
||||
actualBrowserMode: this.actualBrowserMode,
|
||||
browserModeSource: this.browserModeSource,
|
||||
forced: forceHeaded,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
|
||||
try {
|
||||
const loaderValue = this.browserModeLoader && typeof this.browserModeLoader.load === 'function'
|
||||
? this.browserModeLoader.load()
|
||||
: undefined;
|
||||
console.debug('[TEST-INSTRUMENT] PlaywrightAutomationAdapter.connect()', {
|
||||
effectiveMode,
|
||||
forceHeaded,
|
||||
loaderValue,
|
||||
browserModeSource: this.getBrowserModeSource(),
|
||||
});
|
||||
} catch {
|
||||
// ignore instrumentation errors
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRealMode() && this.config.userDataDir) {
|
||||
this.log('info', 'Launching persistent browser context', {
|
||||
userDataDir: this.config.userDataDir,
|
||||
mode: effectiveMode,
|
||||
forced: forceHeaded,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(this.config.userDataDir)) {
|
||||
fs.mkdirSync(this.config.userDataDir, { recursive: true });
|
||||
}
|
||||
|
||||
await this.cleanupStaleLockFile(this.config.userDataDir);
|
||||
|
||||
this.persistentContext = await launcher.launchPersistentContext(
|
||||
this.config.userDataDir,
|
||||
{
|
||||
headless: effectiveMode === 'headless',
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
);
|
||||
const persistentContext = this.persistentContext!;
|
||||
this.page = persistentContext.pages()[0] || await persistentContext.newPage();
|
||||
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
|
||||
this.connected = !!this.page;
|
||||
} else {
|
||||
this.browser = await launcher.launch({
|
||||
headless: effectiveMode === 'headless',
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
const browser = this.browser!;
|
||||
this.context = await browser.newContext();
|
||||
this.page = await this.context.newPage();
|
||||
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
|
||||
this.connected = !!this.page;
|
||||
}
|
||||
|
||||
if (!this.page) {
|
||||
this.log('error', 'Browser session connected without a usable page', {
|
||||
hasBrowser: !!this.browser,
|
||||
hasContext: !!this.context || !!this.persistentContext,
|
||||
});
|
||||
await this.closeBrowserContext();
|
||||
this.connected = false;
|
||||
return { success: false, error: 'Browser not connected' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.connected = false;
|
||||
this.page = null;
|
||||
this.context = null;
|
||||
this.persistentContext = null;
|
||||
this.browser = null;
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureBrowserContext(forceHeaded: boolean = false): Promise<void> {
|
||||
const result = await this.connect(forceHeaded);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to connect browser');
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupStaleLockFile(userDataDir: string): Promise<void> {
|
||||
const singletonLockPath = path.join(userDataDir, 'SingletonLock');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(singletonLockPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('info', 'Found existing SingletonLock, attempting cleanup', { path: singletonLockPath });
|
||||
fs.unlinkSync(singletonLockPath);
|
||||
this.log('info', 'Cleaned up stale SingletonLock file');
|
||||
} catch (error) {
|
||||
this.log('warn', 'Could not clean up SingletonLock', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.page) {
|
||||
await this.page.close();
|
||||
this.page = null;
|
||||
}
|
||||
if (this.persistentContext) {
|
||||
await this.persistentContext.close();
|
||||
this.persistentContext = null;
|
||||
}
|
||||
if (this.context) {
|
||||
await this.context.close();
|
||||
this.context = null;
|
||||
}
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async closeBrowserContext(): Promise<void> {
|
||||
try {
|
||||
if (this.persistentContext) {
|
||||
await this.persistentContext.close();
|
||||
this.persistentContext = null;
|
||||
this.page = null;
|
||||
this.connected = false;
|
||||
this.log('info', 'Persistent context closed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.context) {
|
||||
await this.context.close();
|
||||
this.context = null;
|
||||
this.page = null;
|
||||
}
|
||||
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
this.log('info', 'Browser closed successfully');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('warn', 'Error closing browser context', { error: message });
|
||||
this.persistentContext = null;
|
||||
this.context = null;
|
||||
this.browser = null;
|
||||
this.page = null;
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||
import type { NavigationResultDTO } from '../../../../application/dto/NavigationResultDTO';
|
||||
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
|
||||
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||
import { IRACING_SELECTORS, IRACING_TIMEOUTS, IRACING_URLS } from './IRacingSelectors';
|
||||
|
||||
export class IRacingDomNavigator {
|
||||
private static readonly STEP_TO_PAGE_MAP: Record<number, string> = {
|
||||
7: 'timeLimit',
|
||||
8: 'cars',
|
||||
9: 'cars',
|
||||
10: 'carClasses',
|
||||
11: 'track',
|
||||
12: 'track',
|
||||
13: 'trackOptions',
|
||||
14: 'timeOfDay',
|
||||
15: 'weather',
|
||||
16: 'raceOptions',
|
||||
17: 'trackConditions',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly config: Required<PlaywrightConfig>,
|
||||
private readonly browserSession: PlaywrightBrowserSession,
|
||||
private readonly logger?: LoggerPort,
|
||||
private readonly onWizardDismissed?: () => Promise<void>,
|
||||
) {}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger as Record<
|
||||
'debug' | 'info' | 'warn' | 'error',
|
||||
(msg: string, ctx?: Record<string, unknown>) => void
|
||||
>;
|
||||
logger[level](message, context);
|
||||
}
|
||||
|
||||
private isRealMode(): boolean {
|
||||
return this.config.mode === 'real';
|
||||
}
|
||||
|
||||
private getPage(): Page | null {
|
||||
return this.browserSession.getPage();
|
||||
}
|
||||
|
||||
async navigateToPage(url: string): Promise<NavigationResultDTO> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return { success: false, url, loadTime: 0, error: 'Browser not connected' };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const targetUrl = this.isRealMode() && !url.startsWith('http') ? IRACING_URLS.hostedSessions : url;
|
||||
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.navigation : this.config.timeout;
|
||||
|
||||
this.log('debug', 'Navigating to page', { url: targetUrl, mode: this.config.mode });
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout });
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
if (!this.isRealMode()) {
|
||||
const stepMatch = url.match(/step-(\d+)-/);
|
||||
if (stepMatch) {
|
||||
const [, stepStr] = stepMatch;
|
||||
if (stepStr) {
|
||||
const stepNumber = parseInt(stepStr, 10);
|
||||
await page.evaluate((step) => {
|
||||
document.body.setAttribute('data-step', String(step));
|
||||
}, stepNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, url: targetUrl, loadTime };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const loadTime = Date.now() - startTime;
|
||||
return { success: false, url, loadTime, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return { success: false, target, waitedMs: 0, found: false, error: 'Browser not connected' };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const defaultTimeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
|
||||
|
||||
try {
|
||||
let selector: string;
|
||||
if (target.startsWith('[') || target.startsWith('button') || target.startsWith('#')) {
|
||||
selector = target;
|
||||
} else {
|
||||
selector = IRACING_SELECTORS.wizard.modal;
|
||||
}
|
||||
|
||||
this.log('debug', 'Waiting for element', { target, _selector, mode: this.config.mode });
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'attached',
|
||||
timeout: maxWaitMs ?? defaultTimeout,
|
||||
});
|
||||
return { success: true, target, waitedMs: Date.now() - startTime, found: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
target,
|
||||
waitedMs: Date.now() - startTime,
|
||||
found: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async waitForModal(): Promise<void> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
throw new Error('Browser not connected');
|
||||
}
|
||||
const selector = IRACING_SELECTORS.wizard.modal;
|
||||
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
|
||||
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'attached',
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
async waitForStep(stepNumber: number): Promise<void> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
throw new Error('Browser not connected');
|
||||
}
|
||||
|
||||
if (!this.isRealMode()) {
|
||||
await page.evaluate((step) => {
|
||||
document.body.setAttribute('data-step', String(step));
|
||||
}, stepNumber);
|
||||
}
|
||||
|
||||
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
|
||||
await page.waitForSelector(`[data-step="${stepNumber}"]`, {
|
||||
state: 'attached',
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
async waitForWizardStep(stepName: keyof typeof IRACING_SELECTORS.wizard.stepContainers): Promise<void> {
|
||||
const page = this.getPage();
|
||||
if (!page || !this.isRealMode()) return;
|
||||
|
||||
const containerSelector = IRACING_SELECTORS.wizard.stepContainers[stepName];
|
||||
if (!containerSelector) {
|
||||
this.log('warn', `Unknown wizard step: ${stepName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.log('debug', `Waiting for wizard step: ${stepName}`, { _selector: containerSelector });
|
||||
await page.waitForSelector(containerSelector, {
|
||||
state: 'attached',
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
} catch (error) {
|
||||
this.log('warn', `Wizard step not attached: ${stepName}`, { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async detectCurrentWizardPage(): Promise<string | null> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const containers = IRACING_SELECTORS.wizard.stepContainers;
|
||||
|
||||
for (const [pageName, selector] of Object.entries(containers)) {
|
||||
const count = await page.locator(selector).count();
|
||||
if (count > 0) {
|
||||
this.log('debug', 'Detected wizard page', { pageName, selector });
|
||||
return pageName;
|
||||
}
|
||||
}
|
||||
|
||||
this.log('debug', 'No wizard page detected');
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.log('debug', 'Error detecting wizard page', { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
synchronizeStepCounter(expectedStep: number, actualPage: string | null): number {
|
||||
if (!actualPage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let actualStep: number | null = null;
|
||||
for (const [step, pageName] of Object.entries(IRacingDomNavigator.STEP_TO_PAGE_MAP)) {
|
||||
if (pageName === actualPage) {
|
||||
actualStep = parseInt(step, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (actualStep === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const skipOffset = actualStep - expectedStep;
|
||||
|
||||
if (skipOffset > 0) {
|
||||
const skippedSteps: number[] = [];
|
||||
for (let i = expectedStep; i < actualStep; i++) {
|
||||
skippedSteps.push(i);
|
||||
}
|
||||
|
||||
this.log('warn', 'Wizard auto-skip detected', {
|
||||
expectedStep,
|
||||
actualStep,
|
||||
skipOffset,
|
||||
skippedSteps,
|
||||
});
|
||||
|
||||
return skipOffset;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async getCurrentStep(): Promise<number | null> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isRealMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stepAttr = await page.getAttribute('body', 'data-step');
|
||||
return stepAttr ? parseInt(stepAttr, 10) : null;
|
||||
}
|
||||
|
||||
private async isWizardModalDismissedInternal(): Promise<boolean> {
|
||||
const page = this.getPage();
|
||||
if (!page || !this.isRealMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stepContainerSelectors = Object.values(IRACING_SELECTORS.wizard.stepContainers);
|
||||
|
||||
for (const containerSelector of stepContainerSelectors) {
|
||||
const count = await page.locator(containerSelector).count();
|
||||
if (count > 0) {
|
||||
this.log('debug', 'Wizard step container attached, wizard is active', { containerSelector });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const modalSelector = '#create-race-modal, [role="dialog"], .modal.fade';
|
||||
const modalExists = (await page.locator(modalSelector).count()) > 0;
|
||||
|
||||
if (!modalExists) {
|
||||
this.log('debug', 'No wizard modal element found - dismissed');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.log('debug', 'Wizard step containers not attached, waiting 1000ms to confirm dismissal vs transition');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
for (const containerSelector of stepContainerSelectors) {
|
||||
const count = await page.locator(containerSelector).count();
|
||||
if (count > 0) {
|
||||
this.log('debug', 'Wizard step container attached after delay - was just transitioning', {
|
||||
containerSelector,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.log('info', 'No wizard step containers attached after delay - confirmed dismissed by user');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkWizardDismissed(_currentStep: number): Promise<void> {
|
||||
if (!this.isRealMode() || currentStep < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.isWizardModalDismissedInternal()) {
|
||||
this.log('info', 'Race creation wizard was dismissed by user');
|
||||
if (this.onWizardDismissed) {
|
||||
await this.onWizardDismissed().catch(() => {});
|
||||
}
|
||||
throw new Error('WIZARD_DISMISSED: User closed the race creation wizard');
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user