mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
/**
|
|
* Integration tests for the Teams API routes.
|
|
*
|
|
* Uses the same test server infrastructure as api.test.ts — a real Fastify
|
|
* app backed by an isolated temp SQLite DB, exercised via `app.inject()`.
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { db, schema } from "../../apps/api/src/db/index.js";
|
|
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
|
|
|
let testApp: TestApp;
|
|
let app: TestApp["app"];
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
// Helper: seed a Default team if not present
|
|
function ensureDefaultTeam(): string {
|
|
const existing = db.select().from(schema.teams).where(eq(schema.teams.name, "Default")).get();
|
|
if (existing) return existing.id;
|
|
const id = randomUUID();
|
|
db.insert(schema.teams).values({ id, name: "Default" }).run();
|
|
return id;
|
|
}
|
|
|
|
// Helper: clean all teams except Default, and recreate Default if missing
|
|
function resetTeams(): string {
|
|
// Delete non-Default teams
|
|
db.delete(schema.teams).where(sql`${schema.teams.name} != 'Default'`).run();
|
|
return ensureDefaultTeam();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// GET /api/v1/teams
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("GET /api/v1/teams", () => {
|
|
beforeEach(() => resetTeams());
|
|
|
|
it("returns teams with member counts", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.teams).toBeDefined();
|
|
expect(Array.isArray(body.teams)).toBe(true);
|
|
// Default team should exist
|
|
const defaultTeam = body.teams.find((t: { name: string }) => t.name === "Default");
|
|
expect(defaultTeam).toBeDefined();
|
|
expect(typeof defaultTeam.memberCount).toBe("number");
|
|
expect(typeof defaultTeam.createdAt).toBe("string");
|
|
expect(defaultTeam.id).toBeDefined();
|
|
});
|
|
|
|
it("requires authentication", async () => {
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/teams",
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// POST /api/v1/teams
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("POST /api/v1/teams", () => {
|
|
beforeEach(() => resetTeams());
|
|
|
|
it("creates a team", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "Engineering" },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.id).toBeDefined();
|
|
expect(body.name).toBe("Engineering");
|
|
});
|
|
|
|
it("requires admin", async () => {
|
|
// Register a non-admin user
|
|
const regRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username: "teamuser1", password: "TestPass1", role: "user" },
|
|
});
|
|
expect(regRes.statusCode).toBe(201);
|
|
|
|
// Login as non-admin
|
|
// First clear mustChangePassword
|
|
const userId = JSON.parse(regRes.body).id;
|
|
db.update(schema.users)
|
|
.set({ mustChangePassword: false })
|
|
.where(eq(schema.users.id, userId))
|
|
.run();
|
|
|
|
const loginRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username: "teamuser1", password: "TestPass1" },
|
|
});
|
|
const userToken = JSON.parse(loginRes.body).token;
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${userToken}` },
|
|
payload: { name: "ShouldFail" },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
|
|
// Cleanup
|
|
db.delete(schema.users).where(eq(schema.users.id, userId)).run();
|
|
});
|
|
|
|
it("rejects duplicate names (case-insensitive)", async () => {
|
|
// First create
|
|
const res1 = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "Marketing" },
|
|
});
|
|
expect(res1.statusCode).toBe(201);
|
|
|
|
// Duplicate with different case
|
|
const res2 = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "MARKETING" },
|
|
});
|
|
expect(res2.statusCode).toBe(409);
|
|
expect(JSON.parse(res2.body).code).toBe("CONFLICT");
|
|
});
|
|
|
|
it("rejects empty name", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it("rejects whitespace-only name", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: " " },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects name longer than 50 characters", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "A".repeat(51) },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it("trims whitespace from name", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: " Sales " },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
expect(JSON.parse(res.body).name).toBe("Sales");
|
|
});
|
|
|
|
it("rejects missing name field", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/teams",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// PUT /api/v1/teams/:id
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("PUT /api/v1/teams/:id", () => {
|
|
let teamId: string;
|
|
|
|
beforeEach(() => {
|
|
resetTeams();
|
|
// Create a team to rename
|
|
teamId = randomUUID();
|
|
db.insert(schema.teams).values({ id: teamId, name: "OldName" }).run();
|
|
});
|
|
|
|
it("renames a team", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "NewName" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).ok).toBe(true);
|
|
|
|
// Verify
|
|
const team = db.select().from(schema.teams).where(eq(schema.teams.id, teamId)).get();
|
|
expect(team?.name).toBe("NewName");
|
|
});
|
|
|
|
it("requires admin", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
payload: { name: "Hacked" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("rejects duplicate names", async () => {
|
|
// Try to rename to "Default" which already exists
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "Default" },
|
|
});
|
|
expect(res.statusCode).toBe(409);
|
|
expect(JSON.parse(res.body).code).toBe("CONFLICT");
|
|
});
|
|
|
|
it("allows renaming to same name (case-exact)", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "OldName" },
|
|
});
|
|
// Should succeed — same team, same name
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("returns 404 for non-existent team", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/v1/teams/${randomUUID()}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "Whatever" },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
expect(JSON.parse(res.body).code).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("rejects empty name", async () => {
|
|
const res = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: "" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// DELETE /api/v1/teams/:id
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
describe("DELETE /api/v1/teams/:id", () => {
|
|
let defaultTeamId: string;
|
|
|
|
beforeEach(() => {
|
|
defaultTeamId = resetTeams();
|
|
});
|
|
|
|
it("deletes an empty team", async () => {
|
|
const teamId = randomUUID();
|
|
db.insert(schema.teams).values({ id: teamId, name: "ToDelete" }).run();
|
|
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).ok).toBe(true);
|
|
|
|
// Verify it's gone
|
|
const team = db.select().from(schema.teams).where(eq(schema.teams.id, teamId)).get();
|
|
expect(team).toBeUndefined();
|
|
});
|
|
|
|
it("rejects deleting a team with members", async () => {
|
|
const teamId = randomUUID();
|
|
db.insert(schema.teams).values({ id: teamId, name: "HasMembers" }).run();
|
|
|
|
// Assign a user to this team
|
|
const userId = randomUUID();
|
|
db.insert(schema.users)
|
|
.values({
|
|
id: userId,
|
|
username: "memberuser",
|
|
passwordHash: "dummy:hash",
|
|
role: "user",
|
|
team: teamId,
|
|
})
|
|
.run();
|
|
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).error).toMatch(/members/i);
|
|
|
|
// Cleanup
|
|
db.delete(schema.users).where(eq(schema.users.id, userId)).run();
|
|
db.delete(schema.teams).where(eq(schema.teams.id, teamId)).run();
|
|
});
|
|
|
|
it("rejects deleting the Default team", async () => {
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/v1/teams/${defaultTeamId}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).error).toMatch(/default/i);
|
|
});
|
|
|
|
it("requires admin", async () => {
|
|
const teamId = randomUUID();
|
|
db.insert(schema.teams).values({ id: teamId, name: "NoAuth" }).run();
|
|
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/v1/teams/${teamId}`,
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
|
|
// Cleanup
|
|
db.delete(schema.teams).where(eq(schema.teams.id, teamId)).run();
|
|
});
|
|
|
|
it("returns 404 for non-existent team", async () => {
|
|
const res = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/v1/teams/${randomUUID()}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
expect(JSON.parse(res.body).code).toBe("NOT_FOUND");
|
|
});
|
|
});
|