* Add account deletion and multi-section settings page - Rename /user-profile → /settings with 301 redirect from old URL - Add multi-section settings nav (Profile, Account, API placeholder, Data & Privacy) reusing existing SettingsDesktopNav/SettingsMobileGridNav components - Implement account deletion via anonymization: wipes all PII from users row, releases team ownerships, removes commissioner records, deletes sessions/accounts - Add data export request form that emails privacy@brackt.com via Resend - Add deletedAt timestamp column to users table (migration 0100) - Add anonymizeUserAccount() to user model - Add removeAllCommissionersByUserId() to commissioner model - Tests for both new model functions - Update UserMenu "Profile" link → "Settings" at /settings https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X * Address code review feedback on settings/account deletion 1. Wrap anonymizeUserAccount in a DB transaction so partial failures (e.g. session delete succeeds but user update fails) can't leave accounts in an inconsistent state 2. Escape user email and notes with escapeHtml() before interpolating into the data request email body 3. Reset AlertDialog confirmed state when dialog is dismissed via backdrop click or Escape key (onOpenChange handler) 4. Extract accounts DB query to app/models/account.ts (findLinkedAccountsByUserId) to comply with the "always query through app/models/" convention 5. Replace dynamic imports() in action handlers with static top-level imports 6. Remove the unused hard-delete deleteUser() function 7. Add lastDataRequestAt timestamp to users (migration 0101) and enforce a 30-day server-side cooldown on data export requests 8. Replace fragile actionData type casts with a proper ActionData discriminated union; narrowing now works without `as` assertions 9. Strengthen tests: verify which schema tables are passed to delete() and that operations run inside the transaction https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X * Fix no-non-null-assertion lint error in PrivacySection Replace non-null assertion with optional chaining on dataRequestCooldownUntil to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X --------- Co-authored-by: Claude <noreply@anthropic.com>
471 lines
14 KiB
TypeScript
471 lines
14 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,
|
|
flagConfig: null,
|
|
avatarType: 'owner',
|
|
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 userId = 'user-uuid-123';
|
|
const mockTeam = createMockTeam({ id: teamId, ownerId: userId, name: 'Team 1' });
|
|
|
|
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
|
|
|
|
const result = await assignTeamOwner(teamId, userId);
|
|
|
|
expect(assignTeamOwner).toHaveBeenCalledWith(teamId, userId);
|
|
expect(result.ownerId).toBe(userId);
|
|
expect(result.id).toBe(teamId);
|
|
});
|
|
|
|
it('should handle errors when assigning owner fails', async () => {
|
|
const teamId = 'team-1';
|
|
const userId = 'user-uuid-123';
|
|
const error = new Error('Database error');
|
|
|
|
vi.mocked(assignTeamOwner).mockRejectedValue(error);
|
|
|
|
await expect(assignTeamOwner(teamId, userId)).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 oldOwnerId = 'user-uuid-old';
|
|
const newOwnerId = 'user-uuid-new';
|
|
|
|
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerId, name: 'Team 1' });
|
|
|
|
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
|
|
|
|
const result = await assignTeamOwner(teamId, newOwnerId);
|
|
|
|
expect(result.ownerId).toBe(newOwnerId);
|
|
expect(result.ownerId).not.toBe(oldOwnerId);
|
|
});
|
|
});
|
|
|
|
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',
|
|
imageUrl: null,
|
|
flagConfig: null,
|
|
customAvatarUrl: null,
|
|
avatarType: 'flag',
|
|
isAdmin: false,
|
|
timezone: null,
|
|
lastDataRequestAt: null,
|
|
deletedAt: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: 'user-2',
|
|
clerkId: null,
|
|
email: 'user2@example.com',
|
|
emailVerified: true,
|
|
username: 'user2',
|
|
displayName: 'User Two',
|
|
imageUrl: null,
|
|
flagConfig: null,
|
|
customAvatarUrl: null,
|
|
avatarType: 'flag',
|
|
isAdmin: false,
|
|
timezone: null,
|
|
lastDataRequestAt: null,
|
|
deletedAt: null,
|
|
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',
|
|
imageUrl: null,
|
|
flagConfig: null,
|
|
customAvatarUrl: null,
|
|
avatarType: 'flag',
|
|
isAdmin: false,
|
|
timezone: null,
|
|
lastDataRequestAt: null,
|
|
deletedAt: null,
|
|
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 userId = 'user-uuid-1';
|
|
|
|
// Simulate that user already owns team-1
|
|
const existingTeams = [
|
|
createMockTeam({ id: 'team-1', ownerId: userId, 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 === userId);
|
|
|
|
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 newOwnerId = 'user-uuid-new';
|
|
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerId, name: 'Team 1' });
|
|
|
|
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
|
|
|
|
const result = await assignTeamOwner(teamId, newOwnerId);
|
|
|
|
expect(result.ownerId).toBe(newOwnerId);
|
|
});
|
|
|
|
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 ID gracefully', async () => {
|
|
const teamId = 'team-1';
|
|
const invalidUserId = 'invalid-user-id';
|
|
const error = new Error('User not found');
|
|
|
|
vi.mocked(assignTeamOwner).mockRejectedValue(error);
|
|
|
|
await expect(assignTeamOwner(teamId, invalidUserId)).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 userId = 'user-uuid-123';
|
|
|
|
// First, assign owner
|
|
const assignedTeam = createMockTeam({ id: teamId, ownerId: userId, name: 'Team 1' });
|
|
|
|
vi.mocked(assignTeamOwner).mockResolvedValue(assignedTeam);
|
|
const assignResult = await assignTeamOwner(teamId, userId);
|
|
expect(assignResult.ownerId).toBe(userId);
|
|
|
|
// 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 firstUserId = 'user-uuid-1';
|
|
const secondUserId = 'user-uuid-2';
|
|
|
|
// Assign first owner
|
|
const firstAssignment = createMockTeam({ id: teamId, ownerId: firstUserId, name: 'Team 1' });
|
|
|
|
vi.mocked(assignTeamOwner).mockResolvedValueOnce(firstAssignment);
|
|
const firstResult = await assignTeamOwner(teamId, firstUserId);
|
|
expect(firstResult.ownerId).toBe(firstUserId);
|
|
|
|
// Reassign to second owner
|
|
const secondAssignment = createMockTeam({ id: teamId, ownerId: secondUserId, name: 'Team 1' });
|
|
|
|
vi.mocked(assignTeamOwner).mockResolvedValueOnce(secondAssignment);
|
|
const secondResult = await assignTeamOwner(teamId, secondUserId);
|
|
expect(secondResult.ownerId).toBe(secondUserId);
|
|
expect(secondResult.ownerId).not.toBe(firstUserId);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|