Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1583,11 +1583,17 @@ paths:
properties:
end:
type: "number"
description: "End character position of the match within the file. Starts with 0."
start:
type: "number"
description: "Start character position of the match within the file. Starts with 0."
source:
type: "string"
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source field description says "'filename' or 'content'" but the type is just string. Consider using an enum to be more precise in the OpenAPI spec. Example:

source:
  type: "string"
  enum: ["filename", "content"]
  description: "Where the search term matched"
Suggested change
type: "string"
type: "string"
enum: ["filename", "content"]

Copilot uses AI. Check for mistakes.
description: "Where the search term matched: 'filename' or 'content'"
required:
- "start"
- "end"
- "source"
type: "object"
required:
- "match"
Expand Down
26 changes: 17 additions & 9 deletions mocks/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class DataAdapter {
_read = "";
_readBinary = new ArrayBuffer(0);
_write: [string, string];
_writeBinary : [string, ArrayBuffer];
_writeBinary: [string, ArrayBuffer];
_remove: [string];
_stat = new Stat();

Expand All @@ -42,12 +42,12 @@ class DataAdapter {
return this._readBinary;
}

async write(path: string, content: string, option?:DataWriteOptions): Promise<void> {
async write(path: string, content: string, option?: DataWriteOptions): Promise<void> {
this._write = [path, content];
}

async writeBinary(path: string, content: ArrayBuffer, option?:DataWriteOptions): Promise<void> {
this._writeBinary = [path,content]
async writeBinary(path: string, content: ArrayBuffer, option?: DataWriteOptions): Promise<void> {
this._writeBinary = [path, content];
}

async remove(path: string): Promise<void> {
Expand All @@ -72,7 +72,7 @@ export class Vault {
return this._cachedRead;
}

async createFolder(path: string): Promise<void> {}
async createFolder(path: string): Promise<void> { }

getFiles(): TFile[] {
return this._files;
Expand Down Expand Up @@ -158,14 +158,15 @@ export class FileStats {

export class TFile {
path = "somefile.md";
basename = "somefile";
stat: FileStats = new FileStats();
}

export class PluginManifest {
version = "";
}

export class SettingTab {}
export class SettingTab { }

export const apiVersion = "1.0.0";

Expand All @@ -174,8 +175,15 @@ export class SearchResult {
matches: [number, number][] = [];
}

export function prepareSimpleSearch(
query: string
): (value: string) => null | SearchResult {
// Mock configuration that tests can control
// Tests can set this to override the default behavior
export const _prepareSimpleSearchMock = {
behavior: null as ((query: string) => (text: string) => null | SearchResult) | null,
};

export function prepareSimpleSearch(query: string): (value: string) => null | SearchResult {
if (_prepareSimpleSearchMock.behavior) {
return _prepareSimpleSearchMock.behavior(query);
}
return null;
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type is (value: string) => null | SearchResult, which means the function should return another function. However, return null; returns null directly, not a function. This should be return () => null; to match the return type and maintain consistency with the mock behavior pattern.

Suggested change
return null;
return () => null;

Copilot uses AI. Check for mistakes.
}
289 changes: 289 additions & 0 deletions src/requestHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
Command,
HeadingCache,
PluginManifest,
_prepareSimpleSearchMock,
SearchResult,
} from "../mocks/obsidian";

describe("requestHandler", () => {
Expand Down Expand Up @@ -802,4 +804,291 @@ describe("requestHandler", () => {
.expect(401);
});
});

describe("searchSimplePost", () => {
beforeEach(() => {
// Setup mock for prepareSimpleSearch
_prepareSimpleSearchMock.behavior = (query: string) => {
const queryLower = query.toLowerCase();
const queryLength = query.length;
return (text: string) => {
const textLower = text.toLowerCase();
const matches: [number, number][] = [];
let index = 0;

// Find all matches (case-insensitive)
while ((index = textLower.indexOf(queryLower, index)) !== -1) {
matches.push([index, index + queryLength]);
index += 1;
}

if (matches.length === 0) {
return null;
}

// Calculate score based on number of matches
const score = matches.length;

return {
score,
matches,
} as SearchResult;
};
};
});

afterEach(() => {
// Clean up mock
_prepareSimpleSearchMock.behavior = null;
});

test("match at beginning of filename", async () => {
const testFile = new TFile();
testFile.basename = "Master Plan";
testFile.path = "Master Plan.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "Some content here";

const result = await request(server)
.post("/search/simple/?query=Master")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].filename).toBe("Master Plan.md");
expect(result.body[0].matches).toHaveLength(1);
expect(result.body[0].matches[0].match.source).toBe("filename");
expect(result.body[0].matches[0].match.start).toBe(0);
expect(result.body[0].matches[0].match.end).toBe(6);
expect(result.body[0].matches[0].context).toBe("Master Plan");
});

test("match in middle of filename", async () => {
const testFile = new TFile();
testFile.basename = "1 - Master Plan";
testFile.path = "1 - Master Plan.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "Some content here";

const result = await request(server)
.post("/search/simple/?query=Master")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].filename).toBe("1 - Master Plan.md");
expect(result.body[0].matches).toHaveLength(1);
expect(result.body[0].matches[0].match.source).toBe("filename");
expect(result.body[0].matches[0].match.start).toBe(4);
expect(result.body[0].matches[0].match.end).toBe(10);
expect(result.body[0].matches[0].context).toBe("1 - Master Plan");
});

test("match at end of filename", async () => {
const testFile = new TFile();
testFile.basename = "My Master Plan";
testFile.path = "My Master Plan.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "Some content here";

const result = await request(server)
.post("/search/simple/?query=Plan")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].filename).toBe("My Master Plan.md");
expect(result.body[0].matches).toHaveLength(1);
expect(result.body[0].matches[0].match.source).toBe("filename");
expect(result.body[0].matches[0].match.start).toBe(10);
expect(result.body[0].matches[0].match.end).toBe(14);
expect(result.body[0].matches[0].context).toBe("My Master Plan");
});

test("match in content only", async () => {
const testFile = new TFile();
testFile.basename = "Random Note";
testFile.path = "Random Note.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "This is my master plan for the project.";

const result = await request(server)
.post("/search/simple/?query=master")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].filename).toBe("Random Note.md");
expect(result.body[0].matches).toHaveLength(1);
expect(result.body[0].matches[0].match.source).toBe("content");
expect(result.body[0].matches[0].match.start).toBe(11);
expect(result.body[0].matches[0].match.end).toBe(17);
});

