fix(web): preserve session not-found and member history behavior

This commit is contained in:
digua authored and digua committed 2026-05-22 10:20:39 +08:00
1 parent 5f76e65359
commit 1e7325197c
5 files changed
+135 -6

No files matched your search

@@ -0,0 +1,34 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import Fastify from 'fastify'
import type { SessionRuntimeAdapter } from '@openchatlab/node-runtime'
import { registerSessionRoutes } from './sessions'
function createMissingSessionAdapter(): SessionRuntimeAdapter {
return {
listSessionIds: () => [],
openReadonly: () => null,
openWritable: () => null,
closeSession: () => {},
getDbPath: (sessionId) => `/tmp/${sessionId}.db`,
deleteSessionFile: () => false,
ensureReadonly: () => {
throw Object.assign(new Error('Session not found'), { statusCode: 404 })
},
ensureWritable: () => {
throw Object.assign(new Error('Session not found'), { statusCode: 404 })
},
}
}
describe('CLI Web session routes', () => {
it('returns 404 when requesting a missing session by id', async () => {
const app = Fastify()
registerSessionRoutes(app, createMissingSessionAdapter())
const response = await app.inject({ method: 'GET', url: '/_web/sessions/missing' })
await app.close()
assert.equal(response.statusCode, 404)
})
})
+5 -1
View File
@@ -7,7 +7,11 @@ export function registerSessionRoutes(server: FastifyInstance, adapter: SessionR
})
server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => {
return sessionService.getAnalysisSession(adapter, request.params.id)
const session = sessionService.getAnalysisSession(adapter, request.params.id)
if (!session) {
throw Object.assign(new Error(`Session not found: ${request.params.id}`), { statusCode: 404 })
}
return session
})
server.delete<{ Params: { id: string } }>('/_web/sessions/:id', async (request, reply) => {
@@ -11,7 +11,7 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { getMembersWithAliases, getMembersPaginated } from '../message-queries'
import { getMemberNameHistory, getMembersWithAliases, getMembersPaginated } from '../message-queries'
import type { DatabaseAdapter, PreparedStatement } from '../../interfaces'
// ==================== Mock helpers ====================
@@ -157,6 +157,39 @@ function createMockDb(opts: { hasAliases?: boolean; hasAvatar?: boolean } = {}):
}
}
function createNameHistoryDb(opts: {
hasHistoryTable: boolean
historyRows?: Record<string, unknown>[]
}): DatabaseAdapter {
return {
prepare(sql: string): PreparedStatement {
return {
get(...params: unknown[]) {
if (sql.includes('sqlite_master') && params.includes('member_name_history')) {
return { cnt: opts.hasHistoryTable ? 1 : 0 }
}
return undefined
},
all() {
if (sql.includes('FROM member_name_history')) {
return opts.historyRows ?? []
}
if (sql.includes('FROM message')) {
return [
{ accountName: 'Alice', groupNickname: 'A', startTs: 1000, endTs: 1500 },
{ accountName: 'Alice Chen', groupNickname: null, startTs: 2000, endTs: 2500 },
]
}
return []
},
run() {
return { changes: 0, lastInsertRowid: 0 }
},
}
},
} as unknown as DatabaseAdapter
}
// ==================== getMembersWithAliases ====================
describe('getMembersWithAliases', () => {
@@ -198,6 +231,30 @@ describe('getMembersWithAliases', () => {
})
})
// ==================== getMemberNameHistory ====================
describe('getMemberNameHistory', () => {
it('derives history from message rows when member_name_history table is absent', () => {
const result = getMemberNameHistory(createNameHistoryDb({ hasHistoryTable: false }), 1)
assert.deepEqual(result, [
{ nameType: 'account_name', name: 'Alice', startTs: 1000, endTs: 1500 },
{ nameType: 'group_nickname', name: 'A', startTs: 1000, endTs: 1500 },
{ nameType: 'account_name', name: 'Alice Chen', startTs: 2000, endTs: 2500 },
])
})
it('derives history from message rows when member_name_history table is empty', () => {
const result = getMemberNameHistory(createNameHistoryDb({ hasHistoryTable: true, historyRows: [] }), 1)
assert.deepEqual(result, [
{ nameType: 'account_name', name: 'Alice', startTs: 1000, endTs: 1500 },
{ nameType: 'group_nickname', name: 'A', startTs: 1000, endTs: 1500 },
{ nameType: 'account_name', name: 'Alice Chen', startTs: 2000, endTs: 2500 },
])
})
})
// ==================== getMembersPaginated ====================
describe('getMembersPaginated', () => {
+36 -2
View File
@@ -453,6 +453,38 @@ export interface MemberNameHistoryEntry {
endTs: number | null
}
function getMemberNameHistoryFromMessages(db: DatabaseAdapter, memberId: number): MemberNameHistoryEntry[] {
const rows = db
.prepare(
`SELECT
sender_account_name as accountName,
sender_group_nickname as groupNickname,
MIN(ts) as startTs,
MAX(ts) as endTs
FROM message
WHERE sender_id = ?
GROUP BY sender_account_name, sender_group_nickname
ORDER BY startTs`
)
.all(memberId) as unknown as Array<{
accountName: string | null
groupNickname: string | null
startTs: number
endTs: number | null
}>
const history: MemberNameHistoryEntry[] = []
for (const row of rows) {
if (row.accountName) {
history.push({ nameType: 'account_name', name: row.accountName, startTs: row.startTs, endTs: row.endTs })
}
if (row.groupNickname) {
history.push({ nameType: 'group_nickname', name: row.groupNickname, startTs: row.startTs, endTs: row.endTs })
}
}
return history
}
export interface MemberWithAliases {
id: number
platformId: string
@@ -646,14 +678,16 @@ export function getConversationBetween(
* Get name change history for a member
*/
export function getMemberNameHistory(db: DatabaseAdapter, memberId: number): MemberNameHistoryEntry[] {
if (!hasTable(db, 'member_name_history')) return []
if (!hasTable(db, 'member_name_history')) return getMemberNameHistoryFromMessages(db, memberId)
return db
const history = db
.prepare(
`SELECT name_type as nameType, name, start_ts as startTs, end_ts as endTs
FROM member_name_history WHERE member_id = ? ORDER BY start_ts DESC`
)
.all(memberId) as unknown as MemberNameHistoryEntry[]
return history.length > 0 ? history : getMemberNameHistoryFromMessages(db, memberId)
}
/**
@@ -96,8 +96,8 @@ export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string,
}
/**
* Get member name change history from the member_name_history table.
* Uses core's getMemberNameHistory (not the web.ts message-derived version).
* Get member name change history.
* Core prefers member_name_history and falls back to message-derived names for legacy sessions.
*/
export function getMemberNameHistory(adapter: SessionRuntimeAdapter, sessionId: string, memberId: number) {
const db = adapter.ensureReadonly(sessionId)