馃 distributed transcription service
thistle.dunkirk.sh
1import { Database } from "bun:sqlite";
2import { afterEach, expect, test } from "bun:test";
3import { unlinkSync } from "node:fs";
4
5const TEST_DB = "test-schema.db";
6
7afterEach(() => {
8 try {
9 unlinkSync(TEST_DB);
10 } catch {
11 // File may not exist
12 }
13});
14
15test("schema creates all required tables", () => {
16 const db = new Database(TEST_DB);
17
18 // Create schema_migrations table
19 db.run(`
20 CREATE TABLE IF NOT EXISTS schema_migrations (
21 version INTEGER PRIMARY KEY,
22 applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
23 )
24 `);
25
26 // Apply migration (simplified version of migration 1)
27 const migration = `
28 CREATE TABLE IF NOT EXISTS users (
29 id INTEGER PRIMARY KEY AUTOINCREMENT,
30 email TEXT UNIQUE NOT NULL,
31 password_hash TEXT,
32 name TEXT,
33 avatar TEXT DEFAULT 'd',
34 role TEXT NOT NULL DEFAULT 'user',
35 last_login INTEGER,
36 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
37 );
38
39 CREATE TABLE IF NOT EXISTS classes (
40 id TEXT PRIMARY KEY,
41 course_code TEXT NOT NULL,
42 name TEXT NOT NULL,
43 professor TEXT NOT NULL,
44 semester TEXT NOT NULL,
45 year INTEGER NOT NULL,
46 archived BOOLEAN DEFAULT 0,
47 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
48 );
49
50 CREATE TABLE IF NOT EXISTS class_members (
51 class_id TEXT NOT NULL,
52 user_id INTEGER NOT NULL,
53 enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
54 PRIMARY KEY (class_id, user_id),
55 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
56 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
57 );
58
59 CREATE TABLE IF NOT EXISTS meeting_times (
60 id TEXT PRIMARY KEY,
61 class_id TEXT NOT NULL,
62 label TEXT NOT NULL,
63 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
64 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
65 );
66
67 CREATE TABLE IF NOT EXISTS transcriptions (
68 id TEXT PRIMARY KEY,
69 user_id INTEGER NOT NULL,
70 class_id TEXT,
71 meeting_time_id TEXT,
72 filename TEXT NOT NULL,
73 original_filename TEXT NOT NULL,
74 status TEXT NOT NULL DEFAULT 'pending',
75 progress INTEGER NOT NULL DEFAULT 0,
76 error_message TEXT,
77 whisper_job_id TEXT,
78 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
79 updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
80 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
81 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
82 FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL
83 );
84 `;
85
86 db.run(migration);
87
88 // Verify tables exist
89 const tables = db
90 .query<{ name: string }, []>(
91 "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
92 )
93 .all();
94
95 const tableNames = tables.map((t) => t.name);
96
97 expect(tableNames).toContain("users");
98 expect(tableNames).toContain("classes");
99 expect(tableNames).toContain("class_members");
100 expect(tableNames).toContain("meeting_times");
101 expect(tableNames).toContain("transcriptions");
102
103 db.close();
104});
105
106test("class foreign key constraints work", () => {
107 const db = new Database(TEST_DB);
108
109 // Create tables
110 db.run(`
111 CREATE TABLE users (
112 id INTEGER PRIMARY KEY AUTOINCREMENT,
113 email TEXT UNIQUE NOT NULL,
114 password_hash TEXT,
115 role TEXT NOT NULL DEFAULT 'user',
116 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
117 );
118
119 CREATE TABLE classes (
120 id TEXT PRIMARY KEY,
121 course_code TEXT NOT NULL,
122 name TEXT NOT NULL,
123 professor TEXT NOT NULL,
124 semester TEXT NOT NULL,
125 year INTEGER NOT NULL,
126 archived BOOLEAN DEFAULT 0,
127 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
128 );
129
130 CREATE TABLE class_members (
131 class_id TEXT NOT NULL,
132 user_id INTEGER NOT NULL,
133 enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
134 PRIMARY KEY (class_id, user_id),
135 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
136 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
137 );
138 `);
139
140 db.run("PRAGMA foreign_keys = ON");
141
142 // Create test data
143 db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [
144 "test@example.com",
145 "hash",
146 ]);
147 const userId = db
148 .query<{ id: number }, []>("SELECT last_insert_rowid() as id")
149 .get()?.id;
150
151 db.run(
152 "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
153 ["class-1", "CS 101", "Intro to CS", "Dr. Smith", "Fall", 2024],
154 );
155
156 // Enroll user in class
157 db.run("INSERT INTO class_members (class_id, user_id) VALUES (?, ?)", [
158 "class-1",
159 userId,
160 ]);
161
162 const enrollment = db
163 .query<{ class_id: string; user_id: number }, []>(
164 "SELECT class_id, user_id FROM class_members",
165 )
166 .get();
167
168 expect(enrollment?.class_id).toBe("class-1");
169 expect(enrollment?.user_id).toBe(userId);
170
171 // Delete class should cascade delete enrollment
172 db.run("DELETE FROM classes WHERE id = ?", ["class-1"]);
173
174 const enrollments = db
175 .query<{ class_id: string }, []>("SELECT class_id FROM class_members")
176 .all();
177 expect(enrollments.length).toBe(0);
178
179 db.close();
180});
181
182test("transcription status defaults to pending", () => {
183 const db = new Database(TEST_DB);
184
185 db.run(`
186 CREATE TABLE users (
187 id INTEGER PRIMARY KEY AUTOINCREMENT,
188 email TEXT UNIQUE NOT NULL,
189 password_hash TEXT
190 );
191
192 CREATE TABLE transcriptions (
193 id TEXT PRIMARY KEY,
194 user_id INTEGER NOT NULL,
195 class_id TEXT,
196 meeting_time_id TEXT,
197 filename TEXT NOT NULL,
198 original_filename TEXT NOT NULL,
199 status TEXT NOT NULL DEFAULT 'pending',
200 progress INTEGER NOT NULL DEFAULT 0,
201 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
202 updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
203 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
204 );
205 `);
206
207 db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [
208 "test@example.com",
209 "hash",
210 ]);
211 const userId = db
212 .query<{ id: number }, []>("SELECT last_insert_rowid() as id")
213 .get()?.id;
214
215 db.run(
216 "INSERT INTO transcriptions (id, user_id, filename, original_filename) VALUES (?, ?, ?, ?)",
217 ["trans-1", userId, "file.mp3", "original.mp3"],
218 );
219
220 const transcription = db
221 .query<{ status: string; progress: number }, []>(
222 "SELECT status, progress FROM transcriptions WHERE id = 'trans-1'",
223 )
224 .get();
225
226 expect(transcription?.status).toBe("pending");
227 expect(transcription?.progress).toBe(0);
228
229 db.close();
230});