import assert from 'node:assert/strict'; import test from 'node:test'; import { createExperienceService } from './experience-service.mjs'; // Minimal in-memory pool covering the SQL shapes experience-service issues: // INSERT, scope-filtered SELECT, and the use_count bump UPDATE. function createMockPool() { const rows = []; const query = async (sql, params = []) => { if (sql.includes('INSERT INTO h5_experience')) { const [ id, scope, kind, title, body, tagsJson, sourceSessionId, sourceUserId, createdAt, updatedAt, ] = params; rows.push({ id, scope, kind, title, body, tags_json: tagsJson, source_session_id: sourceSessionId, source_user_id: sourceUserId, use_count: 0, created_at: createdAt, updated_at: updatedAt, }); return [{ affectedRows: 1 }]; } if (sql.includes('FROM h5_experience') && sql.includes('WHERE scope = ?')) { const [scope] = params; const matched = rows .filter((r) => r.scope === scope) .sort((a, b) => b.updated_at - a.updated_at); return [matched.map((r) => ({ ...r }))]; } if (sql.includes('UPDATE h5_experience SET use_count')) { for (const id of params) { const row = rows.find((r) => r.id === id); if (row) row.use_count += 1; } return [{ affectedRows: params.length }]; } throw new Error(`unexpected SQL: ${sql}`); }; return { pool: { query }, rows }; } test('record persists an experience with normalized fields', async () => { const { pool, rows } = createMockPool(); const svc = createExperienceService(pool, { now: () => 1000 }); const saved = await svc.record({ title: 'Headscale 部署', body: '在 103 上用固定公网 IP 部署 headscale,注意防火墙放行 41641/udp。', tags: ['headscale', 'headscale', ' 网络 ', ''], }); assert.equal(rows.length, 1); assert.equal(saved.scope, 'global'); assert.equal(saved.kind, 'lesson'); assert.deepEqual(saved.tags, ['headscale', '网络']); assert.equal(saved.useCount, 0); }); test('record rejects empty title or body', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool); await assert.rejects(() => svc.record({ title: '', body: 'x' }), /标题不能为空/); await assert.rejects(() => svc.record({ title: 'x', body: '' }), /内容不能为空/); }); test('search ranks keyword matches and ignores empty queries', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool, { now: () => 1_000_000 }); await svc.record({ title: 'Headscale 部署', body: 'headscale 防火墙 udp' }); await svc.record({ title: '无关经验', body: '这条跟查询完全无关的内容' }); assert.deepEqual(await svc.search(' '), []); const hits = await svc.search('headscale 部署'); assert.equal(hits.length, 1); assert.equal(hits[0].title, 'Headscale 部署'); }); test('search applies recency decay so newer wins on equal keyword score', async () => { const { pool } = createMockPool(); let clock = 0; const svc = createExperienceService(pool, { now: () => clock, recencyHalfLifeMs: 1000, }); clock = 0; await svc.record({ title: '部署指南 A', body: 'deploy 部署 指南' }); clock = 10_000; // 10 half-lives newer await svc.record({ title: '部署指南 B', body: 'deploy 部署 指南' }); clock = 10_000; const hits = await svc.search('部署 deploy 指南', { limit: 2 }); assert.equal(hits[0].title, '部署指南 B'); }); test('search bumps use_count for returned rows', async () => { const { pool, rows } = createMockPool(); const svc = createExperienceService(pool, { now: () => 5 }); await svc.record({ title: 'Caddy 灰度', body: 'caddy weighted_round_robin 分流' }); await svc.search('caddy 灰度'); assert.equal(rows[0].use_count, 1); });