test(core,import): rebalance database coverage

This commit is contained in:
digua committed 2026-06-08 10:14:42 +08:00
1 parent 2887f1cb73
commit 187268d8a5
2 files changed
+112 -210

No files matched your search

@@ -13,13 +13,3 @@ test('空字符串内容在写库归一化前后应生成同一个去重 key', (
assert.equal(keyAfterPersist, keyBeforePersist)
})
test('同秒同发送者但等长不同内容应生成不同去重 key', () => {
const timestamp = 1710000000
const senderPlatformId = 'user-1'
const firstKey = generateMessageKey(timestamp, senderPlatformId, '你好啊')
const secondKey = generateMessageKey(timestamp, senderPlatformId, '再见呀')
assert.notEqual(firstKey, secondKey)
})
@@ -9,6 +9,7 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import Database from 'better-sqlite3'
import {
DEFAULT_SESSION_GAP_THRESHOLD,
hasSessionIndex,
@@ -23,187 +24,98 @@ import {
} from '../session-queries'
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces'
// ==================== In-memory mock DB ====================
// ==================== SQLite test DB ====================
interface MockRow {
[key: string]: unknown
class SqlitePreparedStatement implements PreparedStatement {
readonly?: boolean
constructor(private stmt: Database.Statement) {
this.readonly = stmt.readonly
}
get(...params: unknown[]): Record<string, unknown> | undefined {
return this.stmt.get(...params) as Record<string, unknown> | undefined
}
all(...params: unknown[]): Record<string, unknown>[] {
return this.stmt.all(...params) as Record<string, unknown>[]
}
run(...params: unknown[]): RunResult {
const result = this.stmt.run(...params)
return { changes: result.changes, lastInsertRowid: result.lastInsertRowid }
}
}
/**
* Lightweight in-memory DB for testing session index write operations.
* Stores tables as arrays of rows and supports basic SQL pattern matching.
*/
function createInMemoryDb(): DatabaseAdapter & { tables: Record<string, MockRow[]> } {
const tables: Record<string, MockRow[]> = {
message: [],
segment: [],
message_context: [],
meta: [{ session_gap_threshold: null }],
sqlite_master: [],
}
let autoIncrement = 0
class TestSqliteDb implements DatabaseAdapter {
constructor(private db: Database.Database) {}
const db: DatabaseAdapter & { tables: Record<string, MockRow[]> } = {
tables,
prepare(sql: string): PreparedStatement {
return {
get(...params: unknown[]) {
return handleGet(sql, params)
},
all(...params: unknown[]) {
return handleAll(sql, params)
},
run(...params: unknown[]): RunResult {
return handleRun(sql, params)
},
}
},
exec(sql: string) {
if (sql.includes('DELETE FROM message_context')) {
tables.message_context = []
}
if (sql.includes('DELETE FROM segment')) {
tables.segment = []
autoIncrement = 0
}
},
transaction<T>(fn: () => T): T {
return fn()
},
pragma() {
return undefined
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
close() {},
exec(sql: string): void {
this.db.exec(sql)
}
function handleGet(sql: string, params: unknown[]): MockRow | undefined {
if (sql.includes('sqlite_master')) {
const tableName = (params[0] as string) ?? ''
return tables[tableName] ? { cnt: 1 } : { cnt: 0 }
}
if (sql.includes('COUNT(*)') && sql.includes('FROM segment')) {
return { count: tables.segment.length }
}
if (sql.includes('COUNT(*)') && sql.includes('FROM message')) {
return { count: tables.message.length }
}
if (sql.includes('session_gap_threshold') && sql.includes('meta')) {
return tables.meta[0] ?? { session_gap_threshold: null }
}
if (sql.includes('summary') && sql.includes('segment') && sql.includes('WHERE id')) {
const id = params[0] as number
const row = tables.segment.find((r) => r.id === id)
return row ? { summary: row.summary ?? null } : undefined
}
if (sql.includes('end_ts') && sql.includes('segment') && sql.includes('ORDER BY end_ts DESC')) {
const sorted = [...tables.segment].sort((a, b) => (b.end_ts as number) - (a.end_ts as number))
return sorted[0] ? { id: sorted[0].id, end_ts: sorted[0].end_ts } : undefined
}
return undefined
prepare(sql: string): PreparedStatement {
return new SqlitePreparedStatement(this.db.prepare(sql))
}
function handleAll(sql: string, params: unknown[]): MockRow[] {
if (sql.includes('sqlite_master')) {
const tableName = (params[0] as string) ?? ''
return tables[tableName] ? [{ cnt: 1 }] : [{ cnt: 0 }]
}
if (sql.includes('PRAGMA table_info')) {
return [{ name: 'id' }]
}
if (sql.includes('session_num')) {
const gt = params[0] as number
const msgs = [...tables.message].sort(
(a, b) => (a.ts as number) - (b.ts as number) || (a.id as number) - (b.id as number)
)
let sessionNum = 0
let prevTs: number | null = null
return msgs.map((m) => {
if (prevTs === null || (m.ts as number) - prevTs > gt) {
sessionNum++
}
prevTs = m.ts as number
return { id: m.id, ts: m.ts, session_num: sessionNum }
})
}
// segment list query (contains subquery on message_context) — must match before message_context
if (sql.includes('FROM segment') && sql.includes('ORDER BY')) {
return [...tables.segment]
.sort((a, b) => (a.start_ts as number) - (b.start_ts as number))
.map((s) => ({
id: s.id,
startTs: s.start_ts,
endTs: s.end_ts,
messageCount: s.message_count,
summary: s.summary ?? null,
firstMessageId:
tables.message_context
.filter((mc) => mc.segment_id === s.id)
.sort((a, b) => (a.message_id as number) - (b.message_id as number))[0]?.message_id ?? 0,
}))
}
if (sql.includes('message_context') && sql.includes('SELECT message_id')) {
return tables.message_context.map((r) => ({ message_id: r.message_id }))
}
if (sql.includes('FROM message') && sql.includes('ORDER BY ts')) {
return [...tables.message].sort(
(a, b) => (a.ts as number) - (b.ts as number) || (a.id as number) - (b.id as number)
)
}
return []
transaction<T>(fn: () => T): T {
return this.db.transaction(fn)()
}
function handleRun(sql: string, params: unknown[]): RunResult {
if (sql.includes('INSERT INTO segment')) {
autoIncrement++
tables.segment.push({
id: autoIncrement,
start_ts: params[0],
end_ts: params[1],
message_count: params[2],
is_manual: 0,
summary: null,
})
return { changes: 1, lastInsertRowid: autoIncrement }
}
if (sql.includes('INSERT') && sql.includes('message_context')) {
tables.message_context.push({ message_id: params[0], segment_id: params[1], topic_id: null })
return { changes: 1, lastInsertRowid: 0 }
}
if (sql.includes('UPDATE segment') && sql.includes('summary')) {
const row = tables.segment.find((r) => r.id === params[1])
if (row) row.summary = params[0]
return { changes: row ? 1 : 0, lastInsertRowid: 0 }
}
if (sql.includes('UPDATE segment') && sql.includes('end_ts')) {
const row = tables.segment.find((r) => r.id === params[2])
if (row) {
row.end_ts = params[0]
row.message_count = (row.message_count as number) + (params[1] as number)
}
return { changes: row ? 1 : 0, lastInsertRowid: 0 }
}
if (sql.includes('UPDATE meta') && sql.includes('session_gap_threshold')) {
if (tables.meta[0]) tables.meta[0].session_gap_threshold = params[0]
return { changes: 1, lastInsertRowid: 0 }
}
if (sql.includes('DELETE FROM segment')) {
tables.segment = []
autoIncrement = 0
return { changes: 0, lastInsertRowid: 0 }
}
if (sql.includes('DELETE FROM message_context')) {
tables.message_context = []
return { changes: 0, lastInsertRowid: 0 }
}
return { changes: 0, lastInsertRowid: 0 }
pragma(pragma: string): unknown {
return this.db.pragma(pragma)
}
close(): void {
this.db.close()
}
}
function createSqliteDb(): TestSqliteDb {
const db = new TestSqliteDb(new Database(':memory:'))
db.exec(`
CREATE TABLE message (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL
);
CREATE TABLE segment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL,
message_count INTEGER NOT NULL,
is_manual INTEGER DEFAULT 0,
summary TEXT
);
CREATE TABLE message_context (
message_id INTEGER NOT NULL,
segment_id INTEGER NOT NULL,
topic_id INTEGER
);
CREATE TABLE meta (
session_gap_threshold INTEGER
);
INSERT INTO meta (session_gap_threshold) VALUES (NULL);
`)
return db
}
function seedMessages(db: ReturnType<typeof createInMemoryDb>, msgs: Array<{ id: number; ts: number }>) {
db.tables.message = msgs.map((m) => ({ id: m.id, ts: m.ts }))
function seedMessages(db: DatabaseAdapter, msgs: Array<{ id: number; ts: number }>) {
const insert = db.prepare('INSERT INTO message (id, ts) VALUES (?, ?)')
for (const msg of msgs) {
insert.run(msg.id, msg.ts)
}
}
function countRows(db: DatabaseAdapter, table: 'segment' | 'message_context'): number {
const row = db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get() as { count: number }
return row.count
}
function getMetaGapThreshold(db: DatabaseAdapter): number | null {
const row = db.prepare('SELECT session_gap_threshold FROM meta LIMIT 1').get() as {
session_gap_threshold: number | null
}
return row.session_gap_threshold
}
// ==================== Tests ====================
@@ -216,12 +128,12 @@ describe('DEFAULT_SESSION_GAP_THRESHOLD', () => {
describe('hasSessionIndex', () => {
it('returns false when no sessions exist', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
assert.equal(hasSessionIndex(db), false)
})
it('returns true after generating sessions', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 1, ts: 1000 },
{ id: 2, ts: 1100 },
@@ -233,7 +145,7 @@ describe('hasSessionIndex', () => {
describe('getSessionIndexStats', () => {
it('returns defaults when no index exists', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
const stats = getSessionIndexStats(db)
assert.equal(stats.sessionCount, 0)
assert.equal(stats.hasIndex, false)
@@ -241,8 +153,8 @@ describe('getSessionIndexStats', () => {
})
it('returns custom gap threshold from meta', () => {
const db = createInMemoryDb()
db.tables.meta[0].session_gap_threshold = 900
const db = createSqliteDb()
updateSessionGapThreshold(db, 900)
const stats = getSessionIndexStats(db)
assert.equal(stats.gapThreshold, 900)
})
@@ -250,12 +162,12 @@ describe('getSessionIndexStats', () => {
describe('generateSessionIndex', () => {
it('returns 0 when no messages exist', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
assert.equal(generateSessionIndex(db), 0)
})
it('creates sessions based on gap threshold', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 1, ts: 1000 },
{ id: 2, ts: 1100 },
@@ -265,12 +177,12 @@ describe('generateSessionIndex', () => {
const count = generateSessionIndex(db, 2000)
assert.equal(count, 2)
assert.equal(db.tables.segment.length, 2)
assert.equal(db.tables.message_context.length, 4)
assert.equal(countRows(db, 'segment'), 2)
assert.equal(countRows(db, 'message_context'), 4)
})
it('puts all messages in one session when gap is large enough', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 1, ts: 1000 },
{ id: 2, ts: 1100 },
@@ -282,21 +194,21 @@ describe('generateSessionIndex', () => {
})
it('clears previous sessions before regenerating', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 1, ts: 1000 },
{ id: 2, ts: 5000 },
])
generateSessionIndex(db, 2000)
assert.equal(db.tables.segment.length, 2)
assert.equal(countRows(db, 'segment'), 2)
generateSessionIndex(db, 99999)
assert.equal(db.tables.segment.length, 1)
assert.equal(countRows(db, 'segment'), 1)
})
it('calls onProgress callback', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 1, ts: 1000 },
{ id: 2, ts: 5000 },
@@ -315,12 +227,12 @@ describe('generateSessionIndex', () => {
describe('getChatSessionList', () => {
it('returns empty array when no sessions', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
assert.deepEqual(getChatSessionList(db), [])
})
it('returns sessions with firstMessageId', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 10, ts: 1000 },
{ id: 20, ts: 1100 },
@@ -338,7 +250,7 @@ describe('getChatSessionList', () => {
describe('getSegmentSummary / saveSegmentSummary', () => {
it('returns null when no summary set', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [{ id: 1, ts: 1000 }])
generateSessionIndex(db)
@@ -346,7 +258,7 @@ describe('getSegmentSummary / saveSegmentSummary', () => {
})
it('saves and retrieves summary', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [{ id: 1, ts: 1000 }])
generateSessionIndex(db)
@@ -357,38 +269,38 @@ describe('getSegmentSummary / saveSegmentSummary', () => {
describe('updateSessionGapThreshold', () => {
it('updates gap threshold in meta', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
updateSessionGapThreshold(db, 900)
assert.equal(db.tables.meta[0].session_gap_threshold, 900)
assert.equal(getMetaGapThreshold(db), 900)
})
it('accepts null to reset', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
updateSessionGapThreshold(db, 900)
updateSessionGapThreshold(db, null)
assert.equal(db.tables.meta[0].session_gap_threshold, null)
assert.equal(getMetaGapThreshold(db), null)
})
})
describe('clearSessionIndex', () => {
it('removes all sessions and contexts', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 1, ts: 1000 },
{ id: 2, ts: 5000 },
])
generateSessionIndex(db, 2000)
assert.ok(db.tables.segment.length > 0)
assert.ok(countRows(db, 'segment') > 0)
clearSessionIndex(db)
assert.equal(db.tables.segment.length, 0)
assert.equal(db.tables.message_context.length, 0)
assert.equal(countRows(db, 'segment'), 0)
assert.equal(countRows(db, 'message_context'), 0)
})
})
describe('generateIncrementalSessionIndex', () => {
it('returns 0 when no new messages', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [{ id: 1, ts: 1000 }])
generateSessionIndex(db)
@@ -397,30 +309,30 @@ describe('generateIncrementalSessionIndex', () => {
})
it('creates new sessions for unindexed messages', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [
{ id: 1, ts: 1000 },
{ id: 2, ts: 1100 },
])
generateSessionIndex(db, 2000)
assert.equal(db.tables.segment.length, 1)
assert.equal(countRows(db, 'segment'), 1)
db.tables.message.push({ id: 3, ts: 50000 })
seedMessages(db, [{ id: 3, ts: 50000 }])
const newCount = generateIncrementalSessionIndex(db, 2000)
assert.equal(newCount, 1)
assert.equal(db.tables.segment.length, 2)
assert.equal(countRows(db, 'segment'), 2)
})
it('appends to existing session when within threshold', () => {
const db = createInMemoryDb()
const db = createSqliteDb()
seedMessages(db, [{ id: 1, ts: 1000 }])
generateSessionIndex(db, 2000)
db.tables.message.push({ id: 2, ts: 1500 })
seedMessages(db, [{ id: 2, ts: 1500 }])
const newCount = generateIncrementalSessionIndex(db, 2000)
assert.equal(newCount, 0, 'should not create new session')
assert.equal(db.tables.message_context.length, 2)
assert.equal(countRows(db, 'message_context'), 2)
})
})