521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|