mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(api): add teams CRUD routes and update auth team references
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -19,25 +19,26 @@ import { dirname } from "node:path";
|
||||
mkdirSync(dirname(process.env.DB_PATH!), { recursive: true });
|
||||
mkdirSync(process.env.WORKSPACE_PATH!, { recursive: true });
|
||||
|
||||
import cors from "@fastify/cors";
|
||||
import { APP_VERSION } from "@stirling-image/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Import app modules. config.ts already captured our env vars.
|
||||
// ---------------------------------------------------------------------------
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { runMigrations } from "../../apps/api/src/db/migrate.js";
|
||||
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "../../apps/api/src/plugins/auth.js";
|
||||
import { env } from "../../apps/api/src/config.js";
|
||||
import { db, schema } from "../../apps/api/src/db/index.js";
|
||||
import { runMigrations } from "../../apps/api/src/db/migrate.js";
|
||||
import { authMiddleware, authRoutes, ensureDefaultAdmin } from "../../apps/api/src/plugins/auth.js";
|
||||
import { registerUpload } from "../../apps/api/src/plugins/upload.js";
|
||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||
import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js";
|
||||
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
|
||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
|
||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
||||
import { registerProgressRoutes } from "../../apps/api/src/routes/progress.js";
|
||||
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
|
||||
import { settingsRoutes } from "../../apps/api/src/routes/settings.js";
|
||||
import { env } from "../../apps/api/src/config.js";
|
||||
import { APP_VERSION } from "@stirling-image/shared";
|
||||
import { teamsRoutes } from "../../apps/api/src/routes/teams.js";
|
||||
import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js";
|
||||
|
||||
// Run migrations to create all tables in the temp DB
|
||||
runMigrations();
|
||||
@@ -98,6 +99,9 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
// Settings routes
|
||||
await settingsRoutes(app);
|
||||
|
||||
// Teams routes
|
||||
await teamsRoutes(app);
|
||||
|
||||
// Health check
|
||||
app.get("/api/v1/health", async () => ({
|
||||
status: "healthy",
|
||||
@@ -133,9 +137,7 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Log in as the default admin and return the session token. */
|
||||
export async function loginAsAdmin(
|
||||
app: ReturnType<typeof Fastify>,
|
||||
): Promise<string> {
|
||||
export async function loginAsAdmin(app: ReturnType<typeof Fastify>): Promise<string> {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
|
||||
Reference in New Issue
Block a user