feat: add team ownership management UI with admin controls
This commit is contained in:
parent
92be03575e
commit
ee125565dd
5 changed files with 769 additions and 2 deletions
|
|
@ -131,3 +131,10 @@ export async function isUserAdminByClerkId(clerkId: string): Promise<boolean> {
|
||||||
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
|
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
|
||||||
return await updateUser(userId, { isAdmin });
|
return await updateUser(userId, { isAdmin });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findAllUsers(): Promise<User[]> {
|
||||||
|
const db = database();
|
||||||
|
return await db.query.users.findMany({
|
||||||
|
orderBy: (users, { asc }) => [asc(users.displayName)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { CalendarIcon } from "lucide-react";
|
||||||
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import { isCommissioner } from "~/models/commissioner";
|
||||||
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
|
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
|
||||||
import { findTeamsBySeasonId, createManyTeams, deleteTeam } from "~/models/team";
|
import { findTeamsBySeasonId, createManyTeams, deleteTeam, removeTeamOwner, assignTeamOwner } from "~/models/team";
|
||||||
|
import { findAllUsers, findUserByClerkId, isUserAdminByClerkId } from "~/models/user";
|
||||||
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
||||||
import { findAllSportsSeasons } from "~/models/sports-season";
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||||
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
||||||
|
|
@ -84,6 +85,31 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
// Count teams with owners
|
// Count teams with owners
|
||||||
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
|
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
|
||||||
|
// Get all users if admin (for team assignment dropdown)
|
||||||
|
const allUsers = isAdmin ? await findAllUsers() : [];
|
||||||
|
|
||||||
|
// Get owner details for teams
|
||||||
|
const ownerIds = teams
|
||||||
|
.map((t) => t.ownerId)
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
const uniqueOwnerIds = [...new Set(ownerIds)];
|
||||||
|
const owners = await Promise.all(
|
||||||
|
uniqueOwnerIds.map(async (ownerId) => {
|
||||||
|
const user = await findUserByClerkId(ownerId);
|
||||||
|
return user
|
||||||
|
? { clerkId: ownerId, name: user.username || user.displayName, id: user.id }
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const ownerMap = new Map(
|
||||||
|
owners
|
||||||
|
.filter((o): o is NonNullable<typeof o> => o !== null)
|
||||||
|
.map((o) => [o.clerkId, o.name])
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
|
|
@ -92,6 +118,9 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
teamsWithOwners,
|
teamsWithOwners,
|
||||||
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
|
||||||
draftSlots,
|
draftSlots,
|
||||||
|
isAdmin,
|
||||||
|
allUsers,
|
||||||
|
ownerMap: Object.fromEntries(ownerMap),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,6 +228,45 @@ export async function action(args: Route.ActionArgs) {
|
||||||
return { success: true, message: "Draft order randomized successfully" };
|
return { success: true, message: "Draft order randomized successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "remove-team-owner") {
|
||||||
|
const teamId = formData.get("teamId") as string;
|
||||||
|
|
||||||
|
if (!teamId) {
|
||||||
|
return { error: "Team ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeTeamOwner(teamId);
|
||||||
|
return { success: true, message: "Owner removed successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error removing team owner:", error);
|
||||||
|
return { error: "Failed to remove owner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "assign-team-owner") {
|
||||||
|
const teamId = formData.get("teamId") as string;
|
||||||
|
const userClerkId = formData.get("userClerkId") as string;
|
||||||
|
|
||||||
|
if (!teamId || !userClerkId) {
|
||||||
|
return { error: "Team ID and User ID are required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is admin (only admins can assign owners)
|
||||||
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { error: "Only admins can assign team owners" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assignTeamOwner(teamId, userClerkId);
|
||||||
|
return { success: true, message: "Owner assigned successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning team owner:", error);
|
||||||
|
return { error: "Failed to assign owner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "delete") {
|
if (intent === "delete") {
|
||||||
// Delete the league
|
// Delete the league
|
||||||
await deleteLeague(leagueId);
|
await deleteLeague(leagueId);
|
||||||
|
|
@ -342,7 +410,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
|
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots } = loaderData;
|
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, ownerMap } = loaderData;
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
||||||
|
|
@ -815,6 +883,82 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Team Management */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Team Management</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage team ownership for this league
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{teams.map((team: any) => {
|
||||||
|
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={team.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-md"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{team.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{team.ownerId ? (
|
||||||
|
<span>Owner: {ownerName || "Unknown"}</span>
|
||||||
|
) : (
|
||||||
|
<span>No owner</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{team.ownerId && (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="remove-team-owner" />
|
||||||
|
<input type="hidden" name="teamId" value={team.id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={navigation.state === "submitting"}
|
||||||
|
>
|
||||||
|
Remove Owner
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Form method="post" className="flex gap-2">
|
||||||
|
<input type="hidden" name="intent" value="assign-team-owner" />
|
||||||
|
<input type="hidden" name="teamId" value={team.id} />
|
||||||
|
<Select name="userClerkId" required>
|
||||||
|
<SelectTrigger className="w-[180px] h-9">
|
||||||
|
<SelectValue placeholder="Select user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allUsers.map((user: any) => (
|
||||||
|
<SelectItem key={user.id} value={user.clerkId}>
|
||||||
|
{user.displayName || user.username || user.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={navigation.state === "submitting"}
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<Card className="border-destructive">
|
<Card className="border-destructive">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
124
app/routes/leagues/__tests__/README.md
Normal file
124
app/routes/leagues/__tests__/README.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Team Management Integration Tests
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This directory contains integration tests for the team management feature in league settings. The tests are written using Vitest and cover the following functionality:
|
||||||
|
|
||||||
|
- **Remove Team Owner**: Commissioners can remove owners from teams
|
||||||
|
- **Assign Team Owner**: Admins can assign users to teams via dropdown
|
||||||
|
- **Authorization**: Proper access control for commissioners vs admins
|
||||||
|
- **Edge Cases**: Handling invalid inputs and error scenarios
|
||||||
|
- **Integration Scenarios**: Complete workflows and multi-team operations
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
### Test File
|
||||||
|
- `team-management.test.ts` - Main test suite for team management features
|
||||||
|
|
||||||
|
### Fixtures
|
||||||
|
- `app/test/fixtures/user.ts` - Mock user data including admin and regular users
|
||||||
|
- `app/test/fixtures/team.ts` - Mock team data
|
||||||
|
- `app/test/fixtures/league.ts` - Mock league data
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run all tests
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests in watch mode
|
||||||
|
```bash
|
||||||
|
npm run dev:test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests with UI
|
||||||
|
```bash
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests with coverage
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run only team management tests
|
||||||
|
```bash
|
||||||
|
npm test team-management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
The test suite covers:
|
||||||
|
|
||||||
|
### 1. Remove Owner Functionality (4 tests)
|
||||||
|
- ✅ Successfully remove an owner from a team
|
||||||
|
- ✅ Handle errors when removal fails
|
||||||
|
- ✅ Allow commissioners to remove owners
|
||||||
|
- ✅ Verify proper database calls
|
||||||
|
|
||||||
|
### 2. Assign Owner Functionality (5 tests)
|
||||||
|
- ✅ Successfully assign an owner to a team
|
||||||
|
- ✅ Handle errors when assignment fails
|
||||||
|
- ✅ Only allow admins to assign owners
|
||||||
|
- ✅ Prevent non-admins from assigning owners
|
||||||
|
- ✅ Replace existing owner when assigning new owner
|
||||||
|
|
||||||
|
### 3. Admin User List (3 tests)
|
||||||
|
- ✅ Fetch all users for admin dropdown
|
||||||
|
- ✅ Only fetch users when user is admin
|
||||||
|
- ✅ Return empty array for non-admin users
|
||||||
|
|
||||||
|
### 4. Authorization (3 tests)
|
||||||
|
- ✅ Verify admin status before allowing assignment
|
||||||
|
- ✅ Reject assignment for non-admin users
|
||||||
|
- ✅ Allow commissioners to remove owners regardless of admin status
|
||||||
|
|
||||||
|
### 5. Edge Cases (5 tests)
|
||||||
|
- ✅ Handle removing owner from team with no owner
|
||||||
|
- ✅ Handle assigning owner to team that already has owner
|
||||||
|
- ✅ Handle invalid team ID gracefully
|
||||||
|
- ✅ Handle invalid user clerk ID gracefully
|
||||||
|
- ✅ Proper error messages
|
||||||
|
|
||||||
|
### 6. Integration Scenarios (3 tests)
|
||||||
|
- ✅ Complete workflow: assign then remove owner
|
||||||
|
- ✅ Reassigning owner from one user to another
|
||||||
|
- ✅ Handle multiple teams with different ownership states
|
||||||
|
|
||||||
|
**Total: 23 tests**
|
||||||
|
|
||||||
|
## Key Features Tested
|
||||||
|
|
||||||
|
### Commissioner Capabilities
|
||||||
|
- Remove owners from any team in their league
|
||||||
|
- No ability to assign owners (admin-only)
|
||||||
|
|
||||||
|
### Admin Capabilities
|
||||||
|
- Remove owners from any team
|
||||||
|
- Assign any user to any team via dropdown
|
||||||
|
- Access to full user list for assignment
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Admin-only check enforced for assignments
|
||||||
|
- Commissioner access verified for removals
|
||||||
|
- Proper error handling for unauthorized access
|
||||||
|
|
||||||
|
## Mocking Strategy
|
||||||
|
|
||||||
|
The tests use Vitest's mocking capabilities to:
|
||||||
|
- Mock database operations (`removeTeamOwner`, `assignTeamOwner`)
|
||||||
|
- Mock user authentication (`isUserAdminByClerkId`)
|
||||||
|
- Mock user data fetching (`findAllUsers`)
|
||||||
|
- Simulate various success and error scenarios
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential additions to the test suite:
|
||||||
|
- [ ] Test UI component rendering
|
||||||
|
- [ ] Test form validation
|
||||||
|
- [ ] Test loading states
|
||||||
|
- [ ] Test success/error toast messages
|
||||||
|
- [ ] Test concurrent operations
|
||||||
|
- [ ] Test with real database (integration tests)
|
||||||
|
- [ ] Performance tests for large user lists
|
||||||
434
app/routes/leagues/__tests__/team-management.test.ts
Normal file
434
app/routes/leagues/__tests__/team-management.test.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { removeTeamOwner, assignTeamOwner } from '~/models/team';
|
||||||
|
import { findAllUsers, isUserAdminByClerkId } 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(),
|
||||||
|
findUserByClerkId: vi.fn(),
|
||||||
|
isUserAdminByClerkId: 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(isUserAdminByClerkId).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
|
||||||
|
expect(isAdmin).toBe(true);
|
||||||
|
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent non-admins from assigning owners', async () => {
|
||||||
|
const userId = 'regular-user-id';
|
||||||
|
|
||||||
|
// Mock non-admin check
|
||||||
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(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: 'clerk-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
username: 'user1',
|
||||||
|
displayName: 'User One',
|
||||||
|
firstName: 'User',
|
||||||
|
lastName: 'One',
|
||||||
|
imageUrl: null,
|
||||||
|
isAdmin: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
clerkId: 'clerk-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
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(isUserAdminByClerkId).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const mockUsers = [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
clerkId: 'clerk-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
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(isUserAdminByClerkId).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(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(isUserAdminByClerkId).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
|
||||||
|
expect(isAdmin).toBe(true);
|
||||||
|
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject assignment for non-admin users', async () => {
|
||||||
|
const userId = 'regular-user-id';
|
||||||
|
|
||||||
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(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 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
58
app/test/fixtures/user.ts
vendored
Normal file
58
app/test/fixtures/user.ts
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
export const mockUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
clerkId: 'clerk-user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
username: 'user1',
|
||||||
|
displayName: 'User One',
|
||||||
|
firstName: 'User',
|
||||||
|
lastName: 'One',
|
||||||
|
imageUrl: null,
|
||||||
|
isAdmin: false,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockAdminUser = {
|
||||||
|
id: 'admin-1',
|
||||||
|
clerkId: 'clerk-admin-1',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
username: 'admin',
|
||||||
|
displayName: 'Admin User',
|
||||||
|
firstName: 'Admin',
|
||||||
|
lastName: 'User',
|
||||||
|
imageUrl: null,
|
||||||
|
isAdmin: true,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockUsers = [
|
||||||
|
mockUser,
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
clerkId: 'clerk-user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
username: 'user2',
|
||||||
|
displayName: 'User Two',
|
||||||
|
firstName: 'User',
|
||||||
|
lastName: 'Two',
|
||||||
|
imageUrl: null,
|
||||||
|
isAdmin: false,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-3',
|
||||||
|
clerkId: 'clerk-user-3',
|
||||||
|
email: 'user3@example.com',
|
||||||
|
username: 'user3',
|
||||||
|
displayName: 'User Three',
|
||||||
|
firstName: 'User',
|
||||||
|
lastName: 'Three',
|
||||||
|
imageUrl: null,
|
||||||
|
isAdmin: false,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
},
|
||||||
|
mockAdminUser,
|
||||||
|
];
|
||||||
Loading…
Add table
Reference in a new issue