brackt/server/__tests__/timer-autodraft.test.ts
chrisp f96c8f5244
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m40s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m26s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Fix draft timer: broadcasts, increments, reconnect sync, overnight pause (#72)
## Summary

- **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started`
- **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display
- **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick
- **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire
- **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero
- **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event
- **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload
- **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt`
- **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure
- **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early
- **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction
- **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join

## Test plan

- [ ] Manual pick: all clients see bank increment immediately after pick
- [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s
- [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log
- [ ] Force-manual-pick: all clients see bank increment
- [ ] Pause while clock running: countdown freezes on all clients
- [ ] Resume: clock continues from frozen value
- [ ] adjust-time-bank on on-clock team: countdown shifts immediately
- [ ] adjust-time-bank to zero: returns 400 error
- [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team
- [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately
- [ ] Draft complete: "Room closes in X" counts down
- [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen
- [ ] `npm run test:run` — all 158 files / 2351 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #72
2026-06-06 05:57:46 +00:00

342 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock dependencies before imports
vi.mock('drizzle-orm/postgres-js', () => ({
drizzle: vi.fn(() => mockDb),
}));
vi.mock('postgres', () => ({
default: vi.fn(() => ({})),
}));
vi.mock('../socket', () => ({
getSocketIO: vi.fn(() => mockSocketIO),
}));
let mockDb: any;
let mockSocketIO: any;
// Setup mocks
beforeEach(() => {
mockSocketIO = {
to: vi.fn().mockReturnThis(),
emit: vi.fn(),
};
mockDb = {
query: {
seasons: { findMany: vi.fn(), findFirst: vi.fn() },
draftSlots: { findMany: vi.fn() },
draftTimers: { findFirst: vi.fn() },
autodraftSettings: { findFirst: vi.fn() },
draftPicks: { findMany: vi.fn(), findFirst: vi.fn() },
draftQueue: { findMany: vi.fn() },
seasonParticipants: { findMany: vi.fn() },
seasonTemplateSports: { findMany: vi.fn() },
},
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
};
});
describe('Timer Autodraft Integration', () => {
describe('Autodraft Settings Check', () => {
it('should check autodraft settings when timer expires', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
mockDb.query.seasons.findMany.mockResolvedValue([{
id: seasonId, status: 'draft', draftPaused: false,
currentPickNumber: 1, draftInitialTime: 120, draftIncrementTime: 30, draftRounds: 10,
}]);
mockDb.query.draftSlots.findMany.mockResolvedValue([
{ id: 'slot-1', teamId, draftOrder: 1 },
{ id: 'slot-2', teamId: 'team-2', draftOrder: 2 },
]);
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: 'timer-1', seasonId, teamId, timeRemaining: 0 });
mockDb.query.autodraftSettings.findFirst.mockResolvedValue({
id: 'settings-1', seasonId, teamId, isEnabled: true, mode: 'next_pick', queueOnly: false,
});
expect(mockDb.query.autodraftSettings.findFirst).toBeDefined();
});
it('should use regular auto-pick when autodraft is disabled', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
mockDb.query.autodraftSettings.findFirst.mockResolvedValue({
id: 'settings-1', seasonId, teamId, isEnabled: false, mode: 'next_pick', queueOnly: false,
});
const settings = await mockDb.query.autodraftSettings.findFirst();
expect(settings.isEnabled).toBe(false);
});
it('should handle missing autodraft settings', async () => {
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
const settings = await mockDb.query.autodraftSettings.findFirst();
expect(settings).toBeNull();
});
});
describe('Autodraft Mode Handling', () => {
it('should disable autodraft after pick when mode is next_pick', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
const autodraftSettings = { id: 'settings-1', seasonId, teamId, isEnabled: true, mode: 'next_pick', queueOnly: false };
mockDb.returning.mockResolvedValue([{ ...autodraftSettings, isEnabled: false, updatedAt: new Date() }]);
if (autodraftSettings.isEnabled && autodraftSettings.mode === 'next_pick') {
await mockDb.update();
await mockDb.set({ isEnabled: false, updatedAt: new Date() });
await mockDb.where();
const result = await mockDb.returning();
expect(result[0].isEnabled).toBe(false);
}
});
it('should keep autodraft enabled when mode is while_on (All Picks)', async () => {
const autodraftSettings = { isEnabled: true, mode: 'while_on', queueOnly: false };
if (autodraftSettings.mode === 'while_on') {
expect(autodraftSettings.isEnabled).toBe(true);
}
});
});
describe('Autodraft Pick Logic', () => {
it('should pick from queue first when autodraft is enabled', async () => {
mockDb.query.draftQueue.findMany.mockResolvedValue([
{ id: 'queue-1', participantId: 'participant-1', queuePosition: 1 },
{ id: 'queue-2', participantId: 'participant-2', queuePosition: 2 },
]);
mockDb.query.draftPicks.findMany.mockResolvedValue([]);
const queueItems = await mockDb.query.draftQueue.findMany();
const draftedPicks = await mockDb.query.draftPicks.findMany();
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
const availableQueueItem = queueItems.find((item: any) => !draftedParticipantIds.includes(item.participantId));
expect(availableQueueItem).toBeDefined();
expect(availableQueueItem.participantId).toBe('participant-1');
});
it('should skip drafted participants in queue and try next', async () => {
mockDb.query.draftQueue.findMany.mockResolvedValue([
{ id: 'queue-1', participantId: 'participant-1', queuePosition: 1 },
{ id: 'queue-2', participantId: 'participant-2', queuePosition: 2 },
]);
mockDb.query.draftPicks.findMany.mockResolvedValue([{ id: 'pick-1', participantId: 'participant-1' }]);
const queueItems = await mockDb.query.draftQueue.findMany();
const draftedPicks = await mockDb.query.draftPicks.findMany();
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
const availableQueueItem = queueItems.find((item: any) => !draftedParticipantIds.includes(item.participantId));
expect(availableQueueItem.participantId).toBe('participant-2');
});
it('should fall back to highest EV when queue is empty and queueOnly is OFF', async () => {
mockDb.query.draftQueue.findMany.mockResolvedValue([]);
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: 'participant-high-ev', name: 'High EV Participant', expectedValue: 1000 },
]);
const queueItems = await mockDb.query.draftQueue.findMany();
expect(queueItems.length).toBe(0);
const queueOnly = false;
const availableParticipants = await mockDb.query.seasonParticipants.findMany();
const selectedParticipant = queueOnly ? null : availableParticipants[0];
expect(selectedParticipant).not.toBeNull();
expect(selectedParticipant?.expectedValue).toBe(1000);
});
});
describe('Queue-Only Constraint (AC2 & AC3)', () => {
it('should NOT fall back to highest EV when queueOnly is ON and queue is empty', async () => {
mockDb.query.draftQueue.findMany.mockResolvedValue([]);
const queueItems = await mockDb.query.draftQueue.findMany();
expect(queueItems.length).toBe(0);
const queueOnly = true;
const selectedParticipant = queueOnly ? null : { id: 'participant-high-ev' };
expect(selectedParticipant).toBeNull();
});
it('should disable autodraft when queueOnly is ON and queue becomes empty (AC3)', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
const autodraftSettings = { id: 'settings-1', seasonId, teamId, isEnabled: true, mode: 'next_pick', queueOnly: true };
mockDb.returning.mockResolvedValue([{ ...autodraftSettings, isEnabled: false, updatedAt: new Date() }]);
const participantId = null; // empty queue + queueOnly
if (!participantId && autodraftSettings.queueOnly) {
await mockDb.update();
await mockDb.set({ isEnabled: false, updatedAt: new Date() });
await mockDb.where();
const result = await mockDb.returning();
expect(result[0].isEnabled).toBe(false);
mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
teamId, isEnabled: false, mode: autodraftSettings.mode, queueOnly: autodraftSettings.queueOnly,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', {
teamId, isEnabled: false, mode: 'next_pick', queueOnly: true,
});
}
});
it('should skip drafted queue items and continue to next available item', async () => {
const queue = [
{ id: 'queue-1', participantId: 'participant-1', queuePosition: 1 },
{ id: 'queue-2', participantId: 'participant-2', queuePosition: 2 },
];
const drafted = new Set(['participant-1']);
const availableQueueItem = queue.find((item) => !drafted.has(item.participantId));
expect(availableQueueItem?.participantId).toBe('participant-2');
});
it('should auto-disable when all queue items are drafted and queueOnly is ON', async () => {
const queue = [
{ id: 'queue-1', participantId: 'participant-1', queuePosition: 1 },
{ id: 'queue-2', participantId: 'participant-2', queuePosition: 2 },
];
const drafted = new Set(['participant-1', 'participant-2']);
const queueOnly = true;
const availableQueueItem = queue.find((item) => !drafted.has(item.participantId));
expect(availableQueueItem).toBeUndefined();
const selectedParticipant = queueOnly ? null : { id: 'fallback-ev' };
expect(selectedParticipant).toBeNull();
});
});
describe('Three-State Autodraft (AC1)', () => {
it('should map Off state to isEnabled=false', () => {
const offState = { isEnabled: false, mode: 'next_pick', queueOnly: false };
expect(offState.isEnabled).toBe(false);
});
it('should map Next Pick state to isEnabled=true, mode=next_pick', () => {
const nextPickState = { isEnabled: true, mode: 'next_pick', queueOnly: false };
expect(nextPickState.isEnabled).toBe(true);
expect(nextPickState.mode).toBe('next_pick');
});
it('should map All Picks state to isEnabled=true, mode=while_on', () => {
const allPicksState = { isEnabled: true, mode: 'while_on', queueOnly: false };
expect(allPicksState.isEnabled).toBe(true);
expect(allPicksState.mode).toBe('while_on');
});
it('should transition Off → Next Pick correctly', () => {
const initial = { isEnabled: false, mode: 'next_pick' };
const updated = { isEnabled: true, mode: 'next_pick' };
expect(updated.isEnabled).not.toBe(initial.isEnabled);
expect(updated.mode).toBe('next_pick');
});
it('should transition Next Pick → All Picks correctly', () => {
const nextPick = { isEnabled: true, mode: 'next_pick' };
const allPicks = { ...nextPick, mode: 'while_on' };
expect(allPicks.mode).toBe('while_on');
expect(allPicks.isEnabled).toBe(true);
});
it('should auto-disable if queue is empty and queueOnly is ON (AC3)', () => {
const settings = { isEnabled: true, mode: 'next_pick', queueOnly: true };
const queue: any[] = [];
const shouldAutoDisable = settings.queueOnly && queue.length === 0;
const finalSettings = shouldAutoDisable ? { ...settings, isEnabled: false } : settings;
expect(finalSettings.isEnabled).toBe(false);
});
it('should NOT auto-disable if queue is empty and queueOnly is OFF', () => {
const settings = { isEnabled: true, mode: 'next_pick', queueOnly: false };
const queue: any[] = [];
const shouldAutoDisable = settings.queueOnly && queue.length === 0;
const finalSettings = shouldAutoDisable ? { ...settings, isEnabled: false } : settings;
expect(finalSettings.isEnabled).toBe(true);
});
});
describe('Socket Event Emission', () => {
it('should emit autodraft-updated with queueOnly when disabling next_pick mode', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
teamId, isEnabled: false, mode: 'next_pick', queueOnly: false,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', {
teamId, isEnabled: false, mode: 'next_pick', queueOnly: false,
});
});
it('should emit pick-made event after autodraft pick', async () => {
const seasonId = 'season-123';
const pickData = { pick: { id: 'pick-1', participantId: 'participant-1', pickNumber: 1 }, nextPickNumber: 2, isDraftComplete: false };
mockSocketIO.to(`draft-${seasonId}`).emit('pick-made', pickData);
expect(mockSocketIO.emit).toHaveBeenCalledWith('pick-made', pickData);
});
it('should emit autodraft-updated with queueOnly preserved on auto-disable (AC3)', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
teamId, isEnabled: false, mode: 'next_pick', queueOnly: true,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', {
teamId, isEnabled: false, mode: 'next_pick', queueOnly: true,
});
});
});
describe('Back-to-Back Picks', () => {
it('should only autodraft first pick when mode is next_pick', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
const autodraftSettings = { id: 'settings-1', seasonId, teamId, isEnabled: true, mode: 'next_pick', queueOnly: false };
if (autodraftSettings.isEnabled && autodraftSettings.mode === 'next_pick') {
const updatedSettings = { ...autodraftSettings, isEnabled: false };
expect(updatedSettings.isEnabled).toBe(false);
}
const settingsForSecondPick = { ...autodraftSettings, isEnabled: false };
expect(settingsForSecondPick.isEnabled).toBe(false);
});
it('should continue autodrafting consecutive picks when mode is while_on (All Picks)', async () => {
const autodraftSettings = { isEnabled: true, mode: 'while_on', queueOnly: false };
if (autodraftSettings.mode === 'while_on') expect(autodraftSettings.isEnabled).toBe(true);
expect(autodraftSettings.isEnabled).toBe(true);
});
});
describe('Time Increment', () => {
it('should add increment time after autodraft pick', () => {
expect(0 + 30).toBe(30);
});
it('should emit timer-pick-started after scheduling next pick', async () => {
const seasonId = 'season-123';
const teamId = 'team-456';
mockSocketIO.to(`draft-${seasonId}`).emit('timer-pick-started', {
seasonId, teamId, pickNumber: 1, expiresAt: Date.now() + 30000, timeRemaining: 30,
});
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-pick-started',
expect.objectContaining({ seasonId, teamId, timeRemaining: 30 })
);
});
});
});