test("match in both filename and content", async () => {
const testFile = new TFile();
testFile.basename = "Master Plan";
testFile.path = "Master Plan.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "The master plan is to complete this project.";

const result = await request(server)
.post("/search/simple/?query=master")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].filename).toBe("Master Plan.md");
expect(result.body[0].matches).toHaveLength(2);

// First match should be in filename (case-insensitive)
expect(result.body[0].matches[0].match.source).toBe("filename");
expect(result.body[0].matches[0].match.start).toBe(0);
expect(result.body[0].matches[0].match.end).toBe(6);
expect(result.body[0].matches[0].context).toBe("Master Plan");

// Second match should be in content
expect(result.body[0].matches[1].match.source).toBe("content");
expect(result.body[0].matches[1].match.start).toBe(4);
expect(result.body[0].matches[1].match.end).toBe(10);
});

test("multiple matches in filename", async () => {
const testFile = new TFile();
testFile.basename = "Test Test Test";
testFile.path = "Test Test Test.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "Content without the search term";

const result = await request(server)
.post("/search/simple/?query=Test")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].filename).toBe("Test Test Test.md");
expect(result.body[0].matches).toHaveLength(3);

// All matches should be in filename
expect(result.body[0].matches[0].match.source).toBe("filename");
expect(result.body[0].matches[0].match.start).toBe(0);
expect(result.body[0].matches[0].match.end).toBe(4);

expect(result.body[0].matches[1].match.source).toBe("filename");
expect(result.body[0].matches[1].match.start).toBe(5);
expect(result.body[0].matches[1].match.end).toBe(9);

expect(result.body[0].matches[2].match.source).toBe("filename");
expect(result.body[0].matches[2].match.start).toBe(10);
expect(result.body[0].matches[2].match.end).toBe(14);
});

test("filename with special characters", async () => {
const testFile = new TFile();
testFile.basename = "Project (2024) - Master Plan";
testFile.path = "Project (2024) - Master Plan.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "Project details";

const result = await request(server)
.post("/search/simple/?query=2024")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].filename).toBe("Project (2024) - Master Plan.md");
expect(result.body[0].matches).toHaveLength(1);
expect(result.body[0].matches[0].match.source).toBe("filename");
expect(result.body[0].matches[0].match.start).toBe(9);
expect(result.body[0].matches[0].match.end).toBe(13);
expect(result.body[0].matches[0].context).toBe("Project (2024) - Master Plan");
});

test("context length for content matches", async () => {
const testFile = new TFile();
testFile.basename = "Note";
testFile.path = "Note.md";

const longContent = "A".repeat(200) + "MATCH" + "B".repeat(200);
app.vault._markdownFiles = [testFile];
app.vault._cachedRead = longContent;

const result = await request(server)
.post("/search/simple/?query=MATCH&contextLength=50")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].matches).toHaveLength(1);
expect(result.body[0].matches[0].match.source).toBe("content");

// Context should be approximately 50 chars before + match + 50 chars after
const context = result.body[0].matches[0].context;
expect(context.length).toBeLessThanOrEqual(105); // 50 + 5 + 50
expect(context).toContain("MATCH");
});

test("no matches returns empty array", async () => {
const testFile = new TFile();
testFile.basename = "Random Note";
testFile.path = "Random Note.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "Some content";

const result = await request(server)
.post("/search/simple/?query=NonExistentTerm")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(0);
});

test("case insensitive search", async () => {
const testFile = new TFile();
testFile.basename = "MASTER Plan";
testFile.path = "MASTER Plan.md";

app.vault._markdownFiles = [testFile];
app.vault._cachedRead = "master plan details";

const result = await request(server)
.post("/search/simple/?query=master")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(200);

expect(result.body).toHaveLength(1);
expect(result.body[0].matches).toHaveLength(2);

// Should match "MASTER" in filename (case-insensitive)
expect(result.body[0].matches[0].match.source).toBe("filename");
expect(result.body[0].matches[0].match.start).toBe(0);
expect(result.body[0].matches[0].match.end).toBe(6);

// Should match "master" in content
expect(result.body[0].matches[1].match.source).toBe("content");
expect(result.body[0].matches[1].match.start).toBe(0);
expect(result.body[0].matches[1].match.end).toBe(6);
});

test("unauthorized", async () => {
await request(server)
.post("/search/simple/?query=test")
.expect(401);
});

test("missing query parameter", async () => {
await request(server)
.post("/search/simple/")
.set("Authorization", `Bearer ${API_KEY}`)
.expect(400);
});
});
});
Loading