import { OpenAdminVoteSessionUseCase } from './OpenAdminVoteSessionUseCase'; import { CastAdminVoteUseCase } from './CastAdminVoteUseCase'; import { CloseAdminVoteSessionUseCase } from './CloseAdminVoteSessionUseCase'; import { AdminVoteSession } from '../../domain/entities/AdminVoteSession'; import { RatingEvent } from '../../domain/entities/RatingEvent'; import { UserRating } from '../../domain/value-objects/UserRating'; import { RatingEventId } from '../../domain/value-objects/RatingEventId'; import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey'; import { RatingDelta } from '../../domain/value-objects/RatingDelta'; import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator'; // Mock Repository class MockAdminVoteSessionRepository { private sessions: Map = new Map(); async save(session: AdminVoteSession): Promise { this.sessions.set(session.id, session); return session; } async findById(id: string): Promise { return this.sessions.get(id) || null; } async findActiveForAdmin(adminId: string, leagueId: string): Promise { const now = new Date(); return Array.from(this.sessions.values()).filter( s => s.adminId === adminId && s.leagueId === leagueId && s.isVotingWindowOpen(now) && !s.closed ); } async findByAdminAndLeague(adminId: string, leagueId: string): Promise { return Array.from(this.sessions.values()).filter( s => s.adminId === adminId && s.leagueId === leagueId ); } async findByLeague(leagueId: string): Promise { return Array.from(this.sessions.values()).filter( s => s.leagueId === leagueId ); } async findClosedUnprocessed(): Promise { return Array.from(this.sessions.values()).filter( s => s.closed && s.outcome !== undefined ); } } class MockRatingEventRepository { private events: Map = new Map(); async save(event: RatingEvent): Promise { this.events.set(event.id.value, event); return event; } async findByUserId(userId: string): Promise { return Array.from(this.events.values()).filter(e => e.userId === userId); } async findByIds(ids: string[]): Promise { return Array.from(this.events.values()).filter(e => ids.includes(e.id.value)); } async getAllByUserId(userId: string): Promise { return Array.from(this.events.values()).filter(e => e.userId === userId); } async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise> { const allEvents = await this.findByUserId(userId); // Apply filters let filtered = allEvents; if (options?.filter) { const filter = options.filter; if (filter.dimensions) { filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value)); } if (filter.sourceTypes) { filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type)); } if (filter.from) { filtered = filtered.filter(e => e.occurredAt >= filter.from!); } if (filter.to) { filtered = filtered.filter(e => e.occurredAt <= filter.to!); } if (filter.reasonCodes) { filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code)); } if (filter.visibility) { filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public')); } } const total = filtered.length; const limit = options?.limit ?? 10; const offset = options?.offset ?? 0; const items = filtered.slice(offset, offset + limit); const hasMore = offset + limit < total; const nextOffset = hasMore ? offset + limit : undefined; const result: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult = { items, total, limit, offset, hasMore }; if (nextOffset !== undefined) { result.nextOffset = nextOffset; } return result; } } class MockUserRatingRepository { private ratings: Map = new Map(); async save(rating: UserRating): Promise { this.ratings.set(rating.userId, rating); return rating; } async findByUserId(userId: string): Promise { return this.ratings.get(userId) || null; } } // Mock AppendRatingEventsUseCase class MockAppendRatingEventsUseCase { constructor( private ratingEventRepository: any, private userRatingRepository: any ) {} async execute(input: any): Promise { const events: RatingEvent[] = []; // Create events from input for (const eventDto of input.events || []) { const event = RatingEvent.create({ id: RatingEventId.generate(), userId: eventDto.userId, dimension: RatingDimensionKey.create(eventDto.dimension), delta: RatingDelta.create(eventDto.delta), weight: eventDto.weight, occurredAt: new Date(eventDto.occurredAt), createdAt: new Date(), source: { type: eventDto.sourceType, id: eventDto.sourceId }, reason: { code: eventDto.reasonCode, summary: eventDto.reasonSummary, details: eventDto.reasonDetails || {}, }, visibility: { public: true, redactedFields: [] }, version: 1, }); events.push(event); await this.ratingEventRepository.save(event); } // Recompute snapshot if (events.length > 0) { const allEvents = await this.ratingEventRepository.getAllByUserId(input.userId); // Use RatingSnapshotCalculator to create proper snapshot const snapshot = RatingSnapshotCalculator.calculate(input.userId, allEvents); await this.userRatingRepository.save(snapshot); } return { events: events.map(e => e.id.value), snapshotUpdated: events.length > 0, }; } } describe('Admin Vote Session Use Cases', () => { let mockSessionRepo: MockAdminVoteSessionRepository; let mockEventRepo: MockRatingEventRepository; let mockRatingRepo: MockUserRatingRepository; let mockAppendUseCase: MockAppendRatingEventsUseCase; let openUseCase: OpenAdminVoteSessionUseCase; let castUseCase: CastAdminVoteUseCase; let closeUseCase: CloseAdminVoteSessionUseCase; // Use dates relative to current time so close() works const now = new Date(Date.now() - 86400000); // Yesterday const tomorrow = new Date(Date.now() + 86400000); // Tomorrow const dayAfter = new Date(Date.now() + 86400000 * 2); // Day after tomorrow beforeEach(() => { mockSessionRepo = new MockAdminVoteSessionRepository(); mockEventRepo = new MockRatingEventRepository(); mockRatingRepo = new MockUserRatingRepository(); mockAppendUseCase = new MockAppendRatingEventsUseCase(mockEventRepo, mockRatingRepo); openUseCase = new OpenAdminVoteSessionUseCase(mockSessionRepo); castUseCase = new CastAdminVoteUseCase(mockSessionRepo); closeUseCase = new CloseAdminVoteSessionUseCase( mockSessionRepo, mockEventRepo, mockRatingRepo, mockAppendUseCase ); }); describe('OpenAdminVoteSessionUseCase', () => { it('should successfully open a vote session', async () => { const input = { voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now.toISOString(), endDate: tomorrow.toISOString(), eligibleVoters: ['user-1', 'user-2', 'user-3'], }; const result = await openUseCase.execute(input); expect(result.success).toBe(true); expect(result.voteSessionId).toBe('vote-123'); // Verify session was saved const saved = await mockSessionRepo.findById('vote-123'); expect(saved).toBeDefined(); expect(saved!.adminId).toBe('admin-789'); expect(saved!.eligibleVoters).toEqual(['user-1', 'user-2', 'user-3']); }); it('should reject duplicate session IDs', async () => { const input = { voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now.toISOString(), endDate: tomorrow.toISOString(), eligibleVoters: ['user-1'], }; await openUseCase.execute(input); const result = await openUseCase.execute(input); expect(result.success).toBe(false); expect(result.errors).toContain('Vote session with this ID already exists'); }); it('should reject overlapping sessions', async () => { // Create first session await openUseCase.execute({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now.toISOString(), endDate: tomorrow.toISOString(), eligibleVoters: ['user-1'], }); // Try to create overlapping session (middle of first session) const overlapStart = new Date(now.getTime() + 12 * 3600000); // 12 hours after start const overlapEnd = new Date(tomorrow.getTime() + 12 * 3600000); // 12 hours after end const result = await openUseCase.execute({ voteSessionId: 'vote-456', leagueId: 'league-456', adminId: 'admin-789', startDate: overlapStart.toISOString(), endDate: overlapEnd.toISOString(), eligibleVoters: ['user-1'], }); expect(result.success).toBe(false); expect(result.errors).toContain('Active vote session already exists for this admin in this league with overlapping dates'); }); it('should validate required fields', async () => { const result = await openUseCase.execute({ voteSessionId: '', leagueId: '', adminId: '', startDate: '', endDate: '', eligibleVoters: [], }); expect(result.success).toBe(false); expect(result.errors?.length).toBeGreaterThan(0); }); it('should validate date order', async () => { const result = await openUseCase.execute({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: tomorrow.toISOString(), endDate: now.toISOString(), eligibleVoters: ['user-1'], }); expect(result.success).toBe(false); expect(result.errors).toContain('startDate must be before endDate'); }); it('should validate duplicate eligible voters', async () => { const result = await openUseCase.execute({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now.toISOString(), endDate: tomorrow.toISOString(), eligibleVoters: ['user-1', 'user-2', 'user-1'], }); expect(result.success).toBe(false); expect(result.errors).toContain('Duplicate eligible voters are not allowed'); }); }); describe('CastAdminVoteUseCase', () => { beforeEach(async () => { // Create a session for voting tests await openUseCase.execute({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now.toISOString(), endDate: tomorrow.toISOString(), eligibleVoters: ['user-1', 'user-2', 'user-3'], }); }); it('should allow eligible voter to cast positive vote', async () => { const result = await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: true, }); expect(result.success).toBe(true); expect(result.voterId).toBe('user-1'); const session = await mockSessionRepo.findById('vote-123'); expect(session!.votes.length).toBe(1); expect(session!.votes[0].voterId).toBe('user-1'); expect(session!.votes[0].positive).toBe(true); }); it('should allow eligible voter to cast negative vote', async () => { const result = await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: false, }); expect(result.success).toBe(true); const session = await mockSessionRepo.findById('vote-123'); expect(session!.votes[0].positive).toBe(false); }); it('should reject non-eligible voter', async () => { const result = await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-999', positive: true, }); expect(result.success).toBe(false); expect(result.errors).toContain('Failed to cast vote: Voter user-999 is not eligible for this session'); }); it('should prevent duplicate votes', async () => { await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: true, }); const result = await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: false, }); expect(result.success).toBe(false); expect(result.errors).toContain('Failed to cast vote: Voter user-1 has already voted'); }); it('should reject votes after session closes', async () => { // Close the session first await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); const result = await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-2', positive: true, }); expect(result.success).toBe(false); expect(result.errors).toContain('Vote session is not open for voting'); }); it('should reject votes outside voting window', async () => { // Create session in future (outside current window) const futureStart = new Date(Date.now() + 86400000 * 10); // 10 days from now const futureEnd = new Date(Date.now() + 86400000 * 11); // 11 days from now await openUseCase.execute({ voteSessionId: 'vote-future', leagueId: 'league-456', adminId: 'admin-789', startDate: futureStart.toISOString(), endDate: futureEnd.toISOString(), eligibleVoters: ['user-1'], }); const result = await castUseCase.execute({ voteSessionId: 'vote-future', voterId: 'user-1', positive: true, }); expect(result.success).toBe(false); expect(result.errors).toContain('Vote session is not open for voting'); }); }); describe('CloseAdminVoteSessionUseCase', () => { beforeEach(async () => { // Create a session for closing tests await openUseCase.execute({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now.toISOString(), endDate: tomorrow.toISOString(), eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4'], }); }); it('should close session and create positive outcome events', async () => { // Cast votes await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: true, }); await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-2', positive: true, }); await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-3', positive: false, }); const result = await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); expect(result.success).toBe(true); expect(result.outcome).toBeDefined(); expect(result.outcome!.outcome).toBe('positive'); expect(result.outcome!.percentPositive).toBe(66.67); expect(result.outcome!.count.total).toBe(3); expect(result.eventsCreated).toBe(1); // Verify session is closed const session = await mockSessionRepo.findById('vote-123'); expect(session!.closed).toBe(true); expect(session!.outcome).toBeDefined(); // Verify rating events were created const events = await mockEventRepo.findByUserId('admin-789'); expect(events.length).toBe(1); expect(events[0].dimension.value).toBe('adminTrust'); expect(events[0].reason.code).toBe('ADMIN_VOTE_OUTCOME_POSITIVE'); }); it('should create negative outcome events', async () => { // Cast mostly negative votes await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: false, }); await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-2', positive: false, }); await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-3', positive: true, }); const result = await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); expect(result.success).toBe(true); expect(result.outcome!.outcome).toBe('negative'); expect(result.outcome!.percentPositive).toBe(33.33); expect(result.eventsCreated).toBe(1); const events = await mockEventRepo.findByUserId('admin-789'); expect(events[0].reason.code).toBe('ADMIN_VOTE_OUTCOME_NEGATIVE'); expect(events[0].delta.value).toBeLessThan(0); }); it('should handle tie outcome', async () => { await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: true, }); await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-2', positive: false, }); const result = await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); expect(result.success).toBe(true); expect(result.outcome!.outcome).toBe('tie'); expect(result.eventsCreated).toBe(0); // No events for tie }); it('should handle no votes', async () => { const result = await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); expect(result.success).toBe(true); expect(result.outcome!.outcome).toBe('tie'); expect(result.outcome!.participationRate).toBe(0); expect(result.eventsCreated).toBe(0); }); it('should reject closing already closed session', async () => { await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); const result = await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); expect(result.success).toBe(false); expect(result.errors).toContain('Vote session is already closed'); }); it('should reject non-owner trying to close', async () => { const result = await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'wrong-admin', }); expect(result.success).toBe(false); expect(result.errors).toContain('Admin does not own this vote session'); }); it('should reject closing outside voting window', async () => { // Create session in future (outside current window) const futureStart = new Date(Date.now() + 86400000 * 10); // 10 days from now const futureEnd = new Date(Date.now() + 86400000 * 11); // 11 days from now await openUseCase.execute({ voteSessionId: 'vote-future', leagueId: 'league-456', adminId: 'admin-789', startDate: futureStart.toISOString(), endDate: futureEnd.toISOString(), eligibleVoters: ['user-1'], }); const result = await closeUseCase.execute({ voteSessionId: 'vote-future', adminId: 'admin-789', }); expect(result.success).toBe(false); expect(result.errors).toContain('Cannot close session outside the voting window'); }); it('should update admin rating snapshot', async () => { // Cast votes await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-1', positive: true, }); await castUseCase.execute({ voteSessionId: 'vote-123', voterId: 'user-2', positive: true, }); await closeUseCase.execute({ voteSessionId: 'vote-123', adminId: 'admin-789', }); // Check snapshot was updated const snapshot = await mockRatingRepo.findByUserId('admin-789'); expect(snapshot).toBeDefined(); expect(snapshot!.admin.value).toBeGreaterThan(50); // Should have increased }); }); describe('Integration: Full vote flow', () => { it('should complete full flow: open -> cast votes -> close -> events', async () => { // 1. Open session const openResult = await openUseCase.execute({ voteSessionId: 'vote-full', leagueId: 'league-full', adminId: 'admin-full', startDate: now.toISOString(), endDate: tomorrow.toISOString(), eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4', 'user-5'], }); expect(openResult.success).toBe(true); // 2. Cast votes const votes = [ { voterId: 'user-1', positive: true }, { voterId: 'user-2', positive: true }, { voterId: 'user-3', positive: true }, { voterId: 'user-4', positive: false }, ]; for (const vote of votes) { const castResult = await castUseCase.execute({ voteSessionId: 'vote-full', voterId: vote.voterId, positive: vote.positive, }); expect(castResult.success).toBe(true); } // 3. Close session const closeResult = await closeUseCase.execute({ voteSessionId: 'vote-full', adminId: 'admin-full', }); expect(closeResult.success).toBe(true); expect(closeResult.outcome).toEqual({ percentPositive: 75, count: { positive: 3, negative: 1, total: 4 }, eligibleVoterCount: 5, participationRate: 80, outcome: 'positive', }); expect(closeResult.eventsCreated).toBe(1); // 4. Verify events in ledger const events = await mockEventRepo.findByUserId('admin-full'); expect(events.length).toBe(1); const event = events[0]; expect(event.userId).toBe('admin-full'); expect(event.dimension.value).toBe('adminTrust'); expect(event.source.type).toBe('vote'); expect(event.source.id).toBe('vote-full'); expect(event.reason.code).toBe('ADMIN_VOTE_OUTCOME_POSITIVE'); expect(event.reason.summary).toContain('75% positive'); expect(event.weight).toBe(4); // vote count // 5. Verify snapshot const snapshot = await mockRatingRepo.findByUserId('admin-full'); expect(snapshot).toBeDefined(); expect(snapshot!.admin.value).toBeGreaterThan(50); }); }); });