···
-
import db from "./db/schema";
import { hashPasswordClient } from "./lib/client-auth";
-
// Test server URL - uses port 3001 for testing to avoid conflicts
const BASE_URL = `http://localhost:${TEST_PORT}`;
-
// Check if server is available
-
let serverAvailable = false;
-
const response = await fetch(`${BASE_URL}/api/health`, {
-
signal: AbortSignal.timeout(1000),
-
serverAvailable = response.ok || response.status === 404;
-
`\n⚠️ Test server not running on port ${TEST_PORT}. Start it with:\n PORT=${TEST_PORT} bun run src/index.ts\n Then run tests in another terminal.\n`,
-
serverAvailable = false;
···
-
function cleanupTestData() {
-
// Delete test users and their related data (cascade will handle most of it)
-
// Include 'newemail%' to catch users whose emails were updated during tests
-
"DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
-
"DELETE FROM passkeys WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
-
"DELETE FROM transcriptions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
-
"DELETE FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%'",
-
// Clear ALL rate limit data to prevent accumulation across tests
-
// (IP-based rate limits don't contain test/admin in the key)
-
db.run("DELETE FROM rate_limit_attempts");
-
// Helper to skip tests if server is not available
-
function serverTest(name: string, fn: () => void | Promise<void>) {
-
test(name, async () => {
-
if (!serverAvailable) {
-
console.log(`⏭️ Skipping: ${name} (server not running)`);
describe("API Endpoints - Authentication", () => {
describe("POST /api/auth/register", () => {
-
serverTest("should register a new user successfully", async () => {
const hashedPassword = await clientHashPassword(
···
expect(response.status).toBe(200);
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
-
expect(extractSessionCookie(response)).toBeTruthy();
-
serverTest("should reject registration with missing email", async () => {
const response = await fetch(`${BASE_URL}/api/auth/register`, {
headers: { "Content-Type": "application/json" },
···
expect(data.error).toBe("Email and password required");
"should reject registration with invalid password format",
const response = await fetch(`${BASE_URL}/api/auth/register`, {
···
-
serverTest("should reject duplicate email registration", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toBe("Email already registered");
-
serverTest("should enforce rate limiting on registration", async () => {
const hashedPassword = await clientHashPassword(
···
describe("POST /api/auth/login", () => {
-
serverTest("should login successfully with valid credentials", async () => {
const hashedPassword = await clientHashPassword(
···
expect(extractSessionCookie(response)).toBeTruthy();
-
serverTest("should reject login with invalid credentials", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toBe("Invalid email or password");
-
serverTest("should reject login with missing fields", async () => {
const response = await fetch(`${BASE_URL}/api/auth/login`, {
headers: { "Content-Type": "application/json" },
···
expect(data.error).toBe("Email and password required");
-
serverTest("should enforce rate limiting on login attempts", async () => {
const hashedPassword = await clientHashPassword(
···
describe("POST /api/auth/logout", () => {
-
serverTest("should logout successfully", async () => {
const hashedPassword = await clientHashPassword(
···
expect(setCookie).toContain("Max-Age=0");
-
serverTest("should logout even without valid session", async () => {
const response = await fetch(`${BASE_URL}/api/auth/logout`, {
···
describe("GET /api/auth/me", () => {
"should return current user info when authenticated",
···
-
serverTest("should return 401 when not authenticated", async () => {
const response = await fetch(`${BASE_URL}/api/auth/me`);
expect(response.status).toBe(401);
···
expect(data.error).toBe("Not authenticated");
-
serverTest("should return 401 with invalid session", async () => {
const response = await authRequest(
`${BASE_URL}/api/auth/me`,
···
describe("API Endpoints - Session Management", () => {
describe("GET /api/sessions", () => {
-
serverTest("should return user sessions", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.sessions[0]).toHaveProperty("user_agent");
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/sessions`);
expect(response.status).toBe(401);
···
describe("DELETE /api/sessions", () => {
-
serverTest("should delete specific session", async () => {
// Register user and create multiple sessions
const hashedPassword = await clientHashPassword(
···
expect(verifyResponse.status).toBe(401);
-
serverTest("should not delete another user's session", async () => {
const hashedPassword1 = await clientHashPassword(
···
expect(response.status).toBe(404);
-
serverTest("should not delete current session", async () => {
const hashedPassword = await clientHashPassword(
···
describe("API Endpoints - User Management", () => {
describe("DELETE /api/user", () => {
-
serverTest("should delete user account", async () => {
const hashedPassword = await clientHashPassword(
···
expect(verifyResponse.status).toBe(401);
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/user`, {
···
describe("PUT /api/user/email", () => {
-
serverTest("should update user email", async () => {
const hashedPassword = await clientHashPassword(
···
expect(meData.email).toBe(newEmail);
-
serverTest("should reject duplicate email", async () => {
const hashedPassword1 = await clientHashPassword(
···
describe("PUT /api/user/password", () => {
-
serverTest("should update user password", async () => {
const hashedPassword = await clientHashPassword(
···
expect(loginResponse.status).toBe(200);
-
serverTest("should reject invalid password format", async () => {
const hashedPassword = await clientHashPassword(
···
describe("PUT /api/user/name", () => {
-
serverTest("should update user name", async () => {
const hashedPassword = await clientHashPassword(
···
expect(meData.name).toBe(newName);
-
serverTest("should reject missing name", async () => {
const hashedPassword = await clientHashPassword(
···
describe("PUT /api/user/avatar", () => {
-
serverTest("should update user avatar", async () => {
const hashedPassword = await clientHashPassword(
···
describe("API Endpoints - Health", () => {
describe("GET /api/health", () => {
"should return service health status with details",
const response = await fetch(`${BASE_URL}/api/health`);
···
describe("API Endpoints - Transcriptions", () => {
describe("GET /api/transcriptions", () => {
-
serverTest("should return user transcriptions", async () => {
const hashedPassword = await clientHashPassword(
···
expect(Array.isArray(data.jobs)).toBe(true);
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/transcriptions`);
expect(response.status).toBe(401);
···
describe("POST /api/transcriptions", () => {
-
serverTest("should upload audio file and start transcription", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.message).toContain("Upload successful");
-
serverTest("should reject non-audio files", async () => {
const hashedPassword = await clientHashPassword(
···
expect(response.status).toBe(400);
-
serverTest("should reject files exceeding size limit", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toContain("File size must be less than");
-
serverTest("should require authentication", async () => {
const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
const formData = new FormData();
formData.append("audio", audioBlob, "test.mp3");
···
-
if (!serverAvailable) return;
const adminHash = await clientHashPassword(
···
describe("GET /api/admin/users", () => {
-
serverTest("should return all users for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
···
expect(data.length).toBeGreaterThan(0);
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
···
expect(response.status).toBe(403);
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/admin/users`);
expect(response.status).toBe(401);
···
describe("GET /api/admin/transcriptions", () => {
-
serverTest("should return all transcriptions for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
···
expect(Array.isArray(data)).toBe(true);
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
···
describe("DELETE /api/admin/users/:id", () => {
-
serverTest("should delete user as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
···
expect(verifyResponse.status).toBe(401);
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
···
describe("PUT /api/admin/users/:id/role", () => {
-
serverTest("should update user role as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
···
expect(meData.role).toBe("admin");
-
serverTest("should reject invalid roles", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
···
describe("GET /api/admin/users/:id/details", () => {
-
serverTest("should return user details for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
···
expect(data).toHaveProperty("sessions");
-
serverTest("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
···
describe("PUT /api/admin/users/:id/name", () => {
-
serverTest("should update user name as admin", async () => {
const newName = "Admin Updated Name";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
···
expect(data.success).toBe(true);
-
serverTest("should reject empty names", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
···
describe("PUT /api/admin/users/:id/email", () => {
-
serverTest("should update user email as admin", async () => {
const newEmail = "newemail@admin.com";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
···
expect(data.success).toBe(true);
-
serverTest("should reject duplicate emails", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
···
describe("GET /api/admin/users/:id/sessions", () => {
-
serverTest("should return user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
···
describe("DELETE /api/admin/users/:id/sessions", () => {
-
serverTest("should delete all user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
···
let sessionCookie: string;
-
if (!serverAvailable) return;
const hashedPassword = await clientHashPassword(
···
describe("GET /api/passkeys", () => {
-
serverTest("should return user passkeys", async () => {
const response = await authRequest(
`${BASE_URL}/api/passkeys`,
···
expect(Array.isArray(data.passkeys)).toBe(true);
-
serverTest("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/passkeys`);
expect(response.status).toBe(401);
···
describe("POST /api/passkeys/register/options", () => {
"should return registration options for authenticated user",
const response = await authRequest(
···
-
serverTest("should require authentication", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/register/options`,
···
describe("POST /api/passkeys/authenticate/options", () => {
-
serverTest("should return authentication options for email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
···
expect(data).toHaveProperty("challenge");
-
serverTest("should handle non-existent email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
···
import { hashPasswordClient } from "./lib/client-auth";
+
import type { Subprocess } from "bun";
+
// Test server configuration
const BASE_URL = `http://localhost:${TEST_PORT}`;
+
const TEST_DB_PATH = "./thistle.test.db";
+
let serverProcess: Subprocess | null = null;
+
// Clean up any existing test database
+
await import("node:fs/promises").then((fs) => fs.unlink(TEST_DB_PATH));
+
// Ignore if doesn't exist
+
// Start test server as subprocess
+
serverProcess = Bun.spawn(["bun", "run", "src/index.ts"], {
+
PORT: TEST_PORT.toString(),
+
SKIP_POLAR_SYNC: "true",
+
// Dummy env vars to pass startup validation (won't be used due to SKIP_EMAILS/SKIP_POLAR_SYNC)
+
MAILCHANNELS_API_KEY: "test-key",
+
DKIM_PRIVATE_KEY: "test-key",
+
LLM_API_KEY: "test-key",
+
LLM_API_BASE_URL: "https://test.com",
+
LLM_MODEL: "test-model",
+
POLAR_ACCESS_TOKEN: "test-token",
+
POLAR_ORGANIZATION_ID: "test-org",
+
POLAR_PRODUCT_ID: "test-product",
+
POLAR_SUCCESS_URL: "http://localhost:3001/success",
+
POLAR_WEBHOOK_SECRET: "test-webhook-secret",
+
ORIGIN: "http://localhost:3001",
+
// Log server output for debugging
+
const stdoutReader = serverProcess.stdout.getReader();
+
const stderrReader = serverProcess.stderr.getReader();
+
const decoder = new TextDecoder();
+
const { value, done } = await stdoutReader.read();
+
const text = decoder.decode(value);
+
console.log("[SERVER OUT]", text.trim());
+
const { value, done } = await stderrReader.read();
+
const text = decoder.decode(value);
+
console.error("[SERVER ERR]", text.trim());
+
// Wait for server to be ready
+
while (retries > 0 && !ready) {
+
const response = await fetch(`${BASE_URL}/api/health`, {
+
signal: AbortSignal.timeout(1000),
+
// Server not ready yet
+
await new Promise((resolve) => setTimeout(resolve, 500));
+
throw new Error("Test server failed to start within 15 seconds");
+
console.log(`✓ Test server running on port ${TEST_PORT}`);
+
await new Promise((resolve) => setTimeout(resolve, 1000));
+
// Clean up test database
+
await import("node:fs/promises").then((fs) => fs.unlink(TEST_DB_PATH));
+
// Ignore if doesn't exist
+
console.log("✓ Test server stopped and test database cleaned up");
···
+
// All tests run against a fresh database, no cleanup needed
describe("API Endpoints - Authentication", () => {
describe("POST /api/auth/register", () => {
+
test("should register a new user successfully", async () => {
const hashedPassword = await clientHashPassword(
···
+
if (response.status !== 200) {
+
const error = await response.json();
+
console.error("Registration failed:", response.status, error);
expect(response.status).toBe(200);
+
// Extract session before consuming response body
+
const sessionCookie = extractSessionCookie(response);
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
+
expect(sessionCookie).toBeTruthy();
+
test("should reject registration with missing email", async () => {
const response = await fetch(`${BASE_URL}/api/auth/register`, {
headers: { "Content-Type": "application/json" },
···
expect(data.error).toBe("Email and password required");
"should reject registration with invalid password format",
const response = await fetch(`${BASE_URL}/api/auth/register`, {
···
+
test("should reject duplicate email registration", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toBe("Email already registered");
+
test("should enforce rate limiting on registration", async () => {
const hashedPassword = await clientHashPassword(
···
describe("POST /api/auth/login", () => {
+
test("should login successfully with valid credentials", async () => {
const hashedPassword = await clientHashPassword(
···
expect(extractSessionCookie(response)).toBeTruthy();
+
test("should reject login with invalid credentials", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toBe("Invalid email or password");
+
test("should reject login with missing fields", async () => {
const response = await fetch(`${BASE_URL}/api/auth/login`, {
headers: { "Content-Type": "application/json" },
···
expect(data.error).toBe("Email and password required");
+
test("should enforce rate limiting on login attempts", async () => {
const hashedPassword = await clientHashPassword(
···
describe("POST /api/auth/logout", () => {
+
test("should logout successfully", async () => {
const hashedPassword = await clientHashPassword(
···
expect(setCookie).toContain("Max-Age=0");
+
test("should logout even without valid session", async () => {
const response = await fetch(`${BASE_URL}/api/auth/logout`, {
···
describe("GET /api/auth/me", () => {
"should return current user info when authenticated",
···
+
test("should return 401 when not authenticated", async () => {
const response = await fetch(`${BASE_URL}/api/auth/me`);
expect(response.status).toBe(401);
···
expect(data.error).toBe("Not authenticated");
+
test("should return 401 with invalid session", async () => {
const response = await authRequest(
`${BASE_URL}/api/auth/me`,
···
describe("API Endpoints - Session Management", () => {
describe("GET /api/sessions", () => {
+
test("should return user sessions", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.sessions[0]).toHaveProperty("user_agent");
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/sessions`);
expect(response.status).toBe(401);
···
describe("DELETE /api/sessions", () => {
+
test("should delete specific session", async () => {
// Register user and create multiple sessions
const hashedPassword = await clientHashPassword(
···
expect(verifyResponse.status).toBe(401);
+
test("should not delete another user's session", async () => {
const hashedPassword1 = await clientHashPassword(
···
expect(response.status).toBe(404);
+
test("should not delete current session", async () => {
const hashedPassword = await clientHashPassword(
···
describe("API Endpoints - User Management", () => {
describe("DELETE /api/user", () => {
+
test("should delete user account", async () => {
const hashedPassword = await clientHashPassword(
···
expect(verifyResponse.status).toBe(401);
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/user`, {
···
describe("PUT /api/user/email", () => {
+
test("should update user email", async () => {
const hashedPassword = await clientHashPassword(
···
expect(meData.email).toBe(newEmail);
+
test("should reject duplicate email", async () => {
const hashedPassword1 = await clientHashPassword(
···
describe("PUT /api/user/password", () => {
+
test("should update user password", async () => {
const hashedPassword = await clientHashPassword(
···
expect(loginResponse.status).toBe(200);
+
test("should reject invalid password format", async () => {
const hashedPassword = await clientHashPassword(
···
describe("PUT /api/user/name", () => {
+
test("should update user name", async () => {
const hashedPassword = await clientHashPassword(
···
expect(meData.name).toBe(newName);
+
test("should reject missing name", async () => {
const hashedPassword = await clientHashPassword(
···
describe("PUT /api/user/avatar", () => {
+
test("should update user avatar", async () => {
const hashedPassword = await clientHashPassword(
···
describe("API Endpoints - Health", () => {
describe("GET /api/health", () => {
"should return service health status with details",
const response = await fetch(`${BASE_URL}/api/health`);
···
describe("API Endpoints - Transcriptions", () => {
describe("GET /api/transcriptions", () => {
+
test("should return user transcriptions", async () => {
const hashedPassword = await clientHashPassword(
···
expect(Array.isArray(data.jobs)).toBe(true);
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/transcriptions`);
expect(response.status).toBe(401);
···
describe("POST /api/transcriptions", () => {
+
test("should upload audio file and start transcription", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.message).toContain("Upload successful");
+
test("should reject non-audio files", async () => {
const hashedPassword = await clientHashPassword(
···
expect(response.status).toBe(400);
+
test("should reject files exceeding size limit", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toContain("File size must be less than");
+
test("should require authentication", async () => {
const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
const formData = new FormData();
formData.append("audio", audioBlob, "test.mp3");
···
const adminHash = await clientHashPassword(
···
describe("GET /api/admin/users", () => {
+
test("should return all users for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
···
expect(data.length).toBeGreaterThan(0);
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
···
expect(response.status).toBe(403);
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/admin/users`);
expect(response.status).toBe(401);
···
describe("GET /api/admin/transcriptions", () => {
+
test("should return all transcriptions for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
···
expect(Array.isArray(data)).toBe(true);
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
···
describe("DELETE /api/admin/users/:id", () => {
+
test("should delete user as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
···
expect(verifyResponse.status).toBe(401);
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
···
describe("PUT /api/admin/users/:id/role", () => {
+
test("should update user role as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
···
expect(meData.role).toBe("admin");
+
test("should reject invalid roles", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
···
describe("GET /api/admin/users/:id/details", () => {
+
test("should return user details for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
···
expect(data).toHaveProperty("sessions");
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
···
describe("PUT /api/admin/users/:id/name", () => {
+
test("should update user name as admin", async () => {
const newName = "Admin Updated Name";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
···
expect(data.success).toBe(true);
+
test("should reject empty names", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
···
describe("PUT /api/admin/users/:id/email", () => {
+
test("should update user email as admin", async () => {
const newEmail = "newemail@admin.com";
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
···
expect(data.success).toBe(true);
+
test("should reject duplicate emails", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
···
describe("GET /api/admin/users/:id/sessions", () => {
+
test("should return user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
···
describe("DELETE /api/admin/users/:id/sessions", () => {
+
test("should delete all user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
···
let sessionCookie: string;
const hashedPassword = await clientHashPassword(
···
describe("GET /api/passkeys", () => {
+
test("should return user passkeys", async () => {
const response = await authRequest(
`${BASE_URL}/api/passkeys`,
···
expect(Array.isArray(data.passkeys)).toBe(true);
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/passkeys`);
expect(response.status).toBe(401);
···
describe("POST /api/passkeys/register/options", () => {
"should return registration options for authenticated user",
const response = await authRequest(
···
+
test("should require authentication", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/register/options`,
···
describe("POST /api/passkeys/authenticate/options", () => {
+
test("should return authentication options for email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
···
expect(data).toHaveProperty("challenge");
+
test("should handle non-existent email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,