馃 distributed transcription service thistle.dunkirk.sh
1import { describe, expect, test, beforeAll, afterAll } from "bun:test"; 2import { Database } from "bun:sqlite"; 3 4let testDb: Database; 5 6// Test helper functions that accept a db parameter 7function getAllTranscriptions_test( 8 db: Database, 9 limit = 50, 10 cursor?: string, 11): { 12 data: Array<{ 13 id: string; 14 user_id: number; 15 user_email: string; 16 user_name: string | null; 17 original_filename: string; 18 status: string; 19 created_at: number; 20 error_message: string | null; 21 }>; 22 pagination: { 23 limit: number; 24 hasMore: boolean; 25 nextCursor: string | null; 26 }; 27} { 28 type TranscriptionRow = { 29 id: string; 30 user_id: number; 31 user_email: string; 32 user_name: string | null; 33 original_filename: string; 34 status: string; 35 created_at: number; 36 error_message: string | null; 37 }; 38 39 let transcriptions: TranscriptionRow[]; 40 41 if (cursor) { 42 const { decodeCursor } = require("./cursor"); 43 const parts = decodeCursor(cursor); 44 45 if (parts.length !== 2) { 46 throw new Error("Invalid cursor format"); 47 } 48 49 const cursorTime = Number.parseInt(parts[0] || "", 10); 50 const id = parts[1] || ""; 51 52 if (Number.isNaN(cursorTime) || !id) { 53 throw new Error("Invalid cursor format"); 54 } 55 56 transcriptions = db 57 .query<TranscriptionRow, [number, number, string, number]>( 58 `SELECT 59 t.id, 60 t.user_id, 61 u.email as user_email, 62 u.name as user_name, 63 t.original_filename, 64 t.status, 65 t.created_at, 66 t.error_message 67 FROM transcriptions t 68 LEFT JOIN users u ON t.user_id = u.id 69 WHERE t.created_at < ? OR (t.created_at = ? AND t.id < ?) 70 ORDER BY t.created_at DESC, t.id DESC 71 LIMIT ?`, 72 ) 73 .all(cursorTime, cursorTime, id, limit + 1); 74 } else { 75 transcriptions = db 76 .query<TranscriptionRow, [number]>( 77 `SELECT 78 t.id, 79 t.user_id, 80 u.email as user_email, 81 u.name as user_name, 82 t.original_filename, 83 t.status, 84 t.created_at, 85 t.error_message 86 FROM transcriptions t 87 LEFT JOIN users u ON t.user_id = u.id 88 ORDER BY t.created_at DESC, t.id DESC 89 LIMIT ?`, 90 ) 91 .all(limit + 1); 92 } 93 94 const hasMore = transcriptions.length > limit; 95 if (hasMore) { 96 transcriptions.pop(); 97 } 98 99 let nextCursor: string | null = null; 100 if (hasMore && transcriptions.length > 0) { 101 const { encodeCursor } = require("./cursor"); 102 const last = transcriptions[transcriptions.length - 1]; 103 if (last) { 104 nextCursor = encodeCursor([last.created_at.toString(), last.id]); 105 } 106 } 107 108 return { 109 data: transcriptions, 110 pagination: { 111 limit, 112 hasMore, 113 nextCursor, 114 }, 115 }; 116} 117 118beforeAll(() => { 119 testDb = new Database(":memory:"); 120 121 // Create test tables 122 testDb.run(` 123 CREATE TABLE users ( 124 id INTEGER PRIMARY KEY AUTOINCREMENT, 125 email TEXT UNIQUE NOT NULL, 126 password_hash TEXT, 127 name TEXT, 128 avatar TEXT DEFAULT 'd', 129 role TEXT NOT NULL DEFAULT 'user', 130 created_at INTEGER NOT NULL, 131 email_verified BOOLEAN DEFAULT 0, 132 last_login INTEGER 133 ) 134 `); 135 136 testDb.run(` 137 CREATE TABLE transcriptions ( 138 id TEXT PRIMARY KEY, 139 user_id INTEGER NOT NULL, 140 filename TEXT NOT NULL, 141 original_filename TEXT NOT NULL, 142 status TEXT NOT NULL, 143 created_at INTEGER NOT NULL, 144 error_message TEXT, 145 FOREIGN KEY (user_id) REFERENCES users(id) 146 ) 147 `); 148 149 testDb.run(` 150 CREATE TABLE classes ( 151 id TEXT PRIMARY KEY, 152 course_code TEXT NOT NULL, 153 name TEXT NOT NULL, 154 professor TEXT NOT NULL, 155 semester TEXT NOT NULL, 156 year INTEGER NOT NULL, 157 archived BOOLEAN DEFAULT 0 158 ) 159 `); 160 161 testDb.run(` 162 CREATE TABLE class_members ( 163 id INTEGER PRIMARY KEY AUTOINCREMENT, 164 user_id INTEGER NOT NULL, 165 class_id TEXT NOT NULL, 166 FOREIGN KEY (user_id) REFERENCES users(id), 167 FOREIGN KEY (class_id) REFERENCES classes(id) 168 ) 169 `); 170 171 // Create test users 172 testDb.run( 173 "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)", 174 [ 175 "user1@test.com", 176 "hash1", 177 Math.floor(Date.now() / 1000) - 100, 178 "user", 179 ], 180 ); 181 testDb.run( 182 "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)", 183 [ 184 "user2@test.com", 185 "hash2", 186 Math.floor(Date.now() / 1000) - 50, 187 "user", 188 ], 189 ); 190 testDb.run( 191 "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)", 192 ["admin@test.com", "hash3", Math.floor(Date.now() / 1000), "admin"], 193 ); 194 195 // Create test transcriptions 196 for (let i = 0; i < 5; i++) { 197 testDb.run( 198 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status, created_at) VALUES (?, ?, ?, ?, ?, ?)", 199 [ 200 `trans-${i}`, 201 1, 202 `file-${i}.mp3`, 203 `original-${i}.mp3`, 204 "completed", 205 Math.floor(Date.now() / 1000) - (100 - i * 10), 206 ], 207 ); 208 } 209 210 // Create test classes 211 testDb.run( 212 "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)", 213 ["class-1", "CS101", "Intro to CS", "Dr. Smith", "Fall", 2024], 214 ); 215 testDb.run( 216 "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)", 217 ["class-2", "CS102", "Data Structures", "Dr. Jones", "Spring", 2024], 218 ); 219 220 // Add user to classes 221 testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [ 222 1, 223 "class-1", 224 ]); 225 testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [ 226 1, 227 "class-2", 228 ]); 229}); 230 231afterAll(() => { 232 testDb.close(); 233}); 234 235describe("Transcription Pagination", () => { 236 test("returns first page without cursor", () => { 237 const result = getAllTranscriptions_test(testDb, 2); 238 239 expect(result.data.length).toBe(2); 240 expect(result.pagination.limit).toBe(2); 241 expect(result.pagination.hasMore).toBe(true); 242 expect(result.pagination.nextCursor).toBeTruthy(); 243 }); 244 245 test("returns second page with cursor", () => { 246 const page1 = getAllTranscriptions_test(testDb, 2); 247 const page2 = getAllTranscriptions_test( 248 testDb, 249 2, 250 page1.pagination.nextCursor || "", 251 ); 252 253 expect(page2.data.length).toBe(2); 254 expect(page2.pagination.hasMore).toBe(true); 255 expect(page2.data[0]?.id).not.toBe(page1.data[0]?.id); 256 }); 257 258 test("returns last page correctly", () => { 259 const result = getAllTranscriptions_test(testDb, 10); 260 261 expect(result.data.length).toBe(5); 262 expect(result.pagination.hasMore).toBe(false); 263 expect(result.pagination.nextCursor).toBeNull(); 264 }); 265 266 test("rejects invalid cursor format", () => { 267 expect(() => { 268 getAllTranscriptions_test(testDb, 10, "invalid-cursor"); 269 }).toThrow("Invalid cursor format"); 270 }); 271 272 test("returns results ordered by created_at DESC", () => { 273 const result = getAllTranscriptions_test(testDb, 10); 274 275 for (let i = 0; i < result.data.length - 1; i++) { 276 const current = result.data[i]; 277 const next = result.data[i + 1]; 278 if (current && next) { 279 expect(current.created_at).toBeGreaterThanOrEqual(next.created_at); 280 } 281 } 282 }); 283}); 284 285describe("Cursor Format", () => { 286 test("transcription cursor format is base64url", () => { 287 const result = getAllTranscriptions_test(testDb, 1); 288 const cursor = result.pagination.nextCursor; 289 290 // Should be base64url-encoded (alphanumeric, no padding) 291 expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); 292 expect(cursor).not.toContain("="); // No padding 293 expect(cursor).not.toContain("+"); // URL-safe 294 expect(cursor).not.toContain("/"); // URL-safe 295 }); 296}); 297 298describe("Limit Boundaries", () => { 299 test("respects minimum limit of 1", () => { 300 const result = getAllTranscriptions_test(testDb, 1); 301 expect(result.data.length).toBeLessThanOrEqual(1); 302 }); 303 304 test("handles empty results", () => { 305 // Query with a user that has no transcriptions 306 const emptyDb = new Database(":memory:"); 307 emptyDb.run(` 308 CREATE TABLE users ( 309 id INTEGER PRIMARY KEY AUTOINCREMENT, 310 email TEXT UNIQUE NOT NULL, 311 password_hash TEXT, 312 name TEXT, 313 created_at INTEGER NOT NULL 314 ) 315 `); 316 emptyDb.run(` 317 CREATE TABLE transcriptions ( 318 id TEXT PRIMARY KEY, 319 user_id INTEGER NOT NULL, 320 filename TEXT NOT NULL, 321 original_filename TEXT NOT NULL, 322 status TEXT NOT NULL, 323 created_at INTEGER NOT NULL, 324 error_message TEXT 325 ) 326 `); 327 328 const result = getAllTranscriptions_test(emptyDb, 10); 329 330 expect(result.data.length).toBe(0); 331 expect(result.pagination.hasMore).toBe(false); 332 expect(result.pagination.nextCursor).toBeNull(); 333 334 emptyDb.close(); 335 }); 336});