diff --git a/package.json b/package.json index 87d005682..d5131ecd7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "yarn clean && yarn vite:build && yarn build:i18n && yarn build:server", "start": "node ./build/server/index.js", "dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=cron,collaboration,websockets,admin,web,worker\"", - "dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations", + "dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore *.test.ts --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations", "dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"", "lint": "eslint app server shared plugins", "prepare": "husky install", diff --git a/server/models/helpers/SearchHelper.test.ts b/server/models/helpers/SearchHelper.test.ts index 80f57ad16..b0349c729 100644 --- a/server/models/helpers/SearchHelper.test.ts +++ b/server/models/helpers/SearchHelper.test.ts @@ -12,444 +12,464 @@ beforeEach(() => { jest.resetAllMocks(); }); -describe("#searchForTeam", () => { - test("should return search results from public collections", async () => { - const team = await buildTeam(); - const collection = await buildCollection({ - teamId: team.id, +describe("SearchHelper", () => { + describe("#searchForTeam", () => { + test("should return search results from public collections", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ + teamId: team.id, + }); + const document = await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + const { results } = await SearchHelper.searchForTeam(team, "test"); + expect(results.length).toBe(1); + expect(results[0].document?.id).toBe(document.id); }); - const document = await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test", + + test("should not return results from private collections without providing collectionId", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ + permission: null, + teamId: team.id, + }); + await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + const { results } = await SearchHelper.searchForTeam(team, "test"); + expect(results.length).toBe(0); + }); + + test("should return results from private collections when collectionId is provided", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ + permission: null, + teamId: team.id, + }); + await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + const { results } = await SearchHelper.searchForTeam(team, "test", { + collectionId: collection.id, + }); + expect(results.length).toBe(1); + }); + + test("should return results from document tree of shared document", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ + permission: null, + teamId: team.id, + }); + const document = await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test 1", + }); + await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test 2", + }); + + const share = await buildShare({ + documentId: document.id, + includeChildDocuments: true, + }); + + const { results } = await SearchHelper.searchForTeam(team, "test", { + collectionId: collection.id, + share, + }); + expect(results.length).toBe(1); + }); + + test("should handle no collections", async () => { + const team = await buildTeam(); + const { results } = await SearchHelper.searchForTeam(team, "test"); + expect(results.length).toBe(0); + }); + + test("should handle backslashes in search term", async () => { + const team = await buildTeam(); + const { results } = await SearchHelper.searchForTeam(team, "\\\\"); + expect(results.length).toBe(0); + }); + + test("should return the total count of search results", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ + teamId: team.id, + }); + await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test number 1", + }); + await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test number 2", + }); + const { totalCount } = await SearchHelper.searchForTeam(team, "test"); + expect(totalCount).toBe("2"); + }); + + test("should return the document when searched with their previous titles", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ + teamId: team.id, + }); + const document = await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test number 1", + }); + document.title = "change"; + await document.save(); + const { totalCount } = await SearchHelper.searchForTeam( + team, + "test number" + ); + expect(totalCount).toBe("1"); + }); + + test("should not return the document when searched with neither the titles nor the previous titles", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ + teamId: team.id, + }); + const document = await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test number 1", + }); + document.title = "change"; + await document.save(); + const { totalCount } = await SearchHelper.searchForTeam( + team, + "title doesn't exist" + ); + expect(totalCount).toBe("0"); }); - const { results } = await SearchHelper.searchForTeam(team, "test"); - expect(results.length).toBe(1); - expect(results[0].document?.id).toBe(document.id); }); - test("should not return results from private collections without providing collectionId", async () => { - const team = await buildTeam(); - const collection = await buildCollection({ - permission: null, - teamId: team.id, + describe("#searchForUser", () => { + test("should return search results from collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const document = await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + const { results } = await SearchHelper.searchForUser(user, "test"); + expect(results.length).toBe(1); + expect(results[0].document?.id).toBe(document.id); }); - await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test", + + test("should handle no collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const { results } = await SearchHelper.searchForUser(user, "test"); + expect(results.length).toBe(0); + }); + + test("should search only drafts created by user", async () => { + const user = await buildUser(); + await buildDraftDocument({ + title: "test", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const { results } = await SearchHelper.searchForUser(user, "test", { + includeDrafts: true, + }); + expect(results.length).toBe(1); + }); + + test("should not include drafts", async () => { + const user = await buildUser(); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const { results } = await SearchHelper.searchForUser(user, "test", { + includeDrafts: false, + }); + expect(results.length).toBe(0); + }); + + test("should include results from drafts as well", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not draft", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "draft", + }); + const { results } = await SearchHelper.searchForUser(user, "draft", { + includeDrafts: true, + }); + expect(results.length).toBe(2); + }); + + test("should not include results from drafts", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not draft", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "draft", + }); + const { results } = await SearchHelper.searchForUser(user, "draft", { + includeDrafts: false, + }); + expect(results.length).toBe(1); + }); + + test("should return the total count of search results", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test number 1", + }); + await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test number 2", + }); + const { totalCount } = await SearchHelper.searchForUser(user, "test"); + expect(totalCount).toBe("2"); + }); + + test("should return the document when searched with their previous titles", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + userId: user.id, + }); + const document = await buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "test number 1", + }); + document.title = "change"; + await document.save(); + const { totalCount } = await SearchHelper.searchForUser( + user, + "test number" + ); + expect(totalCount).toBe("1"); + }); + + test("should not return the document when searched with neither the titles nor the previous titles", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + userId: user.id, + }); + const document = await buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "test number 1", + }); + document.title = "change"; + await document.save(); + const { totalCount } = await SearchHelper.searchForUser( + user, + "title doesn't exist" + ); + expect(totalCount).toBe("0"); }); - const { results } = await SearchHelper.searchForTeam(team, "test"); - expect(results.length).toBe(0); }); - test("should return results from private collections when collectionId is provided", async () => { - const team = await buildTeam(); - const collection = await buildCollection({ - permission: null, - teamId: team.id, + describe("#searchTitlesForUser", () => { + test("should return search results from collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const document = await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test"); + expect(documents.length).toBe(1); + expect(documents[0]?.id).toBe(document.id); }); - await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test", + + test("should filter to specific collection", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const collection1 = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const document = await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + await buildDraftDocument({ + teamId: team.id, + userId: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection1.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + collectionId: collection.id, + }); + expect(documents.length).toBe(1); + expect(documents[0]?.id).toBe(document.id); }); - const { results } = await SearchHelper.searchForTeam(team, "test", { - collectionId: collection.id, + + test("should handle no collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const documents = await SearchHelper.searchTitlesForUser(user, "test"); + expect(documents.length).toBe(0); + }); + + test("should search only drafts created by user", async () => { + const user = await buildUser(); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: true, + }); + expect(documents.length).toBe(1); + }); + + test("should not include drafts", async () => { + const user = await buildUser(); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: false, + }); + expect(documents.length).toBe(0); + }); + + test("should include results from drafts as well", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not test", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: true, + }); + expect(documents.length).toBe(2); + }); + + test("should not include results from drafts", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not test", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: false, + }); + expect(documents.length).toBe(1); }); - expect(results.length).toBe(1); }); - test("should return results from document tree of shared document", async () => { - const team = await buildTeam(); - const collection = await buildCollection({ - permission: null, - teamId: team.id, + describe("webSearchQuery", () => { + test("should correctly sanitize query", () => { + expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*"); + expect(SearchHelper.webSearchQuery("one\\two")).toBe("one\\\\two:*"); + expect(SearchHelper.webSearchQuery("test''")).toBe("test"); }); - const document = await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test 1", + test("should wildcard single word queries", () => { + expect(SearchHelper.webSearchQuery("test")).toBe("test:*"); + expect(SearchHelper.webSearchQuery("'")).toBe(""); + expect(SearchHelper.webSearchQuery("'quoted'")).toBe(`"quoted":*`); }); - await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test 2", + test("should not wildcard other queries", () => { + expect(SearchHelper.webSearchQuery("this is a test")).toBe( + "this&is&a&test" + ); }); - - const share = await buildShare({ - documentId: document.id, - includeChildDocuments: true, - }); - - const { results } = await SearchHelper.searchForTeam(team, "test", { - collectionId: collection.id, - share, - }); - expect(results.length).toBe(1); - }); - - test("should handle no collections", async () => { - const team = await buildTeam(); - const { results } = await SearchHelper.searchForTeam(team, "test"); - expect(results.length).toBe(0); - }); - - test("should handle backslashes in search term", async () => { - const team = await buildTeam(); - const { results } = await SearchHelper.searchForTeam(team, "\\\\"); - expect(results.length).toBe(0); - }); - - test("should return the total count of search results", async () => { - const team = await buildTeam(); - const collection = await buildCollection({ - teamId: team.id, - }); - await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test number 1", - }); - await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test number 2", - }); - const { totalCount } = await SearchHelper.searchForTeam(team, "test"); - expect(totalCount).toBe("2"); - }); - - test("should return the document when searched with their previous titles", async () => { - const team = await buildTeam(); - const collection = await buildCollection({ - teamId: team.id, - }); - const document = await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test number 1", - }); - document.title = "change"; - await document.save(); - const { totalCount } = await SearchHelper.searchForTeam( - team, - "test number" - ); - expect(totalCount).toBe("1"); - }); - - test("should not return the document when searched with neither the titles nor the previous titles", async () => { - const team = await buildTeam(); - const collection = await buildCollection({ - teamId: team.id, - }); - const document = await buildDocument({ - teamId: team.id, - collectionId: collection.id, - title: "test number 1", - }); - document.title = "change"; - await document.save(); - const { totalCount } = await SearchHelper.searchForTeam( - team, - "title doesn't exist" - ); - expect(totalCount).toBe("0"); - }); -}); - -describe("#searchForUser", () => { - test("should return search results from collections", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const collection = await buildCollection({ - userId: user.id, - teamId: team.id, - }); - const document = await buildDocument({ - userId: user.id, - teamId: team.id, - collectionId: collection.id, - title: "test", - }); - const { results } = await SearchHelper.searchForUser(user, "test"); - expect(results.length).toBe(1); - expect(results[0].document?.id).toBe(document.id); - }); - - test("should handle no collections", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const { results } = await SearchHelper.searchForUser(user, "test"); - expect(results.length).toBe(0); - }); - - test("should search only drafts created by user", async () => { - const user = await buildUser(); - await buildDraftDocument({ - title: "test", - }); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "test", - }); - const { results } = await SearchHelper.searchForUser(user, "test", { - includeDrafts: true, - }); - expect(results.length).toBe(1); - }); - - test("should not include drafts", async () => { - const user = await buildUser(); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "test", - }); - const { results } = await SearchHelper.searchForUser(user, "test", { - includeDrafts: false, - }); - expect(results.length).toBe(0); - }); - - test("should include results from drafts as well", async () => { - const user = await buildUser(); - await buildDocument({ - userId: user.id, - teamId: user.teamId, - createdById: user.id, - title: "not draft", - }); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "draft", - }); - const { results } = await SearchHelper.searchForUser(user, "draft", { - includeDrafts: true, - }); - expect(results.length).toBe(2); - }); - - test("should not include results from drafts", async () => { - const user = await buildUser(); - await buildDocument({ - userId: user.id, - teamId: user.teamId, - createdById: user.id, - title: "not draft", - }); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "draft", - }); - const { results } = await SearchHelper.searchForUser(user, "draft", { - includeDrafts: false, - }); - expect(results.length).toBe(1); - }); - - test("should return the total count of search results", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const collection = await buildCollection({ - userId: user.id, - teamId: team.id, - }); - await buildDocument({ - userId: user.id, - teamId: team.id, - collectionId: collection.id, - title: "test number 1", - }); - await buildDocument({ - userId: user.id, - teamId: team.id, - collectionId: collection.id, - title: "test number 2", - }); - const { totalCount } = await SearchHelper.searchForUser(user, "test"); - expect(totalCount).toBe("2"); - }); - - test("should return the document when searched with their previous titles", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const collection = await buildCollection({ - teamId: team.id, - userId: user.id, - }); - const document = await buildDocument({ - teamId: team.id, - userId: user.id, - collectionId: collection.id, - title: "test number 1", - }); - document.title = "change"; - await document.save(); - const { totalCount } = await SearchHelper.searchForUser( - user, - "test number" - ); - expect(totalCount).toBe("1"); - }); - - test("should not return the document when searched with neither the titles nor the previous titles", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const collection = await buildCollection({ - teamId: team.id, - userId: user.id, - }); - const document = await buildDocument({ - teamId: team.id, - userId: user.id, - collectionId: collection.id, - title: "test number 1", - }); - document.title = "change"; - await document.save(); - const { totalCount } = await SearchHelper.searchForUser( - user, - "title doesn't exist" - ); - expect(totalCount).toBe("0"); - }); -}); - -describe("#searchTitlesForUser", () => { - test("should return search results from collections", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const collection = await buildCollection({ - userId: user.id, - teamId: team.id, - }); - const document = await buildDocument({ - userId: user.id, - teamId: team.id, - collectionId: collection.id, - title: "test", - }); - const documents = await SearchHelper.searchTitlesForUser(user, "test"); - expect(documents.length).toBe(1); - expect(documents[0]?.id).toBe(document.id); - }); - - test("should filter to specific collection", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const collection = await buildCollection({ - userId: user.id, - teamId: team.id, - }); - const collection1 = await buildCollection({ - userId: user.id, - teamId: team.id, - }); - const document = await buildDocument({ - userId: user.id, - teamId: team.id, - collectionId: collection.id, - title: "test", - }); - await buildDraftDocument({ - teamId: team.id, - userId: user.id, - title: "test", - }); - await buildDocument({ - userId: user.id, - teamId: team.id, - collectionId: collection1.id, - title: "test", - }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - collectionId: collection.id, - }); - expect(documents.length).toBe(1); - expect(documents[0]?.id).toBe(document.id); - }); - - test("should handle no collections", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const documents = await SearchHelper.searchTitlesForUser(user, "test"); - expect(documents.length).toBe(0); - }); - - test("should search only drafts created by user", async () => { - const user = await buildUser(); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "test", - }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: true, - }); - expect(documents.length).toBe(1); - }); - - test("should not include drafts", async () => { - const user = await buildUser(); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "test", - }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: false, - }); - expect(documents.length).toBe(0); - }); - - test("should include results from drafts as well", async () => { - const user = await buildUser(); - await buildDocument({ - userId: user.id, - teamId: user.teamId, - createdById: user.id, - title: "not test", - }); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "test", - }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: true, - }); - expect(documents.length).toBe(2); - }); - - test("should not include results from drafts", async () => { - const user = await buildUser(); - await buildDocument({ - userId: user.id, - teamId: user.teamId, - createdById: user.id, - title: "not test", - }); - await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, - title: "test", - }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: false, - }); - expect(documents.length).toBe(1); }); }); diff --git a/server/models/helpers/SearchHelper.ts b/server/models/helpers/SearchHelper.ts index f4ef49c0b..ed8cab5c4 100644 --- a/server/models/helpers/SearchHelper.ts +++ b/server/models/helpers/SearchHelper.ts @@ -427,7 +427,7 @@ export default class SearchHelper { * @param query The user search query * @returns The query formatted for Postgres ts_query */ - private static webSearchQuery(query: string): string { + public static webSearchQuery(query: string): string { // limit length of search queries as we're using regex against untrusted input let limitedQuery = this.escapeQuery(query.slice(0, this.maxQueryLength)); @@ -439,7 +439,7 @@ export default class SearchHelper { !limitedQuery.endsWith('"'); // Replace single quote characters with &. - const singleQuotes = limitedQuery.matchAll(/'/g); + const singleQuotes = limitedQuery.matchAll(/'+/g); for (const match of singleQuotes) { if ( @@ -454,8 +454,10 @@ export default class SearchHelper { } } - return queryParser()( - singleUnquotedSearch ? `${limitedQuery}*` : limitedQuery + return ( + queryParser()(singleUnquotedSearch ? `${limitedQuery}*` : limitedQuery) + // Remove any trailing join characters + .replace(/&$/, "") ); }