view models

This commit is contained in:
2025-12-18 01:20:23 +01:00
parent 7c449af311
commit cc2553876a
216 changed files with 485 additions and 10179 deletions

View File

@@ -1,11 +1,11 @@
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { presentMembershipFee } from '../../presenters/MembershipFeePresenter';
import type { MembershipFeeViewModel } from '../../view-models';
import { MembershipFeeViewModel } from '../../view-models';
import type { MembershipFeeDto } from '../../types/generated';
/**
* Membership Fee Service
*
* Orchestrates membership fee operations by coordinating API calls and presentation logic.
* Orchestrates membership fee operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class MembershipFeeService {
@@ -14,14 +14,10 @@ export class MembershipFeeService {
) {}
/**
* Get membership fees by league ID with presentation transformation
* Get membership fees by league ID with view model transformation
*/
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
try {
const dto = await this.apiClient.getMembershipFees(leagueId);
return dto.fees.map(presentMembershipFee);
} catch (error) {
throw error;
}
const dto = await this.apiClient.getMembershipFees(leagueId);
return dto.fees.map((fee: MembershipFeeDto) => new MembershipFeeViewModel(fee));
}
}

View File

@@ -1,506 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PaymentService } from './PaymentService';
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentListPresenter } from '../../presenters/PaymentListPresenter';
import type {
GetPaymentsOutputDto,
CreatePaymentInputDto,
CreatePaymentOutputDto,
GetMembershipFeesOutputDto,
GetPrizesOutputDto,
GetWalletOutputDto,
PaymentDto,
MembershipFeeDto,
PrizeDto,
WalletDto,
} from '../../dtos';
import type {
PaymentViewModel,
MembershipFeeViewModel,
PrizeViewModel,
WalletViewModel,
} from '../../view-models';
describe('PaymentService', () => {
let service: PaymentService;
let mockApiClient: PaymentsApiClient;
let mockPaymentListPresenter: PaymentListPresenter;
let mockPresentPayment: (dto: any) => PaymentViewModel;
let mockPresentMembershipFee: (dto: any) => MembershipFeeViewModel;
let mockPresentPrize: (dto: any) => PrizeViewModel;
let mockPresentWallet: (dto: any) => WalletViewModel;
beforeEach(() => {
mockApiClient = {
getPayments: vi.fn(),
createPayment: vi.fn(),
getMembershipFees: vi.fn(),
getPrizes: vi.fn(),
getWallet: vi.fn(),
} as unknown as PaymentsApiClient;
mockPaymentListPresenter = {
present: vi.fn(),
} as unknown as PaymentListPresenter;
mockPresentPayment = vi.fn();
mockPresentMembershipFee = vi.fn();
mockPresentPrize = vi.fn();
mockPresentWallet = vi.fn();
service = new PaymentService(
mockApiClient,
mockPaymentListPresenter,
mockPresentPayment,
mockPresentMembershipFee,
mockPresentPrize,
mockPresentWallet
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(PaymentService);
});
});
describe('getPayments', () => {
it('should fetch all payments and transform via presenter', async () => {
// Arrange
const mockDto: GetPaymentsOutputDto = {
payments: [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
},
{
id: 'payment-2',
amount: 200,
currency: 'EUR',
status: 'pending',
createdAt: '2024-01-02',
},
],
};
const mockViewModels: PaymentViewModel[] = [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
} as PaymentViewModel,
{
id: 'payment-2',
amount: 200,
currency: 'EUR',
status: 'pending',
createdAt: '2024-01-02',
} as PaymentViewModel,
];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getPayments();
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(undefined, undefined);
expect(mockPaymentListPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModels);
});
it('should filter payments by leagueId', async () => {
// Arrange
const leagueId = 'league-123';
const mockDto: GetPaymentsOutputDto = { payments: [] };
const mockViewModels: PaymentViewModel[] = [];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
await service.getPayments(leagueId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(leagueId, undefined);
});
it('should filter payments by driverId', async () => {
// Arrange
const driverId = 'driver-456';
const mockDto: GetPaymentsOutputDto = { payments: [] };
const mockViewModels: PaymentViewModel[] = [];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
await service.getPayments(undefined, driverId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(undefined, driverId);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch payments');
vi.mocked(mockApiClient.getPayments).mockRejectedValue(error);
// Act & Assert
await expect(service.getPayments()).rejects.toThrow('Failed to fetch payments');
expect(mockApiClient.getPayments).toHaveBeenCalled();
expect(mockPaymentListPresenter.present).not.toHaveBeenCalled();
});
});
describe('getPayment', () => {
it('should fetch single payment by ID', async () => {
// Arrange
const paymentId = 'payment-123';
const mockDto: GetPaymentsOutputDto = {
payments: [
{
id: paymentId,
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
},
],
};
const mockViewModel: PaymentViewModel = {
id: paymentId,
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
} as PaymentViewModel;
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPresentPayment).mockReturnValue(mockViewModel);
// Act
const result = await service.getPayment(paymentId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalled();
expect(mockPresentPayment).toHaveBeenCalledWith(mockDto.payments[0]);
expect(result).toEqual(mockViewModel);
});
it('should throw error when payment not found', async () => {
// Arrange
const paymentId = 'non-existent';
const mockDto: GetPaymentsOutputDto = { payments: [] };
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
// Act & Assert
await expect(service.getPayment(paymentId)).rejects.toThrow(
`Payment with ID ${paymentId} not found`
);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('API error');
vi.mocked(mockApiClient.getPayments).mockRejectedValue(error);
// Act & Assert
await expect(service.getPayment('payment-123')).rejects.toThrow('API error');
});
});
describe('createPayment', () => {
it('should create a new payment', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 150,
currency: 'USD',
leagueId: 'league-123',
driverId: 'driver-456',
};
const mockOutput: CreatePaymentOutputDto = {
paymentId: 'payment-new',
success: true,
};
vi.mocked(mockApiClient.createPayment).mockResolvedValue(mockOutput);
// Act
const result = await service.createPayment(input);
// Assert
expect(mockApiClient.createPayment).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 150,
currency: 'USD',
};
const error = new Error('Payment creation failed');
vi.mocked(mockApiClient.createPayment).mockRejectedValue(error);
// Act & Assert
await expect(service.createPayment(input)).rejects.toThrow('Payment creation failed');
});
});
describe('getMembershipFees', () => {
it('should fetch membership fees for a league', async () => {
// Arrange
const leagueId = 'league-123';
const mockDto: GetMembershipFeesOutputDto = {
fees: [
{
leagueId,
amount: 50,
currency: 'USD',
period: 'monthly',
},
],
memberPayments: [],
};
const mockViewModel: MembershipFeeViewModel = {
leagueId,
amount: 50,
currency: 'USD',
period: 'monthly',
} as MembershipFeeViewModel;
vi.mocked(mockApiClient.getMembershipFees).mockResolvedValue(mockDto);
vi.mocked(mockPresentMembershipFee).mockReturnValue(mockViewModel);
// Act
const result = await service.getMembershipFees(leagueId);
// Assert
expect(mockApiClient.getMembershipFees).toHaveBeenCalledWith(leagueId);
expect(mockPresentMembershipFee).toHaveBeenCalledWith(mockDto.fees[0]);
expect(result).toEqual([mockViewModel]);
});
it('should handle empty fees list', async () => {
// Arrange
const leagueId = 'league-123';
const mockDto: GetMembershipFeesOutputDto = {
fees: [],
memberPayments: [],
};
vi.mocked(mockApiClient.getMembershipFees).mockResolvedValue(mockDto);
// Act
const result = await service.getMembershipFees(leagueId);
// Assert
expect(result).toEqual([]);
expect(mockPresentMembershipFee).not.toHaveBeenCalled();
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch fees');
vi.mocked(mockApiClient.getMembershipFees).mockRejectedValue(error);
// Act & Assert
await expect(service.getMembershipFees('league-123')).rejects.toThrow('Failed to fetch fees');
});
});
describe('getPrizes', () => {
it('should fetch all prizes', async () => {
// Arrange
const mockDto: GetPrizesOutputDto = {
prizes: [
{
id: 'prize-1',
name: 'First Place',
amount: 1000,
currency: 'USD',
position: 1,
},
],
};
const mockViewModel: PrizeViewModel = {
id: 'prize-1',
name: 'First Place',
amount: 1000,
currency: 'USD',
position: 1,
} as PrizeViewModel;
vi.mocked(mockApiClient.getPrizes).mockResolvedValue(mockDto);
vi.mocked(mockPresentPrize).mockReturnValue(mockViewModel);
// Act
const result = await service.getPrizes();
// Assert
expect(mockApiClient.getPrizes).toHaveBeenCalledWith(undefined, undefined);
expect(mockPresentPrize).toHaveBeenCalledWith(mockDto.prizes[0]);
expect(result).toEqual([mockViewModel]);
});
it('should filter prizes by leagueId and seasonId', async () => {
// Arrange
const leagueId = 'league-123';
const seasonId = 'season-456';
const mockDto: GetPrizesOutputDto = { prizes: [] };
vi.mocked(mockApiClient.getPrizes).mockResolvedValue(mockDto);
// Act
await service.getPrizes(leagueId, seasonId);
// Assert
expect(mockApiClient.getPrizes).toHaveBeenCalledWith(leagueId, seasonId);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch prizes');
vi.mocked(mockApiClient.getPrizes).mockRejectedValue(error);
// Act & Assert
await expect(service.getPrizes()).rejects.toThrow('Failed to fetch prizes');
});
});
describe('getWallet', () => {
it('should fetch wallet for a driver', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetWalletOutputDto = {
driverId,
balance: 500,
currency: 'USD',
transactions: [],
};
const mockViewModel: WalletViewModel = {
driverId,
balance: 500,
currency: 'USD',
transactions: [],
} as WalletViewModel;
vi.mocked(mockApiClient.getWallet).mockResolvedValue(mockDto);
vi.mocked(mockPresentWallet).mockReturnValue(mockViewModel);
// Act
const result = await service.getWallet(driverId);
// Assert
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(mockPresentWallet).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch wallet');
vi.mocked(mockApiClient.getWallet).mockRejectedValue(error);
// Act & Assert
await expect(service.getWallet('driver-123')).rejects.toThrow('Failed to fetch wallet');
});
});
describe('processPayment', () => {
it('should process payment using createPayment', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 100,
currency: 'USD',
};
const mockOutput: CreatePaymentOutputDto = {
paymentId: 'payment-123',
success: true,
};
vi.mocked(mockApiClient.createPayment).mockResolvedValue(mockOutput);
// Act
const result = await service.processPayment(input);
// Assert
expect(mockApiClient.createPayment).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 100,
currency: 'USD',
};
const error = new Error('Processing failed');
vi.mocked(mockApiClient.createPayment).mockRejectedValue(error);
// Act & Assert
await expect(service.processPayment(input)).rejects.toThrow('Processing failed');
});
});
describe('getPaymentHistory', () => {
it('should fetch payment history for a driver', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetPaymentsOutputDto = {
payments: [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
},
],
};
const mockViewModels: PaymentViewModel[] = [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
} as PaymentViewModel,
];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getPaymentHistory(driverId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(undefined, driverId);
expect(result).toEqual(mockViewModels);
});
it('should propagate errors', async () => {
// Arrange
const error = new Error('History fetch failed');
vi.mocked(mockApiClient.getPayments).mockRejectedValue(error);
// Act & Assert
await expect(service.getPaymentHistory('driver-123')).rejects.toThrow('History fetch failed');
});
});
});

