mock api testing

This commit is contained in:
Chris Lettieri 2026-02-25 08:03:28 -05:00
parent f23b331902
commit 2d3c5d75ec
24 changed files with 3009 additions and 1188 deletions

4
.gitignore vendored
View file

@ -22,3 +22,7 @@ data.json
.DS_Store
*.env
# Test output artifacts
tests/vault-output/
PLAN.md

119
PLAN.md
View file

@ -1,119 +0,0 @@
# Plan: Automated Testing for OpenAugi Plugin
## Problem
Currently all testing is manual: open Obsidian, create notes, run commands, inspect output. This means:
- Claude Code can't verify changes without you manually testing
- No regression safety net
- Slow iteration cycle
## Analysis: Obsidian API Coupling
After reviewing every service, here's the coupling breakdown:
| Layer | Services | Obsidian APIs Used |
|-------|----------|--------------------|
| **Pure logic** (no Obsidian) | `OpenAIService`, `sanitizeFilename`, `BacklinkMapper`, `estimateTokens` | None - just `fetch` and string ops |
| **Light coupling** | `FileService` | `Vault.adapter.exists`, `Vault.createFolder`, `Vault.create` |
| **Heavy coupling** | `DistillService`, `ContextGatheringService` | `MetadataCache`, `vault.read`, `vault.getMarkdownFiles`, link resolution, Dataview API |
## Strategy: Mock Obsidian, Test Against Real Files
We create a **mock Obsidian API** backed by Node.js `fs`, pointed at a **test vault** with fixture markdown files. This lets us test ~80% of logic without Obsidian running.
No E2E/Electron testing for now — that's complex, fragile, and low-value compared to service-level testing.
## What We'll Build
### 1. Add Vitest (test framework)
- Install `vitest` as dev dependency
- Add `npm test` and `npm run test:watch` scripts
- Configure to handle TypeScript, mock the `obsidian` module globally
### 2. Create mock Obsidian API (`tests/mocks/obsidian-mock.ts`)
A filesystem-backed mock that simulates:
- **`TFile`** — wraps real files with `path`, `basename`, `extension`, `stat`
- **`Vault`** — `read()`, `create()`, `getMarkdownFiles()`, `adapter.exists()`, `createFolder()`, `getAbstractFileByPath()` — all backed by Node.js `fs`
- **`MetadataCache`** — parses `[[wikilinks]]` from markdown files to build `resolvedLinks`, `getFileCache()`, and `getFirstLinkpathDest()`
- **`App`** — ties vault + metadataCache together
- **`Notice`** — no-op (just logs)
This is ~150 lines of code and gives us the ability to test DistillService, ContextGatheringService, and FileService against real markdown files.
### 3. Create test vault with fixtures (`tests/vault/`)
A small set of markdown files committed to the repo that cover key scenarios:
```
tests/vault/
├── Root Note.md # Has [[links]] to other notes
├── Linked Note A.md # Forward-linked from root
├── Linked Note B.md # Forward-linked from root
├── Backlink Source.md # Links back to Root Note
├── Journal Note.md # Has ### 2026-02-20 date headers
├── Dataview Note.md # Has ```dataview blocks
├── Collection Note.md # Has - [x] [[checked]] checkboxes
├── Context Note.md # Has context: section
├── Special Characters!.md # Tests filename sanitization
├── Deeply Linked/
│ └── Deep Note.md # Tests depth traversal
└── Excluded Folder/
└── Should Skip.md # Tests folder exclusion
```
### 4. Write tests
**Pure unit tests** (no mocks needed):
- `filename-utils.test.ts``sanitizeFilename`, `BacklinkMapper.processBacklinks`
- `openai-service.test.ts` — prompt construction, `extractCustomContext`, response parsing (mock `fetch`)
**Integration tests** (with mock Obsidian):
- `distill-service.test.ts` — link extraction, backlink discovery, content aggregation, journal filtering, date parsing
- `context-gathering-service.test.ts` — BFS traversal, character limits, folder exclusion, backlink snippets
- `file-service.test.ts` — file creation, collision handling, session folders, backlink mapping
### 5. npm scripts
```json
{
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
```
## What This Enables
After setup, Claude Code can:
1. Make a code change
2. Run `npm test`
3. See if anything broke
4. Add new test cases for edge cases discovered during development
You can also add test notes to `tests/vault/` to capture edge cases you find during manual testing — they become permanent regression tests.
## File Changes Summary
| Action | File |
|--------|------|
| Install | `vitest` dev dependency |
| Create | `vitest.config.ts` — test config |
| Create | `tests/mocks/obsidian-mock.ts` — mock Obsidian API |
| Create | `tests/vault/*.md` — ~10 fixture notes |
| Create | `tests/filename-utils.test.ts` |
| Create | `tests/openai-service.test.ts` |
| Create | `tests/distill-service.test.ts` |
| Create | `tests/file-service.test.ts` |
| Create | `tests/context-gathering-service.test.ts` |
| Update | `package.json` — add test scripts + vitest dep |
| Update | `tsconfig.json` — exclude tests from build |
| Update | `.gitignore` — add `tests/vault-output/` for test artifacts |
## Not in Scope (for now)
- **E2E testing with real Obsidian** — requires Playwright + Electron orchestration, fragile
- **OpenAI API integration tests** — would cost real API credits; we mock `fetch` instead
- **UI modal testing** — Obsidian modals are tightly coupled to the DOM
- **Task dispatch testing** — requires tmux, hard to automate in CI

2509
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
@ -19,6 +21,7 @@
"esbuild": "^0.17.3",
"obsidian": "^1.8.7",
"tslib": "2.4.0",
"typescript": "^4.7.4"
"typescript": "^4.7.4",
"vitest": "^3.2.4"
}
}

View file

@ -0,0 +1,264 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ContextGatheringService } from '../src/services/context-gathering-service';
import { DistillService } from '../src/services/distill-service';
import { OpenAIService } from '../src/services/openai-service';
import { DEFAULT_SETTINGS } from '../src/types/settings';
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
import * as path from 'path';
const VAULT_DIR = path.resolve(__dirname, 'vault');
describe('ContextGatheringService', () => {
let app: ReturnType<typeof createMockApp>;
let distillService: DistillService;
let service: ContextGatheringService;
beforeEach(() => {
app = createMockApp(VAULT_DIR);
const openAIService = new OpenAIService('test-key', 'gpt-5');
distillService = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
service = new ContextGatheringService(app as any, distillService, DEFAULT_SETTINGS);
});
describe('gatherContext - linked-notes mode', () => {
it('discovers root note and its forward links at depth 1', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: false,
backlinkContextLines: 0,
});
const names = result.notes.map(n => n.file.basename);
expect(names).toContain('Root Note');
expect(names).toContain('Linked Note A');
expect(names).toContain('Linked Note B');
expect(result.totalNotes).toBeGreaterThanOrEqual(3);
});
it('respects depth limit', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: false,
backlinkContextLines: 0,
});
// At depth 1, we should NOT see "Deep Note" (which is depth 2: Root → A → Deep)
const names = result.notes.map(n => n.file.basename);
expect(names).not.toContain('Deep Note');
});
it('discovers deeper links at depth 2', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 2,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: false,
backlinkContextLines: 0,
});
// At depth 2, "Deep Note" should be discovered (Root → Linked Note A → Deep Note)
const names = result.notes.map(n => n.file.basename);
expect(names).toContain('Deep Note');
});
it('includes backlinks when enabled', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: true,
backlinkContextLines: 0,
});
const names = result.notes.map(n => n.file.basename);
// "Backlink Source" links TO Root Note, so should be discovered
expect(names).toContain('Backlink Source');
// Verify the backlink note is marked as such
const backlinkNote = result.notes.find(n => n.file.basename === 'Backlink Source');
expect(backlinkNote?.isBacklink).toBe(true);
expect(backlinkNote?.discoveredVia).toContain('backlink');
});
it('respects character limit', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
// Use a very small character limit
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 2,
maxCharacters: 200, // Very small — root note alone should exceed this
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: false,
backlinkContextLines: 0,
});
// Some notes should be excluded due to size limit
const excludedNotes = result.notes.filter(n => !n.included);
expect(excludedNotes.length).toBeGreaterThan(0);
});
it('throws when root note is missing in linked-notes mode', async () => {
await expect(
service.gatherContext({
sourceMode: 'linked-notes',
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
})
).rejects.toThrow('Root note required');
});
});
describe('folder exclusion', () => {
it('excludes notes from specified folders', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 3,
maxCharacters: 100000,
excludeFolders: ['Excluded Folder'],
filterRecentSectionsOnly: false,
includeBacklinks: true,
backlinkContextLines: 0,
});
const includedNames = result.notes
.filter(n => n.included)
.map(n => n.file.basename);
expect(includedNames).not.toContain('Should Skip');
});
});
describe('aggregateContent', () => {
it('aggregates forward links and backlinks separately', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const gathered = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: true,
backlinkContextLines: 0,
});
expect(gathered.aggregatedContent).toContain('# Note:');
expect(gathered.totalCharacters).toBeGreaterThan(0);
});
it('includes backlink snippets in aggregated content', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const gathered = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: true,
backlinkContextLines: 0,
});
// Backlink content should be in the aggregated output
if (gathered.notes.some(n => n.isBacklink)) {
expect(gathered.aggregatedContent).toContain('# Backlink:');
}
});
});
describe('gatherContext - recent-activity mode', () => {
it('throws when time window is missing', async () => {
await expect(
service.gatherContext({
sourceMode: 'recent-activity',
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
})
).rejects.toThrow('Time window required');
});
});
describe('note metadata', () => {
it('assigns correct depth to discovered notes', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 2,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: false,
backlinkContextLines: 0,
});
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
expect(rootEntry?.depth).toBe(0);
const linkedA = result.notes.find(n => n.file.basename === 'Linked Note A');
expect(linkedA?.depth).toBe(1);
const deepNote = result.notes.find(n => n.file.basename === 'Deep Note');
if (deepNote) {
expect(deepNote.depth).toBe(2);
}
});
it('includes discoveredVia metadata', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const result = await service.gatherContext({
sourceMode: 'linked-notes',
rootNote: rootFile,
linkDepth: 1,
maxCharacters: 100000,
excludeFolders: [],
filterRecentSectionsOnly: false,
includeBacklinks: false,
backlinkContextLines: 0,
});
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
expect(rootEntry?.discoveredVia).toBe('root');
const linkedEntry = result.notes.find(n => n.file.basename === 'Linked Note A');
expect(linkedEntry?.discoveredVia).toContain('linked from');
});
});
});

View file

@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DistillService } from '../src/services/distill-service';
import { OpenAIService } from '../src/services/openai-service';
import { DEFAULT_SETTINGS } from '../src/types/settings';
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
import * as path from 'path';
const VAULT_DIR = path.resolve(__dirname, 'vault');
describe('DistillService', () => {
let app: ReturnType<typeof createMockApp>;
let openAIService: OpenAIService;
let service: DistillService;
beforeEach(() => {
app = createMockApp(VAULT_DIR);
openAIService = new OpenAIService('test-key', 'gpt-5');
service = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
});
describe('getLinkedNotes', () => {
it('extracts forward links from a note', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const linked = await service.getLinkedNotes(rootFile);
const basenames = linked.map(f => f.basename);
expect(basenames).toContain('Linked Note A');
expect(basenames).toContain('Linked Note B');
});
it('does not include the root file itself', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const linked = await service.getLinkedNotes(rootFile);
const paths = linked.map(f => f.path);
expect(paths).not.toContain('Root Note.md');
});
it('deduplicates linked files', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const linked = await service.getLinkedNotes(rootFile);
const paths = linked.map(f => f.path);
const uniquePaths = [...new Set(paths)];
expect(paths.length).toBe(uniquePaths.length);
});
it('extracts only checked items from collection notes', async () => {
const collectionFile = createTestTFile(VAULT_DIR, 'Collection Note.md');
const linked = await service.getLinkedNotes(collectionFile);
const basenames = linked.map(f => f.basename);
// Only [x] items should be included
expect(basenames).toContain('Linked Note A');
expect(basenames).toContain('Journal Note');
// Unchecked items should NOT be included
expect(basenames).not.toContain('Linked Note B');
expect(basenames).not.toContain('Backlink Source');
});
it('handles notes with no links', async () => {
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
const linked = await service.getLinkedNotes(deepNote);
expect(linked).toHaveLength(0);
});
});
describe('getBacklinksForFile', () => {
it('finds notes that link TO the target file', () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const backlinks = service.getBacklinksForFile(rootFile);
const basenames = backlinks.map(f => f.basename);
expect(basenames).toContain('Backlink Source');
expect(basenames).toContain('Special Characters!');
});
it('returns empty array for notes with no backlinks', () => {
const excludedFile = createTestTFile(VAULT_DIR, 'Excluded Folder/Should Skip.md');
const backlinks = service.getBacklinksForFile(excludedFile);
expect(backlinks).toHaveLength(0);
});
});
describe('getBacklinkSnippets', () => {
it('extracts context around backlink references', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const backlinkSource = createTestTFile(VAULT_DIR, 'Backlink Source.md');
const snippets = await service.getBacklinkSnippets(rootFile, backlinkSource, 0);
expect(snippets.length).toBeGreaterThan(0);
// Header section mode (0) should include the section header
expect(snippets[0].snippet).toContain('Section About Root');
expect(snippets[0].snippet).toContain('Root Note');
});
it('returns empty for files with no links to target', async () => {
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
const snippets = await service.getBacklinkSnippets(rootFile, deepNote, 0);
expect(snippets).toHaveLength(0);
});
});
describe('aggregateContent', () => {
it('combines content from multiple files', async () => {
const files = [
createTestTFile(VAULT_DIR, 'Linked Note A.md'),
createTestTFile(VAULT_DIR, 'Linked Note B.md'),
];
const result = await service.aggregateContent(files);
expect(result.content).toContain('# Note: Linked Note A');
expect(result.content).toContain('# Note: Linked Note B');
expect(result.content).toContain('atomic note-taking');
expect(result.content).toContain('Zettelkasten');
expect(result.sourceNotes).toContain('Linked Note A');
expect(result.sourceNotes).toContain('Linked Note B');
});
it('deduplicates files by path', async () => {
const fileA = createTestTFile(VAULT_DIR, 'Linked Note A.md');
const fileDuplicate = createTestTFile(VAULT_DIR, 'Linked Note A.md');
const result = await service.aggregateContent([fileA, fileDuplicate]);
// Should only appear once
const occurrences = (result.content.match(/# Note: Linked Note A/g) || []).length;
expect(occurrences).toBe(1);
});
it('strips dataview queries from output', async () => {
const files = [createTestTFile(VAULT_DIR, 'Dataview Note.md')];
const result = await service.aggregateContent(files);
expect(result.content).not.toContain('```dataview');
expect(result.content).not.toContain('TABLE file.mtime');
expect(result.content).toContain('Regular content after the dataview block');
});
});
describe('journal date filtering', () => {
it('identifies journal-style notes with date headers', () => {
// Access private method via any cast
const content = '# Daily\n\n### 2026-02-25\nToday entry\n\n### 2026-02-20\nOlder entry';
const result = (service as any).isJournalStyleNote(content);
expect(result).toBe(true);
});
it('rejects non-journal notes', () => {
const content = '# Regular Note\n\nJust some content without date headers.';
const result = (service as any).isJournalStyleNote(content);
expect(result).toBe(false);
});
it('extracts date sections correctly', () => {
const content = '# Header\n\n### 2026-02-25\nRecent\n\n### 2026-01-15\nOlder';
const sections = (service as any).getDateSections(content);
expect(sections.length).toBe(3); // header + 2 dated sections
expect(sections[0].date).toBeNull(); // undated header
expect(sections[1].date).toBeInstanceOf(Date);
expect(sections[2].date).toBeInstanceOf(Date);
});
it('filters content by date range', () => {
const content = '# Daily Journal\n\n### 2026-02-25\nRecent entry\n\n### 2025-01-01\nVery old entry';
const filtered = (service as any).extractContentByDateRange(content, 30);
expect(filtered).toContain('Recent entry');
expect(filtered).not.toContain('Very old entry');
});
});
describe('extractDateFromFilename', () => {
it('extracts date from YYYY-MM-DD prefix', () => {
const date = (service as any).extractDateFromFilename('2026-02-25 My Note');
expect(date).toBeInstanceOf(Date);
expect(date.getFullYear()).toBe(2026);
expect(date.getMonth()).toBe(1); // 0-indexed
expect(date.getDate()).toBe(25);
});
it('returns null for non-date filenames', () => {
const date = (service as any).extractDateFromFilename('My Regular Note');
expect(date).toBeNull();
});
});
describe('dataview query handling', () => {
it('detects dataview queries in content', () => {
const content = 'Some text\n\n```dataview\nLIST FROM #tag\n```\n\nMore text';
expect((service as any).containsDataviewQuery(content)).toBe(true);
});
it('returns false for content without dataview', () => {
const content = 'Regular markdown content\n\n```javascript\nconsole.log("hi")\n```';
expect((service as any).containsDataviewQuery(content)).toBe(false);
});
it('extracts dataview query strings', () => {
const content = 'Text\n\n```dataview\nLIST FROM #tag\n```\n\n```dataview\nTABLE file.name\n```';
const queries = (service as any).extractDataviewQueries(content);
expect(queries).toHaveLength(2);
expect(queries[0]).toContain('LIST FROM #tag');
expect(queries[1]).toContain('TABLE file.name');
});
it('strips dataview queries from content', () => {
const content = 'Before\n\n```dataview\nLIST\n```\nAfter';
const stripped = (service as any).stripDataviewQueries(content);
expect(stripped).not.toContain('```dataview');
expect(stripped).toContain('Before');
expect(stripped).toContain('After');
});
});
describe('tag stripping', () => {
it('strips simple tags', () => {
const result = (service as any).stripTags('Some text #tag more text');
expect(result).not.toContain('#tag');
expect(result).toContain('Some text');
expect(result).toContain('more text');
});
it('strips nested tags', () => {
const result = (service as any).stripTags('Content #nested/tag here');
expect(result).not.toContain('#nested/tag');
});
it('preserves markdown headers', () => {
const result = (service as any).stripTags('## Header\n\nContent #tag');
expect(result).toContain('## Header');
expect(result).not.toContain('#tag');
});
});
});

217
tests/file-service.test.ts Normal file
View file

@ -0,0 +1,217 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { FileService } from '../src/services/file-service';
import { MockApp } from './mocks/obsidian-mock';
import { TranscriptResponse, DistillResponse } from '../src/types/transcript';
import * as path from 'path';
import * as fs from 'fs';
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
let testCounter = 0;
describe('FileService', () => {
let app: MockApp;
let service: FileService;
let outputDir: string;
beforeEach(() => {
// Use unique subdirectory per test to avoid parallel cleanup races
testCounter++;
outputDir = path.join(BASE_OUTPUT_DIR, `run-${testCounter}-${Date.now()}`);
fs.mkdirSync(outputDir, { recursive: true });
app = new MockApp(outputDir);
service = new FileService(
app as any,
'OpenAugi/Summaries',
'OpenAugi/Notes',
'OpenAugi/Published'
);
});
describe('ensureDirectoriesExist', () => {
it('creates all output directories', async () => {
await service.ensureDirectoriesExist();
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Notes'))).toBe(true);
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Published'))).toBe(true);
});
it('is idempotent (safe to call multiple times)', async () => {
await service.ensureDirectoriesExist();
await service.ensureDirectoriesExist();
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
});
});
describe('writeTranscriptFiles', () => {
const mockTranscriptData: TranscriptResponse = {
summary: 'This transcript discusses [[Atomic Notes]] and [[Knowledge Management]].',
notes: [
{ title: 'Atomic Notes', content: 'Each note should contain one idea. See [[Knowledge Management]].' },
{ title: 'Knowledge Management', content: 'Systems for organizing knowledge. Related to [[Atomic Notes]].' },
],
tasks: [
'- [ ] Review [[Atomic Notes]] methodology',
'- [ ] Set up Zettelkasten system',
],
};
it('creates summary file with backlinks', async () => {
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
expect(fs.existsSync(summaryPath)).toBe(true);
const content = fs.readFileSync(summaryPath, 'utf-8');
expect(content).toContain('Atomic Notes');
expect(content).toContain('Knowledge Management');
});
it('creates atomic note files in session folder', async () => {
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
const sessionFolders = fs.readdirSync(notesDir);
expect(sessionFolders.length).toBe(1);
expect(sessionFolders[0]).toMatch(/^Transcript \d{4}-\d{2}-\d{2}/);
const sessionPath = path.join(notesDir, sessionFolders[0]);
const noteFiles = fs.readdirSync(sessionPath);
expect(noteFiles).toContain('Atomic Notes.md');
expect(noteFiles).toContain('Knowledge Management.md');
});
it('includes tasks section in summary', async () => {
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
const content = fs.readFileSync(summaryPath, 'utf-8');
expect(content).toContain('## Tasks');
expect(content).toContain('Review');
});
it('processes backlinks in note content', async () => {
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
const sessionFolders = fs.readdirSync(notesDir);
const sessionPath = path.join(notesDir, sessionFolders[0]);
const atomicContent = fs.readFileSync(path.join(sessionPath, 'Atomic Notes.md'), 'utf-8');
// Backlinks should reference sanitized filenames
expect(atomicContent).toContain('[[Knowledge Management]]');
});
it('sanitizes filenames with special characters', async () => {
const dataWithSpecialChars: TranscriptResponse = {
summary: 'Summary of [[Note: Special]]',
notes: [
{ title: 'Note: Special', content: 'Content with special title' },
],
tasks: [],
};
await service.writeTranscriptFiles('Test', dataWithSpecialChars);
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
const sessionFolders = fs.readdirSync(notesDir);
const sessionPath = path.join(notesDir, sessionFolders[0]);
const noteFiles = fs.readdirSync(sessionPath);
// Colon should be sanitized
expect(noteFiles.some(f => f.includes(':'))).toBe(false);
expect(noteFiles.some(f => f.includes('Note'))).toBe(true);
});
});
describe('writeDistilledFiles', () => {
const mockDistillData: DistillResponse = {
summary: 'Distilled insights about [[Topic A]] and [[Topic B]].',
notes: [
{ title: 'Topic A', content: 'Deep analysis of A' },
{ title: 'Topic B', content: 'Overview of B, see also [[Topic A]]' },
],
tasks: ['- [ ] Follow up on Topic A'],
sourceNotes: ['Root Note', 'Linked Note A'],
};
it('creates distilled summary file', async () => {
// Create a mock TFile for the root
const { createTestTFile } = await import('./mocks/obsidian-mock');
// We need a file that exists in the output dir, so create it first
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
fs.writeFileSync(path.join(outputDir, 'test/My Root.md'), 'root content');
const rootFile = createTestTFile(outputDir, 'test/My Root.md');
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
expect(summaryPath).toContain('My Root - distilled');
expect(fs.existsSync(path.join(outputDir, summaryPath))).toBe(true);
});
it('creates atomic notes in session folder', async () => {
const { createTestTFile } = await import('./mocks/obsidian-mock');
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
const rootFile = createTestTFile(outputDir, 'test/Root.md');
await service.writeDistilledFiles(rootFile, mockDistillData);
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
const sessionFolders = fs.readdirSync(notesDir);
expect(sessionFolders.length).toBe(1);
expect(sessionFolders[0]).toMatch(/^Distill Root/);
const sessionPath = path.join(notesDir, sessionFolders[0]);
const noteFiles = fs.readdirSync(sessionPath);
expect(noteFiles).toContain('Topic A.md');
expect(noteFiles).toContain('Topic B.md');
});
it('includes source notes in summary', async () => {
const { createTestTFile } = await import('./mocks/obsidian-mock');
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
const rootFile = createTestTFile(outputDir, 'test/Root.md');
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
const content = fs.readFileSync(path.join(outputDir, summaryPath), 'utf-8');
expect(content).toContain('## Source Notes');
expect(content).toContain('[[Root Note]]');
expect(content).toContain('[[Linked Note A]]');
});
});
describe('writePublishedPost', () => {
it('creates published file with frontmatter', async () => {
const content = '# My Great Post\n\nThis is the blog post content.';
const filePath = await service.writePublishedPost(content, ['Source A', 'Source B'], 'default');
expect(filePath).toContain('Published');
const fullContent = fs.readFileSync(path.join(outputDir, filePath), 'utf-8');
expect(fullContent).toContain('---');
expect(fullContent).toContain('type: published-post');
expect(fullContent).toContain('status: draft');
expect(fullContent).toContain('[[Source A]]');
expect(fullContent).toContain('# My Great Post');
});
it('extracts title from first heading', async () => {
const content = '# Test Title\n\nContent here.';
const filePath = await service.writePublishedPost(content, ['Source'], 'default');
expect(filePath).toContain('Test Title');
});
it('falls back to source note name when no heading', async () => {
const content = 'No heading, just content.';
const filePath = await service.writePublishedPost(content, ['My Source Note'], 'default');
expect(filePath).toContain('My Source Note');
});
});
});

View file

@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { sanitizeFilename, BacklinkMapper, createFileWithCollisionHandling } from '../src/utils/filename-utils';
import { MockVault } from './mocks/obsidian-mock';
import * as path from 'path';
import * as fs from 'fs';
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
let collisionTestDir: string;
let collisionTestCounter = 0;
describe('sanitizeFilename', () => {
it('passes through clean filenames unchanged', () => {
expect(sanitizeFilename('My Note')).toBe('My Note');
expect(sanitizeFilename('simple-name')).toBe('simple-name');
});
it('replaces backslash', () => {
expect(sanitizeFilename('path\\to\\file')).toBe('path - to - file');
});
it('replaces forward slash', () => {
expect(sanitizeFilename('path/to/file')).toBe('path - to - file');
});
it('replaces colon', () => {
expect(sanitizeFilename('Note: Important')).toBe('Note - Important');
});
it('replaces asterisk', () => {
expect(sanitizeFilename('Note*star')).toBe('Note - star');
});
it('replaces question mark', () => {
expect(sanitizeFilename('Is this a note?')).toBe('Is this a note - ');
});
it('replaces double quotes', () => {
expect(sanitizeFilename('He said "hello"')).toBe('He said - hello - ');
});
it('replaces angle brackets', () => {
expect(sanitizeFilename('<tag>')).toBe(' - tag - ');
});
it('replaces pipe', () => {
expect(sanitizeFilename('A | B')).toBe('A - B');
});
it('handles multiple special characters', () => {
const result = sanitizeFilename('Note: "Important" <test> | value');
expect(result).not.toMatch(/[\\/:*?"<>|]/);
});
});
describe('BacklinkMapper', () => {
let mapper: BacklinkMapper;
beforeEach(() => {
mapper = new BacklinkMapper();
});
it('maps registered titles to sanitized filenames in backlinks', () => {
mapper.registerTitle('My Important Note', 'My Important Note');
mapper.registerTitle('Note: Special', 'Note - Special');
const result = mapper.processBacklinks('See [[Note: Special]] and [[My Important Note]]');
expect(result).toBe('See [[Note - Special]] and [[My Important Note]]');
});
it('falls back to sanitizeFilename for unregistered titles', () => {
const result = mapper.processBacklinks('See [[Unregistered: Note]]');
expect(result).toBe('See [[Unregistered - Note]]');
});
it('handles content with no backlinks', () => {
const result = mapper.processBacklinks('Plain text with no links');
expect(result).toBe('Plain text with no links');
});
it('handles multiple backlinks in same line', () => {
mapper.registerTitle('A', 'a-sanitized');
mapper.registerTitle('B', 'b-sanitized');
const result = mapper.processBacklinks('Links: [[A]] and [[B]]');
expect(result).toBe('Links: [[a-sanitized]] and [[b-sanitized]]');
});
});
describe('createFileWithCollisionHandling', () => {
beforeEach(() => {
collisionTestCounter++;
collisionTestDir = path.join(BASE_OUTPUT_DIR, `collision-${collisionTestCounter}-${Date.now()}`);
fs.mkdirSync(collisionTestDir, { recursive: true });
});
it('creates file at original path when no collision', async () => {
const vault = new MockVault(collisionTestDir);
const result = await createFileWithCollisionHandling(vault as any, 'test-note.md', 'Hello');
expect(result).toBe('test-note.md');
const content = fs.readFileSync(path.join(collisionTestDir, 'test-note.md'), 'utf-8');
expect(content).toBe('Hello');
});
it('appends -1 when file already exists', async () => {
const vault = new MockVault(collisionTestDir);
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'existing');
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'New content');
expect(result).toBe('note-1.md');
const content = fs.readFileSync(path.join(collisionTestDir, 'note-1.md'), 'utf-8');
expect(content).toBe('New content');
});
it('increments counter for multiple collisions', async () => {
const vault = new MockVault(collisionTestDir);
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'v1');
fs.writeFileSync(path.join(collisionTestDir, 'note-1.md'), 'v2');
fs.writeFileSync(path.join(collisionTestDir, 'note-2.md'), 'v3');
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'v4');
expect(result).toBe('note-3.md');
});
});

View file

@ -0,0 +1,287 @@
/**
* Filesystem-backed mock of the Obsidian API.
* Reads/writes real markdown files from a test vault directory so we can
* test DistillService, FileService, ContextGatheringService, etc. without
* running Obsidian.
*/
import * as fs from 'fs';
import * as path from 'path';
import { TFile } from './obsidian-module';
// ─── Link Parsing ────────────────────────────────────────────────────────────
interface ParsedLink {
link: string; // The raw link text (e.g., "Note A" or "Note A|alias")
original: string; // The full match including brackets
position: { start: { line: number; col: number }; end: { line: number; col: number } };
}
/** Parse [[wikilinks]] from markdown content */
function parseWikilinks(content: string): ParsedLink[] {
const links: ParsedLink[] = [];
const lines = content.split('\n');
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
const line = lines[lineNum];
const regex = /\[\[(.*?)\]\]/g;
let match;
while ((match = regex.exec(line)) !== null) {
const rawLink = match[1];
// Strip alias: [[path|alias]] → path
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
links.push({
link: linkPath,
original: match[0],
position: {
start: { line: lineNum, col: match.index },
end: { line: lineNum, col: match.index + match[0].length },
},
});
}
}
return links;
}
// ─── MockTFile helpers ───────────────────────────────────────────────────────
/** Create a TFile from a real filesystem path relative to vault root */
function createMockTFile(vaultRoot: string, relativePath: string): TFile {
const absPath = path.join(vaultRoot, relativePath);
let mtime = Date.now();
try {
const stat = fs.statSync(absPath);
mtime = stat.mtimeMs;
} catch { /* file may not exist yet for output tests */ }
const file = new TFile(relativePath, mtime);
file.stat.size = (() => {
try { return fs.statSync(absPath).size; } catch { return 0; }
})();
return file;
}
// ─── MockVaultAdapter ────────────────────────────────────────────────────────
class MockVaultAdapter {
constructor(private vaultRoot: string) {}
async exists(filePath: string): Promise<boolean> {
return fs.existsSync(path.join(this.vaultRoot, filePath));
}
async stat(filePath: string): Promise<{ mtime: number; ctime: number; size: number } | null> {
const absPath = path.join(this.vaultRoot, filePath);
try {
const stat = fs.statSync(absPath);
return { mtime: stat.mtimeMs, ctime: stat.ctimeMs, size: stat.size };
} catch {
return null;
}
}
}
// ─── MockVault ───────────────────────────────────────────────────────────────
export class MockVault {
adapter: MockVaultAdapter;
private vaultRoot: string;
constructor(vaultRoot: string) {
this.vaultRoot = vaultRoot;
this.adapter = new MockVaultAdapter(vaultRoot);
}
/** Read a file's content */
async read(file: TFile): Promise<string> {
const absPath = path.join(this.vaultRoot, file.path);
return fs.readFileSync(absPath, 'utf-8');
}
/** Create a file */
async create(filePath: string, content: string): Promise<TFile> {
const absPath = path.join(this.vaultRoot, filePath);
const dir = path.dirname(absPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(absPath, content, 'utf-8');
return createMockTFile(this.vaultRoot, filePath);
}
/** Create a folder */
async createFolder(folderPath: string): Promise<void> {
const absPath = path.join(this.vaultRoot, folderPath);
if (!fs.existsSync(absPath)) {
fs.mkdirSync(absPath, { recursive: true });
}
}
/** Get all markdown files in the vault */
getMarkdownFiles(): TFile[] {
const files: TFile[] = [];
const walk = (dir: string, relative: string) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const relPath = relative ? `${relative}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(path.join(dir, entry.name), relPath);
} else if (entry.name.endsWith('.md')) {
files.push(createMockTFile(this.vaultRoot, relPath));
}
}
};
walk(this.vaultRoot, '');
return files;
}
/** Get a file by its exact path */
getAbstractFileByPath(filePath: string): TFile | null {
const absPath = path.join(this.vaultRoot, filePath);
if (fs.existsSync(absPath)) {
return createMockTFile(this.vaultRoot, filePath);
}
return null;
}
}
// ─── MockMetadataCache ───────────────────────────────────────────────────────
export class MockMetadataCache {
/** resolvedLinks[sourcePath][targetPath] = linkCount */
resolvedLinks: Record<string, Record<string, number>> = {};
private vaultRoot: string;
private vault: MockVault;
constructor(vaultRoot: string, vault: MockVault) {
this.vaultRoot = vaultRoot;
this.vault = vault;
this.buildLinkGraph();
}
/** Build the resolved links graph by scanning all markdown files */
private buildLinkGraph(): void {
const files = this.vault.getMarkdownFiles();
for (const file of files) {
const absPath = path.join(this.vaultRoot, file.path);
const content = fs.readFileSync(absPath, 'utf-8');
const links = parseWikilinks(content);
if (!this.resolvedLinks[file.path]) {
this.resolvedLinks[file.path] = {};
}
for (const link of links) {
const resolved = this.resolveLink(link.link, file.path);
if (resolved) {
this.resolvedLinks[file.path][resolved.path] =
(this.resolvedLinks[file.path][resolved.path] || 0) + 1;
}
}
}
}
/** Resolve a link path to a TFile (mimics Obsidian's shortest-path resolution) */
private resolveLink(linkPath: string, sourcePath: string): TFile | null {
// Add .md extension if not present
let searchPath = linkPath;
if (!searchPath.endsWith('.md')) {
searchPath += '.md';
}
// Try exact path first
const exactFile = this.vault.getAbstractFileByPath(searchPath);
if (exactFile) return exactFile;
// Try relative to source file's directory
const sourceDir = path.dirname(sourcePath);
const relativePath = sourceDir === '.' ? searchPath : `${sourceDir}/${searchPath}`;
const relativeFile = this.vault.getAbstractFileByPath(relativePath);
if (relativeFile) return relativeFile;
// Obsidian-style: search all files for basename match
const baseName = searchPath.split('/').pop();
if (baseName) {
const allFiles = this.vault.getMarkdownFiles();
const match = allFiles.find(f => f.path.endsWith(baseName) || f.path.endsWith(`/${baseName}`));
if (match) return match;
}
return null;
}
/** Get cached metadata for a file (links extracted from content) */
getFileCache(file: TFile): { links?: ParsedLink[]; embeds?: ParsedLink[] } | null {
const absPath = path.join(this.vaultRoot, file.path);
try {
const content = fs.readFileSync(absPath, 'utf-8');
const links = parseWikilinks(content);
// For simplicity, embeds use the same format as links (Obsidian uses ![[embed]])
const embedRegex = /!\[\[(.*?)\]\]/g;
const embeds: ParsedLink[] = [];
const lines = content.split('\n');
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
let match;
while ((match = embedRegex.exec(lines[lineNum])) !== null) {
const rawLink = match[1];
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
embeds.push({
link: linkPath,
original: match[0],
position: {
start: { line: lineNum, col: match.index },
end: { line: lineNum, col: match.index + match[0].length },
},
});
}
}
return { links, embeds: embeds.length > 0 ? embeds : undefined };
} catch {
return null;
}
}
/** Resolve a link path to a TFile (public API matching Obsidian's) */
getFirstLinkpathDest(linkPath: string, sourcePath: string): TFile | null {
return this.resolveLink(linkPath, sourcePath);
}
}
// ─── MockApp ─────────────────────────────────────────────────────────────────
export class MockApp {
vault: MockVault;
metadataCache: MockMetadataCache;
workspace: { onLayoutReady: (cb: () => void) => void; getActiveFile: () => null; getLeaf: () => any };
plugins: { plugins: Record<string, any> };
fileManager: { processFrontMatter: () => Promise<void> };
constructor(vaultRoot: string) {
this.vault = new MockVault(vaultRoot);
this.metadataCache = new MockMetadataCache(vaultRoot, this.vault);
this.workspace = {
onLayoutReady: (cb) => cb(),
getActiveFile: () => null,
getLeaf: () => ({ openFile: async () => {} }),
};
this.plugins = { plugins: {} };
this.fileManager = { processFrontMatter: async () => {} };
}
}
// ─── Helper to create a fresh test vault ─────────────────────────────────────
/**
* Create a MockApp pointed at a vault directory.
* Use for tests that need the full Obsidian API mock.
*/
export function createMockApp(vaultRoot: string): MockApp {
return new MockApp(vaultRoot);
}
/**
* Create a TFile from a vault-relative path (for use in test assertions).
*/
export function createTestTFile(vaultRoot: string, relativePath: string): TFile {
return createMockTFile(vaultRoot, relativePath);
}

View file

@ -0,0 +1,70 @@
/**
* Stub module that replaces `import { ... } from 'obsidian'` in tests.
* Provides minimal class/type stubs so service code can import without error.
* The real mock implementations (MockVault, MockApp, etc.) are in obsidian-mock.ts.
*/
export class TFile {
path: string;
basename: string;
extension: string;
stat: { mtime: number; ctime: number; size: number };
constructor(path: string, mtime?: number) {
this.path = path;
this.extension = path.split('.').pop() || '';
this.basename = path.split('/').pop()?.replace(`.${this.extension}`, '') || '';
this.stat = { mtime: mtime || Date.now(), ctime: mtime || Date.now(), size: 0 };
}
}
export class TAbstractFile {
path: string = '';
}
export class Vault {}
export class MetadataCache {}
export class App {}
export class Plugin {}
export class Component {}
export class Modal {
app: any;
constructor(app: any) { this.app = app; }
open() {}
close() {}
onOpen() {}
onClose() {}
}
export class PluginSettingTab {
app: any;
plugin: any;
constructor(app: any, plugin: any) { this.app = app; this.plugin = plugin; }
display() {}
hide() {}
}
export class Setting {
constructor(_el: any) {}
setName(_n: string) { return this; }
setDesc(_d: string) { return this; }
addText(_cb: any) { return this; }
addToggle(_cb: any) { return this; }
addDropdown(_cb: any) { return this; }
addButton(_cb: any) { return this; }
}
export class Notice {
constructor(message: string, _timeout?: number) {
// Silent in tests — uncomment for debugging:
// console.log('[Notice]', message);
}
}
export class MarkdownView {}
export class WorkspaceLeaf {}

View file

@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { OpenAIService } from '../src/services/openai-service';
describe('OpenAIService', () => {
let service: OpenAIService;
beforeEach(() => {
service = new OpenAIService('test-api-key', 'gpt-5');
});
describe('constructor', () => {
it('stores API key and model', () => {
// Access private fields via any cast (testing internals)
expect((service as any).apiKey).toBe('test-api-key');
expect((service as any).model).toBe('gpt-5');
});
});
describe('extractCustomContext (via prompt building)', () => {
// We test this through the prompt generation methods since extractCustomContext is private
it('includes context: section in transcript prompt', () => {
const content = 'Some transcript\n\ncontext:\nFocus on tech topics\n\nMore content';
const prompt = (service as any).getPrompt(content);
expect(prompt).toContain('USER CONTEXT');
expect(prompt).toContain('Focus on tech topics');
});
it('includes fenced context: section in transcript prompt', () => {
const content = 'Some content\n\n```context:\nOnly extract action items\n```\n\nMore stuff';
const prompt = (service as any).getPrompt(content);
expect(prompt).toContain('USER CONTEXT');
expect(prompt).toContain('Only extract action items');
});
it('handles content without context section', () => {
const content = 'Just a regular transcript with no special context';
const prompt = (service as any).getPrompt(content);
expect(prompt).not.toContain('USER CONTEXT');
expect(prompt).toContain('Just a regular transcript');
});
});
describe('getPrompt', () => {
it('includes the transcript content', () => {
const prompt = (service as any).getPrompt('My voice note about productivity');
expect(prompt).toContain('Transcript:\nMy voice note about productivity');
});
it('includes atomic notes instructions', () => {
const prompt = (service as any).getPrompt('test');
expect(prompt).toContain('Atomic Notes');
expect(prompt).toContain('self-contained');
});
it('includes task extraction instructions', () => {
const prompt = (service as any).getPrompt('test');
expect(prompt).toContain('Tasks');
expect(prompt).toContain('actionable');
});
it('includes AUGI command handling', () => {
const prompt = (service as any).getPrompt('test');
expect(prompt).toContain('AUGI');
expect(prompt).toContain('auggie');
});
});
describe('getDistillPrompt', () => {
it('includes content to distill', () => {
const prompt = (service as any).getDistillPrompt('Note content here');
expect(prompt).toContain('Content to Distill:\nNote content here');
});
it('uses custom prompt when provided', () => {
const custom = 'You are a custom processor. Do something special.';
const prompt = (service as any).getDistillPrompt('content', custom);
expect(prompt).toContain(custom);
// Should NOT contain the default distill instructions
expect(prompt).not.toContain('expert knowledge curator');
});
it('uses default prompt when no custom prompt', () => {
const prompt = (service as any).getDistillPrompt('content');
expect(prompt).toContain('expert knowledge curator');
});
it('appends custom context from content', () => {
const content = 'Notes here\n\ncontext:\nFocus on architecture\n\nMore notes';
const prompt = (service as any).getDistillPrompt(content);
expect(prompt).toContain('USER CONTEXT');
expect(prompt).toContain('Focus on architecture');
});
});
describe('getPublishPrompt', () => {
it('includes content to transform', () => {
const prompt = (service as any).getPublishPrompt('Blog content');
expect(prompt).toContain('Content to Transform:\nBlog content');
});
it('uses default publish prompt', () => {
const prompt = (service as any).getPublishPrompt('content');
expect(prompt).toContain('publishable blog post');
expect(prompt).toContain('PRESERVE');
});
it('uses custom prompt when provided', () => {
const custom = 'Write a newsletter instead.';
const prompt = (service as any).getPublishPrompt('content', custom);
expect(prompt).toContain('Write a newsletter instead.');
expect(prompt).not.toContain('publishable blog post');
});
});
describe('parseTranscript', () => {
it('throws when API key is not set', async () => {
const noKeyService = new OpenAIService('', 'gpt-5');
await expect(noKeyService.parseTranscript('content')).rejects.toThrow('OpenAI API key not set');
});
it('calls OpenAI API with correct parameters', async () => {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({
summary: 'Test summary',
notes: [{ title: 'Note 1', content: 'Content 1' }],
tasks: ['- [ ] Task 1']
}),
refusal: null
}
}]
})
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
const result = await service.parseTranscript('Test transcript');
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, options] = fetchSpy.mock.calls[0];
expect(url).toBe('https://api.openai.com/v1/chat/completions');
expect((options as any).method).toBe('POST');
expect(JSON.parse((options as any).body).model).toBe('gpt-5');
expect(result.summary).toBe('Test summary');
expect(result.notes).toHaveLength(1);
expect(result.tasks).toHaveLength(1);
fetchSpy.mockRestore();
});
it('throws on API error response', async () => {
const mockResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({ error: { message: 'Invalid API key' } })
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
await expect(service.parseTranscript('content')).rejects.toThrow('OpenAI API error');
fetchSpy.mockRestore();
});
});
describe('distillContent', () => {
it('throws when API key is not set', async () => {
const noKeyService = new OpenAIService('', 'gpt-5');
await expect(noKeyService.distillContent('content')).rejects.toThrow('OpenAI API key not set');
});
it('parses structured response correctly', async () => {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({
summary: 'Distilled summary about [[Topic A]]',
notes: [
{ title: 'Topic A', content: 'Deep dive into A' },
{ title: 'Topic B', content: 'Overview of B' },
],
tasks: []
}),
refusal: null
}
}]
})
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
const result = await service.distillContent('aggregated content');
expect(result.summary).toContain('Topic A');
expect(result.notes).toHaveLength(2);
expect(result.sourceNotes).toEqual([]); // Initialized as empty
expect(result.tasks).toEqual([]);
fetchSpy.mockRestore();
});
});
describe('publishContent', () => {
it('returns plain text content', async () => {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{
message: {
content: '# My Blog Post\n\nThis is the published content.',
refusal: null
}
}]
})
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
const result = await service.publishContent('raw notes');
expect(result).toContain('# My Blog Post');
expect(result).toContain('published content');
fetchSpy.mockRestore();
});
});
});

View file

@ -0,0 +1,10 @@
# Backlink Source
## Section About Root
This section references the [[Root Note]] because it is important for testing backlink discovery.
## Unrelated Section
This section has nothing to do with the root note.
It discusses other topics entirely.

View file

@ -0,0 +1,8 @@
# Collection of Notes
This is a collection note with checkboxes.
- [x] [[Linked Note A]]
- [ ] [[Linked Note B]]
- [x] [[Journal Note]]
- [ ] [[Backlink Source]]

View file

@ -0,0 +1,11 @@
# Note with Custom Context
This note has custom context instructions for AI processing.
Some content about software architecture patterns.
```context:
Only extract notes about design patterns. Ignore implementation details.
```
More content about specific patterns like Observer, Strategy, and Factory.

View file

@ -0,0 +1,14 @@
# Notes with Dataview
This note contains a dataview query that should be stripped from output.
```dataview
TABLE file.mtime as "Modified"
FROM "/"
SORT file.mtime DESC
LIMIT 10
```
Regular content after the dataview block.
Also links to [[Linked Note A]].

View file

@ -0,0 +1,8 @@
# Deep Note
This note is at depth 2 from the root:
Root Note → Linked Note A → Deep Note
It contains insights about deep work and focused concentration.
No further links from here (leaf node).

View file

@ -0,0 +1,4 @@
# Should Be Excluded
This note lives in a folder that should be excluded from discovery.
It should never appear in gathered context.

View file

@ -0,0 +1,17 @@
# Daily Journal
### 2026-02-25
Worked on setting up automated tests for the Obsidian plugin.
Made good progress on the mock layer.
### 2026-02-20
Started thinking about test architecture.
Reviewed several approaches for testing Electron apps.
### 2026-01-15
This is an old entry that should be filtered out by time window.
Did some unrelated work on another project.
### 2025-12-01
Very old entry. Should definitely be filtered out.
Working on something completely different.

View file

@ -0,0 +1,7 @@
# Linked Note A
This note contains ideas about atomic note-taking.
Key insight: each note should contain exactly one idea.
See also [[Deeply Linked/Deep Note]] for more depth.

View file

@ -0,0 +1,10 @@
# Linked Note B
This note discusses knowledge management systems.
The Zettelkasten method emphasizes:
- Atomic notes
- Linking between ideas
- Building a network of knowledge
Related: [[Linked Note A]]

8
tests/vault/Root Note.md Normal file
View file

@ -0,0 +1,8 @@
This is the root note for testing linked note discovery.
It links to [[Linked Note A]] and [[Linked Note B]].
It also has an embed: ![[Linked Note A]]
context:
Focus on extracting key concepts about testing

View file

@ -0,0 +1,6 @@
# Note with Special: Characters* in "Title"
This note tests filename sanitization.
It contains characters that are invalid in filenames: \ / : * ? " < > |
Links to [[Root Note]].

View file

@ -21,5 +21,9 @@
"include": [
"**/*.ts",
"src/**/*.ts"
],
"exclude": [
"tests/**/*.ts",
"vitest.config.ts"
]
}

15
vitest.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
// Mock the obsidian module globally so imports don't fail
'obsidian': path.resolve(__dirname, 'tests/mocks/obsidian-module.ts'),
},
},
test: {
globals: true,
include: ['tests/**/*.test.ts'],
},
});