move automation out of core

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

View File

@@ -0,0 +1,4 @@
export interface AutomationEngineValidationResultDTO {
isValid: boolean;
error?: string;
}

View File

@@ -0,0 +1,5 @@
export interface AutomationResultDTO {
success: boolean;
error?: string;
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,13 @@
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;
}

View File

@@ -0,0 +1,8 @@
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
export interface CheckoutInfoDTO {
price: CheckoutPrice | null;
state: CheckoutState;
buttonHtml: string;
}

View File

@@ -0,0 +1,5 @@
import type { AutomationResultDTO } from './AutomationResultDTO';
export interface ClickResultDTO extends AutomationResultDTO {
target: string;
}

View File

@@ -0,0 +1,6 @@
import type { AutomationResultDTO } from './AutomationResultDTO';
export interface FormFillResultDTO extends AutomationResultDTO {
fieldName: string;
valueSet: string;
}

View File

@@ -0,0 +1,6 @@
import type { AutomationResultDTO } from './AutomationResultDTO';
export interface ModalResultDTO extends AutomationResultDTO {
stepId: number;
action: string;
}

View File

@@ -0,0 +1,6 @@
import type { AutomationResultDTO } from './AutomationResultDTO';
export interface NavigationResultDTO extends AutomationResultDTO {
url: string;
loadTime: number;
}

View File

@@ -0,0 +1,11 @@
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
export interface SessionDTO {
sessionId: string;
state: string;
currentStep: number;
config: HostedSessionConfig;
startedAt?: Date;
completedAt?: Date;
errorMessage?: string;
}

View File

@@ -0,0 +1,7 @@
import type { AutomationResultDTO } from './AutomationResultDTO';
export interface WaitResultDTO extends AutomationResultDTO {
target: string;
waitedMs: number;
found: boolean;
}

View File

@@ -0,0 +1,76 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
import { Result } from '@gridpilot/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>>;
}

View File

@@ -0,0 +1,11 @@
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;
}

View File

@@ -0,0 +1,10 @@
export type AutomationEvent = {
actionId?: string
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
timestamp: number
payload?: unknown
}
export interface AutomationEventPublisherPort {
publish(event: AutomationEvent): Promise<void>
}

View File

@@ -0,0 +1,8 @@
import { AutomationEvent } from './AutomationEventPublisherPort';
export type LifecycleCallback = (event: AutomationEvent) => Promise<void> | void;
export interface AutomationLifecycleEmitterPort {
onLifecycle(cb: LifecycleCallback): void;
offLifecycle(cb: LifecycleCallback): void;
}

View File

@@ -0,0 +1,5 @@
export interface AutomationResult {
success: boolean;
error?: string;
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,9 @@
import { Result } from '@gridpilot/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>>;
}

View File

@@ -0,0 +1,7 @@
import { Result } from '@gridpilot/shared/result/Result';
import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO';
export interface CheckoutServicePort {
extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>>;
proceedWithCheckout(): Promise<Result<void>>;
}

View File

@@ -0,0 +1,10 @@
export type AutomationEvent = {
actionId?: string
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
timestamp: number
payload?: unknown
}
export interface IAutomationEventPublisher {
publish(event: AutomationEvent): Promise<void>
}

View File

@@ -0,0 +1,7 @@
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>
}

View File