View File

@@ -1,10 +1,10 @@
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentListPresenter } from '../../presenters/PaymentListPresenter';
import type {
CreatePaymentInputDto,
CreatePaymentOutputDto,
} from '../../dtos';
import type {
import type { PaymentDto, MembershipFeeDto, PrizeDto } from '../../types/generated';
// TODO: Move these types to apps/website/lib/types/generated when available
type CreatePaymentInputDto = { amount: number; leagueId: string; driverId: string; description: string };
type CreatePaymentOutputDto = { id: string; success: boolean };
import {
PaymentViewModel,
MembershipFeeViewModel,
PrizeViewModel,
@@ -14,114 +14,77 @@ import type {
/**
* Payment Service
*
* Orchestrates payment operations by coordinating API calls and presentation logic.
* Orchestrates payment operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class PaymentService {
constructor(
private readonly apiClient: PaymentsApiClient,
private readonly paymentListPresenter: PaymentListPresenter,
private readonly presentPayment: (dto: any) => PaymentViewModel,
private readonly presentMembershipFee: (dto: any) => MembershipFeeViewModel,
private readonly presentPrize: (dto: any) => PrizeViewModel,
private readonly presentWallet: (dto: any) => WalletViewModel
private readonly apiClient: PaymentsApiClient
) {}
/**
* Get all payments with optional filters
*/
async getPayments(leagueId?: string, driverId?: string): Promise<PaymentViewModel[]> {
try {
const dto = await this.apiClient.getPayments(leagueId, driverId);
return this.paymentListPresenter.present(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.getPayments(leagueId, driverId);
return dto.payments.map((payment: PaymentDto) => new PaymentViewModel(payment));
}
/**
* Get single payment by ID
*/
async getPayment(paymentId: string): Promise<PaymentViewModel> {
try {
// Note: Assuming the API returns a single payment from the list
const dto = await this.apiClient.getPayments();
const payment = dto.payments.find(p => p.id === paymentId);
if (!payment) {
throw new Error(`Payment with ID ${paymentId} not found`);
}
return this.presentPayment(payment);
} catch (error) {
throw error;
// Note: Assuming the API returns a single payment from the list
const dto = await this.apiClient.getPayments();
const payment = dto.payments.find((p: PaymentDto) => p.id === paymentId);
if (!payment) {
throw new Error(`Payment with ID ${paymentId} not found`);
}
return new PaymentViewModel(payment);
}
/**
* Create a new payment
*/
async createPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
try {
return await this.apiClient.createPayment(input);
} catch (error) {
throw error;
}
return await this.apiClient.createPayment(input);
}
/**
* Get membership fees for a league
*/
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
try {
const dto = await this.apiClient.getMembershipFees(leagueId);
return dto.fees.map(fee => this.presentMembershipFee(fee));
} catch (error) {
throw error;
}
const dto = await this.apiClient.getMembershipFees(leagueId);
return dto.fees.map((fee: MembershipFeeDto) => new MembershipFeeViewModel(fee));
}
/**
* Get prizes with optional filters
*/
async getPrizes(leagueId?: string, seasonId?: string): Promise<PrizeViewModel[]> {
try {
const dto = await this.apiClient.getPrizes(leagueId, seasonId);
return dto.prizes.map(prize => this.presentPrize(prize));
} catch (error) {
throw error;
}
const dto = await this.apiClient.getPrizes(leagueId, seasonId);
return dto.prizes.map((prize: PrizeDto) => new PrizeViewModel(prize));
}
/**
* Get wallet for a driver
*/
async getWallet(driverId: string): Promise<WalletViewModel> {
try {
const dto = await this.apiClient.getWallet(driverId);
return this.presentWallet(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.getWallet(driverId);
return new WalletViewModel(dto);
}
/**
* Process a payment (alias for createPayment)
*/
async processPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
try {
return await this.createPayment(input);
} catch (error) {
throw error;
}
return await this.createPayment(input);
}
/**
* Get payment history for a user (driver)
*/
async getPaymentHistory(driverId: string): Promise<PaymentViewModel[]> {
try {
return await this.getPayments(undefined, driverId);
} catch (error) {
throw error;
}
return await this.getPayments(undefined, driverId);
}
}

View File

@@ -1,131 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WalletService } from './WalletService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { GetWalletOutputDto } from '../../dtos';
import { presentWallet } from '../../presenters/WalletPresenter';
// Mock the presenter
vi.mock('../../presenters/WalletPresenter', () => ({
presentWallet: vi.fn(),
}));
describe('WalletService', () => {
let mockApiClient: PaymentsApiClient;
let service: WalletService;
beforeEach(() => {
mockApiClient = {
getWallet: vi.fn(),
getMembershipFees: vi.fn(),
getPayments: vi.fn(),
createPayment: vi.fn(),
getPrizes: vi.fn(),
} as unknown as PaymentsApiClient;
service = new WalletService(mockApiClient);
});
describe('getWallet', () => {
it('should get wallet via API client and present it', async () => {
// Arrange
const driverId = 'driver-1';
const dto: GetWalletOutputDto = {
balance: 1000,
currency: 'USD',
transactions: [],
};
const expectedViewModel = {
balance: 1000,
currency: 'USD',
transactions: [],
};
vi.mocked(mockApiClient.getWallet).mockResolvedValue(dto);
vi.mocked(presentWallet).mockReturnValue(expectedViewModel);
// Act
const result = await service.getWallet(driverId);
// Assert
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(mockApiClient.getWallet).toHaveBeenCalledTimes(1);
expect(presentWallet).toHaveBeenCalledWith(dto);
expect(result).toBe(expectedViewModel);
});
it('should propagate API client errors', async () => {
// Arrange
const driverId = 'driver-1';
const error = new Error('API Error: Failed to get wallet');
vi.mocked(mockApiClient.getWallet).mockRejectedValue(error);
// Act & Assert
await expect(service.getWallet(driverId)).rejects.toThrow(
'API Error: Failed to get wallet'
);
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(mockApiClient.getWallet).toHaveBeenCalledTimes(1);
});
it('should handle different driver IDs', async () => {
// Arrange
const driverId = 'driver-2';
const dto: GetWalletOutputDto = {
balance: 500,
currency: 'EUR',
transactions: [],
};
const expectedViewModel = {
balance: 500,
currency: 'EUR',
transactions: [],
};
vi.mocked(mockApiClient.getWallet).mockResolvedValue(dto);
vi.mocked(presentWallet).mockReturnValue(expectedViewModel);
// Act
const result = await service.getWallet(driverId);
// Assert
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(result).toBe(expectedViewModel);
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient', () => {
// This test verifies the constructor signature
expect(() => {
new WalletService(mockApiClient);
}).not.toThrow();
});
it('should use injected apiClient', async () => {
// Arrange
const customApiClient = {
getWallet: vi.fn().mockResolvedValue({ balance: 200, currency: 'USD', transactions: [] }),
getMembershipFees: vi.fn(),
getPayments: vi.fn(),
createPayment: vi.fn(),
getPrizes: vi.fn(),
} as unknown as PaymentsApiClient;
const customService = new WalletService(customApiClient);
vi.mocked(presentWallet).mockReturnValue({ balance: 200, currency: 'USD', transactions: [] });
// Act
await customService.getWallet('driver-1');
// Assert
expect(customApiClient.getWallet).toHaveBeenCalledWith('driver-1');
});
});
});

View File

@@ -1,11 +1,10 @@
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { presentWallet } from '../../presenters/WalletPresenter';
import type { WalletViewModel } from '../../view-models';
import { WalletViewModel } from '../../view-models';
/**
* Wallet Service
*
* Orchestrates wallet operations by coordinating API calls and presentation logic.
* Orchestrates wallet operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class WalletService {
@@ -14,14 +13,10 @@ export class WalletService {
) {}
/**
* Get wallet by driver ID with presentation transformation
* Get wallet by driver ID with view model transformation
*/
async getWallet(driverId: string): Promise<WalletViewModel> {
try {
const dto = await this.apiClient.getWallet(driverId);
return presentWallet(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.getWallet(driverId);
return new WalletViewModel(dto);
}
}