import { AdminVoteSession } from './AdminVoteSession'; import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError'; describe('AdminVoteSession', () => { const now = new Date('2025-01-01T00:00:00Z'); const tomorrow = new Date('2025-01-02T00:00:00Z'); const dayAfter = new Date('2025-01-03T00:00:00Z'); describe('create', () => { it('should create a valid vote session', () => { const session = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1', 'user-2', 'user-3'], }); expect(session.id).toBe('vote-123'); expect(session.leagueId).toBe('league-456'); expect(session.adminId).toBe('admin-789'); expect(session.startDate).toEqual(now); expect(session.endDate).toEqual(tomorrow); expect(session.eligibleVoters).toEqual(['user-1', 'user-2', 'user-3']); expect(session.votes).toEqual([]); expect(session.closed).toBe(false); expect(session.outcome).toBeUndefined(); }); it('should throw error for missing voteSessionId', () => { expect(() => AdminVoteSession.create({ voteSessionId: '', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1'], })).toThrow(IdentityDomainValidationError); }); it('should throw error for missing leagueId', () => { expect(() => AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: '', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1'], })).toThrow(IdentityDomainValidationError); }); it('should throw error for missing adminId', () => { expect(() => AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: '', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1'], })).toThrow(IdentityDomainValidationError); }); it('should throw error for invalid date range', () => { expect(() => AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: tomorrow, endDate: now, // End before start eligibleVoters: ['user-1'], })).toThrow(IdentityDomainInvariantError); }); it('should throw error for empty eligible voters', () => { expect(() => AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: [], })).toThrow(IdentityDomainValidationError); }); it('should throw error for duplicate eligible voters', () => { expect(() => AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1', 'user-2', 'user-1'], })).toThrow(IdentityDomainInvariantError); }); it('should accept optional votes and outcome', () => { const session = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1', 'user-2'], votes: [ { voterId: 'user-1', positive: true, votedAt: now }, ], closed: true, outcome: { percentPositive: 100, count: { positive: 1, negative: 0, total: 1 }, eligibleVoterCount: 2, participationRate: 50, outcome: 'positive', }, }); expect(session.votes.length).toBe(1); expect(session.closed).toBe(true); expect(session.outcome).toBeDefined(); }); }); describe('rehydrate', () => { it('should rehydrate from persisted data', () => { const session = AdminVoteSession.rehydrate({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1', 'user-2'], votes: [{ voterId: 'user-1', positive: true, votedAt: now }], closed: true, outcome: { percentPositive: 100, count: { positive: 1, negative: 0, total: 1 }, eligibleVoterCount: 2, participationRate: 50, outcome: 'positive', }, createdAt: now, updatedAt: tomorrow, }); expect(session.id).toBe('vote-123'); expect(session.closed).toBe(true); expect(session.votes.length).toBe(1); }); }); describe('castVote', () => { let session: AdminVoteSession; beforeEach(() => { session = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1', 'user-2', 'user-3'], }); }); it('should allow eligible voter to cast positive vote', () => { session.castVote('user-1', true, now); expect(session.votes.length).toBe(1); const vote = session.votes[0]; expect(vote).toBeDefined(); expect(vote!.voterId).toBe('user-1'); expect(vote!.positive).toBe(true); expect(vote!.votedAt).toEqual(now); }); it('should allow eligible voter to cast negative vote', () => { session.castVote('user-1', false, now); expect(session.votes.length).toBe(1); const vote = session.votes[0]; expect(vote).toBeDefined(); expect(vote!.positive).toBe(false); }); it('should throw error if voter is not eligible', () => { expect(() => session.castVote('user-999', true, now)) .toThrow(IdentityDomainInvariantError); }); it('should throw error if voter already voted', () => { session.castVote('user-1', true, now); expect(() => session.castVote('user-1', false, now)) .toThrow(IdentityDomainInvariantError); }); it('should throw error if session is closed', () => { // Close the session by first casting votes within the window, then closing // But we need to be within the voting window, so use the current date const currentSession = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: new Date(Date.now() - 86400000), // Yesterday endDate: new Date(Date.now() + 86400000), // Tomorrow eligibleVoters: ['user-1', 'user-2', 'user-3'], }); currentSession.close(); expect(() => currentSession.castVote('user-1', true, new Date())) .toThrow(IdentityDomainInvariantError); }); it('should throw error if vote is outside voting window', () => { const beforeStart = new Date('2024-12-31T23:59:59Z'); const afterEnd = new Date('2025-01-02T00:00:01Z'); expect(() => session.castVote('user-1', true, beforeStart)) .toThrow(IdentityDomainInvariantError); expect(() => session.castVote('user-1', true, afterEnd)) .toThrow(IdentityDomainInvariantError); }); it('should update updatedAt timestamp', () => { // Create a session with explicit timestamps const createdAt = new Date('2025-01-01T00:00:00Z'); const updatedAt = new Date('2025-01-01T00:00:00Z'); const sessionWithTimestamps = AdminVoteSession.rehydrate({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1', 'user-2', 'user-3'], votes: [], closed: false, createdAt: createdAt, updatedAt: updatedAt, }); const originalUpdatedAt = sessionWithTimestamps.updatedAt; // Wait a tiny bit to ensure different timestamp const voteTime = new Date(now.getTime() + 1000); sessionWithTimestamps.castVote('user-1', true, voteTime); // The updatedAt should be different (set to current time when vote is cast) expect(sessionWithTimestamps.updatedAt.getTime()).toBeGreaterThan(originalUpdatedAt.getTime()); }); }); describe('close', () => { let session: AdminVoteSession; beforeEach(() => { // Use current dates so close() works const startDate = new Date(Date.now() - 86400000); // Yesterday const endDate = new Date(Date.now() + 86400000); // Tomorrow session = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate, endDate, eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4'], }); }); it('should close session and calculate positive outcome', () => { const voteTime = new Date(); session.castVote('user-1', true, voteTime); session.castVote('user-2', true, voteTime); session.castVote('user-3', false, voteTime); const outcome = session.close(); expect(session.closed).toBe(true); expect(outcome).toEqual({ percentPositive: 66.67, count: { positive: 2, negative: 1, total: 3 }, eligibleVoterCount: 4, participationRate: 75, outcome: 'positive', }); }); it('should calculate negative outcome', () => { const voteTime = new Date(); session.castVote('user-1', false, voteTime); session.castVote('user-2', false, voteTime); session.castVote('user-3', true, voteTime); const outcome = session.close(); expect(outcome.outcome).toBe('negative'); expect(outcome.percentPositive).toBe(33.33); }); it('should calculate tie outcome', () => { const voteTime = new Date(); session.castVote('user-1', true, voteTime); session.castVote('user-2', false, voteTime); const outcome = session.close(); expect(outcome.outcome).toBe('tie'); expect(outcome.percentPositive).toBe(50); }); it('should handle no votes', () => { const outcome = session.close(); expect(outcome).toEqual({ percentPositive: 0, count: { positive: 0, negative: 0, total: 0 }, eligibleVoterCount: 4, participationRate: 0, outcome: 'tie', }); }); it('should throw error if already closed', () => { session.close(); expect(() => session.close()).toThrow(IdentityDomainInvariantError); }); it('should throw error if closed outside voting window', () => { const pastSession = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: new Date('2024-01-01'), endDate: new Date('2024-01-02'), eligibleVoters: ['user-1'], }); expect(() => pastSession.close()).toThrow(IdentityDomainInvariantError); }); it('should round percentPositive to 2 decimal places', () => { const voteTime = new Date(); session.castVote('user-1', true, voteTime); session.castVote('user-2', true, voteTime); session.castVote('user-3', true, voteTime); session.castVote('user-4', false, voteTime); const outcome = session.close(); expect(outcome.percentPositive).toBe(75.00); }); }); describe('helper methods', () => { let session: AdminVoteSession; beforeEach(() => { // Use current dates so close() works const startDate = new Date(Date.now() - 86400000); // Yesterday const endDate = new Date(Date.now() + 86400000); // Tomorrow session = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate, endDate, eligibleVoters: ['user-1', 'user-2'], }); }); describe('hasVoted', () => { it('should return true if voter has voted', () => { const voteTime = new Date(); session.castVote('user-1', true, voteTime); expect(session.hasVoted('user-1')).toBe(true); }); it('should return false if voter has not voted', () => { expect(session.hasVoted('user-1')).toBe(false); }); }); describe('getVote', () => { it('should return vote if exists', () => { const voteTime = new Date(); session.castVote('user-1', true, voteTime); const vote = session.getVote('user-1'); expect(vote).toBeDefined(); expect(vote?.voterId).toBe('user-1'); expect(vote?.positive).toBe(true); }); it('should return undefined if vote does not exist', () => { expect(session.getVote('user-1')).toBeUndefined(); }); }); describe('getVoteCount', () => { it('should return correct count', () => { expect(session.getVoteCount()).toBe(0); const voteTime = new Date(); session.castVote('user-1', true, voteTime); expect(session.getVoteCount()).toBe(1); session.castVote('user-2', false, voteTime); expect(session.getVoteCount()).toBe(2); }); }); describe('isVotingWindowOpen', () => { it('should return true during voting window', () => { const voteTime = new Date(); expect(session.isVotingWindowOpen(voteTime)).toBe(true); // Midpoint of the voting window const sessionStart = session.startDate.getTime(); const sessionEnd = session.endDate.getTime(); const midPoint = new Date((sessionStart + sessionEnd) / 2); expect(session.isVotingWindowOpen(midPoint)).toBe(true); }); it('should return false before voting window', () => { const before = new Date(Date.now() - 86400000 * 2); // 2 days ago expect(session.isVotingWindowOpen(before)).toBe(false); }); it('should return false after voting window', () => { const after = new Date(Date.now() + 86400000 * 2); // 2 days from now expect(session.isVotingWindowOpen(after)).toBe(false); }); it('should return false if session is closed', () => { session.close(); expect(session.isVotingWindowOpen(new Date())).toBe(false); }); }); }); describe('toJSON', () => { it('should serialize to JSON correctly', () => { // Use current dates so close() works const startDate = new Date(Date.now() - 86400000); // Yesterday const endDate = new Date(Date.now() + 86400000); // Tomorrow const session = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate, endDate, eligibleVoters: ['user-1', 'user-2'], }); const voteTime = new Date(); session.castVote('user-1', true, voteTime); session.close(); const json = session.toJSON(); expect(json.voteSessionId).toBe('vote-123'); expect(json.leagueId).toBe('league-456'); expect(json.adminId).toBe('admin-789'); expect(json.closed).toBe(true); expect(json.votes).toHaveLength(1); expect(json.outcome).toBeDefined(); }); }); describe('equals', () => { it('should return true for same ID', () => { const session1 = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1'], }); const session2 = AdminVoteSession.rehydrate({ voteSessionId: 'vote-123', leagueId: 'league-789', // Different adminId: 'admin-999', // Different startDate: tomorrow, // Different endDate: dayAfter, // Different eligibleVoters: ['user-999'], // Different }); expect(session1.equals(session2)).toBe(true); }); it('should return false for different IDs', () => { const session1 = AdminVoteSession.create({ voteSessionId: 'vote-123', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1'], }); const session2 = AdminVoteSession.create({ voteSessionId: 'vote-456', leagueId: 'league-456', adminId: 'admin-789', startDate: now, endDate: tomorrow, eligibleVoters: ['user-1'], }); expect(session1.equals(session2)).toBe(false); }); }); });