馃 distributed transcription service
thistle.dunkirk.sh
1import { Database } from "bun:sqlite";
2import { expect, test, afterEach } 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<
164 { class_id: string; user_id: number },
165 []
166 >("SELECT class_id, user_id FROM class_members")
167 .get();
168
169 expect(enrollment?.class_id).toBe("class-1");
170 expect(enrollment?.user_id).toBe(userId);
171
172 // Delete class should cascade delete enrollment
173 db.run("DELETE FROM classes WHERE id = ?", ["class-1"]);
174
175 const enrollments = db
176 .query<{ class_id: string }, []>("SELECT class_id FROM class_members")
177 .all();
178 expect(enrollments.length).toBe(0);
179
180 db.close();
181});
182
183test("transcription status defaults to pending", () => {
184 const db = new Database(TEST_DB);
185
186 db.run(`
187 CREATE TABLE users (
188 id INTEGER PRIMARY KEY AUTOINCREMENT,
189 email TEXT UNIQUE NOT NULL,
190 password_hash TEXT
191 );
192
193 CREATE TABLE transcriptions (
194 id TEXT PRIMARY KEY,
195 user_id INTEGER NOT NULL,
196 class_id TEXT,
197 meeting_time_id TEXT,
198 filename TEXT NOT NULL,
199 original_filename TEXT NOT NULL,
200 status TEXT NOT NULL DEFAULT 'pending',
201 progress INTEGER NOT NULL DEFAULT 0,
202 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
203 updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
204 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
205 );
206 `);
207
208 db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [
209 "test@example.com",
210 "hash",
211 ]);
212 const userId = db
213 .query<{ id: number }, []>("SELECT last_insert_rowid() as id")
214 .get()?.id;
215
216 db.run(
217 "INSERT INTO transcriptions (id, user_id, filename, original_filename) VALUES (?, ?, ?, ?)",
218 ["trans-1", userId, "file.mp3", "original.mp3"],
219 );
220
221 const transcription = db
222 .query<
223 { status: string; progress: number },
224 []
225 >("SELECT status, progress FROM transcriptions WHERE id = 'trans-1'")
226 .get();
227
228 expect(transcription?.status).toBe("pending");
229 expect(transcription?.progress).toBe(0);
230
231 db.close();
232});