Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1db4971
feat(search): implement FST5 w/ sqlite for faster and better searching
perfectra1n Aug 30, 2025
21aaec2
feat(search): also fix tests for new fts functionality
perfectra1n Aug 30, 2025
053f722
feat(search): try to get fts search to work in large environments
perfectra1n Aug 31, 2025
5b79e0d
feat(search): try to decrease complexity
perfectra1n Aug 31, 2025
37d0136
feat(search): try to deal with huge dbs, might need to squash later
perfectra1n Sep 1, 2025
7c5553b
feat(search): further improve fts search
perfectra1n Sep 2, 2025
b09a2c3
feat(search): I honestly have no idea what I'm doing
perfectra1n Sep 2, 2025
8572f82
Revert "feat(search): I honestly have no idea what I'm doing"
perfectra1n Sep 2, 2025
f529ddc
Revert "feat(search): further improve fts search"
perfectra1n Sep 2, 2025
0afb8a1
Revert "feat(search): try to deal with huge dbs, might need to squash…
perfectra1n Sep 2, 2025
06b2d71
Revert "feat(search): try to decrease complexity"
perfectra1n Sep 2, 2025
d074841
Revert "feat(search): try to get fts search to work in large environm…
perfectra1n Sep 2, 2025
58c2252
feat(search): try a ground-up sqlite search approach
perfectra1n Sep 3, 2025
d992a5e
Merge branch 'main' into feat/rice-searching-with-sqlite
perfectra1n Oct 24, 2025
253da13
feat(search): try again to get fts5 searching done well
perfectra1n Oct 25, 2025
1098809
feat(search): get the correct comparison and rice out the fts5 search
perfectra1n Oct 27, 2025
321752a
Merge branch 'main' into feat/rice-searching-with-sqlite
perfectra1n Nov 3, 2025
16912e6
fix(search): resolve compilation issue due to performance log in new …
perfectra1n Nov 3, 2025
052e28a
feat(search): if the search is empty, return all notes
perfectra1n Nov 4, 2025
b8aa740
feat(tests): create a ton of tests for the various search capabilitie…
perfectra1n Nov 4, 2025
942647a
fix(search): get rid of exporting dbConnection
perfectra1n Nov 4, 2025
da03020
fix(tests): resolve issues with new search tests not passing
perfectra1n Nov 4, 2025
5f17736
fix(tests): rename some of the silly-ily named tests
perfectra1n Nov 4, 2025
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
356 changes: 344 additions & 12 deletions apps/server/spec/etapi/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,353 @@ describe("etapi/search", () => {

content = randomUUID();
await createNote(app, token, content);
}, 30000); // Increase timeout to 30 seconds for app initialization

describe("Basic Search", () => {
it("finds by content", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}&debug=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.results).toHaveLength(1);
});

it("does not find by content when fast search is on", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}&debug=true&fastSearch=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.results).toHaveLength(0);
});

it("returns proper response structure", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(response.body).toHaveProperty("results");
expect(Array.isArray(response.body.results)).toBe(true);

if (response.body.results.length > 0) {
const note = response.body.results[0];
expect(note).toHaveProperty("noteId");
expect(note).toHaveProperty("title");
expect(note).toHaveProperty("type");
}
});

it("returns debug info when requested", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}&debug=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(response.body).toHaveProperty("debugInfo");
expect(response.body.debugInfo).toBeTruthy();
});

it("returns 400 for missing search parameter", async () => {
await supertest(app)
.get("/etapi/notes")
.auth(USER, token, { "type": "basic"})
.expect(400);
});

it("returns 400 for empty search parameter", async () => {
await supertest(app)
.get("/etapi/notes?search=")
.auth(USER, token, { "type": "basic"})
.expect(400);
});
});

