This commit is contained in:
288
packages/server/tests/routes/teams.test.ts
Normal file
288
packages/server/tests/routes/teams.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildServer } from '../../src/index.js';
|
||||
import { users, teams, teamMembers } from '../../src/db/schema.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
describe('Team API', () => {
|
||||
let server: Awaited<ReturnType<typeof buildServer>>;
|
||||
let ownerId: string;
|
||||
let memberId: string;
|
||||
let adminId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await buildServer();
|
||||
|
||||
// Clean up any leftover test data (use unique prefix to avoid collision with auth tests)
|
||||
// Narrow to 'test-team%' — previously 'test-%' collided with analytics.test.ts's
|
||||
// 'test-analytics' slug when files ran in parallel and wiped it mid-run, causing
|
||||
// spurious 403s because the analytics team's owner was no longer a member.
|
||||
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'test-team%'`);
|
||||
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'tmtest_%'`);
|
||||
|
||||
// Create test users directly in DB (clerk IDs use 'tmtest_' prefix to avoid auth test cleanup)
|
||||
const [owner] = await server.db.insert(users).values({
|
||||
clerkId: 'tmtest_owner',
|
||||
displayName: 'Team Owner',
|
||||
email: 'teamowner@test.com',
|
||||
}).returning();
|
||||
ownerId = owner.id;
|
||||
|
||||
const [member] = await server.db.insert(users).values({
|
||||
clerkId: 'tmtest_member',
|
||||
displayName: 'Team Member',
|
||||
email: 'teammember@test.com',
|
||||
}).returning();
|
||||
memberId = member.id;
|
||||
|
||||
const [admin] = await server.db.insert(users).values({
|
||||
clerkId: 'tmtest_admin',
|
||||
displayName: 'Team Admin',
|
||||
email: 'teamadmin@test.com',
|
||||
}).returning();
|
||||
adminId = admin.id;
|
||||
|
||||
// Override auth handler to use x-test-user-id header (via indirection object)
|
||||
server._authHandler.fn = async function (request, reply) {
|
||||
const testUserId = request.headers['x-test-user-id'] as string;
|
||||
if (!testUserId) {
|
||||
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
|
||||
}
|
||||
request.userId = testUserId;
|
||||
request.clerkId = 'test';
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Narrow to 'test-team%' — previously 'test-%' collided with analytics.test.ts's
|
||||
// 'test-analytics' slug when files ran in parallel and wiped it mid-run, causing
|
||||
// spurious 403s because the analytics team's owner was no longer a member.
|
||||
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
|
||||
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'test-team%'`);
|
||||
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'tmtest_%'`);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('creates a team and auto-adds owner as member with role owner', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { name: 'Test Team', slug: 'test-team-crud' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.name).toBe('Test Team');
|
||||
expect(body.slug).toBe('test-team-crud');
|
||||
expect(body.ownerId).toBe(ownerId);
|
||||
|
||||
// Verify owner membership
|
||||
const [membership] = await server.db
|
||||
.select()
|
||||
.from(teamMembers)
|
||||
.where(sql`team_id = ${body.id} AND user_id = ${ownerId}`);
|
||||
expect(membership).toBeDefined();
|
||||
expect(membership.role).toBe('owner');
|
||||
});
|
||||
|
||||
it('returns 409 on duplicate slug', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { name: 'Another Team', slug: 'test-team-crud' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('lists only teams the user belongs to', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
const slugs = body.map((t: { slug: string }) => t.slug);
|
||||
expect(slugs).toContain('test-team-crud');
|
||||
});
|
||||
|
||||
it('non-member gets empty list', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams',
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
// memberId is not a member of any team with test- slug yet
|
||||
const testTeams = body.filter((t: { slug: string }) => t.slug.startsWith('test-'));
|
||||
expect(testTeams).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('gets team by slug with members', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/test-team-crud',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.slug).toBe('test-team-crud');
|
||||
expect(body.members).toBeDefined();
|
||||
expect(body.members.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.members[0].displayName).toBe('Team Owner');
|
||||
});
|
||||
|
||||
it('non-member gets 403 when accessing team', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/test-team-crud',
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('adds a member via invite (admin+)', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/test-team-crud/members',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { email: 'teammember@test.com', role: 'member' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.userId).toBe(memberId);
|
||||
expect(body.role).toBe('member');
|
||||
});
|
||||
|
||||
it('returns 409 when inviting existing member', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/test-team-crud/members',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { email: 'teammember@test.com', role: 'member' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('adds an admin member', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/test-team-crud/members',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { email: 'teamadmin@test.com', role: 'admin' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('member can update own roleDescription and interests', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/teams/test-team-crud/members/${memberId}`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
payload: {
|
||||
roleDescription: 'Frontend developer',
|
||||
interests: ['React', 'TypeScript'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.roleDescription).toBe('Frontend developer');
|
||||
expect(body.interests).toEqual(['React', 'TypeScript']);
|
||||
});
|
||||
|
||||
it('member cannot change own role', async () => {
|
||||
// Attempting to change role requires admin, but member is only 'member'
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/teams/test-team-crud/members/${memberId}`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
payload: { role: 'admin' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('admin can update team name', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/teams/test-team-crud',
|
||||
headers: { 'x-test-user-id': adminId },
|
||||
payload: { name: 'Updated Team Name' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.name).toBe('Updated Team Name');
|
||||
});
|
||||
|
||||
it('member cannot update team name', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/teams/test-team-crud',
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
payload: { name: 'Should Fail' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('cannot remove the team owner', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/teams/test-team-crud/members/${ownerId}`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('admin can remove a member', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/teams/test-team-crud/members/${memberId}`,
|
||||
headers: { 'x-test-user-id': adminId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
|
||||
// Verify member is gone
|
||||
const checkResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/test-team-crud',
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
});
|
||||
const body = JSON.parse(checkResponse.body);
|
||||
const memberIds = body.members.map((m: { userId: string }) => m.userId);
|
||||
expect(memberIds).not.toContain(memberId);
|
||||
});
|
||||
|
||||
it('returns 401 without auth header', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user