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:
@@ -29,6 +29,29 @@ describe('profiles', () => {
|
||||
test('findProfileByEmail returns null for unknown email', () => {
|
||||
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', () => {
|
||||
@@ -107,6 +130,19 @@ describe('progress', () => {
|
||||
assert.equal(p.streak, 0)
|
||||
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', () => {
|
||||
@@ -140,3 +176,34 @@ describe('letter stats', () => {
|
||||
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