describe("Search Parameters", () => {
let testNoteId: string;

beforeAll(async () => {
// Create a test note with unique content
const uniqueContent = `test-${randomUUID()}`;
testNoteId = await createNote(app, token, uniqueContent);
}, 10000);

it("respects fastSearch parameter", async () => {
// Fast search should not find by content
const fastResponse = await supertest(app)
.get(`/etapi/notes?search=${content}&fastSearch=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(fastResponse.body.results).toHaveLength(0);

// Regular search should find by content
const regularResponse = await supertest(app)
.get(`/etapi/notes?search=${content}&fastSearch=false`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(regularResponse.body.results.length).toBeGreaterThan(0);
});

it("respects includeArchivedNotes parameter", async () => {
// Default should include archived notes
const withArchivedResponse = await supertest(app)
.get(`/etapi/notes?search=*&includeArchivedNotes=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);

const withoutArchivedResponse = await supertest(app)
.get(`/etapi/notes?search=*&includeArchivedNotes=false`)
.auth(USER, token, { "type": "basic"})
.expect(200);

// Note: Actual behavior depends on whether there are archived notes
expect(withArchivedResponse.body.results).toBeDefined();
expect(withoutArchivedResponse.body.results).toBeDefined();
});

it("respects limit parameter", async () => {
const limit = 5;
const response = await supertest(app)
.get(`/etapi/notes?search=*&limit=${limit}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(response.body.results.length).toBeLessThanOrEqual(limit);
});

it("handles fuzzyAttributeSearch parameter", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=*&fuzzyAttributeSearch=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(response.body.results).toBeDefined();
});
});

describe("Search Queries", () => {
let titleNoteId: string;
let labelNoteId: string;

beforeAll(async () => {
// Create test notes with specific attributes
const uniqueTitle = `SearchTest-${randomUUID()}`;

// Create note with specific title
const titleResponse = await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic"})
.send({
"parentNoteId": "root",
"title": uniqueTitle,
"type": "text",
"content": "Title test content"
})
.expect(201);
titleNoteId = titleResponse.body.note.noteId;

// Create note with label
const labelResponse = await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic"})
.send({
"parentNoteId": "root",
"title": "Label Test",
"type": "text",
"content": "Label test content"
})
.expect(201);
labelNoteId = labelResponse.body.note.noteId;

// Add label to note
await supertest(app)
.post("/etapi/attributes")
.auth(USER, token, { "type": "basic"})
.send({
"noteId": labelNoteId,
"type": "label",
"name": "testlabel",
"value": "testvalue"
})
.expect(201);
}, 15000); // 15 second timeout for setup

it("searches by title", async () => {
// Get the title we created
const noteResponse = await supertest(app)
.get(`/etapi/notes/${titleNoteId}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

const title = noteResponse.body.title;

const searchResponse = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent(title)}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results.length).toBeGreaterThan(0);
const foundNote = searchResponse.body.results.find((n: any) => n.noteId === titleNoteId);
expect(foundNote).toBeTruthy();
});

it("searches by label", async () => {
const searchResponse = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent("#testlabel")}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results.length).toBeGreaterThan(0);
const foundNote = searchResponse.body.results.find((n: any) => n.noteId === labelNoteId);
expect(foundNote).toBeTruthy();
});

it("searches by label with value", async () => {
const searchResponse = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent("#testlabel=testvalue")}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results.length).toBeGreaterThan(0);
const foundNote = searchResponse.body.results.find((n: any) => n.noteId === labelNoteId);
expect(foundNote).toBeTruthy();
});

it("handles complex queries with AND operator", async () => {
const searchResponse = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent("#testlabel AND note.type=text")}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results).toBeDefined();
});

it("handles queries with OR operator", async () => {
const searchResponse = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent("#testlabel OR #nonexistent")}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results.length).toBeGreaterThan(0);
});

it("handles queries with NOT operator", async () => {
const searchResponse = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent("#testlabel NOT #nonexistent")}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results.length).toBeGreaterThan(0);
});

it("handles wildcard searches", async () => {
const searchResponse = await supertest(app)
.get(`/etapi/notes?search=note.type%3Dtext&limit=10`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results).toBeDefined();
// Should return results if any text notes exist
expect(Array.isArray(searchResponse.body.results)).toBe(true);
});

it("handles empty results gracefully", async () => {
const nonexistentQuery = `nonexistent-${randomUUID()}`;
const searchResponse = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent(nonexistentQuery)}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(searchResponse.body.results).toHaveLength(0);
});
});

it("finds by content", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}&debug=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.results).toHaveLength(1);
describe("Error Handling", () => {
it("handles invalid query syntax gracefully", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent("(((")}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

// Should return empty results or handle error gracefully
expect(response.body.results).toBeDefined();
});

it("requires authentication", async () => {
await supertest(app)
.get(`/etapi/notes?search=test`)
.expect(401);
});

it("rejects invalid authentication", async () => {
await supertest(app)
.get(`/etapi/notes?search=test`)
.auth(USER, "invalid-token", { "type": "basic"})
.expect(401);
});
});

it("does not find by content when fast search is on", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}&debug=true&fastSearch=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.results).toHaveLength(0);
describe("Performance", () => {
it("handles large result sets", async () => {
const startTime = Date.now();

const response = await supertest(app)
.get(`/etapi/notes?search=*&limit=100`)
.auth(USER, token, { "type": "basic"})
.expect(200);

const endTime = Date.now();
const duration = endTime - startTime;

expect(response.body.results).toBeDefined();
// Search should complete in reasonable time (5 seconds)
expect(duration).toBeLessThan(5000);
});

it("handles queries efficiently", async () => {
const startTime = Date.now();

await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent("#*")}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

const endTime = Date.now();
const duration = endTime - startTime;

// Attribute search should be fast
expect(duration).toBeLessThan(3000);
});
});

describe("Special Characters", () => {
it("handles special characters in search", async () => {
const specialChars = "test@#$%";
const response = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent(specialChars)}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(response.body.results).toBeDefined();
});

it("handles unicode characters", async () => {
const unicode = "测试";
const response = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent(unicode)}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(response.body.results).toBeDefined();
});

it("handles quotes in search", async () => {
const quoted = '"test phrase"';
const response = await supertest(app)
.get(`/etapi/notes?search=${encodeURIComponent(quoted)}`)
.auth(USER, token, { "type": "basic"})
.expect(200);

expect(response.body.results).toBeDefined();
});
});
});
Loading
Loading