refactor
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { AnalyticsEntityId } from '../../../core/analytics/domain/value-objects/AnalyticsEntityId';
|
||||
|
||||
describe('AnalyticsEntityId', () => {
|
||||
it('creates a valid AnalyticsEntityId from a non-empty string', () => {
|
||||
const id = AnalyticsEntityId.create('entity_123');
|
||||
|
||||
expect(id.value).toBe('entity_123');
|
||||
});
|
||||
|
||||
it('trims whitespace from the raw value', () => {
|
||||
const id = AnalyticsEntityId.create(' entity_456 ');
|
||||
|
||||
expect(id.value).toBe('entity_456');
|
||||
});
|
||||
|
||||
it('throws for empty or whitespace-only strings', () => {
|
||||
expect(() => AnalyticsEntityId.create('')).toThrow(Error);
|
||||
expect(() => AnalyticsEntityId.create(' ')).toThrow(Error);
|
||||
});
|
||||
|
||||
it('compares equality based on underlying value', () => {
|
||||
const a = AnalyticsEntityId.create('entity_1');
|
||||
const b = AnalyticsEntityId.create('entity_1');
|
||||
const c = AnalyticsEntityId.create('entity_2');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AnalyticsSessionId } from '../../../core/analytics/domain/value-objects/AnalyticsSessionId';
|
||||
|
||||
describe('AnalyticsSessionId', () => {
|
||||
it('creates a valid AnalyticsSessionId from a non-empty string', () => {
|
||||
const id = AnalyticsSessionId.create('session_123');
|
||||
|
||||
expect(id.value).toBe('session_123');
|
||||
});
|
||||
|
||||
it('trims whitespace from the raw value', () => {
|
||||
const id = AnalyticsSessionId.create(' session_456 ');
|
||||
|
||||
expect(id.value).toBe('session_456');
|
||||
});
|
||||
|
||||
it('throws for empty or whitespace-only strings', () => {
|
||||
expect(() => AnalyticsSessionId.create('')).toThrow(Error);
|
||||
expect(() => AnalyticsSessionId.create(' ')).toThrow(Error);
|
||||
});
|
||||
|
||||
it('compares equality based on underlying value', () => {
|
||||
const a = AnalyticsSessionId.create('session_1');
|
||||
const b = AnalyticsSessionId.create('session_1');
|
||||
const c = AnalyticsSessionId.create('session_2');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
29
core/analytics/domain/value-objects/PageViewId.test.ts
Normal file
29
core/analytics/domain/value-objects/PageViewId.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { PageViewId } from '../../../core/analytics/domain/value-objects/PageViewId';
|
||||
|
||||
describe('PageViewId', () => {
|
||||
it('creates a valid PageViewId from a non-empty string', () => {
|
||||
const id = PageViewId.create('pv_123');
|
||||
|
||||
expect(id.value).toBe('pv_123');
|
||||
});
|
||||
|
||||
it('trims whitespace from the raw value', () => {
|
||||
const id = PageViewId.create(' pv_456 ');
|
||||
|
||||
expect(id.value).toBe('pv_456');
|
||||
});
|
||||
|
||||
it('throws for empty or whitespace-only strings', () => {
|
||||
expect(() => PageViewId.create('')).toThrow(Error);
|
||||
expect(() => PageViewId.create(' ')).toThrow(Error);
|
||||
});
|
||||
|
||||
it('compares equality based on underlying value', () => {
|
||||
const a = PageViewId.create('pv_1');
|
||||
const b = PageViewId.create('pv_1');
|
||||
const c = PageViewId.create('pv_2');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
400
core/automation/application/CheckAuthenticationUseCase.test.ts
Normal file
400
core/automation/application/CheckAuthenticationUseCase.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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: any; save: any };
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
121
core/automation/application/CompleteRaceCreationUseCase.test.ts
Normal file
121
core/automation/application/CompleteRaceCreationUseCase.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
405
core/automation/application/ConfirmCheckoutUseCase.test.ts
Normal file
405
core/automation/application/ConfirmCheckoutUseCase.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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 { CheckoutState, CheckoutStateEnum } from '@core/automation/domain/value-objects/CheckoutState';
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
33
core/automation/application/GetTotalDriversUseCase.test.ts
Normal file
33
core/automation/application/GetTotalDriversUseCase.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
180
core/automation/application/ICheckoutConfirmationPort.test.ts
Normal file
180
core/automation/application/ICheckoutConfirmationPort.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
52
core/automation/application/OverlaySyncService.test.ts
Normal file
52
core/automation/application/OverlaySyncService.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { OverlayAction, ActionAck } from '@core/automation/application/ports/IOverlaySyncPort'
|
||||
import { IAutomationEventPublisher, AutomationEvent } from '@core/automation/application/ports/IAutomationEventPublisher'
|
||||
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 any,
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
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 any,
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,549 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
293
core/automation/application/StartAutomationSession.test.ts
Normal file
293
core/automation/application/StartAutomationSession.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
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)');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
364
core/automation/domain/entities/AutomationSession.test.ts
Normal file
364
core/automation/domain/entities/AutomationSession.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutomationSession } from '@core/automation/domain/entities/AutomationSession';
|
||||
import { StepId } from '@core/automation/domain/value-objects/StepId';
|
||||
import { SessionState } from '@core/automation/domain/value-objects/SessionState';
|
||||
|
||||
describe('AutomationSession Entity', () => {
|
||||
describe('create', () => {
|
||||
it('should create a new session with PENDING state', () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race Session',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const session = AutomationSession.create(config);
|
||||
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.currentStep.value).toBe(1);
|
||||
expect(session.state.isPending()).toBe(true);
|
||||
expect(session.config).toEqual(config);
|
||||
});
|
||||
|
||||
it('should throw error for empty session name', () => {
|
||||
const config = {
|
||||
sessionName: '',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
expect(() => AutomationSession.create(config)).toThrow('Session name cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for missing track ID', () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: '',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
expect(() => AutomationSession.create(config)).toThrow('Track ID is required');
|
||||
});
|
||||
|
||||
it('should throw error for empty car list', () => {
|
||||
const config = {
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: [],
|
||||
};
|
||||
|
||||
expect(() => AutomationSession.create(config)).toThrow('At least one car must be selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should transition from PENDING to IN_PROGRESS', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
session.start();
|
||||
|
||||
expect(session.state.isInProgress()).toBe(true);
|
||||
expect(session.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error when starting non-PENDING session', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
expect(() => session.start()).toThrow('Cannot start session that is not pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitionToStep', () => {
|
||||
it('should advance to next step when in progress', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
session.transitionToStep(StepId.create(2));
|
||||
|
||||
expect(session.currentStep.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw error when transitioning while not in progress', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
|
||||
expect(() => session.transitionToStep(StepId.create(2))).toThrow(
|
||||
'Cannot transition steps when session is not in progress'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when skipping steps', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
|
||||
expect(() => session.transitionToStep(StepId.create(3))).toThrow(
|
||||
'Cannot skip steps - must transition sequentially'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when moving backward', () => {
|
||||
const session = AutomationSession.create({
|
||||
sessionName: 'Test Race',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
});
|
||||
session.start();
|
||||
session.transitionToStep(StepId.create(2));
|
||||
|
||||
expect(() => session.transitionToStep(StepId.create(1))).toThrow(
|
||||
'Cannot move backward - steps must progress forward only'
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop at step 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
core/automation/domain/services/PageStateValidator.test.ts
Normal file
167
core/automation/domain/services/PageStateValidator.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
231
core/automation/domain/services/StepTransitionValidator.test.ts
Normal file
231
core/automation/domain/services/StepTransitionValidator.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StepTransitionValidator } from '@core/automation/domain/services/StepTransitionValidator';
|
||||
import { StepId } from '@core/automation/domain/value-objects/StepId';
|
||||
import { SessionState } from '@core/automation/domain/value-objects/SessionState';
|
||||
|
||||
describe('StepTransitionValidator Service', () => {
|
||||
describe('canTransition', () => {
|
||||
it('should allow sequential forward transition', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject transition when not IN_PROGRESS', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
const state = SessionState.create('PENDING');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Session must be in progress to transition steps');
|
||||
});
|
||||
|
||||
it('should reject skipping steps', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(3);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Cannot skip steps - must progress sequentially');
|
||||
});
|
||||
|
||||
it('should reject backward transitions', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(4);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Cannot move backward - steps must progress forward only');
|
||||
});
|
||||
|
||||
it('should reject same step transition', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(5);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Already at this step');
|
||||
});
|
||||
|
||||
it('should allow transition through modal steps', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from modal step to next', () => {
|
||||
const currentStep = StepId.create(6);
|
||||
const nextStep = StepId.create(7);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModalStepTransition', () => {
|
||||
it('should allow entering modal step 6', () => {
|
||||
const currentStep = StepId.create(5);
|
||||
const nextStep = StepId.create(6);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow entering modal step 9', () => {
|
||||
const currentStep = StepId.create(8);
|
||||
const nextStep = StepId.create(9);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow entering modal step 12', () => {
|
||||
const currentStep = StepId.create(11);
|
||||
const nextStep = StepId.create(12);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow exiting modal step 6', () => {
|
||||
const currentStep = StepId.create(6);
|
||||
const nextStep = StepId.create(7);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow non-modal transitions', () => {
|
||||
const currentStep = StepId.create(1);
|
||||
const nextStep = StepId.create(2);
|
||||
|
||||
const result = StepTransitionValidator.validateModalStepTransition(currentStep, nextStep);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldStopAtStep18', () => {
|
||||
it('should return true when transitioning to step 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,78 +0,0 @@
|
||||
export class Result<T, E = Error> {
|
||||
private constructor(
|
||||
private readonly _value?: T,
|
||||
private readonly _error?: E,
|
||||
private readonly _isSuccess: boolean = true
|
||||
) {}
|
||||
|
||||
static ok<T, E = Error>(value: T): Result<T, E> {
|
||||
return new Result<T, E>(value, undefined, true);
|
||||
}
|
||||
|
||||
static err<T, E = Error>(error: E): Result<T, E> {
|
||||
return new Result<T, E>(undefined, error, false);
|
||||
}
|
||||
|
||||
isOk(): boolean {
|
||||
return this._isSuccess;
|
||||
}
|
||||
|
||||
isErr(): boolean {
|
||||
return !this._isSuccess;
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
if (!this._isSuccess) {
|
||||
throw new Error('Called unwrap on an error result');
|
||||
}
|
||||
return this._value!;
|
||||
}
|
||||
|
||||
unwrapOr(defaultValue: T): T {
|
||||
return this._isSuccess ? this._value! : defaultValue;
|
||||
}
|
||||
|
||||
unwrapErr(): E {
|
||||
if (this._isSuccess) {
|
||||
throw new Error('Called unwrapErr on a success result');
|
||||
}
|
||||
return this._error!;
|
||||
}
|
||||
|
||||
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||
if (this._isSuccess) {
|
||||
return Result.ok(fn(this._value!));
|
||||
}
|
||||
return Result.err(this._error!);
|
||||
}
|
||||
|
||||
mapErr<F>(fn: (error: E) => F): Result<T, F> {
|
||||
if (!this._isSuccess) {
|
||||
return Result.err(fn(this._error!));
|
||||
}
|
||||
return Result.ok(this._value!);
|
||||
}
|
||||
|
||||
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
|
||||
if (this._isSuccess) {
|
||||
return fn(this._value!);
|
||||
}
|
||||
return Result.err(this._error!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct access to the value (for testing convenience).
|
||||
* Prefer using unwrap() in production code.
|
||||
*/
|
||||
get value(): T | undefined {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct access to the error (for testing convenience).
|
||||
* Prefer using unwrapErr() in production code.
|
||||
*/
|
||||
get error(): E | undefined {
|
||||
return this._error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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 any)).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
184
core/automation/domain/value-objects/CheckoutPrice.test.ts
Normal file
184
core/automation/domain/value-objects/CheckoutPrice.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
127
core/automation/domain/value-objects/CheckoutState.test.ts
Normal file
127
core/automation/domain/value-objects/CheckoutState.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CheckoutState, CheckoutStateEnum } from '@core/automation/domain/value-objects/CheckoutState';
|
||||
|
||||
/**
|
||||
* CheckoutState Value Object - GREEN PHASE
|
||||
*
|
||||
* Tests for checkout button state detection.
|
||||
*/
|
||||
|
||||
describe('CheckoutState Value Object', () => {
|
||||
describe('READY state', () => {
|
||||
it('should create READY state from btn-success class', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should detect ready state correctly', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.isReady()).toBe(true);
|
||||
expect(state.hasInsufficientFunds()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle additional classes with btn-success', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-lg btn-success pull-right');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for btn-success', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn BTN-SUCCESS');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('INSUFFICIENT_FUNDS state', () => {
|
||||
it('should create INSUFFICIENT_FUNDS from btn-default without btn-success', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('should detect insufficient funds correctly', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.isReady()).toBe(false);
|
||||
expect(state.hasInsufficientFunds()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle btn-primary as insufficient funds', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-primary');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('should handle btn-warning as insufficient funds', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-warning');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('should handle disabled button as insufficient funds', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default disabled');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UNKNOWN state', () => {
|
||||
it('should create UNKNOWN when no btn class exists', () => {
|
||||
const state = CheckoutState.fromButtonClasses('some-other-class');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should create UNKNOWN from empty string', () => {
|
||||
const state = CheckoutState.fromButtonClasses('');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should detect unknown state correctly', () => {
|
||||
const state = CheckoutState.fromButtonClasses('');
|
||||
expect(state.isReady()).toBe(false);
|
||||
expect(state.hasInsufficientFunds()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle whitespace in class names', () => {
|
||||
const state = CheckoutState.fromButtonClasses(' btn btn-success ');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should handle multiple spaces between classes', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('should be immutable after creation', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
const originalState = state.getValue();
|
||||
expect(originalState).toBe(CheckoutStateEnum.READY);
|
||||
// Verify no setters exist
|
||||
const mutableState = state as unknown as { setState?: unknown };
|
||||
expect(typeof mutableState.setState).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BDD Scenarios', () => {
|
||||
it('Given button with btn-success, When checking state, Then state is READY', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.READY);
|
||||
});
|
||||
|
||||
it('Given button without btn-success, When checking state, Then state is INSUFFICIENT_FUNDS', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
});
|
||||
|
||||
it('Given no button classes, When checking state, Then state is UNKNOWN', () => {
|
||||
const state = CheckoutState.fromButtonClasses('');
|
||||
expect(state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
|
||||
});
|
||||
|
||||
it('Given READY state, When checking isReady, Then returns true', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-success');
|
||||
expect(state.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('Given INSUFFICIENT_FUNDS state, When checking hasInsufficientFunds, Then returns true', () => {
|
||||
const state = CheckoutState.fromButtonClasses('btn btn-default');
|
||||
expect(state.hasInsufficientFunds()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
288
core/automation/domain/value-objects/CookieConfiguration.test.ts
Normal file
288
core/automation/domain/value-objects/CookieConfiguration.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
107
core/automation/domain/value-objects/RaceCreationResult.test.ts
Normal file
107
core/automation/domain/value-objects/RaceCreationResult.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
103
core/automation/domain/value-objects/SessionLifetime.test.ts
Normal file
103
core/automation/domain/value-objects/SessionLifetime.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
core/automation/domain/value-objects/SessionState.test.ts
Normal file
254
core/automation/domain/value-objects/SessionState.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionState } from '@core/automation/domain/value-objects/SessionState';
|
||||
|
||||
describe('SessionState Value Object', () => {
|
||||
describe('create', () => {
|
||||
it('should create PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.value).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('should create IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.value).toBe('IN_PROGRESS');
|
||||
});
|
||||
|
||||
it('should create PAUSED state', () => {
|
||||
const state = SessionState.create('PAUSED');
|
||||
expect(state.value).toBe('PAUSED');
|
||||
});
|
||||
|
||||
it('should create COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.value).toBe('COMPLETED');
|
||||
});
|
||||
|
||||
it('should create FAILED state', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.value).toBe('FAILED');
|
||||
});
|
||||
|
||||
it('should create STOPPED_AT_STEP_18 state', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.value).toBe('STOPPED_AT_STEP_18');
|
||||
});
|
||||
|
||||
it('should 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 any)).toThrow('Invalid session state');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => SessionState.create('' as any)).toThrow('Invalid session state');
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal states', () => {
|
||||
const state1 = SessionState.create('PENDING');
|
||||
const state2 = SessionState.create('PENDING');
|
||||
expect(state1.equals(state2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different states', () => {
|
||||
const state1 = SessionState.create('PENDING');
|
||||
const state2 = SessionState.create('IN_PROGRESS');
|
||||
expect(state1.equals(state2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPending', () => {
|
||||
it('should return true for PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.isPending()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInProgress', () => {
|
||||
it('should return true for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isInProgress()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.isInProgress()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompleted', () => {
|
||||
it('should return true for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isCompleted()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isCompleted()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFailed', () => {
|
||||
it('should return true for FAILED state', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.isFailed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isFailed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStoppedAtStep18', () => {
|
||||
it('should return true for STOPPED_AT_STEP_18 state', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.isStoppedAtStep18()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isStoppedAtStep18()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canTransitionTo', () => {
|
||||
it('should allow transition from PENDING to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from IN_PROGRESS to PAUSED', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.canTransitionTo(SessionState.create('PAUSED'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from IN_PROGRESS to STOPPED_AT_STEP_18', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.canTransitionTo(SessionState.create('STOPPED_AT_STEP_18'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow transition from PAUSED to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('PAUSED');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow transition from COMPLETED to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow transition from FAILED to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow transition from STOPPED_AT_STEP_18 to IN_PROGRESS', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.canTransitionTo(SessionState.create('IN_PROGRESS'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTerminal', () => {
|
||||
it('should return true for COMPLETED state', () => {
|
||||
const state = SessionState.create('COMPLETED');
|
||||
expect(state.isTerminal()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for FAILED state', () => {
|
||||
const state = SessionState.create('FAILED');
|
||||
expect(state.isTerminal()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for STOPPED_AT_STEP_18 state', () => {
|
||||
const state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
expect(state.isTerminal()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for PENDING state', () => {
|
||||
const state = SessionState.create('PENDING');
|
||||
expect(state.isTerminal()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for IN_PROGRESS state', () => {
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
expect(state.isTerminal()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for PAUSED state', () => {
|
||||
const state = SessionState.create('PAUSED');
|
||||
expect(state.isTerminal()).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
core/automation/domain/value-objects/StepId.test.ts
Normal file
104
core/automation/domain/value-objects/StepId.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
262
core/automation/infrastructure/AutomationConfig.test.ts
Normal file
262
core/automation/infrastructure/AutomationConfig.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { loadAutomationConfig, getAutomationMode, AutomationMode } from '../../../core/automation/infrastructure/config/AutomationConfig';
|
||||
|
||||
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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).NODE_ENV = 'production';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.mode).toBe('production');
|
||||
expect(config.nutJs).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
184
core/automation/infrastructure/BrowserModeConfig.test.ts
Normal file
184
core/automation/infrastructure/BrowserModeConfig.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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 any).NODE_ENV;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('Development Mode with Runtime Control', () => {
|
||||
it('should default to headless in development mode', () => {
|
||||
(process.env as any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).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 any).NODE_ENV;
|
||||
|
||||
const loader = new BrowserModeConfigLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.source).toBe('NODE_ENV');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
core/automation/infrastructure/WizardDismissalDetection.test.ts
Normal file
119
core/automation/infrastructure/WizardDismissalDetection.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
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 any;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
core/identity/EmailValidation.test.ts
Normal file
34
core/identity/EmailValidation.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { validateEmail, isDisposableEmail } from '@core/identity/domain/value-objects/EmailAddress';
|
||||
|
||||
describe('identity-domain email validation', () => {
|
||||
it('accepts a valid email and normalizes it', () => {
|
||||
const result = validateEmail(' USER@example.com ');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.email).toBe('user@example.com');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an invalid email format', () => {
|
||||
const result = validateEmail('not-an-email');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.email).toBeUndefined();
|
||||
expect(result.error).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('rejects an email that is too short', () => {
|
||||
const result = validateEmail('a@b');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('too short');
|
||||
});
|
||||
|
||||
it('detects disposable email domains', () => {
|
||||
expect(isDisposableEmail('foo@tempmail.com')).toBe(true);
|
||||
expect(isDisposableEmail('bar@mailinator.com')).toBe(true);
|
||||
expect(isDisposableEmail('user@example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
36
core/media/MediaUrl.test.ts
Normal file
36
core/media/MediaUrl.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MediaUrl } from '../../../../../core/media/domain/value-objects/MediaUrl';
|
||||
|
||||
describe('MediaUrl', () => {
|
||||
it('creates from valid http/https URLs', () => {
|
||||
expect(MediaUrl.create('http://example.com').value).toBe('http://example.com');
|
||||
expect(MediaUrl.create('https://example.com/path').value).toBe('https://example.com/path');
|
||||
});
|
||||
|
||||
it('creates from data URIs', () => {
|
||||
const url = 'data:image/jpeg;base64,AAA';
|
||||
expect(MediaUrl.create(url).value).toBe(url);
|
||||
});
|
||||
|
||||
it('creates from root-relative paths', () => {
|
||||
expect(MediaUrl.create('/images/avatar.png').value).toBe('/images/avatar.png');
|
||||
});
|
||||
|
||||
it('rejects empty or whitespace URLs', () => {
|
||||
expect(() => MediaUrl.create('')).toThrow();
|
||||
expect(() => MediaUrl.create(' ')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects unsupported schemes', () => {
|
||||
expect(() => MediaUrl.create('ftp://example.com/file')).toThrow();
|
||||
expect(() => MediaUrl.create('mailto:user@example.com')).toThrow();
|
||||
});
|
||||
|
||||
it('implements value-based equality', () => {
|
||||
const a = MediaUrl.create('https://example.com/a.png');
|
||||
const b = MediaUrl.create('https://example.com/a.png');
|
||||
const c = MediaUrl.create('https://example.com/b.png');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
38
core/notifications/NotificationId.test.ts
Normal file
38
core/notifications/NotificationId.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NotificationId } from '../../../../../core/notifications/domain/value-objects/NotificationId';
|
||||
import { NotificationDomainError } from '../../../../../core/notifications/domain/errors/NotificationDomainError';
|
||||
|
||||
describe('NotificationId', () => {
|
||||
it('creates a valid NotificationId from a non-empty string', () => {
|
||||
const id = NotificationId.create('noti_123');
|
||||
|
||||
expect(id.value).toBe('noti_123');
|
||||
});
|
||||
|
||||
it('trims whitespace from the raw value', () => {
|
||||
const id = NotificationId.create(' noti_456 ');
|
||||
|
||||
expect(id.value).toBe('noti_456');
|
||||
});
|
||||
|
||||
it('throws NotificationDomainError for empty string', () => {
|
||||
expect(() => NotificationId.create('')).toThrow(NotificationDomainError);
|
||||
expect(() => NotificationId.create(' ')).toThrow(NotificationDomainError);
|
||||
|
||||
try {
|
||||
NotificationId.create(' ');
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationDomainError) {
|
||||
expect(error.kind).toBe('validation');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('compares equality based on underlying value', () => {
|
||||
const a = NotificationId.create('noti_1');
|
||||
const b = NotificationId.create('noti_1');
|
||||
const c = NotificationId.create('noti_2');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
51
core/notifications/QuietHours.test.ts
Normal file
51
core/notifications/QuietHours.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { QuietHours } from '../../../../../core/notifications/domain/value-objects/QuietHours';
|
||||
|
||||
describe('QuietHours', () => {
|
||||
it('creates a valid normal-range window', () => {
|
||||
const qh = QuietHours.create(9, 17);
|
||||
expect(qh.props.startHour).toBe(9);
|
||||
expect(qh.props.endHour).toBe(17);
|
||||
});
|
||||
|
||||
it('creates a valid overnight window', () => {
|
||||
const qh = QuietHours.create(22, 7);
|
||||
expect(qh.props.startHour).toBe(22);
|
||||
expect(qh.props.endHour).toBe(7);
|
||||
});
|
||||
|
||||
it('throws when hours are out of range', () => {
|
||||
expect(() => QuietHours.create(-1, 10)).toThrow();
|
||||
expect(() => QuietHours.create(0, 24)).toThrow();
|
||||
});
|
||||
|
||||
it('throws when start and end are equal', () => {
|
||||
expect(() => QuietHours.create(10, 10)).toThrow();
|
||||
});
|
||||
|
||||
it('detects containment for normal range', () => {
|
||||
const qh = QuietHours.create(9, 17);
|
||||
expect(qh.containsHour(8)).toBe(false);
|
||||
expect(qh.containsHour(9)).toBe(true);
|
||||
expect(qh.containsHour(12)).toBe(true);
|
||||
expect(qh.containsHour(17)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects containment for overnight range', () => {
|
||||
const qh = QuietHours.create(22, 7);
|
||||
expect(qh.containsHour(21)).toBe(false);
|
||||
expect(qh.containsHour(22)).toBe(true);
|
||||
expect(qh.containsHour(23)).toBe(true);
|
||||
expect(qh.containsHour(0)).toBe(true);
|
||||
expect(qh.containsHour(6)).toBe(true);
|
||||
expect(qh.containsHour(7)).toBe(false);
|
||||
});
|
||||
|
||||
it('implements value-based equality', () => {
|
||||
const a = QuietHours.create(22, 7);
|
||||
const b = QuietHours.create(22, 7);
|
||||
const c = QuietHours.create(9, 17);
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
|
||||
import { ApproveLeagueJoinRequestPresenter } from '@apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter';
|
||||
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
|
||||
describe('ApproveLeagueJoinRequestUseCase', () => {
|
||||
let useCase: ApproveLeagueJoinRequestUseCase;
|
||||
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
|
||||
let presenter: ApproveLeagueJoinRequestPresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
leagueMembershipRepository = {
|
||||
getJoinRequests: jest.fn(),
|
||||
removeJoinRequest: jest.fn(),
|
||||
saveMembership: jest.fn(),
|
||||
} as any;
|
||||
presenter = new ApproveLeagueJoinRequestPresenter();
|
||||
useCase = new ApproveLeagueJoinRequestUseCase(leagueMembershipRepository);
|
||||
});
|
||||
|
||||
it('should approve join request and save membership', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const requestId = 'req-1';
|
||||
const joinRequests = [{ id: requestId, leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }];
|
||||
|
||||
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
|
||||
|
||||
await useCase.execute({ leagueId, requestId }, presenter);
|
||||
|
||||
expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId);
|
||||
expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({
|
||||
leagueId,
|
||||
driverId: 'driver-1',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: expect.any(Date),
|
||||
});
|
||||
expect(presenter.viewModel).toEqual({ success: true, message: 'Join request approved.' });
|
||||
});
|
||||
|
||||
it('should throw error if request not found', async () => {
|
||||
leagueMembershipRepository.getJoinRequests.mockResolvedValue([]);
|
||||
|
||||
await expect(useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }, presenter)).rejects.toThrow('Join request not found');
|
||||
});
|
||||
});
|
||||
653
core/racing/application/DashboardOverviewUseCase.test.ts
Normal file
653
core/racing/application/DashboardOverviewUseCase.test.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { GetDashboardOverviewUseCase } from '@core/racing/application/use-cases/GetDashboardOverviewUseCase';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { Result } from '@core/racing/domain/entities/Result';
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
import { Standing } from '@core/racing/domain/entities/Standing';
|
||||
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
||||
import type { FeedItem } from '@core/social/domain/types/FeedItem';
|
||||
import type {
|
||||
IDashboardOverviewPresenter,
|
||||
DashboardOverviewViewModel,
|
||||
DashboardFeedItemSummaryViewModel,
|
||||
} from '@core/racing/application/presenters/IDashboardOverviewPresenter';
|
||||
|
||||
class FakeDashboardOverviewPresenter implements IDashboardOverviewPresenter {
|
||||
viewModel: DashboardOverviewViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(viewModel: DashboardOverviewViewModel): void {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): DashboardOverviewViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
interface TestImageService {
|
||||
getDriverAvatar(driverId: string): string;
|
||||
getTeamLogo(teamId: string): string;
|
||||
getLeagueCover(leagueId: string): string;
|
||||
getLeagueLogo(leagueId: string): string;
|
||||
}
|
||||
|
||||
function createTestImageService(): TestImageService {
|
||||
return {
|
||||
getDriverAvatar: (driverId: string) => `avatar-${driverId}`,
|
||||
getTeamLogo: (teamId: string) => `team-logo-${teamId}`,
|
||||
getLeagueCover: (leagueId: string) => `league-cover-${leagueId}`,
|
||||
getLeagueLogo: (leagueId: string) => `league-logo-${leagueId}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetDashboardOverviewUseCase', () => {
|
||||
it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => {
|
||||
// Given a driver with memberships in two leagues and future races with mixed registration
|
||||
const driverId = 'driver-1';
|
||||
|
||||
const driver = Driver.create({ id: driverId, iracingId: '12345', name: 'Alice Racer', country: 'US' });
|
||||
|
||||
const leagues = [
|
||||
League.create({ id: 'league-1', name: 'Alpha League', description: 'First league', ownerId: 'owner-1' }),
|
||||
League.create({ id: 'league-2', name: 'Beta League', description: 'Second league', ownerId: 'owner-2' }),
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const races = [
|
||||
Race.create({
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(now + 60 * 60 * 1000),
|
||||
status: 'scheduled',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-2',
|
||||
leagueId: 'league-1',
|
||||
track: 'Spa',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(now + 2 * 60 * 60 * 1000),
|
||||
status: 'scheduled',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-3',
|
||||
leagueId: 'league-2',
|
||||
track: 'Silverstone',
|
||||
car: 'GT4',
|
||||
scheduledAt: new Date(now + 3 * 60 * 60 * 1000),
|
||||
status: 'scheduled',
|
||||
}),
|
||||
Race.create({
|
||||
id: 'race-4',
|
||||
leagueId: 'league-2',
|
||||
track: 'Imola',
|
||||
car: 'GT4',
|
||||
scheduledAt: new Date(now + 4 * 60 * 60 * 1000),
|
||||
status: 'scheduled',
|
||||
}),
|
||||
];
|
||||
|
||||
const results: Result[] = [];
|
||||
|
||||
const memberships = [
|
||||
LeagueMembership.create({
|
||||
leagueId: 'league-1',
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
}),
|
||||
LeagueMembership.create({
|
||||
leagueId: 'league-2',
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
}),
|
||||
];
|
||||
|
||||
const registeredRaceIds = new Set<string>(['race-1', 'race-3']);
|
||||
|
||||
const feedItems: FeedItem[] = [];
|
||||
const friends: Driver[] = [];
|
||||
|
||||
const driverRepository = {
|
||||
findById: async (id: string): Promise<Driver | null> => (id === driver.id ? driver : null),
|
||||
findByIRacingId: async (): Promise<Driver | null> => null,
|
||||
findAll: async (): Promise<Driver[]> => [],
|
||||
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByIRacingId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const raceRepository = {
|
||||
findById: async (): Promise<Race | null> => null,
|
||||
findAll: async (): Promise<Race[]> => races,
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => results,
|
||||
findByRaceId: async (): Promise<Result[]> => [],
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (): Promise<League | null> => null,
|
||||
findAll: async (): Promise<League[]> => leagues,
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const standingRepository = {
|
||||
findByLeagueId: async (): Promise<Standing[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
|
||||
findAll: async (): Promise<Standing[]> => [],
|
||||
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
|
||||
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
recalculate: async (): Promise<Standing[]> => [],
|
||||
};
|
||||
|
||||
const leagueMembershipRepository = {
|
||||
getMembership: async (leagueId: string, driverIdParam: string): Promise<LeagueMembership | null> => {
|
||||
return (
|
||||
memberships.find(
|
||||
(m) => m.leagueId === leagueId && m.driverId === driverIdParam,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
||||
getJoinRequests: async (): Promise<any[]> => [],
|
||||
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
||||
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
|
||||
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
};
|
||||
|
||||
const raceRegistrationRepository = {
|
||||
isRegistered: async (raceId: string, driverIdParam: string): Promise<boolean> => {
|
||||
if (driverIdParam !== driverId) return false;
|
||||
return registeredRaceIds.has(raceId);
|
||||
},
|
||||
getRegisteredDrivers: async (): Promise<string[]> => [],
|
||||
getRegistrationCount: async (): Promise<number> => 0,
|
||||
register: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
withdraw: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
getDriverRegistrations: async (): Promise<string[]> => [],
|
||||
clearRaceRegistrations: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
};
|
||||
|
||||
const feedRepository = {
|
||||
getFeedForDriver: async (): Promise<FeedItem[]> => feedItems,
|
||||
getGlobalFeed: async (): Promise<FeedItem[]> => [],
|
||||
};
|
||||
|
||||
const socialRepository = {
|
||||
getFriends: async (): Promise<Driver[]> => friends,
|
||||
getFriendIds: async (): Promise<string[]> => [],
|
||||
getSuggestedFriends: async (): Promise<Driver[]> => [],
|
||||
};
|
||||
|
||||
const imageService = createTestImageService();
|
||||
|
||||
const getDriverStats = (id: string) =>
|
||||
id === driverId
|
||||
? {
|
||||
rating: 1600,
|
||||
wins: 5,
|
||||
podiums: 12,
|
||||
totalRaces: 40,
|
||||
overallRank: 42,
|
||||
consistency: 88,
|
||||
}
|
||||
: null;
|
||||
|
||||
const presenter = new FakeDashboardOverviewPresenter();
|
||||
|
||||
const useCase = new GetDashboardOverviewUseCase(
|
||||
driverRepository,
|
||||
raceRepository,
|
||||
resultRepository,
|
||||
leagueRepository,
|
||||
standingRepository,
|
||||
leagueMembershipRepository,
|
||||
raceRegistrationRepository,
|
||||
feedRepository,
|
||||
socialRepository,
|
||||
imageService,
|
||||
getDriverStats,
|
||||
);
|
||||
|
||||
// When
|
||||
await useCase.execute({ driverId }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
|
||||
const vm = viewModel!;
|
||||
|
||||
// Then myUpcomingRaces only contains registered races from the driver's leagues
|
||||
expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']);
|
||||
|
||||
// And otherUpcomingRaces contains the other upcoming races in those leagues
|
||||
expect(vm.otherUpcomingRaces.map((r) => r.id)).toEqual(['race-2', 'race-4']);
|
||||
|
||||
// And nextRace is the earliest upcoming race from myUpcomingRaces
|
||||
expect(vm.nextRace).not.toBeNull();
|
||||
expect(vm.nextRace!.id).toBe('race-1');
|
||||
});
|
||||
|
||||
it('builds recentResults sorted by date descending and leagueStandingsSummaries from standings', async () => {
|
||||
// Given completed races with results and standings
|
||||
const driverId = 'driver-2';
|
||||
|
||||
const driver = Driver.create({ id: driverId, iracingId: '67890', name: 'Result Driver', country: 'DE' });
|
||||
|
||||
const leagues = [
|
||||
League.create({ id: 'league-A', name: 'Results League A', description: 'League A', ownerId: 'owner-A' }),
|
||||
League.create({ id: 'league-B', name: 'Results League B', description: 'League B', ownerId: 'owner-B' }),
|
||||
];
|
||||
|
||||
const raceOld = Race.create({
|
||||
id: 'race-old',
|
||||
leagueId: 'league-A',
|
||||
track: 'Old Circuit',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2024-01-01T10:00:00Z'),
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const raceNew = Race.create({
|
||||
id: 'race-new',
|
||||
leagueId: 'league-B',
|
||||
track: 'New Circuit',
|
||||
car: 'GT4',
|
||||
scheduledAt: new Date('2024-02-01T10:00:00Z'),
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const races = [raceOld, raceNew];
|
||||
|
||||
const results = [
|
||||
Result.create({
|
||||
id: 'result-old',
|
||||
raceId: raceOld.id,
|
||||
driverId,
|
||||
position: 5,
|
||||
fastestLap: 120,
|
||||
incidents: 3,
|
||||
startPosition: 5,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-new',
|
||||
raceId: raceNew.id,
|
||||
driverId,
|
||||
position: 2,
|
||||
fastestLap: 115,
|
||||
incidents: 1,
|
||||
startPosition: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
const memberships = [
|
||||
LeagueMembership.create({
|
||||
leagueId: 'league-A',
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
}),
|
||||
LeagueMembership.create({
|
||||
leagueId: 'league-B',
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
}),
|
||||
];
|
||||
|
||||
const standingsByLeague = new Map<
|
||||
string,
|
||||
Standing[]
|
||||
>();
|
||||
standingsByLeague.set('league-A', [
|
||||
Standing.create({ leagueId: 'league-A', driverId, position: 3, points: 50 }),
|
||||
Standing.create({ leagueId: 'league-A', driverId: 'other-1', position: 1, points: 80 }),
|
||||
]);
|
||||
standingsByLeague.set('league-B', [
|
||||
Standing.create({ leagueId: 'league-B', driverId, position: 1, points: 100 }),
|
||||
Standing.create({ leagueId: 'league-B', driverId: 'other-2', position: 2, points: 90 }),
|
||||
]);
|
||||
|
||||
const driverRepository = {
|
||||
findById: async (id: string): Promise<Driver | null> => (id === driver.id ? driver : null),
|
||||
findByIRacingId: async (): Promise<Driver | null> => null,
|
||||
findAll: async (): Promise<Driver[]> => [],
|
||||
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByIRacingId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const raceRepository = {
|
||||
findById: async (): Promise<Race | null> => null,
|
||||
findAll: async (): Promise<Race[]> => races,
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => results,
|
||||
findByRaceId: async (): Promise<Result[]> => [],
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (): Promise<League | null> => null,
|
||||
findAll: async (): Promise<League[]> => leagues,
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const standingRepository = {
|
||||
findByLeagueId: async (leagueId: string): Promise<Standing[]> =>
|
||||
standingsByLeague.get(leagueId) ?? [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
|
||||
findAll: async (): Promise<Standing[]> => [],
|
||||
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
|
||||
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
recalculate: async (): Promise<Standing[]> => [],
|
||||
};
|
||||
|
||||
const leagueMembershipRepository = {
|
||||
getMembership: async (leagueId: string, driverIdParam: string): Promise<LeagueMembership | null> => {
|
||||
return (
|
||||
memberships.find(
|
||||
(m) => m.leagueId === leagueId && m.driverId === driverIdParam,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
||||
getJoinRequests: async (): Promise<any[]> => [],
|
||||
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
||||
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
|
||||
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
};
|
||||
|
||||
const raceRegistrationRepository = {
|
||||
isRegistered: async (): Promise<boolean> => false,
|
||||
getRegisteredDrivers: async (): Promise<string[]> => [],
|
||||
getRegistrationCount: async (): Promise<number> => 0,
|
||||
register: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
withdraw: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
getDriverRegistrations: async (): Promise<string[]> => [],
|
||||
clearRaceRegistrations: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
};
|
||||
|
||||
const feedRepository = {
|
||||
getFeedForDriver: async (): Promise<FeedItem[]> => [],
|
||||
getGlobalFeed: async (): Promise<FeedItem[]> => [],
|
||||
};
|
||||
|
||||
const socialRepository = {
|
||||
getFriends: async (): Promise<Driver[]> => [],
|
||||
getFriendIds: async (): Promise<string[]> => [],
|
||||
getSuggestedFriends: async (): Promise<Driver[]> => [],
|
||||
};
|
||||
|
||||
const imageService = createTestImageService();
|
||||
|
||||
const getDriverStats = (id: string) =>
|
||||
id === driverId
|
||||
? {
|
||||
rating: 1800,
|
||||
wins: 3,
|
||||
podiums: 7,
|
||||
totalRaces: 20,
|
||||
overallRank: 10,
|
||||
consistency: 92,
|
||||
}
|
||||
: null;
|
||||
|
||||
const presenter = new FakeDashboardOverviewPresenter();
|
||||
|
||||
const useCase = new GetDashboardOverviewUseCase(
|
||||
driverRepository,
|
||||
raceRepository,
|
||||
resultRepository,
|
||||
leagueRepository,
|
||||
standingRepository,
|
||||
leagueMembershipRepository,
|
||||
raceRegistrationRepository,
|
||||
feedRepository,
|
||||
socialRepository,
|
||||
imageService,
|
||||
getDriverStats,
|
||||
);
|
||||
|
||||
// When
|
||||
await useCase.execute({ driverId }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
|
||||
const vm = viewModel!;
|
||||
|
||||
// Then recentResults are sorted by finishedAt descending (newest first)
|
||||
expect(vm.recentResults.length).toBe(2);
|
||||
expect(vm.recentResults[0]!.raceId).toBe('race-new');
|
||||
expect(vm.recentResults[1]!.raceId).toBe('race-old');
|
||||
|
||||
// And leagueStandingsSummaries reflect the driver's position and points per league
|
||||
const summariesByLeague = new Map(
|
||||
vm.leagueStandingsSummaries.map((s) => [s.leagueId, s]),
|
||||
);
|
||||
|
||||
const summaryA = summariesByLeague.get('league-A');
|
||||
const summaryB = summariesByLeague.get('league-B');
|
||||
|
||||
expect(summaryA).toBeDefined();
|
||||
expect(summaryA!.position).toBe(3);
|
||||
expect(summaryA!.points).toBe(50);
|
||||
expect(summaryA!.totalDrivers).toBe(2);
|
||||
|
||||
expect(summaryB).toBeDefined();
|
||||
expect(summaryB!.position).toBe(1);
|
||||
expect(summaryB!.points).toBe(100);
|
||||
expect(summaryB!.totalDrivers).toBe(2);
|
||||
});
|
||||
|
||||
it('returns empty collections and safe defaults when driver has no races or standings', async () => {
|
||||
// Given a driver with no related data
|
||||
const driverId = 'driver-empty';
|
||||
|
||||
const driver = Driver.create({ id: driverId, iracingId: '11111', name: 'New Racer', country: 'FR' });
|
||||
|
||||
const driverRepository = {
|
||||
findById: async (id: string): Promise<Driver | null> => (id === driver.id ? driver : null),
|
||||
findByIRacingId: async (): Promise<Driver | null> => null,
|
||||
findAll: async (): Promise<Driver[]> => [],
|
||||
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByIRacingId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const raceRepository = {
|
||||
findById: async (): Promise<Race | null> => null,
|
||||
findAll: async (): Promise<Race[]> => [],
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => [],
|
||||
findByRaceId: async (): Promise<Result[]> => [],
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (): Promise<League | null> => null,
|
||||
findAll: async (): Promise<League[]> => [],
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const standingRepository = {
|
||||
findByLeagueId: async (): Promise<Standing[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
|
||||
findAll: async (): Promise<Standing[]> => [],
|
||||
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
|
||||
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
recalculate: async (): Promise<Standing[]> => [],
|
||||
};
|
||||
|
||||
const leagueMembershipRepository = {
|
||||
getMembership: async (): Promise<LeagueMembership | null> => null,
|
||||
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
||||
getJoinRequests: async (): Promise<any[]> => [],
|
||||
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
||||
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
|
||||
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
};
|
||||
|
||||
const raceRegistrationRepository = {
|
||||
isRegistered: async (): Promise<boolean> => false,
|
||||
getRegisteredDrivers: async (): Promise<string[]> => [],
|
||||
getRegistrationCount: async (): Promise<number> => 0,
|
||||
register: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
withdraw: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
getDriverRegistrations: async (): Promise<string[]> => [],
|
||||
clearRaceRegistrations: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
};
|
||||
|
||||
const feedRepository = {
|
||||
getFeedForDriver: async (): Promise<FeedItem[]> => [],
|
||||
getGlobalFeed: async (): Promise<FeedItem[]> => [],
|
||||
};
|
||||
|
||||
const socialRepository = {
|
||||
getFriends: async (): Promise<Driver[]> => [],
|
||||
getFriendIds: async (): Promise<string[]> => [],
|
||||
getSuggestedFriends: async (): Promise<Driver[]> => [],
|
||||
};
|
||||
|
||||
const imageService = createTestImageService();
|
||||
|
||||
const getDriverStats = () => null;
|
||||
|
||||
const presenter = new FakeDashboardOverviewPresenter();
|
||||
|
||||
const useCase = new GetDashboardOverviewUseCase(
|
||||
driverRepository,
|
||||
raceRepository,
|
||||
resultRepository,
|
||||
leagueRepository,
|
||||
standingRepository,
|
||||
leagueMembershipRepository,
|
||||
raceRegistrationRepository,
|
||||
feedRepository,
|
||||
socialRepository,
|
||||
imageService,
|
||||
getDriverStats,
|
||||
);
|
||||
|
||||
// When
|
||||
await useCase.execute({ driverId }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
|
||||
const vm = viewModel!;
|
||||
|
||||
// Then collections are empty and no errors are thrown
|
||||
expect(vm.myUpcomingRaces).toEqual([]);
|
||||
expect(vm.otherUpcomingRaces).toEqual([]);
|
||||
expect(vm.nextRace).toBeNull();
|
||||
expect(vm.recentResults).toEqual([]);
|
||||
expect(vm.leagueStandingsSummaries).toEqual([]);
|
||||
expect(vm.friends).toEqual([]);
|
||||
expect(vm.feedSummary.notificationCount).toBe(0);
|
||||
expect(vm.feedSummary.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
46
core/racing/application/GetLeagueJoinRequestsUseCase.test.ts
Normal file
46
core/racing/application/GetLeagueJoinRequestsUseCase.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
|
||||
import { LeagueJoinRequestsPresenter } from '@apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter';
|
||||
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
|
||||
describe('GetLeagueJoinRequestsUseCase', () => {
|
||||
let useCase: GetLeagueJoinRequestsUseCase;
|
||||
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
|
||||
let driverRepository: jest.Mocked<IDriverRepository>;
|
||||
let presenter: LeagueJoinRequestsPresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
leagueMembershipRepository = {
|
||||
getJoinRequests: jest.fn(),
|
||||
} as any;
|
||||
driverRepository = {
|
||||
findByIds: jest.fn(),
|
||||
} as any;
|
||||
presenter = new LeagueJoinRequestsPresenter();
|
||||
useCase = new GetLeagueJoinRequestsUseCase(leagueMembershipRepository, driverRepository);
|
||||
});
|
||||
|
||||
it('should return join requests with drivers', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const joinRequests = [
|
||||
{ id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' },
|
||||
];
|
||||
const drivers = [{ id: 'driver-1', name: 'Driver 1' }];
|
||||
|
||||
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
|
||||
driverRepository.findByIds.mockResolvedValue(drivers);
|
||||
|
||||
await useCase.execute({ leagueId }, presenter);
|
||||
|
||||
expect(presenter.viewModel.joinRequests).toEqual([
|
||||
{
|
||||
id: 'req-1',
|
||||
leagueId,
|
||||
driverId: 'driver-1',
|
||||
requestedAt: expect.any(Date),
|
||||
message: 'msg',
|
||||
driver: { id: 'driver-1', name: 'Driver 1' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
125
core/racing/application/MembershipUseCases.test.ts
Normal file
125
core/racing/application/MembershipUseCases.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase';
|
||||
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import {
|
||||
LeagueMembership,
|
||||
type MembershipRole,
|
||||
type MembershipStatus,
|
||||
} from '@core/racing/domain/entities/LeagueMembership';
|
||||
|
||||
class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
|
||||
private memberships: LeagueMembership[] = [];
|
||||
|
||||
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.leagueId === leagueId && m.driverId === driverId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveMembershipForDriver(driverId: string): Promise<LeagueMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.driverId === driverId && m.status === 'active',
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.leagueId === leagueId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async getTeamMembers(leagueId: string): Promise<LeagueMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.leagueId === leagueId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
|
||||
const existingIndex = this.memberships.findIndex(
|
||||
(m) => m.leagueId === membership.leagueId && m.driverId === membership.driverId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.memberships[existingIndex] = membership;
|
||||
} else {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(leagueId: string, driverId: string): Promise<void> {
|
||||
this.memberships = this.memberships.filter(
|
||||
(m) => !(m.leagueId === leagueId && m.driverId === driverId),
|
||||
);
|
||||
}
|
||||
|
||||
async getJoinRequests(): Promise<never> {
|
||||
throw new Error('Not implemented for this test');
|
||||
}
|
||||
|
||||
async saveJoinRequest(): Promise<never> {
|
||||
throw new Error('Not implemented for this test');
|
||||
}
|
||||
|
||||
async removeJoinRequest(): Promise<never> {
|
||||
throw new Error('Not implemented for this test');
|
||||
}
|
||||
|
||||
seedMembership(membership: LeagueMembership): void {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
|
||||
getAllMemberships(): LeagueMembership[] {
|
||||
return [...this.memberships];
|
||||
}
|
||||
}
|
||||
|
||||
describe('Membership use-cases', () => {
|
||||
describe('JoinLeagueUseCase', () => {
|
||||
let repository: InMemoryLeagueMembershipRepository;
|
||||
let useCase: JoinLeagueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryLeagueMembershipRepository();
|
||||
useCase = new JoinLeagueUseCase(repository);
|
||||
});
|
||||
|
||||
it('creates an active member when driver has no membership', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await useCase.execute({ leagueId, driverId });
|
||||
|
||||
const membership = await repository.getMembership(leagueId, driverId);
|
||||
expect(membership).not.toBeNull();
|
||||
expect(membership?.leagueId).toBe(leagueId);
|
||||
expect(membership?.driverId).toBe(driverId);
|
||||
expect(membership?.role as MembershipRole).toBe('member');
|
||||
expect(membership?.status as MembershipStatus).toBe('active');
|
||||
expect(membership?.joinedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('throws when driver already has membership for league', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
repository.seedMembership(LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date('2024-01-01'),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
useCase.execute({ leagueId, driverId }),
|
||||
).rejects.toThrow('Already a member or have a pending request');
|
||||
});
|
||||
});
|
||||
});
|
||||
636
core/racing/application/RaceDetailUseCases.test.ts
Normal file
636
core/racing/application/RaceDetailUseCases.test.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
||||
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
|
||||
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||
import type {
|
||||
IRaceDetailPresenter,
|
||||
RaceDetailViewModel,
|
||||
} from '@core/racing/application/presenters/IRaceDetailPresenter';
|
||||
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
import { Result } from '@core/racing/domain/entities/Result';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
||||
import { GetRaceDetailUseCase } from '@core/racing/application/use-cases/GetRaceDetailUseCase';
|
||||
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
|
||||
|
||||
class InMemoryRaceRepository implements IRaceRepository {
|
||||
private races = new Map<string, Race>();
|
||||
|
||||
constructor(races: Race[]) {
|
||||
for (const race of races) {
|
||||
this.races.set(race.id, race);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Race | null> {
|
||||
return this.races.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Race[]> {
|
||||
return [...this.races.values()];
|
||||
}
|
||||
|
||||
async findByLeagueId(): Promise<Race[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
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.set(race.id, race);
|
||||
return race;
|
||||
}
|
||||
|
||||
async update(race: Race): Promise<Race> {
|
||||
this.races.set(race.id, race);
|
||||
return race;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.races.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.races.has(id);
|
||||
}
|
||||
|
||||
getStored(id: string): Race | null {
|
||||
return this.races.get(id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryLeagueRepository implements ILeagueRepository {
|
||||
private leagues = new Map<string, League>();
|
||||
|
||||
constructor(leagues: League[]) {
|
||||
for (const league of leagues) {
|
||||
this.leagues.set(league.id, league);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<League | null> {
|
||||
return this.leagues.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<League[]> {
|
||||
return [...this.leagues.values()];
|
||||
}
|
||||
|
||||
async findByOwnerId(): Promise<League[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async create(league: League): Promise<League> {
|
||||
this.leagues.set(league.id, league);
|
||||
return league;
|
||||
}
|
||||
|
||||
async update(league: League): Promise<League> {
|
||||
this.leagues.set(league.id, league);
|
||||
return league;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.leagues.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.leagues.has(id);
|
||||
}
|
||||
|
||||
async searchByName(): Promise<League[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryDriverRepository implements IDriverRepository {
|
||||
private drivers = new Map<string, Driver>();
|
||||
|
||||
constructor(drivers: Array<{ id: string; name: string; country: string }>) {
|
||||
for (const driver of drivers) {
|
||||
this.drivers.set(driver.id, Driver.create({
|
||||
id: driver.id,
|
||||
iracingId: `iracing-${driver.id}`,
|
||||
name: driver.name,
|
||||
country: driver.country,
|
||||
joinedAt: new Date('2024-01-01'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Driver | null> {
|
||||
return this.drivers.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Driver[]> {
|
||||
return [...this.drivers.values()];
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<Driver[]> {
|
||||
return ids
|
||||
.map(id => this.drivers.get(id))
|
||||
.filter((d): d is Driver => !!d);
|
||||
}
|
||||
|
||||
async create(): Promise<any> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async update(): Promise<any> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async exists(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async findByIRacingId(): Promise<Driver | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async existsByIRacingId(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
|
||||
private registrations = new Map<string, Set<string>>();
|
||||
|
||||
constructor(seed: Array<{ raceId: string; driverId: string }> = []) {
|
||||
for (const { raceId, driverId } of seed) {
|
||||
if (!this.registrations.has(raceId)) {
|
||||
this.registrations.set(raceId, new Set());
|
||||
}
|
||||
this.registrations.get(raceId)!.add(driverId);
|
||||
}
|
||||
}
|
||||
|
||||
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
|
||||
return this.registrations.get(raceId)?.has(driverId) ?? false;
|
||||
}
|
||||
|
||||
async getRegisteredDrivers(raceId: string): Promise<string[]> {
|
||||
return Array.from(this.registrations.get(raceId) ?? []);
|
||||
}
|
||||
|
||||
async getRegistrationCount(raceId: string): Promise<number> {
|
||||
return this.registrations.get(raceId)?.size ?? 0;
|
||||
}
|
||||
|
||||
async register(registration: { raceId: string; driverId: string }): Promise<void> {
|
||||
if (!this.registrations.has(registration.raceId)) {
|
||||
this.registrations.set(registration.raceId, new Set());
|
||||
}
|
||||
this.registrations.get(registration.raceId)!.add(registration.driverId);
|
||||
}
|
||||
|
||||
async withdraw(raceId: string, driverId: string): Promise<void> {
|
||||
this.registrations.get(raceId)?.delete(driverId);
|
||||
}
|
||||
|
||||
async getDriverRegistrations(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async clearRaceRegistrations(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryResultRepository implements IResultRepository {
|
||||
private results = new Map<string, Result[]>();
|
||||
|
||||
constructor(results: Result[]) {
|
||||
for (const result of results) {
|
||||
const list = this.results.get(result.raceId) ?? [];
|
||||
list.push(result);
|
||||
this.results.set(result.raceId, list);
|
||||
}
|
||||
}
|
||||
|
||||
async findByRaceId(raceId: string): Promise<Result[]> {
|
||||
return this.results.get(raceId) ?? [];
|
||||
}
|
||||
|
||||
async findById(): Promise<Result | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Result[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async findByDriverId(): Promise<Result[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async findByDriverIdAndLeagueId(): Promise<Result[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async create(result: Result): Promise<Result> {
|
||||
const list = this.results.get(result.raceId) ?? [];
|
||||
list.push(result);
|
||||
this.results.set(result.raceId, list);
|
||||
return result;
|
||||
}
|
||||
|
||||
async createMany(results: Result[]): Promise<Result[]> {
|
||||
for (const result of results) {
|
||||
await this.create(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async update(): Promise<Result> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async deleteByRaceId(): Promise<void> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async exists(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async existsByRaceId(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
|
||||
private memberships: LeagueMembership[] = [];
|
||||
|
||||
seedMembership(membership: LeagueMembership): void {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
|
||||
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
m => m.leagueId === leagueId && m.driverId === driverId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async getLeagueMembers(): Promise<LeagueMembership[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getJoinRequests(): Promise<never> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
|
||||
this.memberships.push(membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async saveJoinRequest(): Promise<never> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
|
||||
async removeJoinRequest(): Promise<never> {
|
||||
throw new Error('Not needed for these tests');
|
||||
}
|
||||
}
|
||||
|
||||
class TestDriverRatingProvider implements DriverRatingProvider {
|
||||
private ratings = new Map<string, number>();
|
||||
|
||||
seed(driverId: string, rating: number): void {
|
||||
this.ratings.set(driverId, rating);
|
||||
}
|
||||
|
||||
getRating(driverId: string): number | null {
|
||||
return this.ratings.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
getRatings(driverIds: string[]): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
for (const id of driverIds) {
|
||||
const rating = this.ratings.get(id);
|
||||
if (rating != null) {
|
||||
map.set(id, rating);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
class TestImageService implements IImageServicePort {
|
||||
getDriverAvatar(driverId: string): string {
|
||||
return `avatar-${driverId}`;
|
||||
}
|
||||
|
||||
getTeamLogo(teamId: string): string {
|
||||
return `team-logo-${teamId}`;
|
||||
}
|
||||
|
||||
getLeagueCover(leagueId: string): string {
|
||||
return `league-cover-${leagueId}`;
|
||||
}
|
||||
|
||||
getLeagueLogo(leagueId: string): string {
|
||||
return `league-logo-${leagueId}`;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRaceDetailPresenter implements IRaceDetailPresenter {
|
||||
viewModel: RaceDetailViewModel | null = null;
|
||||
|
||||
present(viewModel: RaceDetailViewModel): RaceDetailViewModel {
|
||||
this.viewModel = viewModel;
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): RaceDetailViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('GetRaceDetailUseCase', () => {
|
||||
it('builds entry list and registration flags for an upcoming race', async () => {
|
||||
// Given (arrange a scheduled race with one registered driver)
|
||||
const league = League.create({
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'League for testing',
|
||||
ownerId: 'owner-1',
|
||||
});
|
||||
|
||||
const race = Race.create({
|
||||
id: 'race-1',
|
||||
leagueId: league.id,
|
||||
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
track: 'Test Track',
|
||||
car: 'GT3',
|
||||
sessionType: 'race',
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
const driverId = 'driver-1';
|
||||
const otherDriverId = 'driver-2';
|
||||
|
||||
const raceRepo = new InMemoryRaceRepository([race]);
|
||||
const leagueRepo = new InMemoryLeagueRepository([league]);
|
||||
const driverRepo = new InMemoryDriverRepository([
|
||||
{ id: driverId, name: 'Alice Racer', country: 'US' },
|
||||
{ id: otherDriverId, name: 'Bob Driver', country: 'GB' },
|
||||
]);
|
||||
|
||||
const registrationRepo = new InMemoryRaceRegistrationRepository([
|
||||
{ raceId: race.id, driverId },
|
||||
{ raceId: race.id, driverId: otherDriverId },
|
||||
]);
|
||||
|
||||
const resultRepo = new InMemoryResultRepository([]);
|
||||
|
||||
const membershipRepo = new InMemoryLeagueMembershipRepository();
|
||||
membershipRepo.seedMembership(LeagueMembership.create({
|
||||
leagueId: league.id,
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date('2024-01-01'),
|
||||
}));
|
||||
|
||||
const ratingProvider = new TestDriverRatingProvider();
|
||||
ratingProvider.seed(driverId, 1500);
|
||||
ratingProvider.seed(otherDriverId, 1600);
|
||||
|
||||
const imageService = new TestImageService();
|
||||
const presenter = new FakeRaceDetailPresenter();
|
||||
|
||||
const useCase = new GetRaceDetailUseCase(
|
||||
raceRepo,
|
||||
leagueRepo,
|
||||
driverRepo,
|
||||
registrationRepo,
|
||||
resultRepo,
|
||||
membershipRepo,
|
||||
ratingProvider,
|
||||
imageService,
|
||||
);
|
||||
|
||||
// When (execute the query for the current driver)
|
||||
await useCase.execute({ raceId: race.id, driverId }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
|
||||
// Then (verify race, league and registration flags)
|
||||
expect(viewModel!.race?.id).toBe(race.id);
|
||||
expect(viewModel!.league?.id).toBe(league.id);
|
||||
expect(viewModel!.registration.isUserRegistered).toBe(true);
|
||||
expect(viewModel!.registration.canRegister).toBe(true);
|
||||
|
||||
// Then (entry list contains both drivers with rating and avatar)
|
||||
expect(viewModel!.entryList.length).toBe(2);
|
||||
const currentDriver = viewModel!.entryList.find(e => e.id === driverId);
|
||||
const otherDriver = viewModel!.entryList.find(e => e.id === otherDriverId);
|
||||
|
||||
expect(currentDriver).toBeDefined();
|
||||
expect(currentDriver!.isCurrentUser).toBe(true);
|
||||
expect(currentDriver!.rating).toBe(1500);
|
||||
expect(currentDriver!.avatarUrl).toBe(`avatar-${driverId}`);
|
||||
|
||||
expect(otherDriver).toBeDefined();
|
||||
expect(otherDriver!.isCurrentUser).toBe(false);
|
||||
expect(otherDriver!.rating).toBe(1600);
|
||||
});
|
||||
|
||||
it('computes rating change for a completed race result using legacy formula', async () => {
|
||||
// Given (a completed race with a result for the current driver)
|
||||
const league = League.create({
|
||||
id: 'league-2',
|
||||
name: 'Results League',
|
||||
description: 'League with results',
|
||||
ownerId: 'owner-2',
|
||||
});
|
||||
|
||||
const race = Race.create({
|
||||
id: 'race-2',
|
||||
leagueId: league.id,
|
||||
scheduledAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
track: 'Historic Circuit',
|
||||
car: 'LMP2',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const driverId = 'driver-results';
|
||||
|
||||
const raceRepo = new InMemoryRaceRepository([race]);
|
||||
const leagueRepo = new InMemoryLeagueRepository([league]);
|
||||
const driverRepo = new InMemoryDriverRepository([
|
||||
{ id: driverId, name: 'Result Hero', country: 'DE' },
|
||||
]);
|
||||
|
||||
const registrationRepo = new InMemoryRaceRegistrationRepository([
|
||||
{ raceId: race.id, driverId },
|
||||
]);
|
||||
|
||||
const resultEntity = Result.create({
|
||||
id: 'result-1',
|
||||
raceId: race.id,
|
||||
driverId,
|
||||
position: 1,
|
||||
fastestLap: 90.123,
|
||||
incidents: 0,
|
||||
startPosition: 3,
|
||||
});
|
||||
|
||||
const resultRepo = new InMemoryResultRepository([resultEntity]);
|
||||
const membershipRepo = new InMemoryLeagueMembershipRepository();
|
||||
membershipRepo.seedMembership(LeagueMembership.create({
|
||||
leagueId: league.id,
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date('2024-01-01'),
|
||||
}));
|
||||
|
||||
const ratingProvider = new TestDriverRatingProvider();
|
||||
ratingProvider.seed(driverId, 2000);
|
||||
|
||||
const imageService = new TestImageService();
|
||||
const presenter = new FakeRaceDetailPresenter();
|
||||
|
||||
const useCase = new GetRaceDetailUseCase(
|
||||
raceRepo,
|
||||
leagueRepo,
|
||||
driverRepo,
|
||||
registrationRepo,
|
||||
resultRepo,
|
||||
membershipRepo,
|
||||
ratingProvider,
|
||||
imageService,
|
||||
);
|
||||
|
||||
// When (executing the query for the completed race)
|
||||
await useCase.execute({ raceId: race.id, driverId }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
expect(viewModel!.userResult).not.toBeNull();
|
||||
|
||||
// Then (rating change uses the same formula as the legacy UI)
|
||||
// For P1: baseChange = 25, positionBonus = (20 - 1) * 2 = 38, total = 63
|
||||
expect(viewModel!.userResult!.ratingChange).toBe(63);
|
||||
expect(viewModel!.userResult!.position).toBe(1);
|
||||
expect(viewModel!.userResult!.startPosition).toBe(3);
|
||||
expect(viewModel!.userResult!.positionChange).toBe(2);
|
||||
expect(viewModel!.userResult!.isPodium).toBe(true);
|
||||
expect(viewModel!.userResult!.isClean).toBe(true);
|
||||
});
|
||||
|
||||
it('presents an error when race does not exist', async () => {
|
||||
// Given (no race in the repository)
|
||||
const raceRepo = new InMemoryRaceRepository([]);
|
||||
const leagueRepo = new InMemoryLeagueRepository([]);
|
||||
const driverRepo = new InMemoryDriverRepository([]);
|
||||
const registrationRepo = new InMemoryRaceRegistrationRepository();
|
||||
const resultRepo = new InMemoryResultRepository([]);
|
||||
const membershipRepo = new InMemoryLeagueMembershipRepository();
|
||||
const ratingProvider = new TestDriverRatingProvider();
|
||||
const imageService = new TestImageService();
|
||||
const presenter = new FakeRaceDetailPresenter();
|
||||
|
||||
const useCase = new GetRaceDetailUseCase(
|
||||
raceRepo,
|
||||
leagueRepo,
|
||||
driverRepo,
|
||||
registrationRepo,
|
||||
resultRepo,
|
||||
membershipRepo,
|
||||
ratingProvider,
|
||||
imageService,
|
||||
);
|
||||
|
||||
// When
|
||||
await useCase.execute({ raceId: 'missing-race', driverId: 'driver-x' }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
// Then
|
||||
expect(viewModel).not.toBeNull();
|
||||
expect(viewModel!.race).toBeNull();
|
||||
expect(viewModel!.error).toBe('Race not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CancelRaceUseCase', () => {
|
||||
it('cancels a scheduled race and persists it via the repository', async () => {
|
||||
// Given (a scheduled race in the repository)
|
||||
const race = Race.create({
|
||||
id: 'cancel-me',
|
||||
leagueId: 'league-cancel',
|
||||
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
track: 'Cancel Circuit',
|
||||
car: 'GT4',
|
||||
sessionType: 'race',
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
const raceRepo = new InMemoryRaceRepository([race]);
|
||||
const useCase = new CancelRaceUseCase(raceRepo);
|
||||
|
||||
// When
|
||||
await useCase.execute({ raceId: race.id });
|
||||
|
||||
// Then (the stored race is now cancelled)
|
||||
const updated = raceRepo.getStored(race.id);
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('throws when trying to cancel a non-existent race', async () => {
|
||||
// Given
|
||||
const raceRepo = new InMemoryRaceRepository([]);
|
||||
const useCase = new CancelRaceUseCase(raceRepo);
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
useCase.execute({ raceId: 'does-not-exist' }),
|
||||
).rejects.toThrow('Race not found');
|
||||
});
|
||||
});
|
||||
716
core/racing/application/RaceResultsUseCases.test.ts
Normal file
716
core/racing/application/RaceResultsUseCases.test.ts
Normal file
@@ -0,0 +1,716 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
import { Result } from '@core/racing/domain/entities/Result';
|
||||
import { Penalty } from '@core/racing/domain/entities/Penalty';
|
||||
import { Standing } from '@core/racing/domain/entities/Standing';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
|
||||
import { GetRaceResultsDetailUseCase } from '@core/racing/application/use-cases/GetRaceResultsDetailUseCase';
|
||||
import { ImportRaceResultsUseCase } from '@core/racing/application/use-cases/ImportRaceResultsUseCase';
|
||||
|
||||
import type {
|
||||
IRaceResultsDetailPresenter,
|
||||
RaceResultsDetailViewModel,
|
||||
} from '@core/racing/application/presenters/IRaceResultsDetailPresenter';
|
||||
import type {
|
||||
IImportRaceResultsPresenter,
|
||||
ImportRaceResultsSummaryViewModel,
|
||||
} from '@core/racing/application/presenters/IImportRaceResultsPresenter';
|
||||
|
||||
class FakeRaceResultsDetailPresenter implements IRaceResultsDetailPresenter {
|
||||
viewModel: RaceResultsDetailViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel {
|
||||
this.viewModel = viewModel;
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): RaceResultsDetailViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeImportRaceResultsPresenter implements IImportRaceResultsPresenter {
|
||||
viewModel: ImportRaceResultsSummaryViewModel | null = null;
|
||||
|
||||
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel {
|
||||
this.viewModel = viewModel;
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): ImportRaceResultsSummaryViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ImportRaceResultsUseCase', () => {
|
||||
it('imports results and triggers standings recalculation for the league', async () => {
|
||||
// Given a league, a race, empty results, and a standing repository
|
||||
const league = League.create({
|
||||
id: 'league-1',
|
||||
name: 'Import League',
|
||||
description: 'League for import tests',
|
||||
ownerId: 'owner-1',
|
||||
});
|
||||
|
||||
const race = Race.create({
|
||||
id: 'race-1',
|
||||
leagueId: league.id,
|
||||
scheduledAt: new Date(),
|
||||
track: 'Import Circuit',
|
||||
car: 'GT3',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const races = new Map<string, typeof race>();
|
||||
races.set(race.id, race);
|
||||
|
||||
const leagues = new Map<string, typeof league>();
|
||||
leagues.set(league.id, league);
|
||||
|
||||
const storedResults: Result[] = [];
|
||||
let existsByRaceIdCalled = false;
|
||||
const recalcCalls: string[] = [];
|
||||
|
||||
const raceRepository = {
|
||||
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
|
||||
findAll: async (): Promise<Race[]> => [],
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
|
||||
findAll: async (): Promise<League[]> => [],
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => [],
|
||||
findByRaceId: async (): Promise<Result[]> => [],
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (results: Result[]): Promise<Result[]> => {
|
||||
storedResults.push(...results);
|
||||
return results;
|
||||
},
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (raceId: string): Promise<boolean> => {
|
||||
existsByRaceIdCalled = true;
|
||||
return storedResults.some((r) => r.raceId === raceId);
|
||||
},
|
||||
};
|
||||
|
||||
const standingRepository = {
|
||||
findByLeagueId: async (): Promise<Standing[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
|
||||
findAll: async (): Promise<Standing[]> => [],
|
||||
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
|
||||
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
recalculate: async (leagueId: string): Promise<Standing[]> => {
|
||||
recalcCalls.push(leagueId);
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const presenter = new FakeImportRaceResultsPresenter();
|
||||
|
||||
const useCase = new ImportRaceResultsUseCase(
|
||||
raceRepository,
|
||||
leagueRepository,
|
||||
resultRepository,
|
||||
standingRepository,
|
||||
presenter,
|
||||
);
|
||||
|
||||
const importedResults = [
|
||||
Result.create({
|
||||
id: 'result-1',
|
||||
raceId: race.id,
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
fastestLap: 90.123,
|
||||
incidents: 0,
|
||||
startPosition: 3,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-2',
|
||||
raceId: race.id,
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
fastestLap: 91.456,
|
||||
incidents: 2,
|
||||
startPosition: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
// When executing the import
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
results: importedResults,
|
||||
});
|
||||
|
||||
// Then new Result entries are persisted
|
||||
expect(existsByRaceIdCalled).toBe(true);
|
||||
expect(storedResults.length).toBe(2);
|
||||
expect(storedResults.map((r) => r.id)).toEqual(['result-1', 'result-2']);
|
||||
|
||||
// And standings are recalculated exactly once for the correct league
|
||||
expect(recalcCalls).toEqual([league.id]);
|
||||
|
||||
// And the presenter receives a summary
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
expect(viewModel!.importedCount).toBe(2);
|
||||
expect(viewModel!.standingsRecalculated).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects import when results already exist for the race', async () => {
|
||||
const league = League.create({
|
||||
id: 'league-2',
|
||||
name: 'Existing Results League',
|
||||
description: 'League with existing results',
|
||||
ownerId: 'owner-2',
|
||||
});
|
||||
|
||||
const race = Race.create({
|
||||
id: 'race-2',
|
||||
leagueId: league.id,
|
||||
scheduledAt: new Date(),
|
||||
track: 'Existing Circuit',
|
||||
car: 'GT4',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const races = new Map<string, typeof race>([[race.id, race]]);
|
||||
const leagues = new Map<string, typeof league>([[league.id, league]]);
|
||||
|
||||
const storedResults: Result[] = [
|
||||
Result.create({
|
||||
id: 'existing',
|
||||
raceId: race.id,
|
||||
driverId: 'driver-x',
|
||||
position: 1,
|
||||
fastestLap: 90.0,
|
||||
incidents: 1,
|
||||
startPosition: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const raceRepository = {
|
||||
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
|
||||
findAll: async (): Promise<Race[]> => [],
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
|
||||
findAll: async (): Promise<League[]> => [],
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => [],
|
||||
findByRaceId: async (): Promise<Result[]> => [],
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (_results: Result[]): Promise<Result[]> => {
|
||||
throw new Error('Should not be called when results already exist');
|
||||
},
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (raceId: string): Promise<boolean> => {
|
||||
return storedResults.some((r) => r.raceId === raceId);
|
||||
},
|
||||
};
|
||||
|
||||
const standingRepository = {
|
||||
findByLeagueId: async (): Promise<Standing[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
|
||||
findAll: async (): Promise<Standing[]> => [],
|
||||
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
|
||||
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
recalculate: async (_leagueId: string): Promise<Standing[]> => {
|
||||
throw new Error('Should not be called when results already exist');
|
||||
},
|
||||
};
|
||||
|
||||
const presenter = new FakeImportRaceResultsPresenter();
|
||||
|
||||
const driverRepository = {
|
||||
findById: async (): Promise<Driver | null> => null,
|
||||
findByIRacingId: async (iracingId: string): Promise<Driver | null> => {
|
||||
// Mock finding driver by iracingId
|
||||
if (iracingId === 'driver-1') {
|
||||
return Driver.create({ id: 'driver-1', iracingId: 'driver-1', name: 'Driver One', country: 'US' });
|
||||
}
|
||||
if (iracingId === 'driver-2') {
|
||||
return Driver.create({ id: 'driver-2', iracingId: 'driver-2', name: 'Driver Two', country: 'GB' });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findAll: async (): Promise<Driver[]> => [],
|
||||
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByIRacingId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const useCase = new ImportRaceResultsUseCase(
|
||||
raceRepository,
|
||||
leagueRepository,
|
||||
resultRepository,
|
||||
driverRepository,
|
||||
standingRepository,
|
||||
presenter,
|
||||
);
|
||||
|
||||
const importedResults = [
|
||||
Result.create({
|
||||
id: 'new-result',
|
||||
raceId: race.id,
|
||||
driverId: 'driver-1',
|
||||
position: 2,
|
||||
fastestLap: 91.0,
|
||||
incidents: 0,
|
||||
startPosition: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
await expect(
|
||||
useCase.execute({
|
||||
raceId: race.id,
|
||||
results: importedResults,
|
||||
}),
|
||||
).rejects.toThrow('Results already exist for this race');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetRaceResultsDetailUseCase', () => {
|
||||
it('computes points system from league settings and identifies fastest lap', async () => {
|
||||
// Given a league with default scoring configuration and two results
|
||||
const league = League.create({
|
||||
id: 'league-scoring',
|
||||
name: 'Scoring League',
|
||||
description: 'League with scoring settings',
|
||||
ownerId: 'owner-scoring',
|
||||
});
|
||||
|
||||
const race = Race.create({
|
||||
id: 'race-scoring',
|
||||
leagueId: league.id,
|
||||
scheduledAt: new Date(),
|
||||
track: 'Scoring Circuit',
|
||||
car: 'Prototype',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const driver1: { id: string; name: string; country: string } = {
|
||||
id: 'driver-a',
|
||||
name: 'Driver A',
|
||||
country: 'US',
|
||||
};
|
||||
const driver2: { id: string; name: string; country: string } = {
|
||||
id: 'driver-b',
|
||||
name: 'Driver B',
|
||||
country: 'GB',
|
||||
};
|
||||
|
||||
const result1 = Result.create({
|
||||
id: 'r1',
|
||||
raceId: race.id,
|
||||
driverId: driver1.id,
|
||||
position: 1,
|
||||
fastestLap: 90.123,
|
||||
incidents: 0,
|
||||
startPosition: 3,
|
||||
});
|
||||
|
||||
const result2 = Result.create({
|
||||
id: 'r2',
|
||||
raceId: race.id,
|
||||
driverId: driver2.id,
|
||||
position: 2,
|
||||
fastestLap: 88.456,
|
||||
incidents: 2,
|
||||
startPosition: 1,
|
||||
});
|
||||
|
||||
const races = new Map<string, typeof race>([[race.id, race]]);
|
||||
const leagues = new Map<string, typeof league>([[league.id, league]]);
|
||||
const results = [result1, result2];
|
||||
const drivers = [driver1, driver2];
|
||||
|
||||
const raceRepository = {
|
||||
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
|
||||
findAll: async (): Promise<Race[]> => [],
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
|
||||
findAll: async (): Promise<League[]> => [],
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => [],
|
||||
findByRaceId: async (raceId: string): Promise<Result[]> =>
|
||||
results.filter((r) => r.raceId === raceId),
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const driverRepository = {
|
||||
findById: async (): Promise<Driver | null> => null,
|
||||
findByIRacingId: async (): Promise<Driver | null> => null,
|
||||
findAll: async (): Promise<Driver[]> => drivers.map(d => Driver.create({ id: d.id, iracingId: '123', name: d.name, country: d.country })),
|
||||
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByIRacingId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const penaltyRepository = {
|
||||
findById: async (): Promise<Penalty | null> => null,
|
||||
findByRaceId: async (): Promise<Penalty[]> => [] as Penalty[],
|
||||
findByDriverId: async (): Promise<Penalty[]> => [],
|
||||
findByProtestId: async (): Promise<Penalty[]> => [],
|
||||
findPending: async (): Promise<Penalty[]> => [],
|
||||
findIssuedBy: async (): Promise<Penalty[]> => [],
|
||||
create: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const presenter = new FakeRaceResultsDetailPresenter();
|
||||
|
||||
const useCase = new GetRaceResultsDetailUseCase(
|
||||
raceRepository,
|
||||
leagueRepository,
|
||||
resultRepository,
|
||||
driverRepository,
|
||||
penaltyRepository,
|
||||
);
|
||||
|
||||
// When executing the query
|
||||
await useCase.execute({ raceId: race.id }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
|
||||
// Then points system matches the default F1-style configuration
|
||||
expect(viewModel!.pointsSystem?.[1]).toBe(25);
|
||||
expect(viewModel!.pointsSystem?.[2]).toBe(18);
|
||||
|
||||
// And fastest lap is identified correctly
|
||||
expect(viewModel!.fastestLapTime).toBeCloseTo(88.456, 3);
|
||||
});
|
||||
|
||||
it('builds race results view model including penalties', async () => {
|
||||
// Given a race with one result and one applied penalty
|
||||
const league = League.create({
|
||||
id: 'league-penalties',
|
||||
name: 'Penalty League',
|
||||
description: 'League with penalties',
|
||||
ownerId: 'owner-penalties',
|
||||
});
|
||||
|
||||
const race = Race.create({
|
||||
id: 'race-penalties',
|
||||
leagueId: league.id,
|
||||
scheduledAt: new Date(),
|
||||
track: 'Penalty Circuit',
|
||||
car: 'Touring',
|
||||
sessionType: 'race',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const driver: { id: string; name: string; country: string } = {
|
||||
id: 'driver-pen',
|
||||
name: 'Penalty Driver',
|
||||
country: 'DE',
|
||||
};
|
||||
|
||||
const result = Result.create({
|
||||
id: 'res-pen',
|
||||
raceId: race.id,
|
||||
driverId: driver.id,
|
||||
position: 3,
|
||||
fastestLap: 95.0,
|
||||
incidents: 4,
|
||||
startPosition: 5,
|
||||
});
|
||||
|
||||
const penalty = Penalty.create({
|
||||
id: 'pen-1',
|
||||
leagueId: league.id,
|
||||
raceId: race.id,
|
||||
driverId: driver.id,
|
||||
type: 'points_deduction',
|
||||
value: 3,
|
||||
reason: 'Track limits',
|
||||
issuedBy: 'steward-1',
|
||||
status: 'applied',
|
||||
issuedAt: new Date(),
|
||||
});
|
||||
|
||||
const races = new Map<string, typeof race>([[race.id, race]]);
|
||||
const leagues = new Map<string, typeof league>([[league.id, league]]);
|
||||
const results = [result];
|
||||
const drivers = [driver];
|
||||
const penalties = [penalty];
|
||||
|
||||
const raceRepository = {
|
||||
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
|
||||
findAll: async (): Promise<Race[]> => [],
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
|
||||
findAll: async (): Promise<League[]> => [],
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => [],
|
||||
findByRaceId: async (raceId: string): Promise<Result[]> =>
|
||||
results.filter((r) => r.raceId === raceId),
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const driverRepository = {
|
||||
findById: async (): Promise<Driver | null> => null,
|
||||
findByIRacingId: async (): Promise<Driver | null> => null,
|
||||
findAll: async (): Promise<Driver[]> => drivers.map(d => Driver.create({ id: d.id, iracingId: '123', name: d.name, country: d.country })),
|
||||
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByIRacingId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const penaltyRepository = {
|
||||
findById: async (): Promise<Penalty | null> => null,
|
||||
findByRaceId: async (raceId: string): Promise<Penalty[]> =>
|
||||
penalties.filter((p) => p.raceId === raceId),
|
||||
findByDriverId: async (): Promise<Penalty[]> => [],
|
||||
findByProtestId: async (): Promise<Penalty[]> => [],
|
||||
findPending: async (): Promise<Penalty[]> => [],
|
||||
findIssuedBy: async (): Promise<Penalty[]> => [],
|
||||
create: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const presenter = new FakeRaceResultsDetailPresenter();
|
||||
|
||||
const useCase = new GetRaceResultsDetailUseCase(
|
||||
raceRepository,
|
||||
leagueRepository,
|
||||
resultRepository,
|
||||
driverRepository,
|
||||
penaltyRepository,
|
||||
);
|
||||
|
||||
// When
|
||||
await useCase.execute({ raceId: race.id }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
|
||||
// Then header and league info are present
|
||||
expect(viewModel!.race).not.toBeNull();
|
||||
expect(viewModel!.race!.id).toBe(race.id);
|
||||
expect(viewModel!.league).not.toBeNull();
|
||||
expect(viewModel!.league!.id).toBe(league.id);
|
||||
|
||||
// And classification and penalties match the underlying data
|
||||
expect(viewModel!.results.length).toBe(1);
|
||||
expect(viewModel!.results[0]!.id).toBe(result.id);
|
||||
|
||||
expect(viewModel!.penalties.length).toBe(1);
|
||||
expect(viewModel!.penalties[0]!.driverId).toBe(driver.id);
|
||||
expect(viewModel!.penalties[0]!.type).toBe('points_deduction');
|
||||
expect(viewModel!.penalties[0]!.value).toBe(3);
|
||||
});
|
||||
|
||||
it('presents an error when race does not exist', async () => {
|
||||
// Given repositories without the requested race
|
||||
const raceRepository = {
|
||||
findById: async (): Promise<Race | null> => null,
|
||||
findAll: async (): Promise<Race[]> => [],
|
||||
findByLeagueId: async (): Promise<Race[]> => [],
|
||||
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
|
||||
findCompletedByLeagueId: async (): Promise<Race[]> => [],
|
||||
findByStatus: async (): Promise<Race[]> => [],
|
||||
findByDateRange: async (): Promise<Race[]> => [],
|
||||
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const leagueRepository = {
|
||||
findById: async (): Promise<League | null> => null,
|
||||
findAll: async (): Promise<League[]> => [],
|
||||
findByOwnerId: async (): Promise<League[]> => [],
|
||||
create: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<League> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
searchByName: async (): Promise<League[]> => [],
|
||||
};
|
||||
|
||||
const resultRepository = {
|
||||
findById: async (): Promise<Result | null> => null,
|
||||
findAll: async (): Promise<Result[]> => [],
|
||||
findByRaceId: async (): Promise<Result[]> => [] as Result[],
|
||||
findByDriverId: async (): Promise<Result[]> => [],
|
||||
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
|
||||
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByRaceId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const driverRepository = {
|
||||
findById: async (): Promise<Driver | null> => null,
|
||||
findByIRacingId: async (): Promise<Driver | null> => null,
|
||||
findAll: async (): Promise<Driver[]> => [],
|
||||
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
existsByIRacingId: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const penaltyRepository = {
|
||||
findById: async (): Promise<Penalty | null> => null,
|
||||
findByRaceId: async (): Promise<Penalty[]> => [] as Penalty[],
|
||||
findByDriverId: async (): Promise<Penalty[]> => [],
|
||||
findByProtestId: async (): Promise<Penalty[]> => [],
|
||||
findPending: async (): Promise<Penalty[]> => [],
|
||||
findIssuedBy: async (): Promise<Penalty[]> => [],
|
||||
create: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
update: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||
exists: async (): Promise<boolean> => false,
|
||||
};
|
||||
|
||||
const presenter = new FakeRaceResultsDetailPresenter();
|
||||
|
||||
const useCase = new GetRaceResultsDetailUseCase(
|
||||
raceRepository,
|
||||
leagueRepository,
|
||||
resultRepository,
|
||||
driverRepository,
|
||||
penaltyRepository,
|
||||
);
|
||||
|
||||
// When
|
||||
await useCase.execute({ raceId: 'missing-race' }, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
expect(viewModel).not.toBeNull();
|
||||
expect(viewModel!.race).toBeNull();
|
||||
expect(viewModel!.error).toBe('Race not found');
|
||||
});
|
||||
});
|
||||
868
core/racing/application/RegistrationAndTeamUseCases.test.ts
Normal file
868
core/racing/application/RegistrationAndTeamUseCases.test.ts
Normal file
@@ -0,0 +1,868 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
||||
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
|
||||
import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
|
||||
import {
|
||||
LeagueMembership,
|
||||
type MembershipStatus,
|
||||
} from '@core/racing/domain/entities/LeagueMembership';
|
||||
import { Team } from '@core/racing/domain/entities/Team';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import type {
|
||||
TeamMembership,
|
||||
TeamMembershipStatus,
|
||||
TeamRole,
|
||||
TeamJoinRequest,
|
||||
} from '@core/racing/domain/types/TeamMembership';
|
||||
|
||||
import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/RegisterForRaceUseCase';
|
||||
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
|
||||
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
|
||||
import { GetRaceRegistrationsUseCase } from '@core/racing/application/use-cases/GetRaceRegistrationsUseCase';
|
||||
|
||||
import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase';
|
||||
import { JoinTeamUseCase } from '@core/racing/application/use-cases/JoinTeamUseCase';
|
||||
import { LeaveTeamUseCase } from '@core/racing/application/use-cases/LeaveTeamUseCase';
|
||||
import { ApproveTeamJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveTeamJoinRequestUseCase';
|
||||
import { RejectTeamJoinRequestUseCase } from '@core/racing/application/use-cases/RejectTeamJoinRequestUseCase';
|
||||
import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase';
|
||||
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
|
||||
import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase';
|
||||
import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase';
|
||||
import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase';
|
||||
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
|
||||
import type { IDriverRegistrationStatusPresenter } from '@core/racing/application/presenters/IDriverRegistrationStatusPresenter';
|
||||
import type { IRaceRegistrationsPresenter } from '@core/racing/application/presenters/IRaceRegistrationsPresenter';
|
||||
import type {
|
||||
IAllTeamsPresenter,
|
||||
AllTeamsResultDTO,
|
||||
AllTeamsViewModel,
|
||||
} from '@core/racing/application/presenters/IAllTeamsPresenter';
|
||||
import type { ITeamDetailsPresenter } from '@core/racing/application/presenters/ITeamDetailsPresenter';
|
||||
import type {
|
||||
ITeamMembersPresenter,
|
||||
TeamMembersResultDTO,
|
||||
TeamMembersViewModel,
|
||||
} from '@core/racing/application/presenters/ITeamMembersPresenter';
|
||||
import type {
|
||||
ITeamJoinRequestsPresenter,
|
||||
TeamJoinRequestsResultDTO,
|
||||
TeamJoinRequestsViewModel,
|
||||
} from '@core/racing/application/presenters/ITeamJoinRequestsPresenter';
|
||||
import type {
|
||||
IDriverTeamPresenter,
|
||||
DriverTeamResultDTO,
|
||||
DriverTeamViewModel,
|
||||
} from '@core/racing/application/presenters/IDriverTeamPresenter';
|
||||
import type { RaceRegistrationsResultDTO } from '@core/racing/application/presenters/IRaceRegistrationsPresenter';
|
||||
|
||||
/**
|
||||
* Simple in-memory fakes mirroring current alpha behavior.
|
||||
*/
|
||||
|
||||
class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
|
||||
private registrations = new Map<string, Set<string>>(); // raceId -> driverIds
|
||||
|
||||
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
|
||||
const set = this.registrations.get(raceId);
|
||||
return set ? set.has(driverId) : false;
|
||||
}
|
||||
|
||||
async getRegisteredDrivers(raceId: string): Promise<string[]> {
|
||||
const set = this.registrations.get(raceId);
|
||||
return set ? Array.from(set) : [];
|
||||
}
|
||||
|
||||
async getRegistrationCount(raceId: string): Promise<number> {
|
||||
const set = this.registrations.get(raceId);
|
||||
return set ? set.size : 0;
|
||||
}
|
||||
|
||||
async register(registration: RaceRegistration): Promise<void> {
|
||||
if (!this.registrations.has(registration.raceId)) {
|
||||
this.registrations.set(registration.raceId, new Set());
|
||||
}
|
||||
this.registrations.get(registration.raceId)!.add(registration.driverId);
|
||||
}
|
||||
|
||||
async withdraw(raceId: string, driverId: string): Promise<void> {
|
||||
const set = this.registrations.get(raceId);
|
||||
if (!set || !set.has(driverId)) {
|
||||
throw new Error('Not registered for this race');
|
||||
}
|
||||
set.delete(driverId);
|
||||
if (set.size === 0) {
|
||||
this.registrations.delete(raceId);
|
||||
}
|
||||
}
|
||||
|
||||
async getDriverRegistrations(driverId: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const [raceId, set] of this.registrations.entries()) {
|
||||
if (set.has(driverId)) {
|
||||
result.push(raceId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async clearRaceRegistrations(raceId: string): Promise<void> {
|
||||
this.registrations.delete(raceId);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryLeagueMembershipRepositoryForRegistrations implements ILeagueMembershipRepository {
|
||||
private memberships: LeagueMembership[] = [];
|
||||
|
||||
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.leagueId === leagueId && m.leagueId === leagueId && m.driverId === driverId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.leagueId === leagueId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async getJoinRequests(): Promise<never> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
|
||||
this.memberships.push(membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(): Promise<void> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
async saveJoinRequest(): Promise<never> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
async removeJoinRequest(): Promise<never> {
|
||||
throw new Error('Not needed for registration tests');
|
||||
}
|
||||
|
||||
seedActiveMembership(leagueId: string, driverId: string): void {
|
||||
this.memberships.push(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId,
|
||||
role: 'member',
|
||||
status: 'active' as MembershipStatus,
|
||||
joinedAt: new Date('2024-01-01'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TestDriverRegistrationStatusPresenter implements IDriverRegistrationStatusPresenter {
|
||||
isRegistered: boolean | null = null;
|
||||
raceId: string | null = null;
|
||||
driverId: string | null = null;
|
||||
|
||||
present(isRegistered: boolean, raceId: string, driverId: string) {
|
||||
this.isRegistered = isRegistered;
|
||||
this.raceId = raceId;
|
||||
this.driverId = driverId;
|
||||
return {
|
||||
isRegistered,
|
||||
raceId,
|
||||
driverId,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel() {
|
||||
return {
|
||||
isRegistered: this.isRegistered!,
|
||||
raceId: this.raceId!,
|
||||
driverId: this.driverId!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TestRaceRegistrationsPresenter implements IRaceRegistrationsPresenter {
|
||||
raceId: string | null = null;
|
||||
driverIds: string[] = [];
|
||||
|
||||
reset(): void {
|
||||
this.raceId = null;
|
||||
this.driverIds = [];
|
||||
}
|
||||
|
||||
present(input: RaceRegistrationsResultDTO) {
|
||||
this.driverIds = input.registeredDriverIds;
|
||||
this.raceId = null;
|
||||
return {
|
||||
registeredDriverIds: input.registeredDriverIds,
|
||||
count: input.registeredDriverIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel() {
|
||||
return {
|
||||
registeredDriverIds: this.driverIds,
|
||||
count: this.driverIds.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryTeamRepository implements ITeamRepository {
|
||||
private teams: Team[] = [];
|
||||
|
||||
async findById(id: string): Promise<Team | null> {
|
||||
return this.teams.find((t) => t.id === id) || null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Team[]> {
|
||||
return [...this.teams];
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Team[]> {
|
||||
return this.teams.filter((t) => t.leagues.includes(leagueId));
|
||||
}
|
||||
|
||||
async create(team: Team): Promise<Team> {
|
||||
this.teams.push(team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async update(team: Team): Promise<Team> {
|
||||
const index = this.teams.findIndex((t) => t.id === team.id);
|
||||
if (index >= 0) {
|
||||
this.teams[index] = team;
|
||||
} else {
|
||||
this.teams.push(team);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.teams = this.teams.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.teams.some((t) => t.id === id);
|
||||
}
|
||||
|
||||
seedTeam(team: Team): void {
|
||||
this.teams.push(team);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryTeamMembershipRepository implements ITeamMembershipRepository {
|
||||
private memberships: TeamMembership[] = [];
|
||||
private joinRequests: TeamJoinRequest[] = [];
|
||||
|
||||
async getMembership(teamId: string, driverId: string): Promise<TeamMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.teamId === teamId && m.driverId === driverId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null> {
|
||||
return (
|
||||
this.memberships.find(
|
||||
(m) => m.driverId === driverId && m.status === 'active',
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: string): Promise<TeamMembership[]> {
|
||||
return this.memberships.filter(
|
||||
(m) => m.teamId === teamId && m.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamMembership[]> {
|
||||
return this.memberships.filter((m) => m.teamId === teamId);
|
||||
}
|
||||
|
||||
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
|
||||
const index = this.memberships.findIndex(
|
||||
(m) => m.teamId === membership.teamId && m.driverId === membership.driverId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.memberships[index] = membership;
|
||||
} else {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
async removeMembership(teamId: string, driverId: string): Promise<void> {
|
||||
this.memberships = this.memberships.filter(
|
||||
(m) => !(m.teamId === teamId && m.driverId === driverId),
|
||||
);
|
||||
}
|
||||
|
||||
async getJoinRequests(teamId: string): Promise<TeamJoinRequest[]> {
|
||||
// For these tests we ignore teamId and return all,
|
||||
// allowing use-cases to look up by request ID only.
|
||||
return [...this.joinRequests];
|
||||
}
|
||||
|
||||
async saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest> {
|
||||
const index = this.joinRequests.findIndex((r) => r.id === request.id);
|
||||
if (index >= 0) {
|
||||
this.joinRequests[index] = request;
|
||||
} else {
|
||||
this.joinRequests.push(request);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async removeJoinRequest(requestId: string): Promise<void> {
|
||||
this.joinRequests = this.joinRequests.filter((r) => r.id !== requestId);
|
||||
}
|
||||
|
||||
seedMembership(membership: TeamMembership): void {
|
||||
this.memberships.push(membership);
|
||||
}
|
||||
|
||||
seedJoinRequest(request: TeamJoinRequest): void {
|
||||
this.joinRequests.push(request);
|
||||
}
|
||||
|
||||
getAllMemberships(): TeamMembership[] {
|
||||
return [...this.memberships];
|
||||
}
|
||||
|
||||
getAllJoinRequests(): TeamJoinRequest[] {
|
||||
return [...this.joinRequests];
|
||||
}
|
||||
|
||||
async countByTeamId(teamId: string): Promise<number> {
|
||||
return this.memberships.filter((m) => m.teamId === teamId).length;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Racing application use-cases - registrations', () => {
|
||||
let registrationRepo: InMemoryRaceRegistrationRepository;
|
||||
let membershipRepo: InMemoryLeagueMembershipRepositoryForRegistrations;
|
||||
let registerForRace: RegisterForRaceUseCase;
|
||||
let withdrawFromRace: WithdrawFromRaceUseCase;
|
||||
let isDriverRegistered: IsDriverRegisteredForRaceUseCase;
|
||||
let getRaceRegistrations: GetRaceRegistrationsUseCase;
|
||||
let driverRegistrationPresenter: TestDriverRegistrationStatusPresenter;
|
||||
let raceRegistrationsPresenter: TestRaceRegistrationsPresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
registrationRepo = new InMemoryRaceRegistrationRepository();
|
||||
membershipRepo = new InMemoryLeagueMembershipRepositoryForRegistrations();
|
||||
|
||||
registerForRace = new RegisterForRaceUseCase(registrationRepo, membershipRepo);
|
||||
withdrawFromRace = new WithdrawFromRaceUseCase(registrationRepo);
|
||||
driverRegistrationPresenter = new TestDriverRegistrationStatusPresenter();
|
||||
isDriverRegistered = new IsDriverRegisteredForRaceUseCase(
|
||||
registrationRepo,
|
||||
driverRegistrationPresenter,
|
||||
);
|
||||
raceRegistrationsPresenter = new TestRaceRegistrationsPresenter();
|
||||
getRaceRegistrations = new GetRaceRegistrationsUseCase(registrationRepo);
|
||||
});
|
||||
|
||||
it('registers an active league member for a race and tracks registration', async () => {
|
||||
const raceId = 'race-1';
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
membershipRepo.seedActiveMembership(leagueId, driverId);
|
||||
|
||||
await registerForRace.execute({ raceId, leagueId, driverId });
|
||||
|
||||
await isDriverRegistered.execute({ raceId, driverId });
|
||||
expect(driverRegistrationPresenter.isRegistered).toBe(true);
|
||||
expect(driverRegistrationPresenter.raceId).toBe(raceId);
|
||||
expect(driverRegistrationPresenter.driverId).toBe(driverId);
|
||||
|
||||
await getRaceRegistrations.execute({ raceId }, raceRegistrationsPresenter);
|
||||
expect(raceRegistrationsPresenter.driverIds).toContain(driverId);
|
||||
});
|
||||
|
||||
it('throws when registering a non-member for a race', async () => {
|
||||
const raceId = 'race-1';
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await expect(
|
||||
registerForRace.execute({ raceId, leagueId, driverId }),
|
||||
).rejects.toThrow('Must be an active league member to register for races');
|
||||
});
|
||||
|
||||
it('withdraws a registration and reflects state in queries', async () => {
|
||||
const raceId = 'race-1';
|
||||
const leagueId = 'league-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
membershipRepo.seedActiveMembership(leagueId, driverId);
|
||||
await registerForRace.execute({ raceId, leagueId, driverId });
|
||||
|
||||
await withdrawFromRace.execute({ raceId, driverId });
|
||||
|
||||
await isDriverRegistered.execute({ raceId, driverId });
|
||||
expect(driverRegistrationPresenter.isRegistered).toBe(false);
|
||||
|
||||
await getRaceRegistrations.execute({ raceId }, raceRegistrationsPresenter);
|
||||
expect(raceRegistrationsPresenter.driverIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Racing application use-cases - teams', () => {
|
||||
let teamRepo: InMemoryTeamRepository;
|
||||
let membershipRepo: InMemoryTeamMembershipRepository;
|
||||
|
||||
let createTeam: CreateTeamUseCase;
|
||||
let joinTeam: JoinTeamUseCase;
|
||||
let leaveTeam: LeaveTeamUseCase;
|
||||
let approveJoin: ApproveTeamJoinRequestUseCase;
|
||||
let rejectJoin: RejectTeamJoinRequestUseCase;
|
||||
let updateTeamUseCase: UpdateTeamUseCase;
|
||||
let getAllTeamsUseCase: GetAllTeamsUseCase;
|
||||
let getTeamDetailsUseCase: GetTeamDetailsUseCase;
|
||||
let getTeamMembersUseCase: GetTeamMembersUseCase;
|
||||
let getTeamJoinRequestsUseCase: GetTeamJoinRequestsUseCase;
|
||||
let getDriverTeamUseCase: GetDriverTeamUseCase;
|
||||
|
||||
class FakeDriverRepository {
|
||||
async findById(driverId: string): Promise<Driver | null> {
|
||||
return Driver.create({ id: driverId, iracingId: '123', name: `Driver ${driverId}`, country: 'US' });
|
||||
}
|
||||
|
||||
async findByIRacingId(id: string): Promise<Driver | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Driver[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async create(driver: Driver): Promise<Driver> {
|
||||
return driver;
|
||||
}
|
||||
|
||||
async update(driver: Driver): Promise<Driver> {
|
||||
return driver;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async existsByIRacingId(iracingId: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Driver[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<Driver[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class FakeImageService {
|
||||
getDriverAvatar(driverId: string): string {
|
||||
return `https://example.com/avatar/${driverId}.png`;
|
||||
}
|
||||
|
||||
getTeamLogo(teamId: string): string {
|
||||
return `https://example.com/logo/${teamId}.png`;
|
||||
}
|
||||
|
||||
getLeagueCover(leagueId: string): string {
|
||||
return `https://example.com/cover/${leagueId}.png`;
|
||||
}
|
||||
|
||||
getLeagueLogo(leagueId: string): string {
|
||||
return `https://example.com/logo/${leagueId}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
class TestAllTeamsPresenter implements IAllTeamsPresenter {
|
||||
private viewModel: AllTeamsViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: AllTeamsResultDTO): void {
|
||||
this.viewModel = {
|
||||
teams: input.teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
memberCount: team.memberCount,
|
||||
leagues: team.leagues,
|
||||
specialization: (team as any).specialization,
|
||||
region: (team as any).region,
|
||||
languages: (team as any).languages,
|
||||
})),
|
||||
totalCount: input.teams.length,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): AllTeamsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
get teams(): any[] {
|
||||
return this.viewModel?.teams ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestTeamDetailsPresenter implements ITeamDetailsPresenter {
|
||||
viewModel: any = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: any): void {
|
||||
this.viewModel = input;
|
||||
}
|
||||
|
||||
getViewModel(): any {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
class TestTeamMembersPresenter implements ITeamMembersPresenter {
|
||||
private viewModel: TeamMembersViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamMembersResultDTO): void {
|
||||
const members = input.memberships.map((membership) => {
|
||||
const driverId = membership.driverId;
|
||||
const driverName = input.driverNames[driverId] ?? driverId;
|
||||
const avatarUrl = input.avatarUrls[driverId] ?? '';
|
||||
|
||||
return {
|
||||
driverId,
|
||||
driverName,
|
||||
role: ((membership.role as any) === 'owner' ? 'owner' : (membership.role as any) === 'member' ? 'member' : (membership.role as any) === 'manager' ? 'manager' : (membership.role as any) === 'driver' ? 'member' : 'member') as "owner" | "member" | "manager",
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.status === 'active',
|
||||
avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||
const managerCount = members.filter((m) => m.role === 'manager').length;
|
||||
const memberCount = members.filter((m) => (m.role as any) === 'member').length;
|
||||
|
||||
this.viewModel = {
|
||||
members,
|
||||
totalCount: members.length,
|
||||
ownerCount,
|
||||
managerCount,
|
||||
memberCount,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): TeamMembersViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
get members(): any[] {
|
||||
return this.viewModel?.members ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestTeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
|
||||
private viewModel: TeamJoinRequestsViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamJoinRequestsResultDTO): void {
|
||||
const requests = input.requests.map((request) => {
|
||||
const driverId = request.driverId;
|
||||
const driverName = input.driverNames[driverId] ?? driverId;
|
||||
const avatarUrl = input.avatarUrls[driverId] ?? '';
|
||||
|
||||
return {
|
||||
requestId: request.id,
|
||||
driverId,
|
||||
driverName,
|
||||
teamId: request.teamId,
|
||||
status: 'pending' as const,
|
||||
requestedAt: request.requestedAt.toISOString(),
|
||||
avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const pendingCount = requests.filter((r) => r.status === 'pending').length;
|
||||
|
||||
this.viewModel = {
|
||||
requests,
|
||||
pendingCount,
|
||||
totalCount: requests.length,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): TeamJoinRequestsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
get requests(): any[] {
|
||||
return this.viewModel?.requests ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestDriverTeamPresenter implements IDriverTeamPresenter {
|
||||
viewModel: DriverTeamViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: DriverTeamResultDTO): void {
|
||||
const { team, membership, driverId } = input;
|
||||
|
||||
const isOwner = team.ownerId === driverId;
|
||||
const canManage = membership.role === 'owner' || membership.role === 'manager';
|
||||
|
||||
this.viewModel = {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
leagues: team.leagues,
|
||||
},
|
||||
membership: {
|
||||
role: (membership.role === 'owner' || membership.role === 'manager') ? membership.role : 'member' as "owner" | "member" | "manager",
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.status === 'active',
|
||||
},
|
||||
isOwner,
|
||||
canManage,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): DriverTeamViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
let allTeamsPresenter: TestAllTeamsPresenter;
|
||||
let teamDetailsPresenter: TestTeamDetailsPresenter;
|
||||
let teamMembersPresenter: TestTeamMembersPresenter;
|
||||
let teamJoinRequestsPresenter: TestTeamJoinRequestsPresenter;
|
||||
let driverTeamPresenter: TestDriverTeamPresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
teamRepo = new InMemoryTeamRepository();
|
||||
membershipRepo = new InMemoryTeamMembershipRepository();
|
||||
|
||||
createTeam = new CreateTeamUseCase(teamRepo, membershipRepo);
|
||||
joinTeam = new JoinTeamUseCase(teamRepo, membershipRepo);
|
||||
leaveTeam = new LeaveTeamUseCase(membershipRepo);
|
||||
approveJoin = new ApproveTeamJoinRequestUseCase(membershipRepo);
|
||||
rejectJoin = new RejectTeamJoinRequestUseCase(membershipRepo);
|
||||
updateTeamUseCase = new UpdateTeamUseCase(teamRepo, membershipRepo);
|
||||
|
||||
allTeamsPresenter = new TestAllTeamsPresenter();
|
||||
getAllTeamsUseCase = new GetAllTeamsUseCase(
|
||||
teamRepo,
|
||||
membershipRepo,
|
||||
);
|
||||
|
||||
teamDetailsPresenter = new TestTeamDetailsPresenter();
|
||||
getTeamDetailsUseCase = new GetTeamDetailsUseCase(
|
||||
teamRepo,
|
||||
membershipRepo,
|
||||
);
|
||||
|
||||
const driverRepository = new FakeDriverRepository();
|
||||
const imageService = new FakeImageService();
|
||||
|
||||
teamMembersPresenter = new TestTeamMembersPresenter();
|
||||
getTeamMembersUseCase = new GetTeamMembersUseCase(
|
||||
membershipRepo,
|
||||
driverRepository,
|
||||
imageService,
|
||||
teamMembersPresenter,
|
||||
);
|
||||
|
||||
teamJoinRequestsPresenter = new TestTeamJoinRequestsPresenter();
|
||||
getTeamJoinRequestsUseCase = new GetTeamJoinRequestsUseCase(
|
||||
membershipRepo,
|
||||
driverRepository,
|
||||
imageService,
|
||||
teamJoinRequestsPresenter,
|
||||
);
|
||||
|
||||
driverTeamPresenter = new TestDriverTeamPresenter();
|
||||
getDriverTeamUseCase = new GetDriverTeamUseCase(
|
||||
teamRepo,
|
||||
membershipRepo,
|
||||
driverTeamPresenter,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a team and assigns creator as active owner', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
|
||||
const result = await createTeam.execute({
|
||||
name: 'Apex Racing',
|
||||
tag: 'APEX',
|
||||
description: 'Professional GT3 racing',
|
||||
ownerId,
|
||||
leagues: ['league-1'],
|
||||
});
|
||||
|
||||
expect(result.team.id).toBeDefined();
|
||||
expect(result.team.ownerId).toBe(ownerId);
|
||||
|
||||
const membership = await membershipRepo.getActiveMembershipForDriver(ownerId);
|
||||
expect(membership?.teamId).toBe(result.team.id);
|
||||
expect(membership?.role as TeamRole).toBe('owner');
|
||||
expect(membership?.status as TeamMembershipStatus).toBe('active');
|
||||
});
|
||||
|
||||
it('prevents driver from joining multiple teams and mirrors legacy error message', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
const otherTeamId = 'team-2';
|
||||
|
||||
// Seed an existing active membership
|
||||
membershipRepo.seedMembership({
|
||||
teamId: otherTeamId,
|
||||
driverId: ownerId,
|
||||
role: 'driver',
|
||||
status: 'active',
|
||||
joinedAt: new Date('2024-02-01'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
joinTeam.execute({ teamId: 'team-1', driverId: ownerId }),
|
||||
).rejects.toThrow('Driver already belongs to a team');
|
||||
});
|
||||
|
||||
it('approves a join request and moves it into active membership', async () => {
|
||||
const teamId = 'team-1';
|
||||
const driverId = 'driver-2';
|
||||
|
||||
const request: TeamJoinRequest = {
|
||||
id: 'req-1',
|
||||
teamId,
|
||||
driverId,
|
||||
requestedAt: new Date('2024-03-01'),
|
||||
message: 'Let me in',
|
||||
};
|
||||
membershipRepo.seedJoinRequest(request);
|
||||
|
||||
await approveJoin.execute({ requestId: request.id });
|
||||
|
||||
const membership = await membershipRepo.getMembership(teamId, driverId);
|
||||
expect(membership).not.toBeNull();
|
||||
expect(membership?.status as TeamMembershipStatus).toBe('active');
|
||||
|
||||
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
|
||||
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a join request and removes it', async () => {
|
||||
const teamId = 'team-1';
|
||||
const driverId = 'driver-2';
|
||||
|
||||
const request: TeamJoinRequest = {
|
||||
id: 'req-2',
|
||||
teamId,
|
||||
driverId,
|
||||
requestedAt: new Date('2024-03-02'),
|
||||
message: 'Please?',
|
||||
};
|
||||
membershipRepo.seedJoinRequest(request);
|
||||
|
||||
await rejectJoin.execute({ requestId: request.id });
|
||||
|
||||
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
|
||||
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates team details when performed by owner or manager and reflects in queries', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
const created = await createTeam.execute({
|
||||
name: 'Original Name',
|
||||
tag: 'ORIG',
|
||||
description: 'Original description',
|
||||
ownerId,
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
await updateTeamUseCase.execute({
|
||||
teamId: created.team.id,
|
||||
updates: { name: 'Updated Name', description: 'Updated description' },
|
||||
updatedBy: ownerId,
|
||||
});
|
||||
|
||||
await getTeamDetailsUseCase.execute({ teamId: created.team.id, driverId: ownerId }, teamDetailsPresenter);
|
||||
|
||||
expect(teamDetailsPresenter.viewModel.team.name).toBe('Updated Name');
|
||||
expect(teamDetailsPresenter.viewModel.team.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('returns driver team via query matching legacy getDriverTeam behavior', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
|
||||
const { team } = await createTeam.execute({
|
||||
name: 'Apex Racing',
|
||||
tag: 'APEX',
|
||||
description: 'Professional GT3 racing',
|
||||
ownerId,
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
await getDriverTeamUseCase.execute({ driverId: ownerId }, driverTeamPresenter);
|
||||
const result = driverTeamPresenter.viewModel;
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.team.id).toBe(team.id);
|
||||
expect(result?.membership.isActive).toBe(true);
|
||||
expect(result?.isOwner).toBe(true);
|
||||
});
|
||||
|
||||
it('lists all teams and members via queries after multiple operations', async () => {
|
||||
const ownerId = 'driver-1';
|
||||
const otherDriverId = 'driver-2';
|
||||
|
||||
const { team } = await createTeam.execute({
|
||||
name: 'Apex Racing',
|
||||
tag: 'APEX',
|
||||
description: 'Professional GT3 racing',
|
||||
ownerId,
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
await joinTeam.execute({ teamId: team.id, driverId: otherDriverId });
|
||||
|
||||
await getAllTeamsUseCase.execute(undefined as void, allTeamsPresenter);
|
||||
expect(allTeamsPresenter.teams.length).toBe(1);
|
||||
|
||||
await getTeamMembersUseCase.execute({ teamId: team.id }, teamMembersPresenter);
|
||||
const memberIds = teamMembersPresenter.members.map((m) => m.driverId).sort();
|
||||
expect(memberIds).toEqual([ownerId, otherDriverId].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase';
|
||||
import { RejectLeagueJoinRequestPresenter } from '@apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter';
|
||||
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
|
||||
describe('RejectLeagueJoinRequestUseCase', () => {
|
||||
let useCase: RejectLeagueJoinRequestUseCase;
|
||||
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
|
||||
let presenter: RejectLeagueJoinRequestPresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
leagueMembershipRepository = {
|
||||
removeJoinRequest: jest.fn(),
|
||||
} as any;
|
||||
presenter = new RejectLeagueJoinRequestPresenter();
|
||||
useCase = new RejectLeagueJoinRequestUseCase(leagueMembershipRepository);
|
||||
});
|
||||
|
||||
it('should reject join request', async () => {
|
||||
const requestId = 'req-1';
|
||||
|
||||
await useCase.execute({ requestId }, presenter);
|
||||
|
||||
expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId);
|
||||
expect(presenter.viewModel).toEqual({ success: true, message: 'Join request rejected.' });
|
||||
});
|
||||
});
|
||||
43
core/racing/application/RemoveLeagueMemberUseCase.test.ts
Normal file
43
core/racing/application/RemoveLeagueMemberUseCase.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
|
||||
import { RemoveLeagueMemberPresenter } from '@apps/api/src/modules/league/presenters/RemoveLeagueMemberPresenter';
|
||||
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
|
||||
describe('RemoveLeagueMemberUseCase', () => {
|
||||
let useCase: RemoveLeagueMemberUseCase;
|
||||
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
|
||||
let presenter: RemoveLeagueMemberPresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
leagueMembershipRepository = {
|
||||
getLeagueMembers: jest.fn(),
|
||||
saveMembership: jest.fn(),
|
||||
} as any;
|
||||
presenter = new RemoveLeagueMemberPresenter();
|
||||
useCase = new RemoveLeagueMemberUseCase(leagueMembershipRepository);
|
||||
});
|
||||
|
||||
it('should remove league member by setting status to inactive', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const targetDriverId = 'driver-1';
|
||||
const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }];
|
||||
|
||||
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
|
||||
|
||||
await useCase.execute({ leagueId, targetDriverId }, presenter);
|
||||
|
||||
expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({
|
||||
leagueId,
|
||||
driverId: targetDriverId,
|
||||
role: 'member',
|
||||
status: 'inactive',
|
||||
joinedAt: expect.any(Date),
|
||||
});
|
||||
expect(presenter.viewModel).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should throw error if membership not found', async () => {
|
||||
leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]);
|
||||
|
||||
await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1' }, presenter)).rejects.toThrow('Membership not found');
|
||||
});
|
||||
});
|
||||
449
core/racing/application/SeasonUseCases.test.ts
Normal file
449
core/racing/application/SeasonUseCases.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
InMemorySeasonRepository,
|
||||
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
|
||||
import { Season } from '@core/racing/domain/entities/Season';
|
||||
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import {
|
||||
CreateSeasonForLeagueUseCase,
|
||||
ListSeasonsForLeagueUseCase,
|
||||
GetSeasonDetailsUseCase,
|
||||
ManageSeasonLifecycleUseCase,
|
||||
type CreateSeasonForLeagueCommand,
|
||||
type ManageSeasonLifecycleCommand,
|
||||
} from '@core/racing/application/use-cases/SeasonUseCases';
|
||||
import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
|
||||
|
||||
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
|
||||
return {
|
||||
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
|
||||
findAll: async () => seed,
|
||||
create: async (league: any) => league,
|
||||
update: async (league: any) => league,
|
||||
} as unknown as ILeagueRepository;
|
||||
}
|
||||
|
||||
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
|
||||
return {
|
||||
basics: {
|
||||
name: 'Test League',
|
||||
visibility: 'ranked',
|
||||
gameId: 'iracing',
|
||||
...overrides?.basics,
|
||||
},
|
||||
structure: {
|
||||
mode: 'solo',
|
||||
maxDrivers: 30,
|
||||
...overrides?.structure,
|
||||
},
|
||||
championships: {
|
||||
enableDriverChampionship: true,
|
||||
enableTeamChampionship: false,
|
||||
enableNationsChampionship: false,
|
||||
enableTrophyChampionship: false,
|
||||
...overrides?.championships,
|
||||
},
|
||||
scoring: {
|
||||
patternId: 'sprint-main-driver',
|
||||
customScoringEnabled: false,
|
||||
...overrides?.scoring,
|
||||
},
|
||||
dropPolicy: {
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
...overrides?.dropPolicy,
|
||||
},
|
||||
timings: {
|
||||
qualifyingMinutes: 10,
|
||||
mainRaceMinutes: 30,
|
||||
sessionCount: 8,
|
||||
seasonStartDate: '2025-01-01',
|
||||
raceStartTime: '20:00',
|
||||
timezoneId: 'UTC',
|
||||
recurrenceStrategy: 'weekly',
|
||||
weekdays: ['Mon'],
|
||||
...overrides?.timings,
|
||||
},
|
||||
stewarding: {
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
...overrides?.stewarding,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('InMemorySeasonRepository', () => {
|
||||
it('add and findById provide a roundtrip for Season', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Test Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(season);
|
||||
const loaded = await repo.findById(season.id);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.id).toBe(season.id);
|
||||
expect(loaded!.leagueId).toBe(season.leagueId);
|
||||
expect(loaded!.status).toBe('planned');
|
||||
});
|
||||
|
||||
it('update persists changed Season state', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Initial Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(season);
|
||||
const activated = season.activate();
|
||||
|
||||
await repo.update(activated);
|
||||
|
||||
const loaded = await repo.findById(season.id);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('listByLeague returns only seasons for that league', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const s1 = Season.create({
|
||||
id: 's1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'L1 S1',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 's2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'L1 S2',
|
||||
status: 'active',
|
||||
});
|
||||
const s3 = Season.create({
|
||||
id: 's3',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'L2 S1',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(s1);
|
||||
await repo.add(s2);
|
||||
await repo.add(s3);
|
||||
|
||||
const league1Seasons = await repo.listByLeague('league-1');
|
||||
const league2Seasons = await repo.listByLeague('league-2');
|
||||
|
||||
expect(league1Seasons.map((s) => s.id).sort()).toEqual(['s1', 's2']);
|
||||
expect(league2Seasons.map((s) => s.id)).toEqual(['s3']);
|
||||
});
|
||||
|
||||
it('listActiveByLeague returns only active seasons for a league', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const s1 = Season.create({
|
||||
id: 's1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Planned',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 's2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Active',
|
||||
status: 'active',
|
||||
});
|
||||
const s3 = Season.create({
|
||||
id: 's3',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Completed',
|
||||
status: 'completed',
|
||||
});
|
||||
const s4 = Season.create({
|
||||
id: 's4',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'Other League Active',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repo.add(s1);
|
||||
await repo.add(s2);
|
||||
await repo.add(s3);
|
||||
await repo.add(s4);
|
||||
|
||||
const activeInLeague1 = await repo.listActiveByLeague('league-1');
|
||||
|
||||
expect(activeInLeague1.map((s) => s.id)).toEqual(['s2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateSeasonForLeagueUseCase', () => {
|
||||
it('creates a planned Season for an existing league with config-derived props', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const config = createLeagueConfigFormModel({
|
||||
basics: {
|
||||
name: 'League With Config',
|
||||
visibility: 'ranked',
|
||||
gameId: 'iracing',
|
||||
},
|
||||
scoring: {
|
||||
patternId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
},
|
||||
dropPolicy: {
|
||||
strategy: 'dropWorstN',
|
||||
n: 2,
|
||||
},
|
||||
// Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation,
|
||||
// focusing this test on scoring/drop/stewarding/maxDrivers mapping.
|
||||
timings: {
|
||||
qualifyingMinutes: 10,
|
||||
mainRaceMinutes: 30,
|
||||
sessionCount: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const command: CreateSeasonForLeagueCommand = {
|
||||
leagueId: 'league-1',
|
||||
name: 'Season from Config',
|
||||
gameId: 'iracing',
|
||||
config,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.seasonId).toBeDefined();
|
||||
|
||||
const created = await seasonRepo.findById(result.seasonId);
|
||||
expect(created).not.toBeNull();
|
||||
const season = created!;
|
||||
|
||||
expect(season.leagueId).toBe('league-1');
|
||||
expect(season.gameId).toBe('iracing');
|
||||
expect(season.name).toBe('Season from Config');
|
||||
expect(season.status).toBe('planned');
|
||||
|
||||
// Schedule is optional when timings lack seasonStartDate / raceStartTime.
|
||||
expect(season.schedule).toBeUndefined();
|
||||
expect(season.scoringConfig).toBeDefined();
|
||||
expect(season.scoringConfig!.scoringPresetId).toBe('club-default');
|
||||
expect(season.scoringConfig!.customScoringEnabled).toBe(true);
|
||||
|
||||
expect(season.dropPolicy).toBeDefined();
|
||||
expect(season.dropPolicy!.strategy).toBe('dropWorstN');
|
||||
expect(season.dropPolicy!.n).toBe(2);
|
||||
|
||||
expect(season.stewardingConfig).toBeDefined();
|
||||
expect(season.maxDrivers).toBe(30);
|
||||
});
|
||||
|
||||
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const sourceSeason = Season.create({
|
||||
id: 'source-season',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Source Season',
|
||||
status: 'planned',
|
||||
}).withMaxDrivers(40);
|
||||
|
||||
await seasonRepo.add(sourceSeason);
|
||||
|
||||
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const command: CreateSeasonForLeagueCommand = {
|
||||
leagueId: 'league-1',
|
||||
name: 'Cloned Season',
|
||||
gameId: 'iracing',
|
||||
sourceSeasonId: 'source-season',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
const created = await seasonRepo.findById(result.seasonId);
|
||||
|
||||
expect(created).not.toBeNull();
|
||||
const season = created!;
|
||||
|
||||
expect(season.id).not.toBe(sourceSeason.id);
|
||||
expect(season.leagueId).toBe(sourceSeason.leagueId);
|
||||
expect(season.gameId).toBe(sourceSeason.gameId);
|
||||
expect(season.status).toBe('planned');
|
||||
expect(season.maxDrivers).toBe(sourceSeason.maxDrivers);
|
||||
expect(season.schedule).toBe(sourceSeason.schedule);
|
||||
expect(season.scoringConfig).toBe(sourceSeason.scoringConfig);
|
||||
expect(season.dropPolicy).toBe(sourceSeason.dropPolicy);
|
||||
expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListSeasonsForLeagueUseCase', () => {
|
||||
it('lists seasons for a league with summaries', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const s1 = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Season One',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 'season-2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Season Two',
|
||||
status: 'active',
|
||||
});
|
||||
const sOtherLeague = Season.create({
|
||||
id: 'season-3',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'Season Other',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await seasonRepo.add(s1);
|
||||
await seasonRepo.add(s2);
|
||||
await seasonRepo.add(sOtherLeague);
|
||||
|
||||
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
||||
|
||||
expect(result.items.map((i) => i.seasonId).sort()).toEqual([
|
||||
'season-1',
|
||||
'season-2',
|
||||
]);
|
||||
expect(result.items.every((i) => i.leagueId === 'league-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetSeasonDetailsUseCase', () => {
|
||||
it('returns full details for a season belonging to the league', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Detailed Season',
|
||||
status: 'planned',
|
||||
}).withMaxDrivers(24);
|
||||
|
||||
await seasonRepo.add(season);
|
||||
|
||||
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const dto = await useCase.execute({
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
});
|
||||
|
||||
expect(dto.seasonId).toBe('season-1');
|
||||
expect(dto.leagueId).toBe('league-1');
|
||||
expect(dto.gameId).toBe('iracing');
|
||||
expect(dto.name).toBe('Detailed Season');
|
||||
expect(dto.status).toBe('planned');
|
||||
expect(dto.maxDrivers).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageSeasonLifecycleUseCase', () => {
|
||||
function setupLifecycleTest() {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Lifecycle Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
seasonRepo.seed(season);
|
||||
|
||||
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
return { leagueRepo, seasonRepo, useCase, season };
|
||||
}
|
||||
|
||||
it('applies activate → complete → archive transitions and persists state', async () => {
|
||||
const { useCase, seasonRepo, season } = setupLifecycleTest();
|
||||
|
||||
const activateCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'activate',
|
||||
};
|
||||
|
||||
const activated = await useCase.execute(activateCommand);
|
||||
expect(activated.status).toBe('active');
|
||||
|
||||
const completeCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'complete',
|
||||
};
|
||||
|
||||
const completed = await useCase.execute(completeCommand);
|
||||
expect(completed.status).toBe('completed');
|
||||
|
||||
const archiveCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'archive',
|
||||
};
|
||||
|
||||
const archived = await useCase.execute(archiveCommand);
|
||||
expect(archived.status).toBe('archived');
|
||||
|
||||
const persisted = await seasonRepo.findById(season.id);
|
||||
expect(persisted!.status).toBe('archived');
|
||||
});
|
||||
|
||||
it('propagates domain invariant errors for invalid transitions', async () => {
|
||||
const { useCase, seasonRepo, season } = setupLifecycleTest();
|
||||
|
||||
const completeCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'complete',
|
||||
};
|
||||
|
||||
await expect(useCase.execute(completeCommand)).rejects.toThrow();
|
||||
|
||||
const persisted = await seasonRepo.findById(season.id);
|
||||
expect(persisted!.status).toBe('planned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
|
||||
import { UpdateLeagueMemberRolePresenter } from '@apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter';
|
||||
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
|
||||
describe('UpdateLeagueMemberRoleUseCase', () => {
|
||||
let useCase: UpdateLeagueMemberRoleUseCase;
|
||||
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
|
||||
let presenter: UpdateLeagueMemberRolePresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
leagueMembershipRepository = {
|
||||
getLeagueMembers: jest.fn(),
|
||||
saveMembership: jest.fn(),
|
||||
} as any;
|
||||
presenter = new UpdateLeagueMemberRolePresenter();
|
||||
useCase = new UpdateLeagueMemberRoleUseCase(leagueMembershipRepository);
|
||||
});
|
||||
|
||||
it('should update league member role', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const targetDriverId = 'driver-1';
|
||||
const newRole = 'admin';
|
||||
const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }];
|
||||
|
||||
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
|
||||
|
||||
await useCase.execute({ leagueId, targetDriverId, newRole }, presenter);
|
||||
|
||||
expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({
|
||||
leagueId,
|
||||
driverId: targetDriverId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt: expect.any(Date),
|
||||
});
|
||||
expect(presenter.viewModel).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should throw error if membership not found', async () => {
|
||||
leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]);
|
||||
|
||||
await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1', newRole: 'admin' }, presenter)).rejects.toThrow('Membership not found');
|
||||
});
|
||||
});
|
||||
486
core/racing/domain/entities/Season.test.ts
Normal file
486
core/racing/domain/entities/Season.test.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
RacingDomainInvariantError,
|
||||
RacingDomainValidationError,
|
||||
} from '@core/racing/domain/errors/RacingDomainError';
|
||||
import {
|
||||
Season,
|
||||
type SeasonStatus,
|
||||
} from '@core/racing/domain/entities/Season';
|
||||
import { SeasonScoringConfig } from '@core/racing/domain/value-objects/SeasonScoringConfig';
|
||||
import {
|
||||
SeasonDropPolicy,
|
||||
type SeasonDropStrategy,
|
||||
} from '@core/racing/domain/value-objects/SeasonDropPolicy';
|
||||
import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/SeasonStewardingConfig';
|
||||
import { createMinimalSeason, createBaseSeason } from '../../testing/factories/racing/SeasonFactory';
|
||||
|
||||
|
||||
describe('Season aggregate lifecycle', () => {
|
||||
it('transitions Planned → Active → Completed → Archived with timestamps', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
|
||||
const activated = planned.activate();
|
||||
expect(activated.status).toBe('active');
|
||||
expect(activated.startDate).toBeInstanceOf(Date);
|
||||
expect(activated.endDate).toBeUndefined();
|
||||
|
||||
const completed = activated.complete();
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.startDate).toEqual(activated.startDate);
|
||||
expect(completed.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const archived = completed.archive();
|
||||
expect(archived.status).toBe('archived');
|
||||
expect(archived.startDate).toEqual(completed.startDate);
|
||||
expect(archived.endDate).toEqual(completed.endDate);
|
||||
});
|
||||
|
||||
it('throws when activating a non-planned season', () => {
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => active.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => completed.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.activate()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('throws when completing a non-active season', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => planned.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => completed.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.complete()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('throws when archiving a non-completed season', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => planned.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => active.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.archive()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('allows cancelling planned or active seasons and rejects completed/archived', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
|
||||
const cancelledFromPlanned = planned.cancel();
|
||||
expect(cancelledFromPlanned.status).toBe('cancelled');
|
||||
expect(cancelledFromPlanned.startDate).toBe(planned.startDate);
|
||||
expect(cancelledFromPlanned.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const cancelledFromActive = active.cancel();
|
||||
expect(cancelledFromActive.status).toBe('cancelled');
|
||||
expect(cancelledFromActive.startDate).toBe(active.startDate);
|
||||
expect(cancelledFromActive.endDate).toBeInstanceOf(Date);
|
||||
|
||||
expect(() => completed.cancel()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.cancel()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('cancel is idempotent for already cancelled seasons', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const cancelled = planned.cancel();
|
||||
|
||||
const cancelledAgain = cancelled.cancel();
|
||||
expect(cancelledAgain).toBe(cancelled);
|
||||
});
|
||||
|
||||
it('canWithdrawFromWallet only when completed', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(planned.canWithdrawFromWallet()).toBe(false);
|
||||
expect(active.canWithdrawFromWallet()).toBe(false);
|
||||
expect(completed.canWithdrawFromWallet()).toBe(true);
|
||||
expect(archived.canWithdrawFromWallet()).toBe(false);
|
||||
expect(cancelled.canWithdrawFromWallet()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Season configuration updates', () => {
|
||||
|
||||
it('withScoringConfig returns a new Season with updated scoringConfig only', () => {
|
||||
const season = createBaseSeason();
|
||||
const scoringConfig = new SeasonScoringConfig({
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
const updated = season.withScoringConfig(scoringConfig);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.scoringConfig).toBe(scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.dropPolicy).toBe(season.dropPolicy);
|
||||
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withDropPolicy returns a new Season with updated dropPolicy only', () => {
|
||||
const season = createBaseSeason();
|
||||
const dropPolicy = new SeasonDropPolicy({
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
});
|
||||
|
||||
const updated = season.withDropPolicy(dropPolicy);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.dropPolicy).toBe(dropPolicy);
|
||||
expect(updated.scoringConfig).toBe(season.scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withStewardingConfig returns a new Season with updated stewardingConfig only', () => {
|
||||
const season = createBaseSeason();
|
||||
const stewardingConfig = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const updated = season.withStewardingConfig(stewardingConfig);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.stewardingConfig).toBe(stewardingConfig);
|
||||
expect(updated.scoringConfig).toBe(season.scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.dropPolicy).toBe(season.dropPolicy);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withMaxDrivers updates maxDrivers when positive', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
const updated = season.withMaxDrivers(30);
|
||||
|
||||
expect(updated.maxDrivers).toBe(30);
|
||||
expect(updated.id).toBe(season.id);
|
||||
expect(updated.leagueId).toBe(season.leagueId);
|
||||
expect(updated.gameId).toBe(season.gameId);
|
||||
});
|
||||
|
||||
it('withMaxDrivers allows undefined to clear value', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
const updated = season.withMaxDrivers(undefined);
|
||||
|
||||
expect(updated.maxDrivers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('withMaxDrivers rejects non-positive values', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
expect(() => season.withMaxDrivers(0)).toThrow(
|
||||
RacingDomainValidationError,
|
||||
);
|
||||
expect(() => season.withMaxDrivers(-5)).toThrow(
|
||||
RacingDomainValidationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonScoringConfig', () => {
|
||||
it('constructs from preset id and customScoringEnabled', () => {
|
||||
const config = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
expect(config.scoringPresetId).toBe('club-default');
|
||||
expect(config.customScoringEnabled).toBe(true);
|
||||
expect(config.props.scoringPresetId).toBe('club-default');
|
||||
expect(config.props.customScoringEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes customScoringEnabled to false when omitted', () => {
|
||||
const config = new SeasonScoringConfig({
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
});
|
||||
|
||||
expect(config.customScoringEnabled).toBe(false);
|
||||
expect(config.props.customScoringEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when scoringPresetId is empty', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonScoringConfig({
|
||||
scoringPresetId: ' ',
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('equals compares by preset id and customScoringEnabled', () => {
|
||||
const a = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: false,
|
||||
});
|
||||
const b = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: false,
|
||||
});
|
||||
const c = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonDropPolicy', () => {
|
||||
it('allows strategy "none" with undefined n', () => {
|
||||
const policy = new SeasonDropPolicy({ strategy: 'none' });
|
||||
|
||||
expect(policy.strategy).toBe('none');
|
||||
expect(policy.n).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when strategy "none" has n defined', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy: 'none',
|
||||
n: 1,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('requires positive integer n for "bestNResults" and "dropWorstN"', () => {
|
||||
const strategies: SeasonDropStrategy[] = ['bestNResults', 'dropWorstN'];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy,
|
||||
n: 0,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy,
|
||||
n: -1,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
}
|
||||
|
||||
const okBest = new SeasonDropPolicy({
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
});
|
||||
const okDrop = new SeasonDropPolicy({
|
||||
strategy: 'dropWorstN',
|
||||
n: 2,
|
||||
});
|
||||
|
||||
expect(okBest.n).toBe(3);
|
||||
expect(okDrop.n).toBe(2);
|
||||
});
|
||||
|
||||
it('equals compares strategy and n', () => {
|
||||
const a = new SeasonDropPolicy({ strategy: 'none' });
|
||||
const b = new SeasonDropPolicy({ strategy: 'none' });
|
||||
const c = new SeasonDropPolicy({ strategy: 'bestNResults', n: 3 });
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonStewardingConfig', () => {
|
||||
it('creates a valid config with voting mode and requiredVotes', () => {
|
||||
const config = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
expect(config.decisionMode).toBe('steward_vote');
|
||||
expect(config.requiredVotes).toBe(3);
|
||||
expect(config.requireDefense).toBe(true);
|
||||
expect(config.defenseTimeLimit).toBe(24);
|
||||
expect(config.voteTimeLimit).toBe(24);
|
||||
expect(config.protestDeadlineHours).toBe(48);
|
||||
expect(config.stewardingClosesHours).toBe(72);
|
||||
expect(config.notifyAccusedOnProtest).toBe(true);
|
||||
expect(config.notifyOnVoteRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when decisionMode is missing', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
// @ts-expect-error intentional invalid
|
||||
decisionMode: undefined,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('requires requiredVotes for voting/veto modes', () => {
|
||||
const votingModes = [
|
||||
'steward_vote',
|
||||
'member_vote',
|
||||
'steward_veto',
|
||||
'member_veto',
|
||||
] as const;
|
||||
|
||||
for (const mode of votingModes) {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: mode,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates numeric limits as non-negative / positive integers', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: -1,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 0,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 0,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 0,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('equals compares all props', () => {
|
||||
const a = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const b = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const c = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: false,
|
||||
defenseTimeLimit: 0,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 24,
|
||||
stewardingClosesHours: 48,
|
||||
notifyAccusedOnProtest: false,
|
||||
notifyOnVoteRequired: false,
|
||||
});
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
88
core/racing/domain/services/DropScoreApplier.test.ts
Normal file
88
core/racing/domain/services/DropScoreApplier.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { DropScoreApplier } from '@core/racing/domain/services/DropScoreApplier';
|
||||
import type { EventPointsEntry } from '@core/racing/domain/services/DropScoreApplier';
|
||||
import type { DropScorePolicy } from '@core/racing/domain/types/DropScorePolicy';
|
||||
|
||||
describe('DropScoreApplier', () => {
|
||||
it('with strategy none counts all events and drops none', () => {
|
||||
const applier = new DropScoreApplier();
|
||||
|
||||
const policy: DropScorePolicy = {
|
||||
strategy: 'none',
|
||||
};
|
||||
|
||||
const events: EventPointsEntry[] = [
|
||||
{ eventId: 'event-1', points: 25 },
|
||||
{ eventId: 'event-2', points: 18 },
|
||||
{ eventId: 'event-3', points: 15 },
|
||||
];
|
||||
|
||||
const result = applier.apply(policy, events);
|
||||
|
||||
expect(result.counted).toHaveLength(3);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
expect(result.totalPoints).toBe(25 + 18 + 15);
|
||||
});
|
||||
|
||||
it('with bestNResults keeps the highest scoring events and drops the rest', () => {
|
||||
const applier = new DropScoreApplier();
|
||||
|
||||
const policy: DropScorePolicy = {
|
||||
strategy: 'bestNResults',
|
||||
count: 6,
|
||||
};
|
||||
|
||||
const events: EventPointsEntry[] = [
|
||||
{ eventId: 'event-1', points: 25 },
|
||||
{ eventId: 'event-2', points: 18 },
|
||||
{ eventId: 'event-3', points: 15 },
|
||||
{ eventId: 'event-4', points: 12 },
|
||||
{ eventId: 'event-5', points: 10 },
|
||||
{ eventId: 'event-6', points: 8 },
|
||||
{ eventId: 'event-7', points: 6 },
|
||||
{ eventId: 'event-8', points: 4 },
|
||||
];
|
||||
|
||||
const result = applier.apply(policy, events);
|
||||
|
||||
expect(result.counted).toHaveLength(6);
|
||||
expect(result.dropped).toHaveLength(2);
|
||||
|
||||
const countedIds = result.counted.map((e) => e.eventId);
|
||||
expect(countedIds).toEqual([
|
||||
'event-1',
|
||||
'event-2',
|
||||
'event-3',
|
||||
'event-4',
|
||||
'event-5',
|
||||
'event-6',
|
||||
]);
|
||||
|
||||
const droppedIds = result.dropped.map((e) => e.eventId);
|
||||
expect(droppedIds).toEqual(['event-7', 'event-8']);
|
||||
|
||||
expect(result.totalPoints).toBe(25 + 18 + 15 + 12 + 10 + 8);
|
||||
});
|
||||
|
||||
it('bestNResults with count greater than available events counts all of them', () => {
|
||||
const applier = new DropScoreApplier();
|
||||
|
||||
const policy: DropScorePolicy = {
|
||||
strategy: 'bestNResults',
|
||||
count: 10,
|
||||
};
|
||||
|
||||
const events: EventPointsEntry[] = [
|
||||
{ eventId: 'event-1', points: 25 },
|
||||
{ eventId: 'event-2', points: 18 },
|
||||
{ eventId: 'event-3', points: 15 },
|
||||
];
|
||||
|
||||
const result = applier.apply(policy, events);
|
||||
|
||||
expect(result.counted).toHaveLength(3);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
expect(result.totalPoints).toBe(25 + 18 + 15);
|
||||
});
|
||||
});
|
||||
211
core/racing/domain/services/EventScoringService.test.ts
Normal file
211
core/racing/domain/services/EventScoringService.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { EventScoringService } from '@core/racing/domain/services/EventScoringService';
|
||||
import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef';
|
||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||
import type { BonusRule } from '@core/racing/domain/types/BonusRule';
|
||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||
import { Result } from '@core/racing/domain/entities/Result';
|
||||
import type { Penalty } from '@core/racing/domain/entities/Penalty';
|
||||
import type { ChampionshipType } from '@core/racing/domain/types/ChampionshipType';
|
||||
import { makeDriverRef } from '../../testing/factories/racing/DriverRefFactory';
|
||||
import { makePointsTable } from '../../testing/factories/racing/PointsTableFactory';
|
||||
import { makeChampionshipConfig } from '../../testing/factories/racing/ChampionshipConfigFactory';
|
||||
|
||||
|
||||
describe('EventScoringService', () => {
|
||||
const seasonId = 'season-1';
|
||||
|
||||
it('assigns base points based on finishing positions for a main race', () => {
|
||||
const service = new EventScoringService();
|
||||
|
||||
const championship = makeChampionshipConfig({
|
||||
id: 'champ-1',
|
||||
name: 'Driver Championship',
|
||||
sessionTypes: ['main'],
|
||||
mainPoints: [25, 18, 15, 12, 10],
|
||||
});
|
||||
|
||||
const results: Result[] = [
|
||||
Result.create({
|
||||
id: 'result-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
fastestLap: 90000,
|
||||
incidents: 0,
|
||||
startPosition: 1,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-2',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
fastestLap: 90500,
|
||||
incidents: 0,
|
||||
startPosition: 2,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-3',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-3',
|
||||
position: 3,
|
||||
fastestLap: 91000,
|
||||
incidents: 0,
|
||||
startPosition: 3,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-4',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-4',
|
||||
position: 4,
|
||||
fastestLap: 91500,
|
||||
incidents: 0,
|
||||
startPosition: 4,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-5',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-5',
|
||||
position: 5,
|
||||
fastestLap: 92000,
|
||||
incidents: 0,
|
||||
startPosition: 5,
|
||||
}),
|
||||
];
|
||||
|
||||
const penalties: Penalty[] = [];
|
||||
|
||||
const points = service.scoreSession({
|
||||
seasonId,
|
||||
championship,
|
||||
sessionType: 'main',
|
||||
results,
|
||||
penalties,
|
||||
});
|
||||
|
||||
const byParticipant = new Map(points.map((p) => [p.participant.id, p]));
|
||||
|
||||
expect(byParticipant.get('driver-1')?.basePoints).toBe(25);
|
||||
expect(byParticipant.get('driver-2')?.basePoints).toBe(18);
|
||||
expect(byParticipant.get('driver-3')?.basePoints).toBe(15);
|
||||
expect(byParticipant.get('driver-4')?.basePoints).toBe(12);
|
||||
expect(byParticipant.get('driver-5')?.basePoints).toBe(10);
|
||||
|
||||
for (const entry of byParticipant.values()) {
|
||||
expect(entry.bonusPoints).toBe(0);
|
||||
expect(entry.penaltyPoints).toBe(0);
|
||||
expect(entry.totalPoints).toBe(entry.basePoints);
|
||||
}
|
||||
});
|
||||
|
||||
it('applies fastest lap bonus only when inside top 10', () => {
|
||||
const service = new EventScoringService();
|
||||
|
||||
const fastestLapBonus: BonusRule = {
|
||||
id: 'bonus-fastest-lap',
|
||||
type: 'fastestLap',
|
||||
points: 1,
|
||||
requiresFinishInTopN: 10,
|
||||
};
|
||||
|
||||
const championship = makeChampionshipConfig({
|
||||
id: 'champ-1',
|
||||
name: 'Driver Championship',
|
||||
sessionTypes: ['main'],
|
||||
mainPoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
|
||||
mainBonusRules: [fastestLapBonus],
|
||||
});
|
||||
|
||||
const baseResultTemplate = {
|
||||
raceId: 'race-1',
|
||||
incidents: 0,
|
||||
} as const;
|
||||
|
||||
const resultsP11Fastest: Result[] = [
|
||||
Result.create({
|
||||
id: 'result-1',
|
||||
...baseResultTemplate,
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
startPosition: 1,
|
||||
fastestLap: 91000,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-2',
|
||||
...baseResultTemplate,
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
startPosition: 2,
|
||||
fastestLap: 90500,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-3',
|
||||
...baseResultTemplate,
|
||||
driverId: 'driver-3',
|
||||
position: 11,
|
||||
startPosition: 15,
|
||||
fastestLap: 90000,
|
||||
}),
|
||||
];
|
||||
|
||||
const penalties: Penalty[] = [];
|
||||
|
||||
const pointsNoBonus = service.scoreSession({
|
||||
seasonId,
|
||||
championship,
|
||||
sessionType: 'main',
|
||||
results: resultsP11Fastest,
|
||||
penalties,
|
||||
});
|
||||
|
||||
const mapNoBonus = new Map(pointsNoBonus.map((p) => [p.participant.id, p]));
|
||||
|
||||
expect(mapNoBonus.get('driver-3')?.bonusPoints).toBe(0);
|
||||
|
||||
const resultsP8Fastest: Result[] = [
|
||||
Result.create({
|
||||
id: 'result-1',
|
||||
...baseResultTemplate,
|
||||
driverId: 'driver-1',
|
||||
position: 1,
|
||||
startPosition: 1,
|
||||
fastestLap: 91000,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-2',
|
||||
...baseResultTemplate,
|
||||
driverId: 'driver-2',
|
||||
position: 2,
|
||||
startPosition: 2,
|
||||
fastestLap: 90500,
|
||||
}),
|
||||
Result.create({
|
||||
id: 'result-3',
|
||||
...baseResultTemplate,
|
||||
driverId: 'driver-3',
|
||||
position: 8,
|
||||
startPosition: 15,
|
||||
fastestLap: 90000,
|
||||
}),
|
||||
];
|
||||
|
||||
const pointsWithBonus = service.scoreSession({
|
||||
seasonId,
|
||||
championship,
|
||||
sessionType: 'main',
|
||||
results: resultsP8Fastest,
|
||||
penalties,
|
||||
});
|
||||
|
||||
const mapWithBonus = new Map(pointsWithBonus.map((p) => [p.participant.id, p]));
|
||||
|
||||
expect(mapWithBonus.get('driver-3')?.bonusPoints).toBe(1);
|
||||
expect(mapWithBonus.get('driver-3')?.totalPoints).toBe(
|
||||
(mapWithBonus.get('driver-3')?.basePoints || 0) +
|
||||
(mapWithBonus.get('driver-3')?.bonusPoints || 0) -
|
||||
(mapWithBonus.get('driver-3')?.penaltyPoints || 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
278
core/racing/domain/services/ScheduleCalculator.test.ts
Normal file
278
core/racing/domain/services/ScheduleCalculator.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from '@core/racing/domain/services/ScheduleCalculator';
|
||||
import type { Weekday } from '@core/racing/domain/types/Weekday';
|
||||
|
||||
describe('ScheduleCalculator', () => {
|
||||
describe('calculateRaceDates', () => {
|
||||
describe('with empty or invalid input', () => {
|
||||
it('should return empty array when weekdays is empty', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: [],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is 0', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 0,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is negative', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: -5,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly scheduling', () => {
|
||||
it('should schedule 8 races on Saturdays starting from a Saturday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// All dates should be Saturdays
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(6); // Saturday
|
||||
});
|
||||
// First race should be Jan 6
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Last race should be 7 weeks later (Feb 24)
|
||||
expect(result.raceDates[7]!.toISOString().split('T')[0]).toBe('2024-02-24');
|
||||
});
|
||||
|
||||
it('should schedule races on multiple weekdays', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Wed', 'Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'), // Monday
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// Should alternate between Wednesday and Saturday
|
||||
result.raceDates.forEach(date => {
|
||||
const day = date.getDay();
|
||||
expect([3, 6]).toContain(day); // Wed=3, Sat=6
|
||||
});
|
||||
});
|
||||
|
||||
it('should schedule 8 races on Sundays', () => {
|
||||
// Given - January 7, 2024 is a Sunday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sun'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(0); // Sunday
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bi-weekly scheduling', () => {
|
||||
it('should schedule races every 2 weeks on Saturdays', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'everyNWeeks',
|
||||
rounds: 4,
|
||||
startDate: new Date('2024-01-06'),
|
||||
intervalWeeks: 2,
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(4);
|
||||
// First race Jan 6
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Second race 2 weeks later (Jan 20)
|
||||
expect(result.raceDates[1]!.toISOString().split('T')[0]).toBe('2024-01-20');
|
||||
// Third race 2 weeks later (Feb 3)
|
||||
expect(result.raceDates[2]!.toISOString().split('T')[0]).toBe('2024-02-03');
|
||||
// Fourth race 2 weeks later (Feb 17)
|
||||
expect(result.raceDates[3]!.toISOString().split('T')[0]).toBe('2024-02-17');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with start and end dates', () => {
|
||||
it('should evenly distribute races across the date range', () => {
|
||||
// Given - 3 month season
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-03-30'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// First race should be at or near start
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Races should be spread across the range, not consecutive weeks
|
||||
});
|
||||
|
||||
it('should use all available days if fewer than rounds requested', () => {
|
||||
// Given - short period with only 3 Saturdays
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 10,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-01-21'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// Only 3 Saturdays in this range: Jan 6, 13, 20
|
||||
expect(result.raceDates.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('season duration calculation', () => {
|
||||
it('should calculate correct season duration in weeks', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// 8 races, 1 week apart = 7 weeks duration
|
||||
expect(result.seasonDurationWeeks).toBe(7);
|
||||
});
|
||||
|
||||
it('should return 0 duration for single race', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 1,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(1);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextWeekday', () => {
|
||||
it('should return next Saturday from a Monday', () => {
|
||||
// Given - January 1, 2024 is a Monday
|
||||
const fromDate = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
expect(result.getDay()).toBe(6);
|
||||
});
|
||||
|
||||
it('should return next occurrence when already on that weekday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const fromDate = new Date('2024-01-06');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
// Should return NEXT Saturday (7 days later), not same day
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-13');
|
||||
});
|
||||
|
||||
it('should return next Sunday from a Friday', () => {
|
||||
// Given - January 5, 2024 is a Friday
|
||||
const fromDate = new Date('2024-01-05');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sun');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-07');
|
||||
expect(result.getDay()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return next Wednesday from a Thursday', () => {
|
||||
// Given - January 4, 2024 is a Thursday
|
||||
const fromDate = new Date('2024-01-04');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Wed');
|
||||
|
||||
// Then
|
||||
// Next Wednesday is 6 days later
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-10');
|
||||
expect(result.getDay()).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@core/*": ["./*"],
|
||||
@@ -16,5 +17,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user