@@ -0,0 +1,17 @@
/**
* 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;
}

View File

@@ -0,0 +1,4 @@
/**
* Log levels in order of severity (lowest to highest)
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';

View File

@@ -0,0 +1,15 @@
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>;
}

View File

@@ -0,0 +1,7 @@
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>
}

View File

@@ -0,0 +1,62 @@
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;
}

View File

@@ -0,0 +1,12 @@
import { SessionStateValue } from '@/automation/domain/value-objects/SessionState';
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[]>;
}

View File

@@ -0,0 +1,5 @@
import type { Result } from '@gridpilot/shared/result/Result';
export interface SessionValidatorPort {
validateSession(): Promise<Result<boolean>>;
}

View File

@@ -0,0 +1,3 @@
export interface IUserConfirmationPort {
confirm(message: string): Promise<boolean>;
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from 'vitest'
import { OverlayAction } from 'apps/companion/main/automation/application/ports/IOverlaySyncPort'
import { IAutomationLifecycleEmitter, LifecycleCallback } from '@core/automation/infrastructure//IAutomationLifecycleEmitter'
import { OverlaySyncService } from 'apps/companion/main/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')
})
})

View File

@@ -0,0 +1,133 @@
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
import { AutomationLifecycleEmitterPort, LifecycleCallback } from '../ports/AutomationLifecycleEmitterPort';
import { LoggerPort } from '../ports/LoggerPort';
import type { IAsyncApplicationService } from '@core/shared/application';
import { OverlayAction, OverlaySyncPort } from '../ports/OverlaySyncPort';
import { ActionAck } from '../ports/IOverlaySyncPort';
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,
})
}
}
}

View File

@@ -0,0 +1,400 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CheckAuthenticationUseCase } from 'apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import { Result } from '@core/shared/result/Result';
import type { AuthenticationServicePort } from 'apps/companion/main/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);
});
});
});

View File

@@ -0,0 +1,119 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import type { Logger } from '@core/shared/application';
import { Result } from '@gridpilot/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.', 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.', error as Error, { expiry });
// 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 as Error);
throw error;
}
}
}

View File

@@ -0,0 +1,106 @@
import { vi, Mock } from 'vitest';
import { ClearSessionUseCase } from './ClearSessionUseCase';
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
import type { Logger } from '@core/shared/application';
import { Result } from '@gridpilot/shared/result/Result';
describe('ClearSessionUseCase', () => {
let useCase: ClearSessionUseCase;
let authService: AuthenticationServicePort;
let logger: Logger;
beforeEach(() => {
const mockAuthService = {
clearSession: vi.fn(),
checkSession: vi.fn(),
initiateLogin: vi.fn(),
getState: vi.fn(),
validateServerSide: vi.fn(),
refreshSession: vi.fn(),
getSessionExpiry: vi.fn(),
verifyPageAuthentication: vi.fn(),
};
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
authService = mockAuthService as unknown as AuthenticationServicePort;
logger = mockLogger as Logger;
useCase = new ClearSessionUseCase(authService, logger);
});
describe('execute', () => {
it('should clear session successfully and return ok result', async () => {
const successResult = Result.ok<void>(undefined);
(authService.clearSession as Mock).mockResolvedValue(successResult);
const result = await useCase.execute();
expect(authService.clearSession).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith('Attempting to clear user session.', {
useCase: 'ClearSessionUseCase'
});
expect(logger.info).toHaveBeenCalledWith('User session cleared successfully.', {
useCase: 'ClearSessionUseCase'
});
expect(result.isOk()).toBe(true);
});
it('should handle clearSession failure and return err result', async () => {
const error = new Error('Clear session failed');
const failureResult = Result.err<void>(error);
(authService.clearSession as Mock).mockResolvedValue(failureResult);
const result = await useCase.execute();
expect(authService.clearSession).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith('Attempting to clear user session.', {
useCase: 'ClearSessionUseCase'
});
expect(logger.warn).toHaveBeenCalledWith('Failed to clear user session.', {
useCase: 'ClearSessionUseCase',
error: error,
});
expect(result.isErr()).toBe(true);
expect(result.error).toBe(error);
});
it('should handle unexpected errors and return err result with Error', async () => {
const thrownError = new Error('Unexpected error');
(authService.clearSession as Mock).mockRejectedValue(thrownError);
const result = await useCase.execute();
expect(authService.clearSession).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith('Attempting to clear user session.', {
useCase: 'ClearSessionUseCase'
});
expect(logger.error).toHaveBeenCalledWith('Error clearing user session.', thrownError, {
useCase: 'ClearSessionUseCase'
});
expect(result.isErr()).toBe(true);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('Unexpected error');
});
it('should handle non-Error thrown values and convert to Error', async () => {
const thrownValue = 'String error';
(authService.clearSession as Mock).mockRejectedValue(thrownValue);
const result = await useCase.execute();
expect(authService.clearSession).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith('Error clearing user session.', expect.any(Error), {
useCase: 'ClearSessionUseCase'
});
expect(result.isErr()).toBe(true);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('String error');
});
});
});

View File

@@ -0,0 +1,48 @@
import { Result } from '@gridpilot/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.isOk()) {
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) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Error clearing user session.', err, {
useCase: 'ClearSessionUseCase'
});
return Result.err(err);
}
}
}

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CompleteRaceCreationUseCase } from 'apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase';
import { Result } from '@core/shared/result/Result';
import { RaceCreationResult } from 'apps/companion/main/automation/domain/value-objects/RaceCreationResult';
import { CheckoutPrice } from 'apps/companion/main/automation/domain/value-objects/CheckoutPrice';
import type { CheckoutServicePort } from 'apps/companion/main/automation/application/ports/CheckoutServicePort';
import { CheckoutState } from 'apps/companion/main/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()
);
});
});
});

View File

@@ -0,0 +1,46 @@
import { Result } from '@gridpilot/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);
}
}
}

View File

@@ -0,0 +1,431 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { Result } from '@core/shared/result/Result';
import { ConfirmCheckoutUseCase } from 'apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase';
import type { CheckoutServicePort } from 'apps/companion/main/automation/application/ports/CheckoutServicePort';
import type { CheckoutConfirmationPort } from 'apps/companion/main/automation/application/ports/CheckoutConfirmationPort';
import { CheckoutPrice } from 'apps/companion/main/automation/domain/value-objects/CheckoutPrice';
import { CheckoutState } from 'apps/companion/main/automation/domain/value-objects/CheckoutState';
import { CheckoutConfirmation } from 'apps/companion/main/automation/domain/value-objects/CheckoutConfirmation';
import type { Logger } from '@core/shared/application';
/**
* 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 mockLogger: Logger;
let mockPrice: CheckoutPrice;
beforeEach(() => {
mockCheckoutService = {
extractCheckoutInfo: vi.fn(),
proceedWithCheckout: vi.fn(),
};
mockConfirmationPort = {
requestCheckoutConfirmation: vi.fn(),
};
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => mockLogger),
flush: vi.fn(),
} as Logger;
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
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,
mockLogger
);
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
Result.err('Button not found')
);
const result = await useCase.execute();
expect(result.isErr()).toBe(true);
});
});
});

View File

@@ -0,0 +1,89 @@
import { Result } from '@gridpilot/shared/result/Result';
import type { Logger } from '@core/shared/application';
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
import type { CheckoutConfirmationPort } from '../ports/CheckoutConfirmationPort';
import { CheckoutStateEnum } from '../../domain/value-objects/CheckoutState';
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', 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', 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', checkoutResult.unwrapErr());
}
return checkoutResult;
}
}

View File

@@ -0,0 +1,40 @@
import { Result } from '@gridpilot/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) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Error initiating login flow.', err);
return Result.err(err);
}
}
}

View File

@@ -0,0 +1,308 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { StartAutomationSessionUseCase } from 'apps/companion/main/automation/application/use-cases/StartAutomationSessionUseCase';
import { AutomationEnginePort as IAutomationEngine } from 'apps/companion/main/automation/application/ports/AutomationEnginePort';
import { IBrowserAutomation as IScreenAutomation } from 'apps/companion/main/automation/application/ports/ScreenAutomationPort';
import { SessionRepositoryPort as ISessionRepository } from 'apps/companion/main/automation/application/ports/SessionRepositoryPort';
import type { Logger } from '@core/shared/application';
import { AutomationSession } from 'apps/companion/main/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 mockLogger: {
debug: Mock;
info: Mock;
warn: Mock;
error: 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(),
};
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
useCase = new StartAutomationSessionUseCase(
mockAutomationEngine as unknown as IAutomationEngine,
mockBrowserAutomation as unknown as IScreenAutomation,
mockSessionRepository as unknown as ISessionRepository,
mockLogger as unknown as Logger
);
});
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)');
});
});
});

View File

@@ -0,0 +1,48 @@
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 });
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;
}
}

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { VerifyAuthenticatedPageUseCase } from 'apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase';
import { AuthenticationServicePort as IAuthenticationService } from 'apps/companion/main/automation/application/ports/AuthenticationServicePort';
import { Result } from '@core/shared/result/Result';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import { AuthenticationState } from 'apps/companion/main/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');
}
});
});

View File

@@ -0,0 +1,36 @@
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
import { Result } from '@gridpilot/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 instanceof Error ? error : undefined);
return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
}
}
}