import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import {
getNotesDirectory,
saveNote,
readNote,
listNotes,
getAllNoteFiles,
saveNoteMetadata,
loadNoteMetadata,
} from '../storage.js';
import type { SessionNote } from '../../types/session.js';
describe('storage', () => {
const testDir = path.join(os.tmpdir(), 'second-brain-test-' + Date.now());
const originalEnv = process.env.SECOND_BRAIN_NOTES_DIR;
beforeEach(async () => {
await fs.mkdir(testDir, { recursive: true });
delete process.env.SECOND_BRAIN_NOTES_DIR;
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
if (originalEnv) {
process.env.SECOND_BRAIN_NOTES_DIR = originalEnv;
} else {
delete process.env.SECOND_BRAIN_NOTES_DIR;
}
});
describe('getNotesDirectory', () => {
it('should return config directory when provided', () => {
const result = getNotesDirectory({ notesDirectory: '/custom/path' });
expect(result).toBe('/custom/path');
});
it('should expand tilde in config directory', () => {
const result = getNotesDirectory({ notesDirectory: '~/notes' });
expect(result).toBe(path.join(os.homedir(), 'notes'));
});
it('should use env var when config not provided', () => {
process.env.SECOND_BRAIN_NOTES_DIR = '/env/path';
const result = getNotesDirectory();
expect(result).toBe('/env/path');
});
it('should expand tilde in env var', () => {
process.env.SECOND_BRAIN_NOTES_DIR = '~/env-notes';
const result = getNotesDirectory();
expect(result).toBe(path.join(os.homedir(), 'env-notes'));
});
it('should return default ~/notes when no config or env', () => {
const result = getNotesDirectory();
expect(result).toBe(path.join(os.homedir(), 'notes'));
});
it('should prioritize config over env var', () => {
process.env.SECOND_BRAIN_NOTES_DIR = '/env/path';
const result = getNotesDirectory({ notesDirectory: '/config/path' });
expect(result).toBe('/config/path');
});
it('should handle lone tilde', () => {
const result = getNotesDirectory({ notesDirectory: '~' });
expect(result).toBe(os.homedir());
});
});
describe('saveNote', () => {
it('should save note to correct directory', async () => {
const note: SessionNote = {
summary: 'Test session',
timestamp: '2025-01-15T10:30:00.000Z',
projectName: 'test-project',
};
const filePath = await saveNote(note, { notesDirectory: testDir });
expect(filePath).toContain('test-project');
expect(filePath).toMatch(/\.md$/);
const exists = await fs.access(filePath).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
it('should create project subdirectory', async () => {
const note: SessionNote = {
summary: 'Test session',
timestamp: '2025-01-15T10:30:00.000Z',
projectName: 'My Cool Project',
};
const filePath = await saveNote(note, { notesDirectory: testDir });
const dirName = path.dirname(filePath);
expect(dirName).toContain('my-cool-project');
});
it('should save note without project name', async () => {
const note: SessionNote = {
summary: 'Test session',
timestamp: '2025-01-15T10:30:00.000Z',
};
const filePath = await saveNote(note, { notesDirectory: testDir });
expect(filePath).toContain(testDir);
expect(filePath).toMatch(/\.md$/);
});
it('should format markdown correctly', async () => {
const note: SessionNote = {
summary: 'Test summary',
timestamp: '2025-01-15T10:30:00.000Z',
projectName: 'test-project',
topic: 'Test Topic',
tags: ['tag1', 'tag2'],
};
const filePath = await saveNote(note, { notesDirectory: testDir });
const content = await fs.readFile(filePath, 'utf-8');
expect(content).toContain('# test-project');
expect(content).toContain('## Test Topic');
expect(content).toContain('Test summary');
expect(content).toContain('#tag1');
expect(content).toContain('#tag2');
});
it('should save metadata file', async () => {
const note: SessionNote = {
summary: 'Test session',
timestamp: '2025-01-15T10:30:00.000Z',
projectName: 'test-project',
tags: ['test'],
};
const filePath = await saveNote(note, { notesDirectory: testDir });
const metaPath = filePath.replace(/\.md$/, '.meta.json');
const metaExists = await fs.access(metaPath).then(() => true).catch(() => false);
expect(metaExists).toBe(true);
const metaContent = await fs.readFile(metaPath, 'utf-8');
const metadata = JSON.parse(metaContent);
expect(metadata.summary).toBe('Test session');
expect(metadata.tags).toEqual(['test']);
});
});
describe('readNote', () => {
it('should read note content', async () => {
const testContent = '# Test Note\n\nThis is a test.';
const testFile = path.join(testDir, 'test.md');
await fs.writeFile(testFile, testContent, 'utf-8');
const content = await readNote(testFile);
expect(content).toBe(testContent);
});
it('should throw error for non-existent file', async () => {
await expect(readNote(path.join(testDir, 'nonexistent.md')))
.rejects.toThrow();
});
});
describe('listNotes', () => {
beforeEach(async () => {
const projectDir = path.join(testDir, 'test-project');
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(projectDir, '2025-01-15_10-00-00_note1.md'), 'Note 1', 'utf-8');
await fs.writeFile(path.join(projectDir, '2025-01-16_10-00-00_note2.md'), 'Note 2', 'utf-8');
await fs.writeFile(path.join(projectDir, 'readme.txt'), 'Not a note', 'utf-8');
});
it('should list all markdown files for project', async () => {
const notes = await listNotes('test-project', { notesDirectory: testDir });
expect(notes).toHaveLength(2);
expect(notes[0]).toMatch(/note2\.md$/);
expect(notes[1]).toMatch(/note1\.md$/);
});
it('should return most recent first', async () => {
const notes = await listNotes('test-project', { notesDirectory: testDir });
expect(notes[0]).toContain('2025-01-16');
expect(notes[1]).toContain('2025-01-15');
});
it('should return empty array for non-existent project', async () => {
const notes = await listNotes('nonexistent', { notesDirectory: testDir });
expect(notes).toEqual([]);
});
it('should list notes in root when no project specified', async () => {
await fs.writeFile(path.join(testDir, 'root-note.md'), 'Root note', 'utf-8');
const notes = await listNotes(undefined, { notesDirectory: testDir });
expect(notes.some(n => n.includes('root-note.md'))).toBe(true);
});
});
describe('getAllNoteFiles', () => {
beforeEach(async () => {
const project1Dir = path.join(testDir, 'project1');
const project2Dir = path.join(testDir, 'project2');
await fs.mkdir(project1Dir, { recursive: true });
await fs.mkdir(project2Dir, { recursive: true });
await fs.writeFile(path.join(project1Dir, 'note1.md'), 'Note 1', 'utf-8');
await fs.writeFile(path.join(project2Dir, 'note2.md'), 'Note 2', 'utf-8');
await fs.writeFile(path.join(testDir, 'root-note.md'), 'Root', 'utf-8');
});
it('should get all notes recursively with config object', async () => {
const notes = await getAllNoteFiles({ notesDirectory: testDir });
expect(notes).toHaveLength(3);
});
it('should get all notes recursively with string path', async () => {
const notes = await getAllNoteFiles(testDir);
expect(notes).toHaveLength(3);
});
it('should sort notes by most recent first', async () => {
const notes = await getAllNoteFiles(testDir);
for (let i = 0; i < notes.length - 1; i++) {
expect(notes[i] >= notes[i + 1]).toBe(true);
}
});
it('should return empty array for non-existent directory', async () => {
const notes = await getAllNoteFiles('/nonexistent/path');
expect(notes).toEqual([]);
});
it('should only include markdown files', async () => {
await fs.writeFile(path.join(testDir, 'test.txt'), 'Not markdown', 'utf-8');
const notes = await getAllNoteFiles(testDir);
expect(notes.every(n => n.endsWith('.md'))).toBe(true);
});
});
describe('saveNoteMetadata', () => {
it('should save metadata as JSON', async () => {
const filePath = path.join(testDir, 'test.md');
const note: SessionNote = {
summary: 'Test summary',
timestamp: '2025-01-15T10:30:00.000Z',
projectName: 'test-project',
topic: 'Test Topic',
tags: ['tag1', 'tag2'],
analysis: {
pattern: 'new-feature',
patternConfidence: 0.9,
complexity: 'moderate',
fileCount: 45,
keyFiles: [],
},
};
await saveNoteMetadata(filePath, note);
const metaPath = path.join(testDir, 'test.meta.json');
const exists = await fs.access(metaPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
const content = await fs.readFile(metaPath, 'utf-8');
const metadata = JSON.parse(content);
expect(metadata.summary).toBe('Test summary');
expect(metadata.projectName).toBe('test-project');
expect(metadata.topic).toBe('Test Topic');
expect(metadata.tags).toEqual(['tag1', 'tag2']);
expect(metadata.analysis.pattern).toBe('new-feature');
});
it('should create valid JSON format', async () => {
const filePath = path.join(testDir, 'test.md');
const note: SessionNote = {
summary: 'Test',
timestamp: '2025-01-15T10:30:00.000Z',
};
await saveNoteMetadata(filePath, note);
const metaPath = path.join(testDir, 'test.meta.json');
const content = await fs.readFile(metaPath, 'utf-8');
expect(() => JSON.parse(content)).not.toThrow();
});
});
describe('loadNoteMetadata', () => {
it('should load metadata from JSON file', async () => {
const filePath = path.join(testDir, 'test.md');
const metaPath = path.join(testDir, 'test.meta.json');
const metadata = {
summary: 'Test summary',
timestamp: '2025-01-15T10:30:00.000Z',
projectName: 'test-project',
tags: ['tag1'],
};
await fs.writeFile(metaPath, JSON.stringify(metadata), 'utf-8');
const loaded = await loadNoteMetadata(filePath);
expect(loaded).toEqual(metadata);
});
it('should return null for non-existent metadata', async () => {
const filePath = path.join(testDir, 'nonexistent.md');
const loaded = await loadNoteMetadata(filePath);
expect(loaded).toBeNull();
});
it('should return null for invalid JSON', async () => {
const filePath = path.join(testDir, 'test.md');
const metaPath = path.join(testDir, 'test.meta.json');
await fs.writeFile(metaPath, 'invalid json{', 'utf-8');
const loaded = await loadNoteMetadata(filePath);
expect(loaded).toBeNull();
});
});
});