brackt/app/routes/leagues/__tests__/settings-update.test.ts
Chris Parsons d468385d90
Add auto-start draft at scheduled time (#437) (#438)
Commissioners can now opt in to having a draft start automatically at
its scheduled draftDateTime rather than requiring a manual "Start Draft"
click. The 1-second timer loop checks for eligible seasons each tick and
calls the new shared startDraft() service, which is also used by the
existing commissioner HTTP route.

- Add autoStartDraft boolean to seasons table (migration 0108)
- Extract core start logic into app/services/draft-autostart.ts so both
  the API route and the timer can call it without AsyncLocalStorage
- Add checkAndAutoStartDrafts() to the timer tick; disables autoStartDraft
  and stops retrying if no draft order is set at fire time
- Guard against null draftDateTime in both the timer query (isNotNull)
  and server actions (autoStartDraft forced false when datetime is null)
- Add "Auto-start at scheduled time" checkbox to league creation wizard
  (step 3) and league settings, gated on date+time being set
- Show countdown + "Start Now" button in draft room when auto-start is
  scheduled; reuses the existing nowForPauseCheck 1-second ticker
- Show orange warning banner on league homepage to commissioners when
  auto-start is within 1 hour and no draft order has been configured

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:37:29 -07:00

483 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { updateLeague } from '~/models/league';
import { findCurrentSeasonWithSports, updateSeason } from '~/models/season';
import { parseDraftSpeed } from '~/lib/draft-timer';
vi.mock('~/models/league', () => ({
findLeagueById: vi.fn(),
updateLeague: vi.fn(),
deleteLeague: vi.fn(),
}));
vi.mock('~/models/commissioner', () => ({
isCommissioner: vi.fn(),
findCommissionersByLeagueId: vi.fn(),
createCommissioner: vi.fn(),
countCommissionersByLeagueId: vi.fn(),
removeCommissionerByLeagueAndUser: vi.fn(),
hasCommissionerRecord: vi.fn(),
}));
vi.mock('~/models/season', () => ({
findCurrentSeasonWithSports: vi.fn(),
updateSeason: vi.fn(),
}));
vi.mock('~/models/team', () => ({
findTeamsBySeasonId: vi.fn(),
createManyTeams: vi.fn(),
deleteTeam: vi.fn(),
removeTeamOwner: vi.fn(),
claimTeam: vi.fn(),
findTeamById: vi.fn(),
renameTeam: vi.fn(),
}));
const mockLeague = {
id: 'league-1',
name: 'Test League',
createdBy: 'user-1',
currentSeasonId: 'season-1',
isPublicDraftBoard: false,
discordWebhookUrl: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
const mockSeason = {
id: 'season-1',
leagueId: 'league-1',
year: 2025,
status: 'pre_draft' as const,
templateId: null,
draftRounds: 20,
flexSpots: 0,
draftDateTime: null,
autoStartDraft: false,
draftInitialTime: 120,
draftIncrementTime: 15,
draftTimerMode: 'chess_clock' as const,
currentPickNumber: null,
draftStartedAt: null,
draftCompletedAt: null,
draftPaused: false,
overnightPauseMode: 'none' as const,
overnightPauseStart: null,
overnightPauseEnd: null,
overnightPauseTimezone: null,
inviteCode: 'TEST123',
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
seasonSports: [],
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
// Mirrors the route-level settings access policy: the settings page requires a
// commissioner or admin record — creator status alone is not sufficient.
function canAccessLeagueSettings(opts: {
isAuthenticated: boolean;
leagueExists: boolean;
isCommissionerOrAdmin: boolean;
}): { allowed: true } | { allowed: false; status: 401 | 403 | 404 } {
if (!opts.isAuthenticated) return { allowed: false, status: 401 };
if (!opts.leagueExists) return { allowed: false, status: 404 };
if (!opts.isCommissionerOrAdmin) return { allowed: false, status: 403 };
return { allowed: true };
}
function canUpdateScoring(status: string) { return status === 'pre_draft'; }
function isValidScoringPoints(points: number) { return points >= 0 && points <= 1000; }
function isValidTeamCount(count: number) { return count >= 6 && count <= 16; }
function validateOwnerAssignment(opts: {
assignedUserId: string;
leagueMemberIds: string[];
existingOwnerIds: string[];
}): { allowed: true } | { allowed: false; error: string } {
if (!opts.leagueMemberIds.includes(opts.assignedUserId)) {
return { allowed: false, error: 'Only current league members can be assigned to teams' };
}
if (opts.existingOwnerIds.includes(opts.assignedUserId)) {
return { allowed: false, error: 'This user is already assigned to a team in this league' };
}
return { allowed: true };
}
// ---------------------------------------------------------------------------
// Access policy
// ---------------------------------------------------------------------------
describe('Settings Access Policy', () => {
it('rejects unauthenticated users', () => {
expect(canAccessLeagueSettings({
isAuthenticated: false,
leagueExists: true,
isCommissionerOrAdmin: false,
})).toEqual({ allowed: false, status: 401 });
});
it('rejects non-commissioner league members and creators', () => {
expect(canAccessLeagueSettings({
isAuthenticated: true,
leagueExists: true,
isCommissionerOrAdmin: false,
})).toEqual({ allowed: false, status: 403 });
});
it('allows commissioners and admins via the shared commissioner check', () => {
expect(canAccessLeagueSettings({
isAuthenticated: true,
leagueExists: true,
isCommissionerOrAdmin: true,
})).toEqual({ allowed: true });
});
});
// ---------------------------------------------------------------------------
// Owner assignment scope
// ---------------------------------------------------------------------------
describe('Settings Update - Owner Assignment Scope', () => {
it('rejects assigning someone who is not already attached to the league', () => {
expect(validateOwnerAssignment({
assignedUserId: 'outside-user',
leagueMemberIds: ['commissioner-1', 'owner-1'],
existingOwnerIds: ['owner-1'],
})).toEqual({
allowed: false,
error: 'Only current league members can be assigned to teams',
});
});
it('allows league commissioners without a team to be assigned', () => {
expect(validateOwnerAssignment({
assignedUserId: 'commissioner-1',
leagueMemberIds: ['commissioner-1', 'owner-1'],
existingOwnerIds: ['owner-1'],
})).toEqual({ allowed: true });
});
});
// ---------------------------------------------------------------------------
// Name validation
// ---------------------------------------------------------------------------
describe('Settings Update - Name Validation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should reject an empty league name', () => {
const name = '';
const isInvalid = typeof name !== 'string' || !name.trim();
expect(isInvalid).toBe(true);
expect(updateLeague).not.toHaveBeenCalled();
});
it('should reject a whitespace-only league name', () => {
const name = ' ';
const isInvalid = typeof name !== 'string' || !name.trim();
expect(isInvalid).toBe(true);
});
it('should reject a name shorter than 3 characters', () => {
const name = 'AB';
const trimmed = name.trim();
expect(trimmed.length < 3 || trimmed.length > 50).toBe(true);
});
it('should reject a name longer than 50 characters', () => {
const name = 'A'.repeat(51);
const trimmed = name.trim();
expect(trimmed.length < 3 || trimmed.length > 50).toBe(true);
});
it('should accept a name that is exactly 3 characters', () => {
const name = 'ABC';
const trimmed = name.trim();
expect(trimmed.length >= 3 && trimmed.length <= 50).toBe(true);
});
it('should accept a name that is exactly 50 characters', () => {
const name = 'A'.repeat(50);
const trimmed = name.trim();
expect(trimmed.length >= 3 && trimmed.length <= 50).toBe(true);
});
it('should trim whitespace from the name before saving', async () => {
vi.mocked(updateLeague).mockResolvedValue({
...mockLeague,
name: 'Trimmed Name',
});
await updateLeague('league-1', { name: ' Trimmed Name '.trim(), isPublicDraftBoard: false });
expect(updateLeague).toHaveBeenCalledWith('league-1', {
name: 'Trimmed Name',
isPublicDraftBoard: false,
});
});
});
// ---------------------------------------------------------------------------
// League-level save (first update block)
// ---------------------------------------------------------------------------
describe('Settings Update - League Save', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should save name and isPublicDraftBoard=false when checkbox is off', async () => {
vi.mocked(updateLeague).mockResolvedValue({ ...mockLeague, name: 'New Name', isPublicDraftBoard: false });
await updateLeague('league-1', { name: 'New Name', isPublicDraftBoard: false });
expect(updateLeague).toHaveBeenCalledWith('league-1', {
name: 'New Name',
isPublicDraftBoard: false,
});
});
it('should save isPublicDraftBoard=true when checkbox is on', async () => {
vi.mocked(updateLeague).mockResolvedValue({ ...mockLeague, isPublicDraftBoard: true });
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: true });
expect(updateLeague).toHaveBeenCalledWith('league-1', {
name: 'Test League',
isPublicDraftBoard: true,
});
});
it('should return an error when updateLeague throws', async () => {
const error = new Error('Database connection failed');
vi.mocked(updateLeague).mockRejectedValue(error);
let result: { error: string } | undefined;
try {
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false });
} catch {
result = { error: 'Failed to update league. Please try again.' };
}
expect(result).toEqual({ error: 'Failed to update league. Please try again.' });
});
it('should not reach season update when updateLeague throws', async () => {
vi.mocked(updateLeague).mockRejectedValue(new Error('DB error'));
vi.mocked(findCurrentSeasonWithSports).mockResolvedValue(mockSeason);
try {
await updateLeague('league-1', { name: 'Test', isPublicDraftBoard: false });
} catch {
// early return happens here in the real action
}
expect(findCurrentSeasonWithSports).not.toHaveBeenCalled();
expect(updateSeason).not.toHaveBeenCalled();
});
it('should redirect when league saves successfully but no season exists', async () => {
vi.mocked(updateLeague).mockResolvedValue(mockLeague);
vi.mocked(findCurrentSeasonWithSports).mockResolvedValue(undefined);
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false });
const season = await findCurrentSeasonWithSports('league-1');
expect(season).toBeUndefined();
// In the real action this triggers redirect(`/leagues/league-1?updated=true`)
});
});
// ---------------------------------------------------------------------------
// Season-level save (second update block)
// ---------------------------------------------------------------------------
describe('Settings Update - Season Save', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should update season with valid draft rounds', async () => {
vi.mocked(updateLeague).mockResolvedValue(mockLeague);
vi.mocked(findCurrentSeasonWithSports).mockResolvedValue(mockSeason);
vi.mocked(updateSeason).mockResolvedValue({ ...mockSeason, draftRounds: 15 });
await updateSeason('season-1', { draftRounds: 15 });
expect(updateSeason).toHaveBeenCalledWith('season-1', { draftRounds: 15 });
});
it('should reject draft rounds below 1', () => {
const rounds = 0;
expect(rounds < 1 || rounds > 50).toBe(true);
});
it('should reject draft rounds above 50', () => {
const rounds = 51;
expect(rounds < 1 || rounds > 50).toBe(true);
});
it('should reject draft rounds less than the number of sports selected', () => {
const sportsCount = 5;
const draftRounds = 3;
expect(draftRounds < sportsCount).toBe(true);
});
it('should map "fast" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('fast', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 60, draftIncrementTime: 10 });
});
it('should map "standard" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('standard', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 });
});
it('should map "slow" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('slow', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 28800, draftIncrementTime: 3600 });
});
it('should map "very-slow" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('very-slow', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 43200, draftIncrementTime: 3600 });
});
it('should fall back to standard speed for an unknown chess_clock speed value', () => {
const result = parseDraftSpeed('turbo-ultra', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 });
});
it('should use raw seconds for standard timer mode', () => {
const result = parseDraftSpeed('90', 'standard');
expect(result).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 });
});
it('should fall back to 90s when standard mode speed is not a number', () => {
const result = parseDraftSpeed(null, 'standard');
expect(result).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 });
});
it('should return a season-specific error message when updateSeason throws', async () => {
vi.mocked(updateSeason).mockRejectedValue(new Error('Season DB error'));
let result: { error: string } | undefined;
try {
await updateSeason('season-1', { draftRounds: 10 });
} catch {
result = { error: 'Failed to update season settings. Please try again.' };
}
expect(result).toEqual({ error: 'Failed to update season settings. Please try again.' });
});
it('should not say "league" in the season error message', async () => {
vi.mocked(updateSeason).mockRejectedValue(new Error('DB error'));
let errorMessage = '';
try {
await updateSeason('season-1', { draftRounds: 5 });
} catch {
errorMessage = 'Failed to update season settings. Please try again.';
}
expect(errorMessage).not.toContain('update league');
});
it('should only update scoring fields when season status is pre_draft', () => {
expect(canUpdateScoring('pre_draft')).toBe(true);
expect(canUpdateScoring('draft')).toBe(false);
});
it('should validate scoring points are between 0 and 1000', () => {
expect(isValidScoringPoints(500)).toBe(true);
expect(isValidScoringPoints(-1)).toBe(false);
expect(isValidScoringPoints(1001)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Team count changes within the update intent
// ---------------------------------------------------------------------------
describe('Settings Update - Team Count Changes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should reject team count below 6', () => {
expect(isValidTeamCount(5)).toBe(false);
});
it('should reject team count above 16', () => {
expect(isValidTeamCount(17)).toBe(false);
});
it('should reject team count below the number of teams with owners', () => {
const teamsWithOwners = 8;
const newTeamCount = 6;
expect(newTeamCount < teamsWithOwners).toBe(true);
});
it('should allow a valid team count that is >= teams with owners', () => {
const teamsWithOwners = 6;
const newTeamCount = 10;
expect(isValidTeamCount(newTeamCount) && newTeamCount >= teamsWithOwners).toBe(true);
});
it('should create new teams when increasing team count', async () => {
const { createManyTeams } = await import('~/models/team');
vi.mocked(createManyTeams).mockResolvedValue([]);
const currentTeamCount = 8;
const newTeamCount = 10;
const teamsToAdd = Array.from(
{ length: newTeamCount - currentTeamCount },
(_, i) => ({
seasonId: 'season-1',
name: `Team ${currentTeamCount + i + 1}`,
ownerId: null,
})
);
await createManyTeams(teamsToAdd);
expect(createManyTeams).toHaveBeenCalledWith([
{ seasonId: 'season-1', name: 'Team 9', ownerId: null },
{ seasonId: 'season-1', name: 'Team 10', ownerId: null },
]);
});
});
// ---------------------------------------------------------------------------
// Draft order action responses
// ---------------------------------------------------------------------------
describe('Settings - Draft Order Action Responses', () => {
it('set-draft-order success includes section: draft-order', () => {
const result = { success: true, message: 'Draft order updated successfully', section: 'draft-order' as const };
expect(result.section).toBe('draft-order');
expect(result.success).toBe(true);
});
it('randomize-draft-order success includes section: draft-order', () => {
const result = { success: true, message: 'Draft order randomized successfully', section: 'draft-order' as const };
expect(result.section).toBe('draft-order');
expect(result.message).toContain('randomized');
});
it('draft-order messages should not be shown in global SettingsMessage', () => {
const actionData = { success: true, message: 'Draft order randomized successfully', section: 'draft-order' as const };
const isDraftOrderMessage = 'section' in actionData && actionData.section === 'draft-order';
expect(isDraftOrderMessage).toBe(true);
});
});