import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { runMigrations } from '../db/migrate.js'; import { NotesRepository } from '../notes/notes-repository.js'; import { NotesService } from '../notes/notes-service.js'; import { buildInjectSection, InjectConfig } from './notes-inject.js'; describe('buildInjectSection', () => { let tmpRoot: string; let db: Database.Database; let service: NotesService; let bobUser: any; beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'inject-test-')); db = new Database(join(tmpRoot, 'test.db')); runMigrations(db); db.prepare(`INSERT INTO users (id, email, name) VALUES ('alice','a@x.com','Alice'),('bob','b@x.com','Bob')`).run(); const repo = new NotesRepository(db); service = new NotesService({ db, repo, userFolderRoot: tmpRoot, getUserOrgIds: () => ['team1'] }); bobUser = { id: 'bob', role: 'user', orgIds: [] }; }); afterEach(() => { db.close(); rmSync(tmpRoot, { recursive: true, force: true }); }); it('returns empty string when no inject subscriptions', () => { const out = buildInjectSection({ user: bobUser, service, config: { perNoteMaxKb: 8, totalMaxKb: 32, overBudgetStrategy: 'skip_remaining' } }); expect(out).toBe(''); }); it('emits section header and one note when subscribed', () => { service.writeNote({ ownerId: 'alice', folder: 'runbooks', fileName: 'failover.md', content: '---\nvisibility: public\n---\nstep 1 do X' }); service.upsertSubscription({ consumerUser: bobUser, publisherUserId: 'alice', folder: 'runbooks', mode: 'inject', enabled: 1 }); const out = buildInjectSection({ user: bobUser, service, config: { perNoteMaxKb: 8, totalMaxKb: 32, overBudgetStrategy: 'skip_remaining' } }); expect(out).toContain('## Subscribed Notes'); expect(out).toContain('### From Alice/runbooks/failover.md'); expect(out).toContain('step 1 do X'); }); it('skips notes over per_note_max_kb', () => { const big = 'x'.repeat(10 * 1024); // 10 KB body service.writeNote({ ownerId: 'alice', folder: 'big', fileName: 'huge.md', content: `---\nvisibility: public\n---\n${big}` }); service.upsertSubscription({ consumerUser: bobUser, publisherUserId: 'alice', folder: 'big', mode: 'inject', enabled: 1 }); const out = buildInjectSection({ user: bobUser, service, config: { perNoteMaxKb: 4, totalMaxKb: 32, overBudgetStrategy: 'skip_remaining' } }); expect(out).not.toContain('xxxx'); }); it('applies skip_remaining over total budget', () => { const med = 'y'.repeat(3 * 1024); // 3 KB service.writeNote({ ownerId: 'alice', folder: 'a', fileName: 'one.md', content: `---\nvisibility: public\n---\n${med}` }); service.writeNote({ ownerId: 'alice', folder: 'a', fileName: 'two.md', content: `---\nvisibility: public\n---\n${med}` }); service.writeNote({ ownerId: 'alice', folder: 'a', fileName: 'three.md', content: `---\nvisibility: public\n---\n${med}` }); service.upsertSubscription({ consumerUser: bobUser, publisherUserId: 'alice', folder: 'a', mode: 'inject', enabled: 1 }); const out = buildInjectSection({ user: bobUser, service, config: { perNoteMaxKb: 8, totalMaxKb: 4, overBudgetStrategy: 'skip_remaining' } }); // Only first note fits (≈ 3 KB, second would push past 4 KB total) const occurrences = (out.match(/### From Alice\//g) || []).length; expect(occurrences).toBe(1); }); it('degrade_to_search emits placeholder with skipped note names when over budget', () => { const med = 'z'.repeat(3 * 1024); // 3 KB each service.writeNote({ ownerId: 'alice', folder: 'kb', fileName: 'first.md', content: `---\nvisibility: public\n---\n${med}` }); service.writeNote({ ownerId: 'alice', folder: 'kb', fileName: 'second.md', content: `---\nvisibility: public\n---\n${med}` }); service.upsertSubscription({ consumerUser: bobUser, publisherUserId: 'alice', folder: 'kb', mode: 'inject', enabled: 1 }); // totalMaxKb=4 means the second note won't fit const out = buildInjectSection({ user: bobUser, service, config: { perNoteMaxKb: 8, totalMaxKb: 4, overBudgetStrategy: 'degrade_to_search' }, }); expect(out).toContain('## Subscribed Notes'); // First note fits, second goes into the placeholder expect(out).toContain('use SearchNotes'); // The skipped note should be listed in the placeholder expect(out).toContain('second.md'); }); it('returns deterministic order on equal updated_at (tiebreak by owner/folder/file)', () => { // Insert 2 notes at the same timestamp by direct DB manipulation db.prepare(`INSERT INTO note_index (owner_id, folder, file_name, title, visibility, visibility_scope_org_id, mode_hint, tags_json, content_size, content_hash, body, updated_at) VALUES ('alice','f','b.md','B','public',NULL,NULL,'[]', 10, 'h', 'body B', 999), ('alice','f','a.md','A','public',NULL,NULL,'[]', 10, 'h', 'body A', 999) `).run(); service.upsertSubscription({ consumerUser: bobUser, publisherUserId: 'alice', folder: 'f', mode: 'inject', enabled: 1 }); const out = buildInjectSection({ user: bobUser, service, config: { perNoteMaxKb: 8, totalMaxKb: 32, overBudgetStrategy: 'skip_remaining' } }); const aIdx = out.indexOf('### From Alice/f/a.md'); const bIdx = out.indexOf('### From Alice/f/b.md'); expect(aIdx).toBeGreaterThan(-1); expect(bIdx).toBeGreaterThan(-1); expect(aIdx).toBeLessThan(bIdx); }); });