mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
test(core,import): rebalance database coverage
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user