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, discordPicksAnnouncementEnabled: false, 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 slot guard when increasing team count // Mirrors the server action condition: existingSlots.length === currentTeamCount // --------------------------------------------------------------------------- function shouldAppendDraftSlots(existingSlotsCount: number, currentTeamCount: number): boolean { return existingSlotsCount === currentTeamCount; } describe('Settings Update - Draft Slot Creation Guard', () => { it('does not create slots when no draft order was ever set', () => { expect(shouldAppendDraftSlots(0, 12)).toBe(false); }); it('creates slots when draft order is fully configured', () => { expect(shouldAppendDraftSlots(12, 12)).toBe(true); }); it('does not create slots when existing slots are partial (legacy edge case)', () => { expect(shouldAppendDraftSlots(8, 12)).toBe(false); }); it('appends new slots at consecutive positions after the current max', () => { const existingSlots = [{ draftOrder: 1 }, { draftOrder: 2 }, { draftOrder: 3 }]; const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0); const newTeams = [{ id: 'team-4' }, { id: 'team-5' }]; const newSlots = newTeams.map((team, i) => ({ seasonId: 'season-1', teamId: team.id, draftOrder: maxOrder + i + 1, })); expect(newSlots).toEqual([ { seasonId: 'season-1', teamId: 'team-4', draftOrder: 4 }, { seasonId: 'season-1', teamId: 'team-5', draftOrder: 5 }, ]); }); }); // --------------------------------------------------------------------------- // 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); }); }); describe('discordPicksAnnouncementEnabled setting', () => { it('parses discordPicksAnnouncementEnabled as true when form value is "on"', () => { const formData = new FormData(); formData.set('discordPicksAnnouncementEnabled', 'on'); const value = formData.get('discordPicksAnnouncementEnabled') === 'on'; expect(value).toBe(true); }); it('parses discordPicksAnnouncementEnabled as false when field is absent', () => { const formData = new FormData(); const value = formData.get('discordPicksAnnouncementEnabled') === 'on'; expect(value).toBe(false); }); it('updateLeague is called with discordPicksAnnouncementEnabled: true', async () => { vi.mocked(updateLeague).mockResolvedValue({ ...mockLeague, discordPicksAnnouncementEnabled: true }); await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false, discordPicksAnnouncementEnabled: true }); expect(updateLeague).toHaveBeenCalledWith('league-1', { name: 'Test League', isPublicDraftBoard: false, discordPicksAnnouncementEnabled: true, }); }); it('updateLeague is called with discordPicksAnnouncementEnabled: false', async () => { vi.mocked(updateLeague).mockResolvedValue({ ...mockLeague, discordPicksAnnouncementEnabled: false }); await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false, discordPicksAnnouncementEnabled: false }); expect(updateLeague).toHaveBeenCalledWith('league-1', { name: 'Test League', isPublicDraftBoard: false, discordPicksAnnouncementEnabled: false, }); }); });