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:
+174
-176
@@ -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 }
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user