···
9
-
import db from "./db/schema";
import { hashPasswordClient } from "./lib/client-auth";
10
+
import type { Subprocess } from "bun";
12
-
// Test server URL - uses port 3001 for testing to avoid conflicts
12
+
// Test server configuration
const BASE_URL = `http://localhost:${TEST_PORT}`;
15
+
const TEST_DB_PATH = "./thistle.test.db";
16
-
// Check if server is available
17
-
let serverAvailable = false;
17
+
// Test server process
18
+
let serverProcess: Subprocess | null = null;
21
+
// Clean up any existing test database
21
-
const response = await fetch(`${BASE_URL}/api/health`, {
22
-
signal: AbortSignal.timeout(1000),
24
-
serverAvailable = response.ok || response.status === 404;
23
+
await import("node:fs/promises").then((fs) => fs.unlink(TEST_DB_PATH));
27
-
`\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`,
29
-
serverAvailable = false;
25
+
// Ignore if doesn't exist
28
+
// Start test server as subprocess
29
+
serverProcess = Bun.spawn(["bun", "run", "src/index.ts"], {
33
+
PORT: TEST_PORT.toString(),
34
+
SKIP_EMAILS: "true",
35
+
SKIP_POLAR_SYNC: "true",
36
+
// Dummy env vars to pass startup validation (won't be used due to SKIP_EMAILS/SKIP_POLAR_SYNC)
37
+
MAILCHANNELS_API_KEY: "test-key",
38
+
DKIM_PRIVATE_KEY: "test-key",
39
+
LLM_API_KEY: "test-key",
40
+
LLM_API_BASE_URL: "https://test.com",
41
+
LLM_MODEL: "test-model",
42
+
POLAR_ACCESS_TOKEN: "test-token",
43
+
POLAR_ORGANIZATION_ID: "test-org",
44
+
POLAR_PRODUCT_ID: "test-product",
45
+
POLAR_SUCCESS_URL: "http://localhost:3001/success",
46
+
POLAR_WEBHOOK_SECRET: "test-webhook-secret",
47
+
ORIGIN: "http://localhost:3001",
53
+
// Log server output for debugging
54
+
const stdoutReader = serverProcess.stdout.getReader();
55
+
const stderrReader = serverProcess.stderr.getReader();
56
+
const decoder = new TextDecoder();
61
+
const { value, done } = await stdoutReader.read();
63
+
const text = decoder.decode(value);
64
+
console.log("[SERVER OUT]", text.trim());
72
+
const { value, done } = await stderrReader.read();
74
+
const text = decoder.decode(value);
75
+
console.error("[SERVER ERR]", text.trim());
80
+
// Wait for server to be ready
83
+
while (retries > 0 && !ready) {
85
+
const response = await fetch(`${BASE_URL}/api/health`, {
86
+
signal: AbortSignal.timeout(1000),
93
+
// Server not ready yet
95
+
await new Promise((resolve) => setTimeout(resolve, 500));
100
+
throw new Error("Test server failed to start within 15 seconds");
103
+
console.log(`✓ Test server running on port ${TEST_PORT}`);
106
+
afterAll(async () => {
107
+
// Kill test server
108
+
if (serverProcess) {
109
+
serverProcess.kill();
110
+
await new Promise((resolve) => setTimeout(resolve, 1000));
113
+
// Clean up test database
115
+
await import("node:fs/promises").then((fs) => fs.unlink(TEST_DB_PATH));
117
+
// Ignore if doesn't exist
120
+
console.log("✓ Test server stopped and test database cleaned up");
···
85
-
function cleanupTestData() {
86
-
// Delete test users and their related data (cascade will handle most of it)
87
-
// Include 'newemail%' to catch users whose emails were updated during tests
89
-
"DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
92
-
"DELETE FROM passkeys WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
95
-
"DELETE FROM transcriptions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
98
-
"DELETE FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%'",
101
-
// Clear ALL rate limit data to prevent accumulation across tests
102
-
// (IP-based rate limits don't contain test/admin in the key)
103
-
db.run("DELETE FROM rate_limit_attempts");
107
-
if (serverAvailable) {
113
-
if (serverAvailable) {
118
-
// Helper to skip tests if server is not available
119
-
function serverTest(name: string, fn: () => void | Promise<void>) {
120
-
test(name, async () => {
121
-
if (!serverAvailable) {
122
-
console.log(`⏭️ Skipping: ${name} (server not running)`);
174
+
// All tests run against a fresh database, no cleanup needed
describe("API Endpoints - Authentication", () => {
describe("POST /api/auth/register", () => {
131
-
serverTest("should register a new user successfully", async () => {
178
+
test("should register a new user successfully", async () => {
const hashedPassword = await clientHashPassword(
···
194
+
if (response.status !== 200) {
195
+
const error = await response.json();
196
+
console.error("Registration failed:", response.status, error);
expect(response.status).toBe(200);
201
+
// Extract session before consuming response body
202
+
const sessionCookie = extractSessionCookie(response);
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
151
-
expect(extractSessionCookie(response)).toBeTruthy();
207
+
expect(sessionCookie).toBeTruthy();
154
-
serverTest("should reject registration with missing email", async () => {
210
+
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`, {
···
186
-
serverTest("should reject duplicate email registration", async () => {
242
+
test("should reject duplicate email registration", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toBe("Email already registered");
219
-
serverTest("should enforce rate limiting on registration", async () => {
275
+
test("should enforce rate limiting on registration", async () => {
const hashedPassword = await clientHashPassword(
···
describe("POST /api/auth/login", () => {
249
-
serverTest("should login successfully with valid credentials", async () => {
305
+
test("should login successfully with valid credentials", async () => {
const hashedPassword = await clientHashPassword(
···
expect(extractSessionCookie(response)).toBeTruthy();
282
-
serverTest("should reject login with invalid credentials", async () => {
338
+
test("should reject login with invalid credentials", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toBe("Invalid email or password");
316
-
serverTest("should reject login with missing fields", async () => {
372
+
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");
330
-
serverTest("should enforce rate limiting on login attempts", async () => {
386
+
test("should enforce rate limiting on login attempts", async () => {
const hashedPassword = await clientHashPassword(
···
describe("POST /api/auth/logout", () => {
360
-
serverTest("should logout successfully", async () => {
416
+
test("should logout successfully", async () => {
const hashedPassword = await clientHashPassword(
···
expect(setCookie).toContain("Max-Age=0");
394
-
serverTest("should logout even without valid session", async () => {
450
+
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",
···
439
-
serverTest("should return 401 when not authenticated", async () => {
495
+
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");
447
-
serverTest("should return 401 with invalid session", async () => {
503
+
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", () => {
462
-
serverTest("should return user sessions", async () => {
518
+
test("should return user sessions", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.sessions[0]).toHaveProperty("user_agent");
493
-
serverTest("should require authentication", async () => {
549
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/sessions`);
expect(response.status).toBe(401);
···
describe("DELETE /api/sessions", () => {
501
-
serverTest("should delete specific session", async () => {
557
+
test("should delete specific session", async () => {
// Register user and create multiple sessions
const hashedPassword = await clientHashPassword(
···
expect(verifyResponse.status).toBe(401);
560
-
serverTest("should not delete another user's session", async () => {
616
+
test("should not delete another user's session", async () => {
const hashedPassword1 = await clientHashPassword(
···
expect(response.status).toBe(404);
604
-
serverTest("should not delete current session", async () => {
660
+
test("should not delete current session", async () => {
const hashedPassword = await clientHashPassword(
···
describe("API Endpoints - User Management", () => {
describe("DELETE /api/user", () => {
640
-
serverTest("should delete user account", async () => {
696
+
test("should delete user account", async () => {
const hashedPassword = await clientHashPassword(
···
expect(verifyResponse.status).toBe(401);
677
-
serverTest("should require authentication", async () => {
733
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/user`, {
···
describe("PUT /api/user/email", () => {
687
-
serverTest("should update user email", async () => {
743
+
test("should update user email", async () => {
const hashedPassword = await clientHashPassword(
···
expect(meData.email).toBe(newEmail);
728
-
serverTest("should reject duplicate email", async () => {
784
+
test("should reject duplicate email", async () => {
const hashedPassword1 = await clientHashPassword(
···
describe("PUT /api/user/password", () => {
775
-
serverTest("should update user password", async () => {
831
+
test("should update user password", async () => {
const hashedPassword = await clientHashPassword(
···
expect(loginResponse.status).toBe(200);
822
-
serverTest("should reject invalid password format", async () => {
878
+
test("should reject invalid password format", async () => {
const hashedPassword = await clientHashPassword(
···
describe("PUT /api/user/name", () => {
856
-
serverTest("should update user name", async () => {
912
+
test("should update user name", async () => {
const hashedPassword = await clientHashPassword(
···
expect(meData.name).toBe(newName);
898
-
serverTest("should reject missing name", async () => {
954
+
test("should reject missing name", async () => {
const hashedPassword = await clientHashPassword(
···
describe("PUT /api/user/avatar", () => {
929
-
serverTest("should update user avatar", async () => {
985
+
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", () => {
994
-
serverTest("should return user transcriptions", async () => {
1050
+
test("should return user transcriptions", async () => {
const hashedPassword = await clientHashPassword(
···
expect(Array.isArray(data.jobs)).toBe(true);
1022
-
serverTest("should require authentication", async () => {
1078
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/transcriptions`);
expect(response.status).toBe(401);
···
describe("POST /api/transcriptions", () => {
1030
-
serverTest("should upload audio file and start transcription", async () => {
1086
+
test("should upload audio file and start transcription", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.message).toContain("Upload successful");
1068
-
serverTest("should reject non-audio files", async () => {
1124
+
test("should reject non-audio files", async () => {
const hashedPassword = await clientHashPassword(
···
expect(response.status).toBe(400);
1101
-
serverTest("should reject files exceeding size limit", async () => {
1157
+
test("should reject files exceeding size limit", async () => {
const hashedPassword = await clientHashPassword(
···
expect(data.error).toContain("File size must be less than");
1138
-
serverTest("should require authentication", async () => {
1194
+
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");
···
1159
-
if (!serverAvailable) return;
const adminHash = await clientHashPassword(
···
describe("GET /api/admin/users", () => {
1206
-
serverTest("should return all users for admin", async () => {
1261
+
test("should return all users for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
···
expect(data.length).toBeGreaterThan(0);
1218
-
serverTest("should reject non-admin users", async () => {
1273
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users`,
···
expect(response.status).toBe(403);
1227
-
serverTest("should require authentication", async () => {
1282
+
test("should require authentication", async () => {
const response = await fetch(`${BASE_URL}/api/admin/users`);
expect(response.status).toBe(401);
···
describe("GET /api/admin/transcriptions", () => {
1235
-
serverTest("should return all transcriptions for admin", async () => {
1290
+
test("should return all transcriptions for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
···
expect(Array.isArray(data)).toBe(true);
1246
-
serverTest("should reject non-admin users", async () => {
1301
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/transcriptions`,
···
describe("DELETE /api/admin/users/:id", () => {
1257
-
serverTest("should delete user as admin", async () => {
1312
+
test("should delete user as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
···
expect(verifyResponse.status).toBe(401);
1278
-
serverTest("should reject non-admin users", async () => {
1333
+
test("should reject non-admin users", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}`,
···
describe("PUT /api/admin/users/:id/role", () => {
1292
-
serverTest("should update user role as admin", async () => {
1347
+
test("should update user role as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
···
expect(meData.role).toBe("admin");
1316
-
serverTest("should reject invalid roles", async () => {
1371
+
test("should reject invalid roles", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/role`,
···
describe("GET /api/admin/users/:id/details", () => {
1332
-
serverTest("should return user details for admin", async () => {
1387
+
test("should return user details for admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/details`,
···
expect(data).toHaveProperty("sessions");
1346
-
serverTest("should reject non-admin users", async () => {
1401
+
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", () => {
1357
-
serverTest("should update user name as admin", async () => {
1412
+
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);
1374
-
serverTest("should reject empty names", async () => {
1429
+
test("should reject empty names", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/name`,
···
describe("PUT /api/admin/users/:id/email", () => {
1390
-
serverTest("should update user email as admin", async () => {
1445
+
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);
1407
-
serverTest("should reject duplicate emails", async () => {
1462
+
test("should reject duplicate emails", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/email`,
···
describe("GET /api/admin/users/:id/sessions", () => {
1425
-
serverTest("should return user sessions as admin", async () => {
1480
+
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", () => {
1438
-
serverTest("should delete all user sessions as admin", async () => {
1493
+
test("should delete all user sessions as admin", async () => {
const response = await authRequest(
`${BASE_URL}/api/admin/users/${userId}/sessions`,
···
let sessionCookie: string;
1465
-
if (!serverAvailable) return;
const hashedPassword = await clientHashPassword(
···
describe("GET /api/passkeys", () => {
1484
-
serverTest("should return user passkeys", async () => {
1538
+
test("should return user passkeys", async () => {
const response = await authRequest(
`${BASE_URL}/api/passkeys`,
···
expect(Array.isArray(data.passkeys)).toBe(true);
1496
-
serverTest("should require authentication", async () => {
1550
+
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(
···
1523
-
serverTest("should require authentication", async () => {
1577
+
test("should require authentication", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/register/options`,
···
describe("POST /api/passkeys/authenticate/options", () => {
1536
-
serverTest("should return authentication options for email", async () => {
1590
+
test("should return authentication options for email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,
···
expect(data).toHaveProperty("challenge");
1551
-
serverTest("should handle non-existent email", async () => {
1605
+
test("should handle non-existent email", async () => {
const response = await fetch(
`${BASE_URL}/api/passkeys/authenticate/options`,