* Refactor league settings into per-section components (#347) Extract all settings sections from the monolithic 1009-line route file into individual components under app/components/league/settings/. Route file drops to ~300 lines. Separates draft-order dirty state from general settings dirty state, deduplicates section-change handling, and fixes several bugs found during review (typo in pointsFor5th, wrong mock in tests, lint violations). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix Stop hook loop: suppress output on typecheck success The Stop hook was producing stdout on every run, causing Claude Code to feed it back as context and rewake the model each turn. Now emits a systemMessage JSON only on failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Typescript fix * Remove type asserting --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
482 lines
16 KiB
TypeScript
482 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,
|
|
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);
|
|
});
|
|
});
|