brackt/app/routes/leagues/__tests__/team-management.test.ts
Chris Parsons ba9bf64e37
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)

Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.

**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field

**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict

better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00

457 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { removeTeamOwner, assignTeamOwner } from '~/models/team';
import { findAllUsers, isUserAdmin } from '~/models/user';
// Helper function to create mock team objects with all required properties
const createMockTeam = (overrides: {
id: string;
ownerId: string | null;
name?: string;
seasonId?: string;
draftPosition?: number;
}) => ({
id: overrides.id,
name: overrides.name || `Team ${overrides.id}`,
seasonId: overrides.seasonId || 'season-1',
ownerId: overrides.ownerId,
logoUrl: null,
draftPosition: overrides.draftPosition || 1,
createdAt: new Date(),
updatedAt: new Date(),
});
// Mock the models
vi.mock('~/models/team', () => ({
removeTeamOwner: vi.fn(),
assignTeamOwner: vi.fn(),
findTeamsBySeasonId: vi.fn(),
createManyTeams: vi.fn(),
deleteTeam: vi.fn(),
}));
vi.mock('~/models/user', () => ({
findAllUsers: vi.fn(),
findUserById: vi.fn(),
isUserAdmin: vi.fn(),
}));
vi.mock('~/models/league', () => ({
findLeagueById: vi.fn(),
updateLeague: vi.fn(),
deleteLeague: vi.fn(),
}));
vi.mock('~/models/commissioner', () => ({
isCommissioner: vi.fn(),
}));
vi.mock('~/models/season', () => ({
findCurrentSeasonWithSports: vi.fn(),
updateSeason: vi.fn(),
}));
vi.mock('~/models/sports-season', () => ({
findAllSportsSeasons: vi.fn(),
}));
vi.mock('~/models/season-sport', () => ({
unlinkSportFromSeason: vi.fn(),
linkMultipleSportsToSeason: vi.fn(),
}));
vi.mock('~/models/draft-slot', () => ({
findDraftSlotsBySeasonId: vi.fn(),
setDraftOrder: vi.fn(),
randomizeDraftOrder: vi.fn(),
}));
describe('Team Management - Remove Owner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should successfully remove an owner from a team', async () => {
const teamId = 'team-1';
const mockTeam = createMockTeam({ id: teamId, ownerId: null, name: 'Team 1' });
vi.mocked(removeTeamOwner).mockResolvedValue(mockTeam);
const result = await removeTeamOwner(teamId);
expect(removeTeamOwner).toHaveBeenCalledWith(teamId);
expect(result.ownerId).toBeNull();
expect(result.id).toBe(teamId);
});
it('should handle errors when removing owner fails', async () => {
const teamId = 'team-1';
const error = new Error('Database error');
vi.mocked(removeTeamOwner).mockRejectedValue(error);
await expect(removeTeamOwner(teamId)).rejects.toThrow('Database error');
});
it('should allow commissioners to remove owners', async () => {
// This would be tested in the action handler
const teamId = 'team-1';
const mockTeam = createMockTeam({ id: teamId, ownerId: null, name: 'Team 1' });
vi.mocked(removeTeamOwner).mockResolvedValue(mockTeam);
const result = await removeTeamOwner(teamId);
expect(result.ownerId).toBeNull();
});
});
describe('Team Management - Assign Owner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should successfully assign an owner to a team', async () => {
const teamId = 'team-1';
const userClerkId = 'user-clerk-123';
const mockTeam = createMockTeam({ id: teamId, ownerId: userClerkId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
const result = await assignTeamOwner(teamId, userClerkId);
expect(assignTeamOwner).toHaveBeenCalledWith(teamId, userClerkId);
expect(result.ownerId).toBe(userClerkId);
expect(result.id).toBe(teamId);
});
it('should handle errors when assigning owner fails', async () => {
const teamId = 'team-1';
const userClerkId = 'user-clerk-123';
const error = new Error('Database error');
vi.mocked(assignTeamOwner).mockRejectedValue(error);
await expect(assignTeamOwner(teamId, userClerkId)).rejects.toThrow('Database error');
});
it('should only allow admins to assign owners', async () => {
const userId = 'admin-user-id';
// Mock admin check
vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(true);
expect(isUserAdmin).toHaveBeenCalledWith(userId);
});
it('should prevent non-admins from assigning owners', async () => {
const userId = 'regular-user-id';
// Mock non-admin check
vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(false);
});
it('should replace existing owner when assigning new owner', async () => {
const teamId = 'team-1';
const oldOwnerClerkId = 'user-clerk-old';
const newOwnerClerkId = 'user-clerk-new';
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerClerkId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
const result = await assignTeamOwner(teamId, newOwnerClerkId);
expect(result.ownerId).toBe(newOwnerClerkId);
expect(result.ownerId).not.toBe(oldOwnerClerkId);
});
});
describe('Team Management - Admin User List', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch all users for admin dropdown', async () => {
const mockUsers = [
{
id: 'user-1',
clerkId: null,
email: 'user1@example.com',
emailVerified: true,
username: 'user1',
displayName: 'User One',
firstName: 'User',
lastName: 'One',
imageUrl: null,
isAdmin: false,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'user-2',
clerkId: null,
email: 'user2@example.com',
emailVerified: true,
username: 'user2',
displayName: 'User Two',
firstName: 'User',
lastName: 'Two',
imageUrl: null,
isAdmin: false,
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.mocked(findAllUsers).mockResolvedValue(mockUsers);
const users = await findAllUsers();
expect(findAllUsers).toHaveBeenCalled();
expect(users).toHaveLength(2);
expect(users[0].displayName).toBe('User One');
expect(users[1].displayName).toBe('User Two');
});
it('should only fetch users when user is admin', async () => {
const userId = 'admin-user-id';
vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdmin(userId);
if (isAdmin) {
const mockUsers = [
{
id: 'user-1',
clerkId: null,
email: 'user1@example.com',
emailVerified: true,
username: 'user1',
displayName: 'User One',
firstName: 'User',
lastName: 'One',
imageUrl: null,
isAdmin: false,
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.mocked(findAllUsers).mockResolvedValue(mockUsers);
const users = await findAllUsers();
expect(users).toHaveLength(1);
}
expect(isAdmin).toBe(true);
});
it('should return empty array for non-admin users', async () => {
const userId = 'regular-user-id';
vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdmin(userId);
// Simulate loader behavior: only fetch users if admin
const users = isAdmin ? await findAllUsers() : [];
expect(isAdmin).toBe(false);
expect(users).toHaveLength(0);
expect(findAllUsers).not.toHaveBeenCalled();
});
});
describe('Team Management - Authorization', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should verify admin status before allowing assignment', async () => {
const userId = 'test-user-id';
vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(true);
expect(isUserAdmin).toHaveBeenCalledWith(userId);
});
it('should reject assignment for non-admin users', async () => {
const userId = 'regular-user-id';
vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(false);
// Simulate action handler logic
if (!isAdmin) {
expect(assignTeamOwner).not.toHaveBeenCalled();
}
});
it('should allow commissioners to remove owners regardless of admin status', async () => {
// Commissioners can remove owners even if they're not admins
const teamId = 'team-1';
const mockTeam = createMockTeam({ id: teamId, ownerId: null, name: 'Team 1' });
vi.mocked(removeTeamOwner).mockResolvedValue(mockTeam);
const result = await removeTeamOwner(teamId);
expect(removeTeamOwner).toHaveBeenCalledWith(teamId);
expect(result.ownerId).toBeNull();
});
});
describe('Team Management - Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should prevent assigning user who already owns a team in the league', async () => {
const userClerkId = 'user-clerk-1';
// Simulate that user already owns team-1
const existingTeams = [
createMockTeam({ id: 'team-1', ownerId: userClerkId, name: 'Team 1' }),
createMockTeam({ id: 'team-2', ownerId: null, name: 'Team 2' }),
];
// Check if user already has a team
const userAlreadyHasTeam = existingTeams.some(team => team.ownerId === userClerkId);
expect(userAlreadyHasTeam).toBe(true);
// Should not call assignTeamOwner if user already has a team
if (userAlreadyHasTeam) {
expect(assignTeamOwner).not.toHaveBeenCalled();
}
});
it('should handle removing owner from team that has no owner', async () => {
const teamId = 'team-1';
const mockTeam = createMockTeam({ id: teamId, ownerId: null, name: 'Team 1' });
vi.mocked(removeTeamOwner).mockResolvedValue(mockTeam);
const result = await removeTeamOwner(teamId);
expect(result.ownerId).toBeNull();
});
it('should handle assigning owner to team that already has owner', async () => {
const teamId = 'team-1';
const newOwnerClerkId = 'user-clerk-new';
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerClerkId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
const result = await assignTeamOwner(teamId, newOwnerClerkId);
expect(result.ownerId).toBe(newOwnerClerkId);
});
it('should handle invalid team ID gracefully', async () => {
const invalidTeamId = 'invalid-team-id';
const error = new Error('Team not found');
vi.mocked(removeTeamOwner).mockRejectedValue(error);
await expect(removeTeamOwner(invalidTeamId)).rejects.toThrow('Team not found');
});
it('should handle invalid user clerk ID gracefully', async () => {
const teamId = 'team-1';
const invalidUserClerkId = 'invalid-clerk-id';
const error = new Error('User not found');
vi.mocked(assignTeamOwner).mockRejectedValue(error);
await expect(assignTeamOwner(teamId, invalidUserClerkId)).rejects.toThrow('User not found');
});
});
describe('Team Management - Integration Scenarios', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should handle complete workflow: assign then remove owner', async () => {
const teamId = 'team-1';
const userClerkId = 'user-clerk-123';
// First, assign owner
const assignedTeam = createMockTeam({ id: teamId, ownerId: userClerkId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(assignedTeam);
const assignResult = await assignTeamOwner(teamId, userClerkId);
expect(assignResult.ownerId).toBe(userClerkId);
// Then, remove owner
const removedTeam = createMockTeam({ id: teamId, ownerId: null, name: 'Team 1' });
vi.mocked(removeTeamOwner).mockResolvedValue(removedTeam);
const removeResult = await removeTeamOwner(teamId);
expect(removeResult.ownerId).toBeNull();
});
it('should handle reassigning owner from one user to another', async () => {
const teamId = 'team-1';
const firstUserClerkId = 'user-clerk-1';
const secondUserClerkId = 'user-clerk-2';
// Assign first owner
const firstAssignment = createMockTeam({ id: teamId, ownerId: firstUserClerkId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValueOnce(firstAssignment);
const firstResult = await assignTeamOwner(teamId, firstUserClerkId);
expect(firstResult.ownerId).toBe(firstUserClerkId);
// Reassign to second owner
const secondAssignment = createMockTeam({ id: teamId, ownerId: secondUserClerkId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValueOnce(secondAssignment);
const secondResult = await assignTeamOwner(teamId, secondUserClerkId);
expect(secondResult.ownerId).toBe(secondUserClerkId);
expect(secondResult.ownerId).not.toBe(firstUserClerkId);
});
it('should handle multiple teams with different ownership states', async () => {
const teams = [
{ id: 'team-1', ownerId: 'user-1' },
{ id: 'team-2', ownerId: null },
{ id: 'team-3', ownerId: 'user-2' },
];
// Remove owner from team-1
vi.mocked(removeTeamOwner).mockResolvedValueOnce(
createMockTeam({ id: 'team-1', ownerId: null, name: 'Team 1', draftPosition: 1 })
);
const result1 = await removeTeamOwner('team-1');
expect(result1.ownerId).toBeNull();
// Assign owner to team-2
vi.mocked(assignTeamOwner).mockResolvedValueOnce(
createMockTeam({ id: 'team-2', ownerId: 'user-3', name: 'Team 2', draftPosition: 2 })
);
const result2 = await assignTeamOwner('team-2', 'user-3');
expect(result2.ownerId).toBe('user-3');
// Leave team-3 unchanged
expect(teams[2].ownerId).toBe('user-2');
});
});