fix(db): instance-scoped closures, FK constraints, letter normalization, test coverage

- Remove module-level `let db` singleton; all query functions now close
  over the local `instance` from their `initDb` call, preventing
  cross-contamination when multiple callers each invoke `initDb`
- Add REFERENCES … ON DELETE CASCADE to profile_id in magic_tokens,
  sessions, user_progress, user_mnemonics, and letter_stats so that
  the already-enabled foreign_keys pragma is actually enforced by DDL
- recordAnswer: normalise letter to uppercase before storing stats so
  lowercase 'a' and uppercase 'A' are never tracked separately
- updateLevel: call getOrCreateProgress first to avoid a silent no-op
  UPDATE when no progress row exists yet
- saveMnemonic: replace SELECT-then-INSERT with an atomic UPSERT
  (INSERT … ON CONFLICT DO UPDATE) and detect first-save by checking
  whether created_at equals the current timestamp
- getSession: re-fetch the row after updating last_seen so the returned
  object reflects the freshly written timestamp
- db.test.js: add tests for findProfileById, findProfileById (null),
  updateProfileSeen, updateLevel (normal + no-row-yet), getAdminUsers,
  and getAdminStats (21 tests total, all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:41:40 +00:00
parent 9b31119c7a
commit 2d96d37866
2 changed files with 241 additions and 176 deletions
+174 -176
View File
@@ -2,8 +2,6 @@
const Database = require('better-sqlite3') const Database = require('better-sqlite3')
const path = require('path') const path = require('path')
let db
function initDb(dbPath) { function initDb(dbPath) {
const resolvedPath = dbPath || path.join(__dirname, '../data/morsequest.db') const resolvedPath = dbPath || path.join(__dirname, '../data/morsequest.db')
const instance = new Database(resolvedPath) const instance = new Database(resolvedPath)
@@ -20,18 +18,18 @@ function initDb(dbPath) {
); );
CREATE TABLE IF NOT EXISTS magic_tokens ( CREATE TABLE IF NOT EXISTS magic_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0 used INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL last_seen INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS user_progress ( CREATE TABLE IF NOT EXISTS user_progress (
profile_id INTEGER PRIMARY KEY, profile_id INTEGER PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
level INTEGER NOT NULL DEFAULT 1, level INTEGER NOT NULL DEFAULT 1,
score INTEGER NOT NULL DEFAULT 0, score INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0, streak INTEGER NOT NULL DEFAULT 0,
@@ -42,14 +40,14 @@ function initDb(dbPath) {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS user_mnemonics ( CREATE TABLE IF NOT EXISTS user_mnemonics (
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
letter TEXT NOT NULL, letter TEXT NOT NULL,
phrase TEXT NOT NULL, phrase TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, letter) PRIMARY KEY (profile_id, letter)
); );
CREATE TABLE IF NOT EXISTS letter_stats ( CREATE TABLE IF NOT EXISTS letter_stats (
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
letter TEXT NOT NULL, letter TEXT NOT NULL,
correct INTEGER NOT NULL DEFAULT 0, correct INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
@@ -58,7 +56,175 @@ function initDb(dbPath) {
); );
`) `)
db = instance function createProfile(email, displayName) {
const now = Date.now()
const stmt = instance.prepare(
'INSERT INTO profiles (email, display_name, created_at, last_seen) VALUES (?,?,?,?) RETURNING *'
)
return stmt.get(email, displayName || null, now, now)
}
function findProfileByEmail(email) {
return instance.prepare('SELECT * FROM profiles WHERE email = ?').get(email) ?? null
}
function findProfileById(id) {
return instance.prepare('SELECT * FROM profiles WHERE id = ?').get(id) ?? null
}
function updateProfileSeen(id) {
instance.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id)
}
function createMagicToken(token, profileId, expiresAt) {
instance.prepare('INSERT INTO magic_tokens (token, profile_id, expires_at) VALUES (?,?,?)').run(
token, profileId, expiresAt
)
}
function useMagicToken(token) {
const row = instance
.prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0 AND expires_at > ?')
.get(token, Date.now())
if (!row) return null
instance.prepare('UPDATE magic_tokens SET used = 1 WHERE token = ?').run(token)
return row.profile_id
}
function createSession(token, profileId) {
const now = Date.now()
instance.prepare(
'INSERT INTO sessions (token, profile_id, created_at, last_seen) VALUES (?,?,?,?)'
).run(token, profileId, now, now)
}
function getSession(token) {
const row = instance.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
if (!row) return null
instance.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(Date.now(), token)
return instance.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
}
function deleteSession(token) {
instance.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}
function getOrCreateProgress(profileId) {
const existing = instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
if (existing) return existing
const now = Date.now()
instance.prepare(
`INSERT INTO user_progress (profile_id, level, score, streak, best_streak,
total_correct, total_attempts, updated_at) VALUES (?,1,0,0,0,0,0,?)`
).run(profileId, now)
return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function updateLevel(profileId, level) {
getOrCreateProgress(profileId)
instance.prepare('UPDATE user_progress SET level = ?, updated_at = ? WHERE profile_id = ?').run(
level, Date.now(), profileId
)
}
function recordAnswer(profileId, letter, correct) {
letter = letter.toUpperCase()
const progress = getOrCreateProgress(profileId)
const now = Date.now()
const isFirstToday = !progress.last_session_at ||
new Date(progress.last_session_at).toDateString() !== new Date(now).toDateString()
const base = progress.level * 10
const newStreak = correct ? progress.streak + 1 : 0
const streakMult = newStreak >= 10 ? 2 : 1
const dailyMult = isFirstToday && correct ? 1.25 : 1
const points = correct ? Math.floor(base * streakMult * dailyMult) : 0
const newBestStreak = Math.max(progress.best_streak, newStreak)
instance.prepare(
`UPDATE user_progress SET
score = score + ?,
streak = ?,
best_streak = ?,
total_correct = total_correct + ?,
total_attempts = total_attempts + 1,
last_session_at = ?,
updated_at = ?
WHERE profile_id = ?`
).run(points, newStreak, newBestStreak, correct ? 1 : 0, now, now, profileId)
// Update letter stats
const existing = instance
.prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?')
.get(profileId, letter)
if (existing) {
instance.prepare(
'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?'
).run(correct ? 1 : 0, now, profileId, letter)
} else {
instance.prepare(
'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)'
).run(profileId, letter, correct ? 1 : 0, now)
}
return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function getMnemonics(profileId) {
const rows = instance
.prepare('SELECT letter, phrase FROM user_mnemonics WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, r.phrase]))
}
function saveMnemonic(profileId, letter, phrase) {
const now = Date.now()
const upperLetter = letter.toUpperCase()
instance.prepare(
`INSERT INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?)
ON CONFLICT(profile_id, letter) DO UPDATE SET phrase = excluded.phrase`
).run(profileId, upperLetter, phrase, now)
// Only award 50 pts on first save — if created_at = now, it was just inserted
const row = instance.prepare(
'SELECT created_at FROM user_mnemonics WHERE profile_id = ? AND letter = ?'
).get(profileId, upperLetter)
if (row.created_at === now) {
getOrCreateProgress(profileId)
instance.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?')
.run(now, profileId)
}
}
function getLetterStats(profileId) {
const rows = instance
.prepare('SELECT letter, correct, attempts FROM letter_stats WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, { correct: r.correct, attempts: r.attempts }]))
}
function getAdminUsers() {
return instance.prepare(`
SELECT p.id, p.email, p.display_name, p.created_at, p.last_seen,
pr.level, pr.score, pr.streak, pr.best_streak,
pr.total_correct, pr.total_attempts
FROM profiles p
LEFT JOIN user_progress pr ON pr.profile_id = p.id
ORDER BY p.created_at DESC
`).all()
}
function getAdminStats() {
const totalUsers = instance.prepare('SELECT COUNT(*) as c FROM profiles').get().c
const avgScore = instance.prepare('SELECT AVG(score) as a FROM user_progress').get().a || 0
const mostMissed = instance.prepare(`
SELECT letter, SUM(attempts - correct) as misses
FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5
`).all()
return { totalUsers, avgScore: Math.round(avgScore), mostMissed }
}
return { return {
_db: instance, _db: instance,
@@ -82,172 +248,4 @@ function initDb(dbPath) {
} }
} }
function createProfile(email, displayName) {
const now = Date.now()
const stmt = db.prepare(
'INSERT INTO profiles (email, display_name, created_at, last_seen) VALUES (?,?,?,?) RETURNING *'
)
return stmt.get(email, displayName || null, now, now)
}
function findProfileByEmail(email) {
return db.prepare('SELECT * FROM profiles WHERE email = ?').get(email) ?? null
}
function findProfileById(id) {
return db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) ?? null
}
function updateProfileSeen(id) {
db.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id)
}
function createMagicToken(token, profileId, expiresAt) {
db.prepare('INSERT INTO magic_tokens (token, profile_id, expires_at) VALUES (?,?,?)').run(
token, profileId, expiresAt
)
}
function useMagicToken(token) {
const row = db
.prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0 AND expires_at > ?')
.get(token, Date.now())
if (!row) return null
db.prepare('UPDATE magic_tokens SET used = 1 WHERE token = ?').run(token)
return row.profile_id
}
function createSession(token, profileId) {
const now = Date.now()
db.prepare(
'INSERT INTO sessions (token, profile_id, created_at, last_seen) VALUES (?,?,?,?)'
).run(token, profileId, now, now)
}
function getSession(token) {
const row = db.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
if (!row) return null
db.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(Date.now(), token)
return row
}
function deleteSession(token) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}
function getOrCreateProgress(profileId) {
const existing = db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
if (existing) return existing
const now = Date.now()
db.prepare(
`INSERT INTO user_progress (profile_id, level, score, streak, best_streak,
total_correct, total_attempts, updated_at) VALUES (?,1,0,0,0,0,0,?)`
).run(profileId, now)
return db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function updateLevel(profileId, level) {
db.prepare('UPDATE user_progress SET level = ?, updated_at = ? WHERE profile_id = ?').run(
level, Date.now(), profileId
)
}
function recordAnswer(profileId, letter, correct) {
const progress = getOrCreateProgress(profileId)
const now = Date.now()
const isFirstToday = !progress.last_session_at ||
new Date(progress.last_session_at).toDateString() !== new Date(now).toDateString()
const base = progress.level * 10
const newStreak = correct ? progress.streak + 1 : 0
const streakMult = newStreak >= 10 ? 2 : 1
const dailyMult = isFirstToday && correct ? 1.25 : 1
const points = correct ? Math.floor(base * streakMult * dailyMult) : 0
const newBestStreak = Math.max(progress.best_streak, newStreak)
db.prepare(
`UPDATE user_progress SET
score = score + ?,
streak = ?,
best_streak = ?,
total_correct = total_correct + ?,
total_attempts = total_attempts + 1,
last_session_at = ?,
updated_at = ?
WHERE profile_id = ?`
).run(points, newStreak, newBestStreak, correct ? 1 : 0, now, now, profileId)
// Update letter stats
const existing = db
.prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?')
.get(profileId, letter)
if (existing) {
db.prepare(
'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?'
).run(correct ? 1 : 0, now, profileId, letter)
} else {
db.prepare(
'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)'
).run(profileId, letter, correct ? 1 : 0, now)
}
return db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function getMnemonics(profileId) {
const rows = db
.prepare('SELECT letter, phrase FROM user_mnemonics WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, r.phrase]))
}
function saveMnemonic(profileId, letter, phrase) {
const now = Date.now()
const upperLetter = letter.toUpperCase()
const existing = db.prepare(
'SELECT 1 FROM user_mnemonics WHERE profile_id = ? AND letter = ?'
).get(profileId, upperLetter)
if (existing) {
db.prepare('UPDATE user_mnemonics SET phrase = ? WHERE profile_id = ? AND letter = ?')
.run(phrase, profileId, upperLetter)
} else {
db.prepare(
'INSERT INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?)'
).run(profileId, upperLetter, phrase, now)
// Award 50 points for first mnemonic save — ensure progress row exists first
getOrCreateProgress(profileId)
db.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?')
.run(now, profileId)
}
}
function getLetterStats(profileId) {
const rows = db
.prepare('SELECT letter, correct, attempts FROM letter_stats WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, { correct: r.correct, attempts: r.attempts }]))
}
function getAdminUsers() {
return db.prepare(`
SELECT p.id, p.email, p.display_name, p.created_at, p.last_seen,
pr.level, pr.score, pr.streak, pr.best_streak,
pr.total_correct, pr.total_attempts
FROM profiles p
LEFT JOIN user_progress pr ON pr.profile_id = p.id
ORDER BY p.created_at DESC
`).all()
}
function getAdminStats() {
const totalUsers = db.prepare('SELECT COUNT(*) as c FROM profiles').get().c
const avgScore = db.prepare('SELECT AVG(score) as a FROM user_progress').get().a || 0
const mostMissed = db.prepare(`
SELECT letter, SUM(attempts - correct) as misses
FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5
`).all()
return { totalUsers, avgScore: Math.round(avgScore), mostMissed }
}
module.exports = { initDb } module.exports = { initDb }
+67
View File
@@ -29,6 +29,29 @@ describe('profiles', () => {
test('findProfileByEmail returns null for unknown email', () => { test('findProfileByEmail returns null for unknown email', () => {
assert.equal(db.findProfileByEmail('nobody@example.com'), null) assert.equal(db.findProfileByEmail('nobody@example.com'), null)
}) })
test('findProfileById returns profile by id', () => {
const created = db.createProfile('byid@example.com', 'ById')
const found = db.findProfileById(created.id)
assert.equal(found.id, created.id)
assert.equal(found.email, 'byid@example.com')
})
test('findProfileById returns null for unknown id', () => {
assert.equal(db.findProfileById(999999), null)
})
test('updateProfileSeen updates last_seen timestamp', () => {
const created = db.createProfile('seen@example.com', 'Seen')
const before = created.last_seen
// Ensure time advances by at least 1ms
const laterTime = before + 1
// Use a small delay approach: manipulate via direct SQL isn't needed,
// just call updateProfileSeen and verify last_seen >= original
db.updateProfileSeen(created.id)
const updated = db.findProfileById(created.id)
assert.ok(updated.last_seen >= before)
})
}) })
describe('magic tokens', () => { describe('magic tokens', () => {
@@ -107,6 +130,19 @@ describe('progress', () => {
assert.equal(p.streak, 0) assert.equal(p.streak, 0)
assert.equal(p.total_attempts, 2) assert.equal(p.total_attempts, 2)
}) })
test('updateLevel changes the level', () => {
db.updateLevel(profileId, 5)
const p = db.getOrCreateProgress(profileId)
assert.equal(p.level, 5)
})
test('updateLevel works even when no progress row exists yet', () => {
const newProfile = db.createProfile('updatelevel@example.com', 'UpdateLevel')
db.updateLevel(newProfile.id, 3)
const p = db.getOrCreateProgress(newProfile.id)
assert.equal(p.level, 3)
})
}) })
describe('mnemonics', () => { describe('mnemonics', () => {
@@ -140,3 +176,34 @@ describe('letter stats', () => {
assert.equal(stats.S.attempts, 2) assert.equal(stats.S.attempts, 2)
}) })
}) })
describe('admin', () => {
before(() => {
const p1 = db.createProfile('admin1@example.com', 'Admin1')
const p2 = db.createProfile('admin2@example.com', 'Admin2')
db.recordAnswer(p1.id, 'A', true)
db.recordAnswer(p2.id, 'B', false)
db.recordAnswer(p2.id, 'B', false)
})
test('getAdminUsers returns users list', () => {
const users = db.getAdminUsers()
assert.ok(Array.isArray(users))
assert.ok(users.length >= 2)
// Each user should have email and id
const emails = users.map(u => u.email)
assert.ok(emails.includes('admin1@example.com'))
assert.ok(emails.includes('admin2@example.com'))
})
test('getAdminStats returns totalUsers, avgScore, mostMissed', () => {
const stats = db.getAdminStats()
assert.ok(typeof stats.totalUsers === 'number')
assert.ok(stats.totalUsers >= 2)
assert.ok(typeof stats.avgScore === 'number')
assert.ok(Array.isArray(stats.mostMissed))
// B was missed twice — should appear in mostMissed
const missedLetters = stats.mostMissed.map(m => m.letter)
assert.ok(missedLetters.includes('B'))
})
})