馃 distributed transcription service
thistle.dunkirk.sh
1import { Database } from "bun:sqlite";
2import { afterAll, beforeAll, describe, expect, test } from "bun:test";
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 ["user1@test.com", "hash1", Math.floor(Date.now() / 1000) - 100, "user"],
175 );
176 testDb.run(
177 "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
178 ["user2@test.com", "hash2", Math.floor(Date.now() / 1000) - 50, "user"],
179 );
180 testDb.run(
181 "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
182 ["admin@test.com", "hash3", Math.floor(Date.now() / 1000), "admin"],
183 );
184
185 // Create test transcriptions
186 for (let i = 0; i < 5; i++) {
187 testDb.run(
188 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
189 [
190 `trans-${i}`,
191 1,
192 `file-${i}.mp3`,
193 `original-${i}.mp3`,
194 "completed",
195 Math.floor(Date.now() / 1000) - (100 - i * 10),
196 ],
197 );
198 }
199
200 // Create test classes
201 testDb.run(
202 "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
203 ["class-1", "CS101", "Intro to CS", "Dr. Smith", "Fall", 2024],
204 );
205 testDb.run(
206 "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
207 ["class-2", "CS102", "Data Structures", "Dr. Jones", "Spring", 2024],
208 );
209
210 // Add user to classes
211 testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [
212 1,
213 "class-1",
214 ]);
215 testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [
216 1,
217 "class-2",
218 ]);
219});
220
221afterAll(() => {
222 testDb.close();
223});
224
225describe("Transcription Pagination", () => {
226 test("returns first page without cursor", () => {
227 const result = getAllTranscriptions_test(testDb, 2);
228
229 expect(result.data.length).toBe(2);
230 expect(result.pagination.limit).toBe(2);
231 expect(result.pagination.hasMore).toBe(true);
232 expect(result.pagination.nextCursor).toBeTruthy();
233 });
234
235 test("returns second page with cursor", () => {
236 const page1 = getAllTranscriptions_test(testDb, 2);
237 const page2 = getAllTranscriptions_test(
238 testDb,
239 2,
240 page1.pagination.nextCursor || "",
241 );
242
243 expect(page2.data.length).toBe(2);
244 expect(page2.pagination.hasMore).toBe(true);
245 expect(page2.data[0]?.id).not.toBe(page1.data[0]?.id);
246 });
247
248 test("returns last page correctly", () => {
249 const result = getAllTranscriptions_test(testDb, 10);
250
251 expect(result.data.length).toBe(5);
252 expect(result.pagination.hasMore).toBe(false);
253 expect(result.pagination.nextCursor).toBeNull();
254 });
255
256 test("rejects invalid cursor format", () => {
257 expect(() => {
258 getAllTranscriptions_test(testDb, 10, "invalid-cursor");
259 }).toThrow("Invalid cursor format");
260 });
261
262 test("returns results ordered by created_at DESC", () => {
263 const result = getAllTranscriptions_test(testDb, 10);
264
265 for (let i = 0; i < result.data.length - 1; i++) {
266 const current = result.data[i];
267 const next = result.data[i + 1];
268 if (current && next) {
269 expect(current.created_at).toBeGreaterThanOrEqual(next.created_at);
270 }
271 }
272 });
273});
274
275describe("Cursor Format", () => {
276 test("transcription cursor format is base64url", () => {
277 const result = getAllTranscriptions_test(testDb, 1);
278 const cursor = result.pagination.nextCursor;
279
280 // Should be base64url-encoded (alphanumeric, no padding)
281 expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
282 expect(cursor).not.toContain("="); // No padding
283 expect(cursor).not.toContain("+"); // URL-safe
284 expect(cursor).not.toContain("/"); // URL-safe
285 });
286});
287
288describe("Limit Boundaries", () => {
289 test("respects minimum limit of 1", () => {
290 const result = getAllTranscriptions_test(testDb, 1);
291 expect(result.data.length).toBeLessThanOrEqual(1);
292 });
293
294 test("handles empty results", () => {
295 // Query with a user that has no transcriptions
296 const emptyDb = new Database(":memory:");
297 emptyDb.run(`
298 CREATE TABLE users (
299 id INTEGER PRIMARY KEY AUTOINCREMENT,
300 email TEXT UNIQUE NOT NULL,
301 password_hash TEXT,
302 name TEXT,
303 created_at INTEGER NOT NULL
304 )
305 `);
306 emptyDb.run(`
307 CREATE TABLE transcriptions (
308 id TEXT PRIMARY KEY,
309 user_id INTEGER NOT NULL,
310 filename TEXT NOT NULL,
311 original_filename TEXT NOT NULL,
312 status TEXT NOT NULL,
313 created_at INTEGER NOT NULL,
314 error_message TEXT
315 )
316 `);
317
318 const result = getAllTranscriptions_test(emptyDb, 10);
319
320 expect(result.data.length).toBe(0);
321 expect(result.pagination.hasMore).toBe(false);
322 expect(result.pagination.nextCursor).toBeNull();
323
324 emptyDb.close();
325 });
326});