From be3dceccfae463cc4a5042e26315d606db665edc Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 22 Mar 2022 02:23:06 +0100 Subject: [PATCH] DEV: Merge two spec files (#16244) Also reenabled two specs on macOS as they're green now. --- spec/lib/search_spec copy.rb | 2026 ---------------------------------- spec/lib/search_spec.rb | 1998 +++++++++++++++++++++++++++++++++ 2 files changed, 1998 insertions(+), 2026 deletions(-) delete mode 100644 spec/lib/search_spec copy.rb diff --git a/spec/lib/search_spec copy.rb b/spec/lib/search_spec copy.rb deleted file mode 100644 index 83c665323f3..00000000000 --- a/spec/lib/search_spec copy.rb +++ /dev/null @@ -1,2026 +0,0 @@ -# encoding: utf-8 -# frozen_string_literal: true - -describe Search do - fab!(:admin) { Fabricate(:admin) } - fab!(:topic) { Fabricate(:topic) } - - before do - SearchIndexer.enable - Jobs.run_immediately! - end - - context 'post indexing' do - fab!(:category) { Fabricate(:category_with_definition, name: 'america') } - fab!(:topic) { Fabricate(:topic, title: 'sam saffron test topic', category: category) } - let!(:post) { Fabricate(:post, topic: topic, raw: 'this fun test ') } - let!(:post2) { Fabricate(:post, topic: topic) } - - it "should index correctly" do - search_data = post.post_search_data.search_data - - expect(search_data).to match(/fun/) - expect(search_data).to match(/sam/) - expect(search_data).to match(/america/) - - expect do - topic.update!(title: "harpi is the new title") - end.to change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) - - expect(post.post_search_data.reload.search_data).to match(/harpi/) - end - - it 'should update posts index when topic category changes' do - expect do - topic.update!(category: Fabricate(:category)) - end.to change { post.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) - .and change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) - end - - it 'should update posts index when topic tags changes' do - SiteSetting.tagging_enabled = true - tag = Fabricate(:tag) - - expect do - DiscourseTagging.tag_topic_by_names(topic, Guardian.new(admin), [tag.name]) - topic.save! - end.to change { post.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) - .and change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) - - expect(topic.tags).to eq([tag]) - end - end - - context 'user indexing' do - before do - @user = Fabricate(:user, username: 'fred', name: 'bob jones') - @indexed = @user.user_search_data.search_data - end - - it "should pick up on data" do - expect(@indexed).to match(/fred/) - expect(@indexed).to match(/jone/) - end - end - - context 'category indexing' do - let!(:category) { Fabricate(:category_with_definition, name: 'america') } - let!(:topic) { Fabricate(:topic, category: category) } - let!(:post) { Fabricate(:post, topic: topic) } - let!(:post2) { Fabricate(:post, topic: topic) } - let!(:post3) { Fabricate(:post) } - - it "should index correctly" do - expect(category.category_search_data.search_data).to match(/america/) - end - - it 'should update posts index when category name changes' do - expect do - category.update!(name: 'some new name') - end.to change { post.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) - .and change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) - - expect(post3.post_search_data.version).to eq(SearchIndexer::POST_INDEX_VERSION) - end - end - - it 'strips zero-width characters from search terms' do - term = "\u0063\u0061\u0070\u0079\u200b\u200c\u200d\ufeff\u0062\u0061\u0072\u0061".encode("UTF-8") - - expect(term == 'capybara').to eq(false) - - search = Search.new(term) - expect(search.valid?).to eq(true) - expect(search.term).to eq('capybara') - expect(search.clean_term).to eq('capybara') - end - - it 'replaces curly quotes to regular quotes in search terms' do - term = '“discourse”' - - expect(term == '"discourse"').to eq(false) - - search = Search.new(term) - expect(search.valid?).to eq(true) - expect(search.term).to eq('"discourse"') - expect(search.clean_term).to eq('"discourse"') - end - - it 'does not search when the search term is too small' do - search = Search.new('evil', min_search_term_length: 5) - search.execute - expect(search.valid?).to eq(false) - expect(search.term).to eq('') - end - - it 'needs at least one term that hits the length' do - search = Search.new('a b c d', min_search_term_length: 5) - search.execute - expect(search.valid?).to eq(false) - expect(search.term).to eq('') - end - - it 'searches for quoted short terms' do - search = Search.new('"a b c d"', min_search_term_length: 5) - search.execute - expect(search.valid?).to eq(true) - expect(search.term).to eq('"a b c d"') - end - - it 'searches for short terms if one hits the length' do - search = Search.new('a b c okaylength', min_search_term_length: 5) - search.execute - expect(search.valid?).to eq(true) - expect(search.term).to eq('a b c okaylength') - end - - context 'query sanitization' do - let!(:post) { Fabricate(:post, raw: 'hello world') } - - it 'escapes backslash' do - expect(Search.execute('hello\\').posts).to contain_exactly(post) - end - - it 'escapes single quote' do - expect(Search.execute("hello'").posts).to contain_exactly(post) - end - - it 'escapes non-alphanumeric characters' do - expect(Search.execute('hello :!$);}]>@\#\"\'').posts).to contain_exactly(post) - end - end - - it 'works when given two terms with spaces' do - expect { Search.execute('evil trout') }.not_to raise_error - end - - context 'users' do - let!(:user) { Fabricate(:user) } - let(:result) { Search.execute('bruce', type_filter: 'user') } - - it 'returns a result' do - expect(result.users.length).to eq(1) - expect(result.users[0].id).to eq(user.id) - end - - context 'hiding user profiles' do - before { SiteSetting.hide_user_profiles_from_public = true } - - it 'returns no result for anon' do - expect(result.users.length).to eq(0) - end - - it 'returns a result for logged in users' do - result = Search.execute('bruce', type_filter: 'user', guardian: Guardian.new(user)) - expect(result.users.length).to eq(1) - end - - end - - end - - context 'inactive users' do - let!(:inactive_user) { Fabricate(:inactive_user, active: false) } - let(:result) { Search.execute('bruce') } - - it 'does not return a result' do - expect(result.users.length).to eq(0) - end - end - - context 'staged users' do - let(:staged) { Fabricate(:staged) } - let(:result) { Search.execute(staged.username) } - - it 'does not return a result' do - expect(result.users.length).to eq(0) - end - end - - context 'private messages' do - let!(:post) { Fabricate(:private_message_post) } - - let(:topic) { post.topic } - - let!(:reply) do - Fabricate(:private_message_post, - topic: post.topic, - raw: 'hello from mars, we just landed', - user: post.user - ) - end - - let!(:post2) do - Fabricate(:private_message_post, - raw: 'another secret pm from mars, testing' - ) - end - - it 'searches correctly as an admin' do - results = Search.execute( - 'mars', - type_filter: 'private_messages', - guardian: Guardian.new(admin) - ) - - expect(results.posts).to eq([]) - end - - it "searches correctly as an admin given another user's context" do - results = Search.execute( - 'mars', - type_filter: 'private_messages', - search_context: reply.user, - guardian: Guardian.new(admin) - ) - - expect(results.posts).to contain_exactly(reply) - end - - it "raises the right error when a normal user searches for another user's context" do - expect do - Search.execute( - 'mars', - search_context: reply.user, - type_filter: 'private_messages', - guardian: Guardian.new(Fabricate(:user)) - ) - end.to raise_error(Discourse::InvalidAccess) - end - - it 'searches correctly as a user' do - results = Search.execute( - 'mars', - type_filter: 'private_messages', - guardian: Guardian.new(reply.user) - ) - - expect(results.posts).to contain_exactly(reply) - end - - it 'searches correctly for a user with no private messages' do - results = Search.execute( - 'mars', - type_filter: 'private_messages', - guardian: Guardian.new(Fabricate(:user)) - ) - - expect(results.posts).to eq([]) - end - - it 'searches correctly' do - expect do - Search.execute('mars', type_filter: 'private_messages') - end.to raise_error(Discourse::InvalidAccess) - - results = Search.execute( - 'mars', - type_filter: 'private_messages', - guardian: Guardian.new(reply.user) - ) - - expect(results.posts).to contain_exactly(reply) - - results = Search.execute( - 'mars', - search_context: topic, - guardian: Guardian.new(reply.user) - ) - - expect(results.posts).to contain_exactly(reply) - - # can search group PMs as well as non admin - # - user = Fabricate(:user) - group = Fabricate.build(:group) - group.add(user) - group.save! - - TopicAllowedGroup.create!(group_id: group.id, topic_id: topic.id) - - ["mars in:personal", "mars IN:PERSONAL"].each do |query| - results = Search.execute(query, guardian: Guardian.new(user)) - expect(results.posts).to contain_exactly(reply) - end - end - - context 'personal_messages filter' do - it 'does not allow a normal user to search for personal messages of another user' do - expect do - Search.execute( - "mars personal_messages:#{post.user.username}", - guardian: Guardian.new(Fabricate(:user)) - ) - end.to raise_error(Discourse::InvalidAccess) - end - - it 'searches correctly for the PM of the given user' do - results = Search.execute( - "mars personal_messages:#{post.user.username}", - guardian: Guardian.new(admin) - ) - - expect(results.posts).to contain_exactly(reply) - end - - it 'returns the right results if username is invalid' do - results = Search.execute( - "mars personal_messages:random_username", - guardian: Guardian.new(admin) - ) - - expect(results.posts).to eq([]) - end - end - - context 'all-pms flag' do - it 'returns matching PMs if the user is an admin' do - results = Search.execute('mars in:all-pms', guardian: Guardian.new(admin)) - - expect(results.posts).to include(reply, post2) - end - - it 'returns nothing if the user is not an admin' do - results = Search.execute('mars in:all-pms', guardian: Guardian.new(Fabricate(:user))) - - expect(results.posts).to be_empty - end - - it 'returns nothing if the user is a moderator' do - results = Search.execute('mars in:all-pms', guardian: Guardian.new(Fabricate(:moderator))) - - expect(results.posts).to be_empty - end - end - - context 'personal-direct flag' do - let(:current) { Fabricate(:user, admin: true, username: "current_user") } - let(:participant) { Fabricate(:user, username: "participant_1") } - let(:participant_2) { Fabricate(:user, username: "participant_2") } - - let(:group) do - group = Fabricate(:group, has_messages: true) - group.add(current) - group.add(participant) - group - end - - def create_pm(users:, group: nil) - pm = Fabricate(:private_message_post_one_user, user: users.first).topic - users[1..-1].each do |u| - pm.invite(users.first, u.username) - Fabricate(:post, user: u, topic: pm) - end - if group - pm.invite_group(users.first, group) - group.users.each do |u| - Fabricate(:post, user: u, topic: pm) - end - end - pm.reload - end - - it 'can find all direct PMs of the current user' do - pm = create_pm(users: [current, participant]) - _pm_2 = create_pm(users: [participant_2, participant]) - pm_3 = create_pm(users: [participant, current]) - pm_4 = create_pm(users: [participant_2, current]) - - ["in:personal-direct", "In:PeRsOnAl-DiReCt"].each do |query| - results = Search.execute(query, guardian: Guardian.new(current)) - expect(results.posts.size).to eq(3) - expect(results.posts.map(&:topic_id)).to eq([pm_4.id, pm_3.id, pm.id]) - end - end - - it 'can filter direct PMs by @username' do - pm = create_pm(users: [current, participant]) - pm_2 = create_pm(users: [participant, current]) - pm_3 = create_pm(users: [participant_2, current]) - [ - "@#{participant.username} in:personal-direct", - "@#{participant.username} iN:pErSoNaL-dIrEcT", - ].each do |query| - results = Search.execute(query, guardian: Guardian.new(current)) - expect(results.posts.size).to eq(2) - expect(results.posts.map(&:topic_id)).to contain_exactly(pm_2.id, pm.id) - expect(results.posts.map(&:user_id).uniq).to eq([participant.id]) - end - - results = Search.execute("@me in:personal-direct", guardian: Guardian.new(current)) - expect(results.posts.size).to eq(3) - expect(results.posts.map(&:topic_id)).to contain_exactly(pm_3.id, pm_2.id, pm.id) - expect(results.posts.map(&:user_id).uniq).to eq([current.id]) - end - - it "doesn't include PMs that have more than 2 participants" do - _pm = create_pm(users: [current, participant, participant_2]) - results = Search.execute("@#{participant.username} in:personal-direct", guardian: Guardian.new(current)) - expect(results.posts.size).to eq(0) - end - - it "doesn't include PMs that have groups" do - _pm = create_pm(users: [current, participant], group: group) - results = Search.execute("@#{participant.username} in:personal-direct", guardian: Guardian.new(current)) - expect(results.posts.size).to eq(0) - end - end - - context 'all topics' do - - let!(:u1) { Fabricate(:user, username: 'fred', name: 'bob jones', email: 'foo+1@bar.baz') } - let!(:u2) { Fabricate(:user, username: 'bob', name: 'fred jones', email: 'foo+2@bar.baz') } - let!(:u3) { Fabricate(:user, username: 'jones', name: 'bob fred', email: 'foo+3@bar.baz') } - let!(:u4) { Fabricate(:user, username: 'alice', name: 'bob fred', email: 'foo+4@bar.baz', admin: true) } - - let!(:public_topic) { Fabricate(:topic, user: u1) } - let!(:public_post1) { Fabricate(:post, topic: public_topic, raw: "what do you want for breakfast? ham and eggs?", user: u1) } - let!(:public_post2) { Fabricate(:post, topic: public_topic, raw: "ham and spam", user: u2) } - - let!(:private_topic) { Fabricate(:topic, user: u1, category_id: nil, archetype: 'private_message') } - let!(:private_post1) { Fabricate(:post, topic: private_topic, raw: "what do you want for lunch? ham and cheese?", user: u1) } - let!(:private_post2) { Fabricate(:post, topic: private_topic, raw: "cheese and spam", user: u2) } - - it 'finds private messages' do - TopicAllowedUser.create!(user_id: u1.id, topic_id: private_topic.id) - TopicAllowedUser.create!(user_id: u2.id, topic_id: private_topic.id) - - # case insensitive only - results = Search.execute('iN:aLL cheese', guardian: Guardian.new(u1)) - expect(results.posts).to contain_exactly(private_post1) - - # private only - results = Search.execute('in:all cheese', guardian: Guardian.new(u1)) - expect(results.posts).to contain_exactly(private_post1) - - # public only - results = Search.execute('in:all eggs', guardian: Guardian.new(u1)) - expect(results.posts).to contain_exactly(public_post1) - - # both - results = Search.execute('in:all spam', guardian: Guardian.new(u1)) - expect(results.posts).to contain_exactly(public_post2, private_post2) - - # for anon - results = Search.execute('in:all spam', guardian: Guardian.new) - expect(results.posts).to contain_exactly(public_post2) - - # nonparticipatory user - results = Search.execute('in:all cheese', guardian: Guardian.new(u3)) - expect(results.posts.empty?).to eq(true) - - results = Search.execute('in:all eggs', guardian: Guardian.new(u3)) - expect(results.posts).to contain_exactly(public_post1) - - results = Search.execute('in:all spam', guardian: Guardian.new(u3)) - expect(results.posts).to contain_exactly(public_post2) - - # Admin doesn't see private topic - results = Search.execute('in:all spam', guardian: Guardian.new(u4)) - expect(results.posts).to contain_exactly(public_post2) - - # same keyword for different users - results = Search.execute('in:all ham', guardian: Guardian.new(u1)) - expect(results.posts).to contain_exactly(public_post1, private_post1) - - results = Search.execute('in:all ham', guardian: Guardian.new(u2)) - expect(results.posts).to contain_exactly(public_post1, private_post1) - - results = Search.execute('in:all ham', guardian: Guardian.new(u3)) - expect(results.posts).to contain_exactly(public_post1) - end - end - end - - context 'posts' do - fab!(:post) do - SearchIndexer.enable - Fabricate(:post) - end - - let(:topic) { post.topic } - - let!(:reply) do - Fabricate(:post_with_long_raw_content, - topic: topic, - user: topic.user, - ).tap { |post| post.update!(raw: "#{post.raw} elephant") } - end - - let(:expected_blurb) do - "#{Search::GroupedSearchResults::OMISSION}hundred characters to satisfy any test conditions that require content longer than the typical test post raw content. It really is some long content, folks. elephant" - end - - it 'returns the post' do - SiteSetting.use_pg_headlines_for_excerpt = true - - result = Search.execute('elephant', - type_filter: 'topic', - include_blurbs: true - ) - - expect(result.posts.map(&:id)).to contain_exactly(reply.id) - - post = result.posts.first - - expect(result.blurb(post)).to eq(expected_blurb) - expect(post.topic_title_headline).to eq(topic.fancy_title) - end - - it "only applies highlighting to the first #{Search::MAX_LENGTH_FOR_HEADLINE} characters" do - SiteSetting.use_pg_headlines_for_excerpt = true - - reply.update!(raw: "#{'a' * Search::MAX_LENGTH_FOR_HEADLINE} #{reply.raw}") - - result = Search.execute('elephant') - - expect(result.posts.map(&:id)).to contain_exactly(reply.id) - - post = result.posts.first - - expect(post.headline.include?('elephant')).to eq(false) - end - - it "does not truncate topic title when applying highlights" do - SiteSetting.use_pg_headlines_for_excerpt = true - - topic = reply.topic - topic.update!(title: "#{'very ' * 7}long topic title with our search term in the middle of the title") - - result = Search.execute('search term') - - expect(result.posts.first.topic_title_headline).to eq(<<~HTML.chomp) - Very very very very very very very long topic title with our search term in the middle of the title - HTML - end - - it "limits the search headline to #{Search::GroupedSearchResults::BLURB_LENGTH} characters" do - SiteSetting.use_pg_headlines_for_excerpt = true - - reply.update!(raw: "#{'a' * Search::GroupedSearchResults::BLURB_LENGTH} elephant") - - result = Search.execute('elephant') - - expect(result.posts.map(&:id)).to contain_exactly(reply.id) - - post = result.posts.first - - expect(result.blurb(post)).to eq("#{'a' * Search::GroupedSearchResults::BLURB_LENGTH}#{Search::GroupedSearchResults::OMISSION}") - end - - it 'returns the right post and blurb for searches with phrase' do - SiteSetting.use_pg_headlines_for_excerpt = true - - result = Search.execute('"elephant"', - type_filter: 'topic', - include_blurbs: true - ) - - expect(result.posts.map(&:id)).to contain_exactly(reply.id) - expect(result.blurb(result.posts.first)).to eq(expected_blurb) - end - - it 'applies a small penalty to closed topic when ranking' do - post = Fabricate(:post, - raw: "My weekly update", - topic: Fabricate(:topic, - title: "A topic that will be closed", - closed: true - ) - ) - - post2 = Fabricate(:post, - raw: "My weekly update", - topic: Fabricate(:topic, - title: "A topic that will be open" - ) - ) - - result = Search.execute('weekly update') - expect(result.posts.pluck(:id)).to eq([post2.id, post.id]) - end - - it 'aggregates searches in a topic by returning the post with the lowest post number' do - post = Fabricate(:post, topic: topic, raw: "this is a play post") - post2 = Fabricate(:post, topic: topic, raw: "play play playing played play") - post3 = Fabricate(:post, raw: "this is a play post") - - 5.times do - Fabricate(:post, topic: topic, raw: "play playing played") - end - - results = Search.execute('play') - - expect(results.posts.map(&:id)).to eq([ - post.id, - post3.id - ]) - end - - it "is able to search with an offset when configured" do - post_1 = Fabricate(:post, raw: "this is a play post") - SiteSetting.search_recent_regular_posts_offset_post_id = post_1.id + 1 - - results = Search.execute('play post') - - expect(results.posts).to eq([post_1]) - - post_2 = Fabricate(:post, raw: "this is another play post") - - SiteSetting.search_recent_regular_posts_offset_post_id = post_2.id - - results = Search.execute('play post') - - expect(results.posts.map(&:id)).to eq([ - post_2.id, - post_1.id - ]) - end - - it 'allows staff to search for whispers' do - post.update!(post_type: Post.types[:whisper], raw: 'this is a tiger') - - results = Search.execute('tiger') - - expect(results.posts).to eq([]) - - results = Search.execute('tiger', guardian: Guardian.new(admin)) - - expect(results.posts).to eq([post]) - end - end - - context 'topics' do - let(:post) { Fabricate(:post) } - let(:topic) { post.topic } - - context 'search within topic' do - - def new_post(raw, topic = nil, created_at: nil) - topic ||= Fabricate(:topic) - Fabricate(:post, topic: topic, topic_id: topic.id, user: topic.user, raw: raw, created_at: created_at) - end - - it 'works in Chinese' do - SiteSetting.search_tokenize_chinese_japanese_korean = true - post = new_post('I am not in English 何点になると思いますか') - - results = Search.execute('何点になると思', search_context: post.topic) - expect(results.posts.map(&:id)).to eq([post.id]) - end - - it 'displays multiple results within a topic' do - topic2 = Fabricate(:topic) - - new_post('this is the other post I am posting', topic2, created_at: 6.minutes.ago) - new_post('this is my fifth post I am posting', topic2, created_at: 5.minutes.ago) - - post1 = new_post('this is the other post I am posting', topic, created_at: 4.minutes.ago) - post2 = new_post('this is my first post I am posting', topic, created_at: 3.minutes.ago) - post3 = new_post('this is a real long and complicated bla this is my second post I am Posting birds with more stuff bla bla', topic, created_at: 2.minutes.ago) - post4 = new_post('this is my fourth post I am posting', topic, created_at: 1.minute.ago) - - # update posts_count - topic.reload - - results = Search.execute('posting', search_context: post1.topic) - expect(results.posts.map(&:id)).to eq([post1.id, post2.id, post3.id, post4.id]) - - results = Search.execute('posting l', search_context: post1.topic) - expect(results.posts.map(&:id)).to eq([post4.id, post3.id, post2.id, post1.id]) - - # stop words should work - results = Search.execute('this', search_context: post1.topic) - expect(results.posts.length).to eq(4) - - # phrase search works as expected - results = Search.execute('"fourth post I am posting"', search_context: post1.topic) - expect(results.posts.length).to eq(1) - end - - it "works for unlisted topics" do - topic.update(visible: false) - _post = new_post('discourse is awesome', topic) - results = Search.execute('discourse', search_context: topic) - expect(results.posts.length).to eq(1) - end - end - - context 'searching the OP' do - let!(:post) { Fabricate(:post_with_long_raw_content) } - let(:result) { Search.execute('hundred', type_filter: 'topic') } - - it 'returns a result correctly' do - expect(result.posts.length).to eq(1) - expect(result.posts[0].id).to eq(post.id) - end - end - - context 'searching for quoted title' do - it "can find quoted title" do - create_post(raw: "this is the raw body", title: "I am a title yeah") - result = Search.execute('"a title yeah"') - - expect(result.posts.length).to eq(1) - end - - end - - context "search for a topic by id" do - let(:result) { Search.execute(topic.id, type_filter: 'topic', search_for_id: true, min_search_term_length: 1) } - - it 'returns the topic' do - expect(result.posts.length).to eq(1) - expect(result.posts.first.id).to eq(post.id) - end - end - - context "search for a topic by url" do - it 'returns the topic' do - result = Search.execute(topic.relative_url, search_for_id: true, type_filter: 'topic') - expect(result.posts.length).to eq(1) - expect(result.posts.first.id).to eq(post.id) - end - - context 'restrict_to_archetype' do - let(:personal_message) { Fabricate(:private_message_topic) } - let!(:p1) { Fabricate(:post, topic: personal_message, post_number: 1) } - - it 'restricts result to topics' do - result = Search.execute(personal_message.relative_url, search_for_id: true, type_filter: 'topic', restrict_to_archetype: Archetype.default) - expect(result.posts.length).to eq(0) - - result = Search.execute(topic.relative_url, search_for_id: true, type_filter: 'topic', restrict_to_archetype: Archetype.default) - expect(result.posts.length).to eq(1) - end - - it 'restricts result to messages' do - result = Search.execute(topic.relative_url, search_for_id: true, type_filter: 'private_messages', guardian: Guardian.new(admin), restrict_to_archetype: Archetype.private_message) - expect(result.posts.length).to eq(0) - - result = Search.execute(personal_message.relative_url, search_for_id: true, type_filter: 'private_messages', guardian: Guardian.new(admin), restrict_to_archetype: Archetype.private_message) - expect(result.posts.length).to eq(1) - end - end - end - - context 'security' do - - def result(current_user) - Search.execute('hello', guardian: Guardian.new(current_user)) - end - - it 'secures results correctly' do - category = Fabricate(:category_with_definition) - - topic.category_id = category.id - topic.save - - category.set_permissions(staff: :full) - category.save - - expect(result(nil).posts).not_to be_present - expect(result(Fabricate(:user)).posts).not_to be_present - expect(result(admin).posts).to be_present - - end - end - - end - - context 'cyrillic topic' do - let!(:cyrillic_topic) { - Fabricate(:topic) do - user - title { sequence(:title) { |i| "Тестовая запись #{i}" } } - end - } - - let!(:post) { Fabricate(:post, topic: cyrillic_topic, user: cyrillic_topic.user) } - let(:result) { Search.execute('запись') } - - it 'finds something when given cyrillic query' do - expect(result.posts).to contain_exactly(post) - end - end - - it 'does not tokenize search term' do - Fabricate(:post, raw: 'thing is canned should still be found!') - expect(Search.execute('canned').posts).to be_present - end - - context 'categories' do - let(:category) { Fabricate(:category_with_definition, name: "monkey Category 2") } - let(:topic) { Fabricate(:topic, category: category) } - let!(:post) { Fabricate(:post, topic: topic, raw: "snow monkey") } - - let!(:ignored_category) do - Fabricate(:category_with_definition, - name: "monkey Category 1", - slug: "test", - search_priority: Searchable::PRIORITIES[:ignore] - ) - end - - it "should return the right categories" do - search = Search.execute("monkey") - - expect(search.categories).to contain_exactly( - category, ignored_category - ) - - expect(search.posts).to eq([category.topic.first_post, post]) - - search = Search.execute("monkey #test") - - expect(search.posts).to eq([ignored_category.topic.first_post]) - end - - describe "with child categories" do - let!(:child_of_ignored_category) do - Fabricate(:category_with_definition, - name: "monkey Category 3", - parent_category: ignored_category - ) - end - - let!(:post2) do - Fabricate(:post, - topic: Fabricate(:topic, category: child_of_ignored_category), - raw: "snow monkey park" - ) - end - - it 'returns the right results' do - search = Search.execute("monkey") - - expect(search.categories).to contain_exactly( - category, ignored_category, child_of_ignored_category - ) - - expect(search.posts.map(&:id)).to eq([ - child_of_ignored_category.topic.first_post, - category.topic.first_post, - post2, - post - ].map(&:id)) - - search = Search.execute("snow") - expect(search.posts.map(&:id)).to eq([post2.id, post.id]) - - category.set_permissions({}) - category.save! - search = Search.execute("monkey") - - expect(search.categories).to contain_exactly( - ignored_category, child_of_ignored_category - ) - - expect(search.posts.map(&:id)).to eq([ - child_of_ignored_category.topic.first_post, - post2 - ].map(&:id)) - end - end - - describe 'categories with different priorities' do - let(:category2) { Fabricate(:category_with_definition) } - - it "should return posts in the right order" do - raw = "The pure genuine evian" - post = Fabricate(:post, topic: category.topic, raw: raw) - post2 = Fabricate(:post, topic: category2.topic, raw: raw) - post2.topic.update!(bumped_at: 10.seconds.from_now) - - search = Search.execute(raw) - - expect(search.posts.map(&:id)).to eq([post2.id, post.id]) - - category.update!(search_priority: Searchable::PRIORITIES[:high]) - - search = Search.execute(raw) - - expect(search.posts.map(&:id)).to eq([post.id, post2.id]) - end - end - - end - - context 'groups' do - def search(user = Fabricate(:user)) - Search.execute(group.name, guardian: Guardian.new(user)) - end - - let!(:group) { Group[:trust_level_0] } - - it 'shows group' do - expect(search.groups.map(&:name)).to eq([group.name]) - end - - context 'group visibility' do - let!(:group) { Fabricate(:group) } - - before do - group.update!(visibility_level: 3) - end - - context 'staff logged in' do - it 'shows group' do - expect(search(admin).groups.map(&:name)).to eq([group.name]) - end - end - - context 'non staff logged in' do - it 'shows doesn’t show group' do - expect(search.groups.map(&:name)).to be_empty - end - end - end - end - - context 'tags' do - def search - Search.execute(tag.name) - end - - let!(:tag) { Fabricate(:tag) } - let!(:uppercase_tag) { Fabricate(:tag, name: "HeLlO") } - let(:tag_group) { Fabricate(:tag_group) } - let(:category) { Fabricate(:category_with_definition) } - - context 'post searching' do - before do - SiteSetting.tagging_enabled = true - DiscourseTagging.tag_topic_by_names(post.topic, Guardian.new(Fabricate.build(:admin)), [tag.name, uppercase_tag.name]) - post.topic.save - end - - let(:post) { Fabricate(:post, raw: 'I am special post') } - - it 'can find posts with tags' do - # we got to make this index (it is deferred) - Jobs::ReindexSearch.new.rebuild_posts - - result = Search.execute(tag.name) - expect(result.posts.length).to eq(1) - - result = Search.execute("hElLo") - expect(result.posts.length).to eq(1) - - SiteSetting.tagging_enabled = false - - result = Search.execute(tag.name) - expect(result.posts.length).to eq(0) - end - - it 'can find posts with tag synonyms' do - synonym = Fabricate(:tag, name: 'synonym', target_tag: tag) - Jobs::ReindexSearch.new.rebuild_posts - result = Search.execute(synonym.name) - expect(result.posts.length).to eq(1) - end - end - - context 'tagging is disabled' do - before { SiteSetting.tagging_enabled = false } - - it 'does not include tags' do - expect(search.tags).to_not be_present - end - end - - context 'tagging is enabled' do - before { SiteSetting.tagging_enabled = true } - - it 'returns the tag in the result' do - expect(search.tags).to eq([tag]) - end - - it 'shows staff tags' do - create_staff_only_tags(["#{tag.name}9"]) - - expect(Search.execute(tag.name, guardian: Guardian.new(admin)).tags.map(&:name)).to eq([tag.name, "#{tag.name}9"]) - expect(search.tags.map(&:name)).to eq([tag.name, "#{tag.name}9"]) - end - - it 'includes category-restricted tags' do - category_tag = Fabricate(:tag, name: "#{tag.name}9") - tag_group.tags = [category_tag] - category.set_permissions(admins: :full) - category.allowed_tag_groups = [tag_group.name] - category.save! - - expect(Search.execute(tag.name, guardian: Guardian.new(admin)).tags).to eq([tag, category_tag]) - expect(search.tags).to eq([tag, category_tag]) - end - end - end - - context 'type_filter' do - - let!(:user) { Fabricate(:user, username: 'amazing', email: 'amazing@amazing.com') } - let!(:category) { Fabricate(:category_with_definition, name: 'amazing category', user: user) } - - context 'user filter' do - let(:results) { Search.execute('amazing', type_filter: 'user') } - - it "returns a user result" do - expect(results.categories.length).to eq(0) - expect(results.posts.length).to eq(0) - expect(results.users.length).to eq(1) - end - - end - - context 'category filter' do - let(:results) { Search.execute('amazing', type_filter: 'category') } - - it "returns a category result" do - expect(results.categories.length).to eq(1) - expect(results.posts.length).to eq(0) - expect(results.users.length).to eq(0) - end - - end - - end - - context 'search_context' do - - it 'can find a user when using search context' do - - coding_horror = Fabricate(:coding_horror) - post = Fabricate(:post) - - Fabricate(:post, user: coding_horror) - - result = Search.execute('hello', search_context: post.user) - - result.posts.first.topic_id = post.topic_id - expect(result.posts.length).to eq(1) - end - - it 'can use category as a search context' do - category = Fabricate(:category_with_definition, - search_priority: Searchable::PRIORITIES[:ignore] - ) - - topic = Fabricate(:topic, category: category) - topic_no_cat = Fabricate(:topic) - - # includes subcategory in search - subcategory = Fabricate(:category_with_definition, parent_category_id: category.id) - sub_topic = Fabricate(:topic, category: subcategory) - - post = Fabricate(:post, topic: topic, user: topic.user) - Fabricate(:post, topic: topic_no_cat, user: topic.user) - sub_post = Fabricate(:post, raw: 'I am saying hello from a subcategory', topic: sub_topic, user: topic.user) - - search = Search.execute('hello', search_context: category) - expect(search.posts.map(&:id)).to match_array([post.id, sub_post.id]) - expect(search.posts.length).to eq(2) - end - - it 'can use tag as a search context' do - tag = Fabricate(:tag, name: 'important-stuff') - - topic_no_tag = Fabricate(:topic) - Fabricate(:topic_tag, tag: tag, topic: topic) - - post = Fabricate(:post, topic: topic, user: topic.user, raw: 'This is my hello') - Fabricate(:post, topic: topic_no_tag, user: topic.user) - - search = Search.execute('hello', search_context: tag) - expect(search.posts.map(&:id)).to contain_exactly(post.id) - expect(search.posts.length).to eq(1) - end - - end - - context 'Japanese search' do - let!(:topic) { Fabricate(:topic) } - let!(:post) { Fabricate(:post, topic: topic, raw: 'This is some japanese text 日本が大好きです。') } - let!(:topic_2) { Fabricate(:topic, title: '日本の話題、 more japanese text') } - let!(:post_2) { Fabricate(:post, topic: topic_2) } - - describe '.prepare_data' do - it 'removes punctuations' do - SiteSetting.search_tokenize_japanese = true - - expect(Search.prepare_data(post.raw)).to eq("This is some japanese text 日本 が 大好き です") - end - end - - describe '.execute' do - before do - @old_default = SiteSetting.defaults.get(:min_search_term_length) - SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, 1) - SiteSetting.refresh! - end - - after do - SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, @old_default) - SiteSetting.refresh! - end - - it 'finds posts containing Japanese text if tokenization is forced' do - SiteSetting.search_tokenize_japanese = true - - expect(Search.execute('日本').posts.map(&:id)).to eq([post_2.id, post.id]) - expect(Search.execute('日').posts.map(&:id)).to eq([post_2.id, post.id]) - end - - it "find posts containing search term when site's locale is set to Japanese" do - SiteSetting.default_locale = 'ja' - - expect(Search.execute('日本').posts.map(&:id)).to eq([post_2.id, post.id]) - expect(Search.execute('日').posts.map(&:id)).to eq([post_2.id, post.id]) - end - - it 'does not include superfluous spaces in blurbs' do - SiteSetting.default_locale = 'ja' - - post.update!(raw: '場サアマネ織企ういかせ竹域ヱイマ穂基ホ神3予読ずねいぱ松査ス禁多サウ提懸イふ引小43改こょドめ。深とつぐ主思料農ぞかル者杯検める活分えほづぼ白犠') - - results = Search.execute('ういかせ竹域', type_filter: 'topic') - - expect(results.posts.length).to eq(1) - expect(results.blurb(results.posts.first)).to include('ういかせ竹域') - end - end - end - - describe 'Chinese search' do - let(:sentence) { 'Discourse is a software company 中国的基础设施网络正在组装。' } - let(:sentence_t) { 'Discourse is a software company 太平山森林遊樂區。' } - - it 'splits English / Chinese and filter out Chinese stop words' do - SiteSetting.default_locale = 'zh_CN' - data = Search.prepare_data(sentence) - expect(data).to eq("Discourse is a software company 中国 基础设施 网络 正在 组装") - end - - it 'splits for indexing and filter out stop words' do - SiteSetting.default_locale = 'zh_CN' - data = Search.prepare_data(sentence, :index) - expect(data).to eq("Discourse is a software company 中国 基础设施 网络 正在 组装") - end - - it 'splits English / Traditional Chinese and filter out stop words' do - SiteSetting.default_locale = 'zh_TW' - data = Search.prepare_data(sentence_t) - expect(data).to eq("Discourse is a software company 太平山 森林 遊樂區") - end - - it 'does not split strings beginning with numeric chars into different segments' do - SiteSetting.default_locale = 'zh_TW' - data = Search.prepare_data("#{sentence} 123abc") - expect(data).to eq("Discourse is a software company 中国 基础设施 网络 正在 组装 123abc") - end - - it 'finds chinese topic based on title' do - skip("skipped until pg app installs the db correctly") if RbConfig::CONFIG["arch"] =~ /darwin/ - - SiteSetting.default_locale = 'zh_TW' - SiteSetting.min_search_term_length = 1 - - topic = Fabricate(:topic, title: 'My Title Discourse社區指南') - post = Fabricate(:post, topic: topic) - - expect(Search.execute('社區指南').posts.first.id).to eq(post.id) - expect(Search.execute('指南').posts.first.id).to eq(post.id) - end - - it 'finds chinese topic based on title if tokenization is forced' do - skip("skipped until pg app installs the db correctly") if RbConfig::CONFIG["arch"] =~ /darwin/ - - begin - SiteSetting.search_tokenize_chinese = true - default_min_search_term_length = SiteSetting.defaults.get(:min_search_term_length) - SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, 1) - SiteSetting.refresh! - - topic = Fabricate(:topic, title: 'My Title Discourse社區指南') - post = Fabricate(:post, topic: topic) - - expect(Search.execute('社區指南').posts.first.id).to eq(post.id) - expect(Search.execute('指南').posts.first.id).to eq(post.id) - ensure - if default_min_search_term_length - SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, default_min_search_term_length) - SiteSetting.refresh! - end - end - end - end - - describe 'Advanced search' do - - it 'supports pinned' do - Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic) - _post = Fabricate(:post, raw: 'boom boom shake the room', topic: topic) - - topic.update_pinned(true) - - expect(Search.execute('boom in:pinned').posts.length).to eq(1) - expect(Search.execute('boom IN:PINNED').posts.length).to eq(1) - end - - it 'supports wiki' do - topic_2 = Fabricate(:topic) - post = Fabricate(:post, raw: 'this is a test 248', wiki: true, topic: topic) - Fabricate(:post, raw: 'this is a test 248', wiki: false, topic: topic_2) - - expect(Search.execute('test 248').posts.length).to eq(2) - expect(Search.execute('test 248 in:wiki').posts.first).to eq(post) - expect(Search.execute('test 248 IN:WIKI').posts.first).to eq(post) - end - - it 'supports searching for posts that the user has seen/unseen' do - topic_2 = Fabricate(:topic) - post = Fabricate(:post, raw: 'logan is longan', topic: topic) - post_2 = Fabricate(:post, raw: 'longan is logan', topic: topic_2) - - [post.user, topic.user].each do |user| - PostTiming.create!( - post_number: post.post_number, - topic: topic, - user: user, - msecs: 1 - ) - end - - expect(post.seen?(post.user)).to eq(true) - - expect(Search.execute('longan').posts.sort).to eq([post, post_2]) - - expect(Search.execute('longan in:seen', guardian: Guardian.new(post.user)).posts) - .to eq([post]) - - expect(Search.execute('longan IN:SEEN', guardian: Guardian.new(post.user)).posts) - .to eq([post]) - - expect(Search.execute('longan in:seen').posts.sort).to eq([post, post_2]) - - expect(Search.execute('longan in:seen', guardian: Guardian.new(post_2.user)).posts) - .to eq([]) - - expect(Search.execute('longan', guardian: Guardian.new(post_2.user)).posts.sort) - .to eq([post, post_2]) - - expect(Search.execute('longan in:unseen', guardian: Guardian.new(post_2.user)).posts.sort) - .to eq([post, post_2]) - - expect(Search.execute('longan in:unseen', guardian: Guardian.new(post.user)).posts) - .to eq([post_2]) - - expect(Search.execute('longan IN:UNSEEN', guardian: Guardian.new(post.user)).posts) - .to eq([post_2]) - end - - it 'supports before and after filters' do - time = Time.zone.parse('2001-05-20 2:55') - freeze_time(time) - - post_1 = Fabricate(:post, raw: 'hi this is a test 123 123', created_at: time.months_ago(2)) - post_2 = Fabricate(:post, raw: 'boom boom shake the room test') - - expect(Search.execute('test before:1').posts).to contain_exactly(post_1) - expect(Search.execute('test before:2001-04-20').posts).to contain_exactly(post_1) - expect(Search.execute('test before:2001').posts).to eq([]) - expect(Search.execute('test after:2001').posts).to contain_exactly(post_1, post_2) - expect(Search.execute('test before:monday').posts).to contain_exactly(post_1) - expect(Search.execute('test after:jan').posts).to contain_exactly(post_1, post_2) - end - - it 'supports in:first, user:, @username' do - post_1 = Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic) - post_2 = Fabricate(:post, raw: 'boom boom shake the room test', topic: topic) - - expect(Search.execute('test in:first').posts).to contain_exactly(post_1) - expect(Search.execute('test IN:FIRST').posts).to contain_exactly(post_1) - - expect(Search.execute('boom').posts).to contain_exactly(post_2) - - expect(Search.execute('boom in:first').posts).to eq([]) - expect(Search.execute('boom f').posts).to eq([]) - - expect(Search.execute('123 in:first').posts).to contain_exactly(post_1) - expect(Search.execute('123 f').posts).to contain_exactly(post_1) - - expect(Search.execute('user:nobody').posts).to eq([]) - expect(Search.execute("user:#{post_1.user.username}").posts).to contain_exactly(post_1) - expect(Search.execute("user:#{post_1.user_id}").posts).to contain_exactly(post_1) - - expect(Search.execute("@#{post_1.user.username}").posts).to contain_exactly(post_1) - end - - context "searching for posts made by users of a group" do - fab!(:topic) { Fabricate(:topic, created_at: 3.months.ago) } - fab!(:user) { Fabricate(:user) } - fab!(:user_2) { Fabricate(:user) } - fab!(:user_3) { Fabricate(:user) } - fab!(:group) { Fabricate(:group, name: "Like_a_Boss").tap { |g| g.add(user) } } - fab!(:group_2) { Fabricate(:group).tap { |g| g.add(user_2) } } - let!(:post) { Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic, user: user) } - let!(:post_2) { Fabricate(:post, user: user_2) } - - it 'should not return any posts if group does not exist' do - group.update!( - visibility_level: Group.visibility_levels[:public], - members_visibility_level: Group.visibility_levels[:public] - ) - - expect(Search.execute('group:99999').posts).to eq([]) - end - - it 'should return the right posts for a public group' do - group.update!( - visibility_level: Group.visibility_levels[:public], - members_visibility_level: Group.visibility_levels[:public] - ) - - expect(Search.execute('group:like_a_boss').posts).to contain_exactly(post) - expect(Search.execute("group:#{group.id}").posts).to contain_exactly(post) - end - - it "should return the right posts for a public group with members' visibility restricted to logged on users" do - group.update!( - visibility_level: Group.visibility_levels[:public], - members_visibility_level: Group.visibility_levels[:logged_on_users] - ) - - expect(Search.execute("group:#{group.id}").posts).to eq([]) - expect(Search.execute("group:#{group.id}", guardian: Guardian.new(user_3)).posts).to contain_exactly(post) - end - - it "should return the right posts for a group with visibility restricted to logged on users with members' visibility restricted to members" do - group.update!( - visibility_level: Group.visibility_levels[:logged_on_users], - members_visibility_level: Group.visibility_levels[:members] - ) - - expect(Search.execute("group:#{group.id}").posts).to eq([]) - expect(Search.execute("group:#{group.id}", guardian: Guardian.new(user_3)).posts).to eq([]) - expect(Search.execute("group:#{group.id}", guardian: Guardian.new(user)).posts).to contain_exactly(post) - end - end - - it 'supports badge' do - - topic = Fabricate(:topic, created_at: 3.months.ago) - post = Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic) - - badge = Badge.create!(name: "Like a Boss", badge_type_id: 1) - UserBadge.create!(user_id: post.user_id, badge_id: badge.id, granted_at: 1.minute.ago, granted_by_id: -1) - - expect(Search.execute('badge:"like a boss"').posts.length).to eq(1) - expect(Search.execute('BADGE:"LIKE A BOSS"').posts.length).to eq(1) - expect(Search.execute('badge:"test"').posts.length).to eq(0) - end - - it 'can match exact phrases' do - post = Fabricate(:post, raw: %{this is a test post with 'a URL https://some.site.com/search?q=test.test.test some random text I have to add}) - post2 = Fabricate(:post, raw: 'test URL post with') - - expect(Search.execute("test post with 'a URL).posts").posts).to eq([post2, post]) - expect(Search.execute(%{"test post with 'a URL"}).posts).to eq([post]) - expect(Search.execute(%{"https://some.site.com/search?q=test.test.test"}).posts).to eq([post]) - expect(Search.execute(%{" with 'a URL https://some.site.com/search?q=test.test.test"}).posts).to eq([post]) - end - - it 'can search numbers correctly, and match exact phrases' do - post = Fabricate(:post, raw: '3.0 eta is in 2 days horrah') - post2 = Fabricate(:post, raw: '3.0 is eta in 2 days horrah') - - expect(Search.execute('3.0 eta').posts).to eq([post, post2]) - expect(Search.execute("'3.0 eta'").posts).to eq([post, post2]) - expect(Search.execute("\"3.0 eta\"").posts).to contain_exactly(post) - expect(Search.execute('"3.0, eta is"').posts).to eq([]) - end - - it 'can find by status' do - public_category = Fabricate(:category, read_restricted: false) - post = Fabricate(:post, raw: 'hi this is a test 123 123') - topic = post.topic - topic.update(category: public_category) - - private_category = Fabricate(:category, read_restricted: true) - post2 = Fabricate(:post, raw: 'hi this is another test 123 123') - second_topic = post2.topic - second_topic.update(category: private_category) - - _post3 = Fabricate(:post, raw: "another test!", user: topic.user, topic: second_topic) - - expect(Search.execute('test status:public').posts.length).to eq(1) - expect(Search.execute('test status:closed').posts.length).to eq(0) - expect(Search.execute('test status:open').posts.length).to eq(1) - expect(Search.execute('test STATUS:OPEN').posts.length).to eq(1) - expect(Search.execute('test posts_count:1').posts.length).to eq(1) - expect(Search.execute('test min_post_count:1').posts.length).to eq(1) - expect(Search.execute('test min_posts:1').posts.length).to eq(1) - expect(Search.execute('test max_posts:2').posts.length).to eq(1) - - topic.update(closed: true) - second_topic.update(category: public_category) - - expect(Search.execute('test status:public').posts.length).to eq(2) - expect(Search.execute('test status:closed').posts.length).to eq(1) - expect(Search.execute('status:closed').posts.length).to eq(1) - expect(Search.execute('test status:open').posts.length).to eq(1) - - topic.update(archived: true, closed: false) - second_topic.update(closed: true) - - expect(Search.execute('test status:archived').posts.length).to eq(1) - expect(Search.execute('test status:open').posts.length).to eq(0) - - expect(Search.execute('test status:noreplies').posts.length).to eq(1) - - expect(Search.execute('test in:likes', guardian: Guardian.new(topic.user)).posts.length).to eq(0) - - expect(Search.execute('test in:posted', guardian: Guardian.new(topic.user)).posts.length).to eq(2) - expect(Search.execute('test In:PoStEd', guardian: Guardian.new(topic.user)).posts.length).to eq(2) - - in_created = Search.execute('test in:created', guardian: Guardian.new(topic.user)).posts - created_by_user = Search.execute("test created:@#{topic.user.username}", guardian: Guardian.new(topic.user)).posts - expect(in_created.length).to eq(1) - expect(created_by_user.length).to eq(1) - expect(in_created).to eq(created_by_user) - - expect(Search.execute("test created:@#{second_topic.user.username}", guardian: Guardian.new(topic.user)).posts.length).to eq(1) - - new_user = Fabricate(:user) - expect(Search.execute("test created:@#{new_user.username}", guardian: Guardian.new(topic.user)).posts.length).to eq(0) - - TopicUser.change(topic.user.id, topic.id, notification_level: TopicUser.notification_levels[:tracking]) - expect(Search.execute('test in:watching', guardian: Guardian.new(topic.user)).posts.length).to eq(0) - expect(Search.execute('test in:tracking', guardian: Guardian.new(topic.user)).posts.length).to eq(1) - end - - it 'can find posts with images' do - post_uploaded = Fabricate(:post_with_uploaded_image) - Fabricate(:post) - - CookedPostProcessor.new(post_uploaded).update_post_image - - expect(Search.execute('with:images').posts.map(&:id)).to contain_exactly(post_uploaded.id) - end - - it 'can find by latest' do - topic1 = Fabricate(:topic, title: 'I do not like that Sam I am') - post1 = Fabricate(:post, topic: topic1, created_at: 10.minutes.ago) - post2 = Fabricate(:post, raw: 'that Sam I am, that Sam I am', created_at: 5.minutes.ago) - - expect(Search.execute('sam').posts.map(&:id)).to eq([post1.id, post2.id]) - expect(Search.execute('sam ORDER:LATEST').posts.map(&:id)).to eq([post2.id, post1.id]) - expect(Search.execute('sam l').posts.map(&:id)).to eq([post2.id, post1.id]) - expect(Search.execute('l sam').posts.map(&:id)).to eq([post2.id, post1.id]) - end - - it 'can order by topic creation' do - today = Date.today - yesterday = 1.day.ago - two_days_ago = 2.days.ago - category = Fabricate(:category_with_definition) - - old_topic = Fabricate(:topic, - title: 'First Topic, testing the created_at sort', - created_at: two_days_ago, - category: category - ) - - latest_topic = Fabricate(:topic, - title: 'Second Topic, testing the created_at sort', - created_at: yesterday, - category: category - ) - - old_relevant_topic_post = Fabricate(:post, - topic: old_topic, - created_at: yesterday, - raw: 'Relevant Relevant Topic' - ) - - latest_irrelevant_topic_post = Fabricate(:post, - topic: latest_topic, - created_at: today, - raw: 'Not Relevant' - ) - - # Expecting the default results - expect(Search.execute('Topic').posts.map(&:id)).to eq([ - old_relevant_topic_post.id, - latest_irrelevant_topic_post.id, - category.topic.first_post.id - ]) - - # Expecting the ordered by topic creation results - expect(Search.execute('Topic order:latest_topic').posts.map(&:id)).to eq([ - category.topic.first_post.id, - latest_irrelevant_topic_post.id, - old_relevant_topic_post.id - ]) - end - - it 'can order by topic views' do - topic = Fabricate(:topic, views: 1) - topic2 = Fabricate(:topic, views: 2) - post = Fabricate(:post, raw: 'Topic', topic: topic) - post2 = Fabricate(:post, raw: 'Topic', topic: topic2) - - expect(Search.execute('Topic order:views').posts.map(&:id)).to eq([ - post2.id, - post.id - ]) - end - - it 'can filter by topic views' do - topic = Fabricate(:topic, views: 100) - topic2 = Fabricate(:topic, views: 200) - post = Fabricate(:post, raw: 'Topic', topic: topic) - post2 = Fabricate(:post, raw: 'Topic', topic: topic2) - - expect(Search.execute('Topic min_views:150').posts.map(&:id)).to eq([post2.id]) - expect(Search.execute('Topic max_views:150').posts.map(&:id)).to eq([post.id]) - end - - it 'can search for terms with dots' do - post = Fabricate(:post, raw: 'Will.2000 Will.Bob.Bill...') - expect(Search.execute('bill').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('bob').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('2000').posts.map(&:id)).to eq([post.id]) - end - - it 'can search URLS correctly' do - post = Fabricate(:post, raw: 'i like http://wb.camra.org.uk/latest#test so yay') - - expect(Search.execute('http://wb.camra.org.uk/latest#test').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('camra').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('http://wb').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('wb.camra').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('wb.camra.org').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('org.uk').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('camra.org.uk').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('wb.camra.org.uk').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('wb.camra.org.uk/latest').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('/latest#test').posts.map(&:id)).to eq([post.id]) - end - - it 'supports category slug and tags' do - # main category - category = Fabricate(:category_with_definition, name: 'category 24', slug: 'cateGory-24') - topic = Fabricate(:topic, created_at: 3.months.ago, category: category) - post = Fabricate(:post, raw: 'Sams first post', topic: topic) - - expect(Search.execute('sams post #categoRy-24').posts.length).to eq(1) - expect(Search.execute("sams post category:#{category.id}").posts.length).to eq(1) - expect(Search.execute('sams post #categoRy-25').posts.length).to eq(0) - - sub_category = Fabricate(:category_with_definition, name: 'sub category', slug: 'sub-category', parent_category_id: category.id) - second_topic = Fabricate(:topic, created_at: 3.months.ago, category: sub_category) - Fabricate(:post, raw: 'sams second post', topic: second_topic) - - expect(Search.execute("sams post category:categoRY-24").posts.length).to eq(2) - expect(Search.execute("sams post category:=cAtegory-24").posts.length).to eq(1) - - expect(Search.execute("sams post #category-24").posts.length).to eq(2) - expect(Search.execute("sams post #=category-24").posts.length).to eq(1) - expect(Search.execute("sams post #sub-category").posts.length).to eq(1) - - expect(Search.execute("sams post #categoRY-24:SUB-category").posts.length) - .to eq(1) - - # tags - topic.tags = [Fabricate(:tag, name: 'alpha'), Fabricate(:tag, name: 'привет'), Fabricate(:tag, name: 'HeLlO')] - expect(Search.execute('this is a test #alpha').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('this is a test #привет').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('this is a test #hElLo').posts.map(&:id)).to eq([post.id]) - expect(Search.execute('this is a test #beta').posts.size).to eq(0) - end - - it 'supports sub-sub category slugs' do - - SiteSetting.max_category_nesting = 3 - - category = Fabricate(:category, name: 'top', slug: 'top') - sub = Fabricate(:category, name: 'middle', slug: 'middle', parent_category_id: category.id) - leaf = Fabricate(:category, name: 'leaf', slug: 'leaf', parent_category_id: sub.id) - - topic = Fabricate(:topic, created_at: 3.months.ago, category: leaf) - _post = Fabricate(:post, raw: 'Sams first post', topic: topic) - - expect(Search.execute('#Middle:leaf first post').posts.size).to eq(1) - end - - it 'correctly handles #symbol when no tag or category match' do - Fabricate(:post, raw: 'testing #1 #9998') - results = Search.new('testing #1').execute - expect(results.posts.length).to eq(1) - - results = Search.new('#9998').execute - expect(results.posts.length).to eq(1) - - results = Search.new('#777').execute - expect(results.posts.length).to eq(0) - - results = Search.new('xxx #:').execute - expect(results.posts.length).to eq(0) - end - - context 'tags' do - fab!(:tag1) { Fabricate(:tag, name: 'lunch') } - fab!(:tag2) { Fabricate(:tag, name: 'eggs') } - fab!(:tag3) { Fabricate(:tag, name: 'sandwiches') } - - fab!(:tag_group) do - group = TagGroup.create!(name: 'mid day') - TagGroupMembership.create!(tag_id: tag1.id, tag_group_id: group.id) - TagGroupMembership.create!(tag_id: tag3.id, tag_group_id: group.id) - group - end - - fab!(:topic1) { Fabricate(:topic, tags: [tag2, Fabricate(:tag)]) } - fab!(:topic2) { Fabricate(:topic, tags: [tag2]) } - fab!(:topic3) { Fabricate(:topic, tags: [tag1, tag2]) } - fab!(:topic4) { Fabricate(:topic, tags: [tag1, tag2, tag3]) } - fab!(:topic5) { Fabricate(:topic, tags: [tag2, tag3]) } - - def indexed_post(*args) - SearchIndexer.enable - Fabricate(:post, *args) - end - - fab!(:post1) { indexed_post(topic: topic1) } - fab!(:post2) { indexed_post(topic: topic2) } - fab!(:post3) { indexed_post(topic: topic3) } - fab!(:post4) { indexed_post(topic: topic4) } - fab!(:post5) { indexed_post(topic: topic5) } - - it 'can find posts by tag group' do - expect(Search.execute('#mid-day').posts.map(&:id)).to eq([ - post5, post4, post3 - ].map(&:id)) - end - - it 'can find posts with tag' do - post4 = Fabricate(:post, topic: topic3, raw: "It probably doesn't help that they're green...") - - expect(Search.execute('green tags:eggs').posts.map(&:id)).to eq([post4.id]) - expect(Search.execute('tags:plants').posts.size).to eq(0) - end - - it 'can find posts with non-latin tag' do - topic.tags = [Fabricate(:tag, name: 'さようなら')] - post = Fabricate(:post, raw: 'Testing post', topic: topic) - - expect(Search.execute('tags:さようなら').posts.map(&:id)).to eq([post.id]) - end - - it 'can find posts with thai tag' do - topic.tags = [Fabricate(:tag, name: 'เรซิ่น')] - post = Fabricate(:post, raw: 'Testing post', topic: topic) - - expect(Search.execute('tags:เรซิ่น').posts.map(&:id)).to eq([post.id]) - end - - it 'can find posts with any tag from multiple tags' do - expect(Search.execute('tags:eggs,lunch').posts.map(&:id).sort).to eq([post1.id, post2.id, post3.id, post4.id, post5.id].sort) - end - - it 'can find posts which contains all provided tags' do - expect(Search.execute('tags:lunch+eggs+sandwiches').posts.map(&:id)).to eq([post4.id].sort) - expect(Search.execute('tags:eggs+lunch+sandwiches').posts.map(&:id)).to eq([post4.id].sort) - end - - it 'can find posts which contains provided tags and does not contain selected ones' do - expect(Search.execute('tags:eggs -tags:lunch').posts.map(&:id)) - .to eq([post5, post2, post1].map(&:id)) - - expect(Search.execute('tags:eggs -tags:lunch+sandwiches').posts.map(&:id)) - .to eq([post5, post3, post2, post1].map(&:id)) - - expect(Search.execute('tags:eggs -tags:lunch,sandwiches').posts.map(&:id)) - .to eq([post2, post1].map(&:id)) - end - - it 'orders posts correctly when combining tags with categories or terms' do - cat1 = Fabricate(:category_with_definition, name: 'food') - topic6 = Fabricate(:topic, tags: [tag1, tag2], category: cat1) - topic7 = Fabricate(:topic, tags: [tag1, tag2, tag3], category: cat1) - post7 = Fabricate(:post, topic: topic6, raw: "Wakey, wakey, eggs and bakey.", like_count: 5, created_at: 2.minutes.ago) - post8 = Fabricate(:post, topic: topic7, raw: "Bakey, bakey, eggs to makey.", like_count: 2, created_at: 1.minute.ago) - - expect(Search.execute('bakey tags:lunch order:latest').posts.map(&:id)) - .to eq([post8.id, post7.id]) - - expect(Search.execute('#food tags:lunch order:latest').posts.map(&:id)) - .to eq([post8.id, post7.id]) - - expect(Search.execute('#food tags:lunch order:likes').posts.map(&:id)) - .to eq([post7.id, post8.id]) - end - - end - - it "can find posts which contains filetypes" do - post1 = Fabricate(:post, raw: "http://example.com/image.png") - - post2 = Fabricate(:post, - raw: "Discourse logo\n"\ - "http://example.com/logo.png\n"\ - "http://example.com/vector_image.svg" - ) - - post_with_upload = Fabricate(:post, uploads: [Fabricate(:upload)]) - Fabricate(:post) - - TopicLink.extract_from(post1) - TopicLink.extract_from(post2) - - expect(Search.execute('filetype:svg').posts).to eq([post2]) - - expect(Search.execute('filetype:png').posts.map(&:id)).to eq([ - post_with_upload, post2, post1 - ].map(&:id)) - - expect(Search.execute('logo filetype:png').posts).to eq([post2]) - end - end - - context '#ts_query' do - it 'can parse complex strings using ts_query helper' do - str = +" grigio:babel deprecated? " - str << "page page on Atmosphere](https://atmospherejs.com/grigio/babel)xxx: aaa.js:222 aaa'\"bbb" - - ts_query = Search.ts_query(term: str, ts_config: "simple") - expect { DB.exec(+"SELECT to_tsvector('bbb') @@ " << ts_query) }.to_not raise_error - - ts_query = Search.ts_query(term: "foo.bar/'&baz", ts_config: "simple") - expect { DB.exec(+"SELECT to_tsvector('bbb') @@ " << ts_query) }.to_not raise_error - expect(ts_query).to include("baz") - end - - it 'escapes the term correctly' do - expect(Search.ts_query(term: 'Title with trailing backslash\\')) - .to eq("TO_TSQUERY('english', '''Title with trailing backslash\\\\\\\\'':*')") - - expect(Search.ts_query(term: "Title with trailing quote'")) - .to eq("TO_TSQUERY('english', '''Title with trailing quote'''''':*')") - end - end - - context '#word_to_date' do - it 'parses relative dates correctly' do - time = Time.zone.parse('2001-02-20 2:55') - freeze_time(time) - - expect(Search.word_to_date('yesterday')).to eq(time.beginning_of_day.yesterday) - expect(Search.word_to_date('suNday')).to eq(Time.zone.parse('2001-02-18')) - expect(Search.word_to_date('thursday')).to eq(Time.zone.parse('2001-02-15')) - expect(Search.word_to_date('deCember')).to eq(Time.zone.parse('2000-12-01')) - expect(Search.word_to_date('deC')).to eq(Time.zone.parse('2000-12-01')) - expect(Search.word_to_date('january')).to eq(Time.zone.parse('2001-01-01')) - expect(Search.word_to_date('jan')).to eq(Time.zone.parse('2001-01-01')) - - expect(Search.word_to_date('100')).to eq(time.beginning_of_day.days_ago(100)) - - expect(Search.word_to_date('invalid')).to eq(nil) - end - - it 'parses absolute dates correctly' do - expect(Search.word_to_date('2001-1-20')).to eq(Time.zone.parse('2001-01-20')) - expect(Search.word_to_date('2030-10-2')).to eq(Time.zone.parse('2030-10-02')) - expect(Search.word_to_date('2030-10')).to eq(Time.zone.parse('2030-10-01')) - expect(Search.word_to_date('2030')).to eq(Time.zone.parse('2030-01-01')) - expect(Search.word_to_date('2030-01-32')).to eq(nil) - expect(Search.word_to_date('10000')).to eq(nil) - end - end - - context "#min_post_id" do - it "returns 0 when prefer_recent_posts is disabled" do - SiteSetting.search_prefer_recent_posts = false - expect(Search.min_post_id_no_cache).to eq(0) - end - - it "returns a value when prefer_recent_posts is enabled" do - SiteSetting.search_prefer_recent_posts = true - SiteSetting.search_recent_posts_size = 1 - - Fabricate(:post) - p2 = Fabricate(:post) - - expect(Search.min_post_id_no_cache).to eq(p2.id) - end - end - - context "search_log_id" do - it "returns an id when the search succeeds" do - s = Search.new( - 'indiana jones', - search_type: :header, - ip_address: '127.0.0.1' - ) - results = s.execute - expect(results.search_log_id).to be_present - end - - it "does not log search if search_type is not present" do - s = Search.new('foo bar', ip_address: '127.0.0.1') - results = s.execute - expect(results.search_log_id).not_to be_present - end - end - - context 'in:title' do - it 'allows for search in title' do - topic = Fabricate(:topic, title: 'I am testing a title search') - _post2 = Fabricate(:post, topic: topic, raw: 'this is the second post', post_number: 2) - post = Fabricate(:post, topic: topic, raw: 'this is the first post', post_number: 1) - - results = Search.execute('title in:title') - expect(results.posts.map(&:id)).to eq([post.id]) - - results = Search.execute('title iN:tItLe') - expect(results.posts.map(&:id)).to eq([post.id]) - - results = Search.execute('first in:title') - expect(results.posts).to eq([]) - end - - it 'works irrespective of the order' do - topic = Fabricate(:topic, title: "A topic about Discourse") - Fabricate(:post, topic: topic, raw: "This is another post") - topic2 = Fabricate(:topic, title: "This is another topic") - Fabricate(:post, topic: topic2, raw: "Discourse is awesome") - - results = Search.execute('Discourse in:title status:open') - expect(results.posts.length).to eq(1) - - results = Search.execute('in:title status:open Discourse') - expect(results.posts.length).to eq(1) - end - end - - context 'ignore_diacritics' do - before { SiteSetting.search_ignore_accents = true } - let!(:post1) { Fabricate(:post, raw: 'สวัสดี Rágis hello') } - - it ('allows strips correctly') do - results = Search.execute('hello', type_filter: 'topic') - expect(results.posts.length).to eq(1) - - results = Search.execute('ragis', type_filter: 'topic') - expect(results.posts.length).to eq(1) - - results = Search.execute('Rágis', type_filter: 'topic') - expect(results.posts.length).to eq(1) - - # TODO: this is a test we need to fix! - #expect(results.blurb(results.posts.first)).to include('Rágis') - - results = Search.execute('สวัสดี', type_filter: 'topic') - expect(results.posts.length).to eq(1) - end - end - - context 'include_diacritics' do - before { SiteSetting.search_ignore_accents = false } - let!(:post1) { Fabricate(:post, raw: 'สวัสดี Régis hello') } - - it ('allows strips correctly') do - results = Search.execute('hello', type_filter: 'topic') - expect(results.posts.length).to eq(1) - - results = Search.execute('regis', type_filter: 'topic') - expect(results.posts.length).to eq(0) - - results = Search.execute('Régis', type_filter: 'topic') - expect(results.posts.length).to eq(1) - - expect(results.blurb(results.posts.first)).to include('Régis') - - results = Search.execute('สวัสดี', type_filter: 'topic') - expect(results.posts.length).to eq(1) - end - end - - context 'pagination' do - let(:number_of_results) { 2 } - let!(:post1) { Fabricate(:post, raw: 'hello hello hello hello hello') } - let!(:post2) { Fabricate(:post, raw: 'hello hello hello hello') } - let!(:post3) { Fabricate(:post, raw: 'hello hello hello') } - let!(:post4) { Fabricate(:post, raw: 'hello hello') } - let!(:post5) { Fabricate(:post, raw: 'hello') } - before do - Search.stubs(:per_filter).returns(number_of_results) - end - - it 'returns more results flag' do - results = Search.execute('hello', type_filter: 'topic') - results2 = Search.execute('hello', type_filter: 'topic', page: 2) - - expect(results.posts.length).to eq(number_of_results) - expect(results.posts.map(&:id)).to eq([post1.id, post2.id]) - expect(results.more_full_page_results).to eq(true) - expect(results2.posts.length).to eq(number_of_results) - expect(results2.posts.map(&:id)).to eq([post3.id, post4.id]) - expect(results2.more_full_page_results).to eq(true) - end - - it 'correctly search with page parameter' do - search = Search.new('hello', type_filter: 'topic', page: 3) - results = search.execute - - expect(search.offset).to eq(2 * number_of_results) - expect(results.posts.length).to eq(1) - expect(results.posts).to eq([post5]) - expect(results.more_full_page_results).to eq(nil) - end - - end - - context 'in:tagged' do - it 'allows for searching by presence of any tags' do - topic = Fabricate(:topic, title: 'I am testing a tagged search') - _post = Fabricate(:post, topic: topic, raw: 'this is the first post') - tag = Fabricate(:tag) - _topic_tag = Fabricate(:topic_tag, topic: topic, tag: tag) - - results = Search.execute('in:untagged') - expect(results.posts.length).to eq(0) - - results = Search.execute('in:tagged') - expect(results.posts.length).to eq(1) - - results = Search.execute('In:TaGgEd') - expect(results.posts.length).to eq(1) - end - end - - context 'in:untagged' do - it 'allows for searching by presence of no tags' do - topic = Fabricate(:topic, title: 'I am testing a untagged search') - _post = Fabricate(:post, topic: topic, raw: 'this is the first post') - - results = Search.execute('iN:uNtAgGeD') - expect(results.posts.length).to eq(1) - - results = Search.execute('in:tagged') - expect(results.posts.length).to eq(0) - end - end - - context 'plugin extensions' do - let!(:post0) { Fabricate(:post, raw: 'this is the first post about advanced filter with length more than 50 chars') } - let!(:post1) { Fabricate(:post, raw: 'this is the second post about advanced filter') } - - it 'allows to define custom filter' do - expect(Search.new("advanced").execute.posts).to eq([post1, post0]) - Search.advanced_filter(/^min_chars:(\d+)$/) do |posts, match| - posts.where("(SELECT LENGTH(p2.raw) FROM posts p2 WHERE p2.id = posts.id) >= ?", match.to_i) - end - expect(Search.new("advanced min_chars:50").execute.posts).to eq([post0]) - end - - it 'allows to define custom order' do - expect(Search.new("advanced").execute.posts).to eq([post1, post0]) - - Search.advanced_order(:chars) do |posts| - posts.reorder("MAX(LENGTH(posts.raw)) DESC") - end - - expect(Search.new("advanced order:chars").execute.posts).to eq([post0, post1]) - end - end - - context 'exclude_topics filter' do - before { SiteSetting.tagging_enabled = true } - let!(:user) { Fabricate(:user) } - fab!(:group) { Fabricate(:group, name: 'bruce-world-fans') } - fab!(:topic) { Fabricate(:topic, title: 'Bruce topic not a result') } - - it 'works' do - category = Fabricate(:category_with_definition, name: 'bruceland', user: user) - tag = Fabricate(:tag, name: 'brucealicious') - - result = Search.execute('bruce', type_filter: 'exclude_topics') - - expect(result.users.map(&:id)).to contain_exactly(user.id) - - expect(result.categories.map(&:id)).to contain_exactly(category.id) - - expect(result.groups.map(&:id)).to contain_exactly(group.id) - - expect(result.tags.map(&:id)).to contain_exactly(tag.id) - - expect(result.posts.length).to eq(0) - end - - it 'does not fail when parsed term is empty' do - result = Search.execute('#cat ', type_filter: 'exclude_topics') - expect(result.categories.length).to eq(0) - end - end -end diff --git a/spec/lib/search_spec.rb b/spec/lib/search_spec.rb index ac00c7e0fb0..42a3c5925e9 100644 --- a/spec/lib/search_spec.rb +++ b/spec/lib/search_spec.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true describe Search do + fab!(:admin) { Fabricate(:admin) } + fab!(:topic) { Fabricate(:topic) } + + before do + SearchIndexer.enable + Jobs.run_immediately! + end context "#ts_config" do it "maps locales to correct Postgres dictionaries" do @@ -234,4 +241,1995 @@ describe Search do expect(Search.execute("test #=#{subsubcategory.slug}").posts).to contain_exactly(post) end end + + context 'post indexing' do + fab!(:category) { Fabricate(:category_with_definition, name: 'america') } + fab!(:topic) { Fabricate(:topic, title: 'sam saffron test topic', category: category) } + let!(:post) { Fabricate(:post, topic: topic, raw: 'this fun test ') } + let!(:post2) { Fabricate(:post, topic: topic) } + + it "should index correctly" do + search_data = post.post_search_data.search_data + + expect(search_data).to match(/fun/) + expect(search_data).to match(/sam/) + expect(search_data).to match(/america/) + + expect do + topic.update!(title: "harpi is the new title") + end.to change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) + + expect(post.post_search_data.reload.search_data).to match(/harpi/) + end + + it 'should update posts index when topic category changes' do + expect do + topic.update!(category: Fabricate(:category)) + end.to change { post.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) + .and change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) + end + + it 'should update posts index when topic tags changes' do + SiteSetting.tagging_enabled = true + tag = Fabricate(:tag) + + expect do + DiscourseTagging.tag_topic_by_names(topic, Guardian.new(admin), [tag.name]) + topic.save! + end.to change { post.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) + .and change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) + + expect(topic.tags).to eq([tag]) + end + end + + context 'user indexing' do + before do + @user = Fabricate(:user, username: 'fred', name: 'bob jones') + @indexed = @user.user_search_data.search_data + end + + it "should pick up on data" do + expect(@indexed).to match(/fred/) + expect(@indexed).to match(/jone/) + end + end + + context 'category indexing' do + let!(:category) { Fabricate(:category_with_definition, name: 'america') } + let!(:topic) { Fabricate(:topic, category: category) } + let!(:post) { Fabricate(:post, topic: topic) } + let!(:post2) { Fabricate(:post, topic: topic) } + let!(:post3) { Fabricate(:post) } + + it "should index correctly" do + expect(category.category_search_data.search_data).to match(/america/) + end + + it 'should update posts index when category name changes' do + expect do + category.update!(name: 'some new name') + end.to change { post.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) + .and change { post2.reload.post_search_data.version }.from(SearchIndexer::POST_INDEX_VERSION).to(SearchIndexer::REINDEX_VERSION) + + expect(post3.post_search_data.version).to eq(SearchIndexer::POST_INDEX_VERSION) + end + end + + it 'strips zero-width characters from search terms' do + term = "\u0063\u0061\u0070\u0079\u200b\u200c\u200d\ufeff\u0062\u0061\u0072\u0061".encode("UTF-8") + + expect(term == 'capybara').to eq(false) + + search = Search.new(term) + expect(search.valid?).to eq(true) + expect(search.term).to eq('capybara') + expect(search.clean_term).to eq('capybara') + end + + it 'replaces curly quotes to regular quotes in search terms' do + term = '“discourse”' + + expect(term == '"discourse"').to eq(false) + + search = Search.new(term) + expect(search.valid?).to eq(true) + expect(search.term).to eq('"discourse"') + expect(search.clean_term).to eq('"discourse"') + end + + it 'does not search when the search term is too small' do + search = Search.new('evil', min_search_term_length: 5) + search.execute + expect(search.valid?).to eq(false) + expect(search.term).to eq('') + end + + it 'needs at least one term that hits the length' do + search = Search.new('a b c d', min_search_term_length: 5) + search.execute + expect(search.valid?).to eq(false) + expect(search.term).to eq('') + end + + it 'searches for quoted short terms' do + search = Search.new('"a b c d"', min_search_term_length: 5) + search.execute + expect(search.valid?).to eq(true) + expect(search.term).to eq('"a b c d"') + end + + it 'searches for short terms if one hits the length' do + search = Search.new('a b c okaylength', min_search_term_length: 5) + search.execute + expect(search.valid?).to eq(true) + expect(search.term).to eq('a b c okaylength') + end + + context 'query sanitization' do + let!(:post) { Fabricate(:post, raw: 'hello world') } + + it 'escapes backslash' do + expect(Search.execute('hello\\').posts).to contain_exactly(post) + end + + it 'escapes single quote' do + expect(Search.execute("hello'").posts).to contain_exactly(post) + end + + it 'escapes non-alphanumeric characters' do + expect(Search.execute('hello :!$);}]>@\#\"\'').posts).to contain_exactly(post) + end + end + + it 'works when given two terms with spaces' do + expect { Search.execute('evil trout') }.not_to raise_error + end + + context 'users' do + let!(:user) { Fabricate(:user) } + let(:result) { Search.execute('bruce', type_filter: 'user') } + + it 'returns a result' do + expect(result.users.length).to eq(1) + expect(result.users[0].id).to eq(user.id) + end + + context 'hiding user profiles' do + before { SiteSetting.hide_user_profiles_from_public = true } + + it 'returns no result for anon' do + expect(result.users.length).to eq(0) + end + + it 'returns a result for logged in users' do + result = Search.execute('bruce', type_filter: 'user', guardian: Guardian.new(user)) + expect(result.users.length).to eq(1) + end + end + end + + context 'inactive users' do + let!(:inactive_user) { Fabricate(:inactive_user, active: false) } + let(:result) { Search.execute('bruce') } + + it 'does not return a result' do + expect(result.users.length).to eq(0) + end + end + + context 'staged users' do + let(:staged) { Fabricate(:staged) } + let(:result) { Search.execute(staged.username) } + + it 'does not return a result' do + expect(result.users.length).to eq(0) + end + end + + context 'private messages' do + let!(:post) { Fabricate(:private_message_post) } + + let(:topic) { post.topic } + + let!(:reply) do + Fabricate(:private_message_post, + topic: post.topic, + raw: 'hello from mars, we just landed', + user: post.user + ) + end + + let!(:post2) do + Fabricate(:private_message_post, + raw: 'another secret pm from mars, testing' + ) + end + + it 'searches correctly as an admin' do + results = Search.execute( + 'mars', + type_filter: 'private_messages', + guardian: Guardian.new(admin) + ) + + expect(results.posts).to eq([]) + end + + it "searches correctly as an admin given another user's context" do + results = Search.execute( + 'mars', + type_filter: 'private_messages', + search_context: reply.user, + guardian: Guardian.new(admin) + ) + + expect(results.posts).to contain_exactly(reply) + end + + it "raises the right error when a normal user searches for another user's context" do + expect do + Search.execute( + 'mars', + search_context: reply.user, + type_filter: 'private_messages', + guardian: Guardian.new(Fabricate(:user)) + ) + end.to raise_error(Discourse::InvalidAccess) + end + + it 'searches correctly as a user' do + results = Search.execute( + 'mars', + type_filter: 'private_messages', + guardian: Guardian.new(reply.user) + ) + + expect(results.posts).to contain_exactly(reply) + end + + it 'searches correctly for a user with no private messages' do + results = Search.execute( + 'mars', + type_filter: 'private_messages', + guardian: Guardian.new(Fabricate(:user)) + ) + + expect(results.posts).to eq([]) + end + + it 'searches correctly' do + expect do + Search.execute('mars', type_filter: 'private_messages') + end.to raise_error(Discourse::InvalidAccess) + + results = Search.execute( + 'mars', + type_filter: 'private_messages', + guardian: Guardian.new(reply.user) + ) + + expect(results.posts).to contain_exactly(reply) + + results = Search.execute( + 'mars', + search_context: topic, + guardian: Guardian.new(reply.user) + ) + + expect(results.posts).to contain_exactly(reply) + + # can search group PMs as well as non admin + user = Fabricate(:user) + group = Fabricate.build(:group) + group.add(user) + group.save! + + TopicAllowedGroup.create!(group_id: group.id, topic_id: topic.id) + + ["mars in:personal", "mars IN:PERSONAL"].each do |query| + results = Search.execute(query, guardian: Guardian.new(user)) + expect(results.posts).to contain_exactly(reply) + end + end + + context 'personal_messages filter' do + it 'does not allow a normal user to search for personal messages of another user' do + expect do + Search.execute( + "mars personal_messages:#{post.user.username}", + guardian: Guardian.new(Fabricate(:user)) + ) + end.to raise_error(Discourse::InvalidAccess) + end + + it 'searches correctly for the PM of the given user' do + results = Search.execute( + "mars personal_messages:#{post.user.username}", + guardian: Guardian.new(admin) + ) + + expect(results.posts).to contain_exactly(reply) + end + + it 'returns the right results if username is invalid' do + results = Search.execute( + "mars personal_messages:random_username", + guardian: Guardian.new(admin) + ) + + expect(results.posts).to eq([]) + end + end + + context 'all-pms flag' do + it 'returns matching PMs if the user is an admin' do + results = Search.execute('mars in:all-pms', guardian: Guardian.new(admin)) + + expect(results.posts).to include(reply, post2) + end + + it 'returns nothing if the user is not an admin' do + results = Search.execute('mars in:all-pms', guardian: Guardian.new(Fabricate(:user))) + + expect(results.posts).to be_empty + end + + it 'returns nothing if the user is a moderator' do + results = Search.execute('mars in:all-pms', guardian: Guardian.new(Fabricate(:moderator))) + + expect(results.posts).to be_empty + end + end + + context 'personal-direct flag' do + let(:current) { Fabricate(:user, admin: true, username: "current_user") } + let(:participant) { Fabricate(:user, username: "participant_1") } + let(:participant_2) { Fabricate(:user, username: "participant_2") } + + let(:group) do + group = Fabricate(:group, has_messages: true) + group.add(current) + group.add(participant) + group + end + + def create_pm(users:, group: nil) + pm = Fabricate(:private_message_post_one_user, user: users.first).topic + users[1..-1].each do |u| + pm.invite(users.first, u.username) + Fabricate(:post, user: u, topic: pm) + end + if group + pm.invite_group(users.first, group) + group.users.each do |u| + Fabricate(:post, user: u, topic: pm) + end + end + pm.reload + end + + it 'can find all direct PMs of the current user' do + pm = create_pm(users: [current, participant]) + _pm_2 = create_pm(users: [participant_2, participant]) + pm_3 = create_pm(users: [participant, current]) + pm_4 = create_pm(users: [participant_2, current]) + + ["in:personal-direct", "In:PeRsOnAl-DiReCt"].each do |query| + results = Search.execute(query, guardian: Guardian.new(current)) + expect(results.posts.size).to eq(3) + expect(results.posts.map(&:topic_id)).to eq([pm_4.id, pm_3.id, pm.id]) + end + end + + it 'can filter direct PMs by @username' do + pm = create_pm(users: [current, participant]) + pm_2 = create_pm(users: [participant, current]) + pm_3 = create_pm(users: [participant_2, current]) + [ + "@#{participant.username} in:personal-direct", + "@#{participant.username} iN:pErSoNaL-dIrEcT", + ].each do |query| + results = Search.execute(query, guardian: Guardian.new(current)) + expect(results.posts.size).to eq(2) + expect(results.posts.map(&:topic_id)).to contain_exactly(pm_2.id, pm.id) + expect(results.posts.map(&:user_id).uniq).to eq([participant.id]) + end + + results = Search.execute("@me in:personal-direct", guardian: Guardian.new(current)) + expect(results.posts.size).to eq(3) + expect(results.posts.map(&:topic_id)).to contain_exactly(pm_3.id, pm_2.id, pm.id) + expect(results.posts.map(&:user_id).uniq).to eq([current.id]) + end + + it "doesn't include PMs that have more than 2 participants" do + _pm = create_pm(users: [current, participant, participant_2]) + results = Search.execute("@#{participant.username} in:personal-direct", guardian: Guardian.new(current)) + expect(results.posts.size).to eq(0) + end + + it "doesn't include PMs that have groups" do + _pm = create_pm(users: [current, participant], group: group) + results = Search.execute("@#{participant.username} in:personal-direct", guardian: Guardian.new(current)) + expect(results.posts.size).to eq(0) + end + end + + context 'all topics' do + let!(:u1) { Fabricate(:user, username: 'fred', name: 'bob jones', email: 'foo+1@bar.baz') } + let!(:u2) { Fabricate(:user, username: 'bob', name: 'fred jones', email: 'foo+2@bar.baz') } + let!(:u3) { Fabricate(:user, username: 'jones', name: 'bob fred', email: 'foo+3@bar.baz') } + let!(:u4) { Fabricate(:user, username: 'alice', name: 'bob fred', email: 'foo+4@bar.baz', admin: true) } + + let!(:public_topic) { Fabricate(:topic, user: u1) } + let!(:public_post1) { Fabricate(:post, topic: public_topic, raw: "what do you want for breakfast? ham and eggs?", user: u1) } + let!(:public_post2) { Fabricate(:post, topic: public_topic, raw: "ham and spam", user: u2) } + + let!(:private_topic) { Fabricate(:topic, user: u1, category_id: nil, archetype: 'private_message') } + let!(:private_post1) { Fabricate(:post, topic: private_topic, raw: "what do you want for lunch? ham and cheese?", user: u1) } + let!(:private_post2) { Fabricate(:post, topic: private_topic, raw: "cheese and spam", user: u2) } + + it 'finds private messages' do + TopicAllowedUser.create!(user_id: u1.id, topic_id: private_topic.id) + TopicAllowedUser.create!(user_id: u2.id, topic_id: private_topic.id) + + # case insensitive only + results = Search.execute('iN:aLL cheese', guardian: Guardian.new(u1)) + expect(results.posts).to contain_exactly(private_post1) + + # private only + results = Search.execute('in:all cheese', guardian: Guardian.new(u1)) + expect(results.posts).to contain_exactly(private_post1) + + # public only + results = Search.execute('in:all eggs', guardian: Guardian.new(u1)) + expect(results.posts).to contain_exactly(public_post1) + + # both + results = Search.execute('in:all spam', guardian: Guardian.new(u1)) + expect(results.posts).to contain_exactly(public_post2, private_post2) + + # for anon + results = Search.execute('in:all spam', guardian: Guardian.new) + expect(results.posts).to contain_exactly(public_post2) + + # nonparticipatory user + results = Search.execute('in:all cheese', guardian: Guardian.new(u3)) + expect(results.posts.empty?).to eq(true) + + results = Search.execute('in:all eggs', guardian: Guardian.new(u3)) + expect(results.posts).to contain_exactly(public_post1) + + results = Search.execute('in:all spam', guardian: Guardian.new(u3)) + expect(results.posts).to contain_exactly(public_post2) + + # Admin doesn't see private topic + results = Search.execute('in:all spam', guardian: Guardian.new(u4)) + expect(results.posts).to contain_exactly(public_post2) + + # same keyword for different users + results = Search.execute('in:all ham', guardian: Guardian.new(u1)) + expect(results.posts).to contain_exactly(public_post1, private_post1) + + results = Search.execute('in:all ham', guardian: Guardian.new(u2)) + expect(results.posts).to contain_exactly(public_post1, private_post1) + + results = Search.execute('in:all ham', guardian: Guardian.new(u3)) + expect(results.posts).to contain_exactly(public_post1) + end + end + end + + context 'posts' do + fab!(:post) do + SearchIndexer.enable + Fabricate(:post) + end + + let(:topic) { post.topic } + + let!(:reply) do + Fabricate(:post_with_long_raw_content, + topic: topic, + user: topic.user, + ).tap { |post| post.update!(raw: "#{post.raw} elephant") } + end + + let(:expected_blurb) do + "#{Search::GroupedSearchResults::OMISSION}hundred characters to satisfy any test conditions that require content longer than the typical test post raw content. It really is some long content, folks. elephant" + end + + it 'returns the post' do + SiteSetting.use_pg_headlines_for_excerpt = true + + result = Search.execute('elephant', + type_filter: 'topic', + include_blurbs: true + ) + + expect(result.posts.map(&:id)).to contain_exactly(reply.id) + + post = result.posts.first + + expect(result.blurb(post)).to eq(expected_blurb) + expect(post.topic_title_headline).to eq(topic.fancy_title) + end + + it "only applies highlighting to the first #{Search::MAX_LENGTH_FOR_HEADLINE} characters" do + SiteSetting.use_pg_headlines_for_excerpt = true + + reply.update!(raw: "#{'a' * Search::MAX_LENGTH_FOR_HEADLINE} #{reply.raw}") + + result = Search.execute('elephant') + + expect(result.posts.map(&:id)).to contain_exactly(reply.id) + + post = result.posts.first + + expect(post.headline.include?('elephant')).to eq(false) + end + + it "does not truncate topic title when applying highlights" do + SiteSetting.use_pg_headlines_for_excerpt = true + + topic = reply.topic + topic.update!(title: "#{'very ' * 7}long topic title with our search term in the middle of the title") + + result = Search.execute('search term') + + expect(result.posts.first.topic_title_headline).to eq(<<~HTML.chomp) + Very very very very very very very long topic title with our search term in the middle of the title + HTML + end + + it "limits the search headline to #{Search::GroupedSearchResults::BLURB_LENGTH} characters" do + SiteSetting.use_pg_headlines_for_excerpt = true + + reply.update!(raw: "#{'a' * Search::GroupedSearchResults::BLURB_LENGTH} elephant") + + result = Search.execute('elephant') + + expect(result.posts.map(&:id)).to contain_exactly(reply.id) + + post = result.posts.first + + expect(result.blurb(post)).to eq("#{'a' * Search::GroupedSearchResults::BLURB_LENGTH}#{Search::GroupedSearchResults::OMISSION}") + end + + it 'returns the right post and blurb for searches with phrase' do + SiteSetting.use_pg_headlines_for_excerpt = true + + result = Search.execute('"elephant"', + type_filter: 'topic', + include_blurbs: true + ) + + expect(result.posts.map(&:id)).to contain_exactly(reply.id) + expect(result.blurb(result.posts.first)).to eq(expected_blurb) + end + + it 'applies a small penalty to closed topic when ranking' do + post = Fabricate(:post, + raw: "My weekly update", + topic: Fabricate(:topic, + title: "A topic that will be closed", + closed: true + ) + ) + + post2 = Fabricate(:post, + raw: "My weekly update", + topic: Fabricate(:topic, + title: "A topic that will be open" + ) + ) + + result = Search.execute('weekly update') + expect(result.posts.pluck(:id)).to eq([post2.id, post.id]) + end + + it 'aggregates searches in a topic by returning the post with the lowest post number' do + post = Fabricate(:post, topic: topic, raw: "this is a play post") + post2 = Fabricate(:post, topic: topic, raw: "play play playing played play") + post3 = Fabricate(:post, raw: "this is a play post") + + 5.times do + Fabricate(:post, topic: topic, raw: "play playing played") + end + + results = Search.execute('play') + + expect(results.posts.map(&:id)).to eq([ + post.id, + post3.id + ]) + end + + it "is able to search with an offset when configured" do + post_1 = Fabricate(:post, raw: "this is a play post") + SiteSetting.search_recent_regular_posts_offset_post_id = post_1.id + 1 + + results = Search.execute('play post') + + expect(results.posts).to eq([post_1]) + + post_2 = Fabricate(:post, raw: "this is another play post") + + SiteSetting.search_recent_regular_posts_offset_post_id = post_2.id + + results = Search.execute('play post') + + expect(results.posts.map(&:id)).to eq([ + post_2.id, + post_1.id + ]) + end + + it 'allows staff to search for whispers' do + post.update!(post_type: Post.types[:whisper], raw: 'this is a tiger') + + results = Search.execute('tiger') + + expect(results.posts).to eq([]) + + results = Search.execute('tiger', guardian: Guardian.new(admin)) + + expect(results.posts).to eq([post]) + end + end + + context 'topics' do + let(:post) { Fabricate(:post) } + let(:topic) { post.topic } + + context 'search within topic' do + def new_post(raw, topic = nil, created_at: nil) + topic ||= Fabricate(:topic) + Fabricate(:post, topic: topic, topic_id: topic.id, user: topic.user, raw: raw, created_at: created_at) + end + + it 'works in Chinese' do + SiteSetting.search_tokenize_chinese_japanese_korean = true + post = new_post('I am not in English 何点になると思いますか') + + results = Search.execute('何点になると思', search_context: post.topic) + expect(results.posts.map(&:id)).to eq([post.id]) + end + + it 'displays multiple results within a topic' do + topic2 = Fabricate(:topic) + + new_post('this is the other post I am posting', topic2, created_at: 6.minutes.ago) + new_post('this is my fifth post I am posting', topic2, created_at: 5.minutes.ago) + + post1 = new_post('this is the other post I am posting', topic, created_at: 4.minutes.ago) + post2 = new_post('this is my first post I am posting', topic, created_at: 3.minutes.ago) + post3 = new_post('this is a real long and complicated bla this is my second post I am Posting birds with more stuff bla bla', topic, created_at: 2.minutes.ago) + post4 = new_post('this is my fourth post I am posting', topic, created_at: 1.minute.ago) + + # update posts_count + topic.reload + + results = Search.execute('posting', search_context: post1.topic) + expect(results.posts.map(&:id)).to eq([post1.id, post2.id, post3.id, post4.id]) + + results = Search.execute('posting l', search_context: post1.topic) + expect(results.posts.map(&:id)).to eq([post4.id, post3.id, post2.id, post1.id]) + + # stop words should work + results = Search.execute('this', search_context: post1.topic) + expect(results.posts.length).to eq(4) + + # phrase search works as expected + results = Search.execute('"fourth post I am posting"', search_context: post1.topic) + expect(results.posts.length).to eq(1) + end + + it "works for unlisted topics" do + topic.update(visible: false) + _post = new_post('discourse is awesome', topic) + results = Search.execute('discourse', search_context: topic) + expect(results.posts.length).to eq(1) + end + end + + context 'searching the OP' do + let!(:post) { Fabricate(:post_with_long_raw_content) } + let(:result) { Search.execute('hundred', type_filter: 'topic') } + + it 'returns a result correctly' do + expect(result.posts.length).to eq(1) + expect(result.posts[0].id).to eq(post.id) + end + end + + context 'searching for quoted title' do + it "can find quoted title" do + create_post(raw: "this is the raw body", title: "I am a title yeah") + result = Search.execute('"a title yeah"') + + expect(result.posts.length).to eq(1) + end + end + + context "search for a topic by id" do + let(:result) { Search.execute(topic.id, type_filter: 'topic', search_for_id: true, min_search_term_length: 1) } + + it 'returns the topic' do + expect(result.posts.length).to eq(1) + expect(result.posts.first.id).to eq(post.id) + end + end + + context "search for a topic by url" do + it 'returns the topic' do + result = Search.execute(topic.relative_url, search_for_id: true, type_filter: 'topic') + expect(result.posts.length).to eq(1) + expect(result.posts.first.id).to eq(post.id) + end + + context 'restrict_to_archetype' do + let(:personal_message) { Fabricate(:private_message_topic) } + let!(:p1) { Fabricate(:post, topic: personal_message, post_number: 1) } + + it 'restricts result to topics' do + result = Search.execute(personal_message.relative_url, search_for_id: true, type_filter: 'topic', restrict_to_archetype: Archetype.default) + expect(result.posts.length).to eq(0) + + result = Search.execute(topic.relative_url, search_for_id: true, type_filter: 'topic', restrict_to_archetype: Archetype.default) + expect(result.posts.length).to eq(1) + end + + it 'restricts result to messages' do + result = Search.execute(topic.relative_url, search_for_id: true, type_filter: 'private_messages', guardian: Guardian.new(admin), restrict_to_archetype: Archetype.private_message) + expect(result.posts.length).to eq(0) + + result = Search.execute(personal_message.relative_url, search_for_id: true, type_filter: 'private_messages', guardian: Guardian.new(admin), restrict_to_archetype: Archetype.private_message) + expect(result.posts.length).to eq(1) + end + end + end + + context 'security' do + def result(current_user) + Search.execute('hello', guardian: Guardian.new(current_user)) + end + + it 'secures results correctly' do + category = Fabricate(:category_with_definition) + + topic.category_id = category.id + topic.save + + category.set_permissions(staff: :full) + category.save + + expect(result(nil).posts).not_to be_present + expect(result(Fabricate(:user)).posts).not_to be_present + expect(result(admin).posts).to be_present + end + end + end + + context 'cyrillic topic' do + let!(:cyrillic_topic) do + Fabricate(:topic) do + user + title { sequence(:title) { |i| "Тестовая запись #{i}" } } + end + end + + let!(:post) { Fabricate(:post, topic: cyrillic_topic, user: cyrillic_topic.user) } + let(:result) { Search.execute('запись') } + + it 'finds something when given cyrillic query' do + expect(result.posts).to contain_exactly(post) + end + end + + it 'does not tokenize search term' do + Fabricate(:post, raw: 'thing is canned should still be found!') + expect(Search.execute('canned').posts).to be_present + end + + context 'categories' do + let(:category) { Fabricate(:category_with_definition, name: "monkey Category 2") } + let(:topic) { Fabricate(:topic, category: category) } + let!(:post) { Fabricate(:post, topic: topic, raw: "snow monkey") } + + let!(:ignored_category) do + Fabricate(:category_with_definition, + name: "monkey Category 1", + slug: "test", + search_priority: Searchable::PRIORITIES[:ignore] + ) + end + + it "should return the right categories" do + search = Search.execute("monkey") + + expect(search.categories).to contain_exactly( + category, ignored_category + ) + + expect(search.posts).to eq([category.topic.first_post, post]) + + search = Search.execute("monkey #test") + + expect(search.posts).to eq([ignored_category.topic.first_post]) + end + + describe "with child categories" do + let!(:child_of_ignored_category) do + Fabricate(:category_with_definition, + name: "monkey Category 3", + parent_category: ignored_category + ) + end + + let!(:post2) do + Fabricate(:post, + topic: Fabricate(:topic, category: child_of_ignored_category), + raw: "snow monkey park" + ) + end + + it 'returns the right results' do + search = Search.execute("monkey") + + expect(search.categories).to contain_exactly( + category, ignored_category, child_of_ignored_category + ) + + expect(search.posts.map(&:id)).to eq([ + child_of_ignored_category.topic.first_post, + category.topic.first_post, + post2, + post + ].map(&:id)) + + search = Search.execute("snow") + expect(search.posts.map(&:id)).to eq([post2.id, post.id]) + + category.set_permissions({}) + category.save! + search = Search.execute("monkey") + + expect(search.categories).to contain_exactly( + ignored_category, child_of_ignored_category + ) + + expect(search.posts.map(&:id)).to eq([ + child_of_ignored_category.topic.first_post, + post2 + ].map(&:id)) + end + end + + describe 'categories with different priorities' do + let(:category2) { Fabricate(:category_with_definition) } + + it "should return posts in the right order" do + raw = "The pure genuine evian" + post = Fabricate(:post, topic: category.topic, raw: raw) + post2 = Fabricate(:post, topic: category2.topic, raw: raw) + post2.topic.update!(bumped_at: 10.seconds.from_now) + + search = Search.execute(raw) + + expect(search.posts.map(&:id)).to eq([post2.id, post.id]) + + category.update!(search_priority: Searchable::PRIORITIES[:high]) + + search = Search.execute(raw) + + expect(search.posts.map(&:id)).to eq([post.id, post2.id]) + end + end + end + + context 'groups' do + def search(user = Fabricate(:user)) + Search.execute(group.name, guardian: Guardian.new(user)) + end + + let!(:group) { Group[:trust_level_0] } + + it 'shows group' do + expect(search.groups.map(&:name)).to eq([group.name]) + end + + context 'group visibility' do + let!(:group) { Fabricate(:group) } + + before do + group.update!(visibility_level: 3) + end + + context 'staff logged in' do + it 'shows group' do + expect(search(admin).groups.map(&:name)).to eq([group.name]) + end + end + + context 'non staff logged in' do + it 'shows doesn’t show group' do + expect(search.groups.map(&:name)).to be_empty + end + end + end + end + + context 'tags' do + def search + Search.execute(tag.name) + end + + let!(:tag) { Fabricate(:tag) } + let!(:uppercase_tag) { Fabricate(:tag, name: "HeLlO") } + let(:tag_group) { Fabricate(:tag_group) } + let(:category) { Fabricate(:category_with_definition) } + + context 'post searching' do + before do + SiteSetting.tagging_enabled = true + DiscourseTagging.tag_topic_by_names(post.topic, Guardian.new(Fabricate.build(:admin)), [tag.name, uppercase_tag.name]) + post.topic.save + end + + let(:post) { Fabricate(:post, raw: 'I am special post') } + + it 'can find posts with tags' do + # we got to make this index (it is deferred) + Jobs::ReindexSearch.new.rebuild_posts + + result = Search.execute(tag.name) + expect(result.posts.length).to eq(1) + + result = Search.execute("hElLo") + expect(result.posts.length).to eq(1) + + SiteSetting.tagging_enabled = false + + result = Search.execute(tag.name) + expect(result.posts.length).to eq(0) + end + + it 'can find posts with tag synonyms' do + synonym = Fabricate(:tag, name: 'synonym', target_tag: tag) + Jobs::ReindexSearch.new.rebuild_posts + result = Search.execute(synonym.name) + expect(result.posts.length).to eq(1) + end + end + + context 'tagging is disabled' do + before { SiteSetting.tagging_enabled = false } + + it 'does not include tags' do + expect(search.tags).to_not be_present + end + end + + context 'tagging is enabled' do + before { SiteSetting.tagging_enabled = true } + + it 'returns the tag in the result' do + expect(search.tags).to eq([tag]) + end + + it 'shows staff tags' do + create_staff_only_tags(["#{tag.name}9"]) + + expect(Search.execute(tag.name, guardian: Guardian.new(admin)).tags.map(&:name)).to eq([tag.name, "#{tag.name}9"]) + expect(search.tags.map(&:name)).to eq([tag.name, "#{tag.name}9"]) + end + + it 'includes category-restricted tags' do + category_tag = Fabricate(:tag, name: "#{tag.name}9") + tag_group.tags = [category_tag] + category.set_permissions(admins: :full) + category.allowed_tag_groups = [tag_group.name] + category.save! + + expect(Search.execute(tag.name, guardian: Guardian.new(admin)).tags).to eq([tag, category_tag]) + expect(search.tags).to eq([tag, category_tag]) + end + end + end + + context 'type_filter' do + let!(:user) { Fabricate(:user, username: 'amazing', email: 'amazing@amazing.com') } + let!(:category) { Fabricate(:category_with_definition, name: 'amazing category', user: user) } + + context 'user filter' do + let(:results) { Search.execute('amazing', type_filter: 'user') } + + it "returns a user result" do + expect(results.categories.length).to eq(0) + expect(results.posts.length).to eq(0) + expect(results.users.length).to eq(1) + end + end + + context 'category filter' do + let(:results) { Search.execute('amazing', type_filter: 'category') } + + it "returns a category result" do + expect(results.categories.length).to eq(1) + expect(results.posts.length).to eq(0) + expect(results.users.length).to eq(0) + end + end + end + + context 'search_context' do + it 'can find a user when using search context' do + coding_horror = Fabricate(:coding_horror) + post = Fabricate(:post) + + Fabricate(:post, user: coding_horror) + + result = Search.execute('hello', search_context: post.user) + + result.posts.first.topic_id = post.topic_id + expect(result.posts.length).to eq(1) + end + + it 'can use category as a search context' do + category = Fabricate(:category_with_definition, + search_priority: Searchable::PRIORITIES[:ignore] + ) + + topic = Fabricate(:topic, category: category) + topic_no_cat = Fabricate(:topic) + + # includes subcategory in search + subcategory = Fabricate(:category_with_definition, parent_category_id: category.id) + sub_topic = Fabricate(:topic, category: subcategory) + + post = Fabricate(:post, topic: topic, user: topic.user) + Fabricate(:post, topic: topic_no_cat, user: topic.user) + sub_post = Fabricate(:post, raw: 'I am saying hello from a subcategory', topic: sub_topic, user: topic.user) + + search = Search.execute('hello', search_context: category) + expect(search.posts.map(&:id)).to match_array([post.id, sub_post.id]) + expect(search.posts.length).to eq(2) + end + + it 'can use tag as a search context' do + tag = Fabricate(:tag, name: 'important-stuff') + + topic_no_tag = Fabricate(:topic) + Fabricate(:topic_tag, tag: tag, topic: topic) + + post = Fabricate(:post, topic: topic, user: topic.user, raw: 'This is my hello') + Fabricate(:post, topic: topic_no_tag, user: topic.user) + + search = Search.execute('hello', search_context: tag) + expect(search.posts.map(&:id)).to contain_exactly(post.id) + expect(search.posts.length).to eq(1) + end + end + + context 'Japanese search' do + let!(:topic) { Fabricate(:topic) } + let!(:post) { Fabricate(:post, topic: topic, raw: 'This is some japanese text 日本が大好きです。') } + let!(:topic_2) { Fabricate(:topic, title: '日本の話題、 more japanese text') } + let!(:post_2) { Fabricate(:post, topic: topic_2) } + + describe '.prepare_data' do + it 'removes punctuations' do + SiteSetting.search_tokenize_japanese = true + + expect(Search.prepare_data(post.raw)).to eq("This is some japanese text 日本 が 大好き です") + end + end + + describe '.execute' do + before do + @old_default = SiteSetting.defaults.get(:min_search_term_length) + SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, 1) + SiteSetting.refresh! + end + + after do + SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, @old_default) + SiteSetting.refresh! + end + + it 'finds posts containing Japanese text if tokenization is forced' do + SiteSetting.search_tokenize_japanese = true + + expect(Search.execute('日本').posts.map(&:id)).to eq([post_2.id, post.id]) + expect(Search.execute('日').posts.map(&:id)).to eq([post_2.id, post.id]) + end + + it "find posts containing search term when site's locale is set to Japanese" do + SiteSetting.default_locale = 'ja' + + expect(Search.execute('日本').posts.map(&:id)).to eq([post_2.id, post.id]) + expect(Search.execute('日').posts.map(&:id)).to eq([post_2.id, post.id]) + end + + it 'does not include superfluous spaces in blurbs' do + SiteSetting.default_locale = 'ja' + + post.update!(raw: '場サアマネ織企ういかせ竹域ヱイマ穂基ホ神3予読ずねいぱ松査ス禁多サウ提懸イふ引小43改こょドめ。深とつぐ主思料農ぞかル者杯検める活分えほづぼ白犠') + + results = Search.execute('ういかせ竹域', type_filter: 'topic') + + expect(results.posts.length).to eq(1) + expect(results.blurb(results.posts.first)).to include('ういかせ竹域') + end + end + end + + describe 'Chinese search' do + let(:sentence) { 'Discourse is a software company 中国的基础设施网络正在组装。' } + let(:sentence_t) { 'Discourse is a software company 太平山森林遊樂區。' } + + it 'splits English / Chinese and filter out Chinese stop words' do + SiteSetting.default_locale = 'zh_CN' + data = Search.prepare_data(sentence) + expect(data).to eq("Discourse is a software company 中国 基础设施 网络 正在 组装") + end + + it 'splits for indexing and filter out stop words' do + SiteSetting.default_locale = 'zh_CN' + data = Search.prepare_data(sentence, :index) + expect(data).to eq("Discourse is a software company 中国 基础设施 网络 正在 组装") + end + + it 'splits English / Traditional Chinese and filter out stop words' do + SiteSetting.default_locale = 'zh_TW' + data = Search.prepare_data(sentence_t) + expect(data).to eq("Discourse is a software company 太平山 森林 遊樂區") + end + + it 'does not split strings beginning with numeric chars into different segments' do + SiteSetting.default_locale = 'zh_TW' + data = Search.prepare_data("#{sentence} 123abc") + expect(data).to eq("Discourse is a software company 中国 基础设施 网络 正在 组装 123abc") + end + + it 'finds chinese topic based on title' do + SiteSetting.default_locale = 'zh_TW' + SiteSetting.min_search_term_length = 1 + + topic = Fabricate(:topic, title: 'My Title Discourse社區指南') + post = Fabricate(:post, topic: topic) + + expect(Search.execute('社區指南').posts.first.id).to eq(post.id) + expect(Search.execute('指南').posts.first.id).to eq(post.id) + end + + it 'finds chinese topic based on title if tokenization is forced' do + begin + SiteSetting.search_tokenize_chinese = true + default_min_search_term_length = SiteSetting.defaults.get(:min_search_term_length) + SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, 1) + SiteSetting.refresh! + + topic = Fabricate(:topic, title: 'My Title Discourse社區指南') + post = Fabricate(:post, topic: topic) + + expect(Search.execute('社區指南').posts.first.id).to eq(post.id) + expect(Search.execute('指南').posts.first.id).to eq(post.id) + ensure + if default_min_search_term_length + SiteSetting.defaults.set_regardless_of_locale(:min_search_term_length, default_min_search_term_length) + SiteSetting.refresh! + end + end + end + end + + describe 'Advanced search' do + it 'supports pinned' do + Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic) + _post = Fabricate(:post, raw: 'boom boom shake the room', topic: topic) + + topic.update_pinned(true) + + expect(Search.execute('boom in:pinned').posts.length).to eq(1) + expect(Search.execute('boom IN:PINNED').posts.length).to eq(1) + end + + it 'supports wiki' do + topic_2 = Fabricate(:topic) + post = Fabricate(:post, raw: 'this is a test 248', wiki: true, topic: topic) + Fabricate(:post, raw: 'this is a test 248', wiki: false, topic: topic_2) + + expect(Search.execute('test 248').posts.length).to eq(2) + expect(Search.execute('test 248 in:wiki').posts.first).to eq(post) + expect(Search.execute('test 248 IN:WIKI').posts.first).to eq(post) + end + + it 'supports searching for posts that the user has seen/unseen' do + topic_2 = Fabricate(:topic) + post = Fabricate(:post, raw: 'logan is longan', topic: topic) + post_2 = Fabricate(:post, raw: 'longan is logan', topic: topic_2) + + [post.user, topic.user].each do |user| + PostTiming.create!( + post_number: post.post_number, + topic: topic, + user: user, + msecs: 1 + ) + end + + expect(post.seen?(post.user)).to eq(true) + + expect(Search.execute('longan').posts.sort).to eq([post, post_2]) + + expect(Search.execute('longan in:seen', guardian: Guardian.new(post.user)).posts) + .to eq([post]) + + expect(Search.execute('longan IN:SEEN', guardian: Guardian.new(post.user)).posts) + .to eq([post]) + + expect(Search.execute('longan in:seen').posts.sort).to eq([post, post_2]) + + expect(Search.execute('longan in:seen', guardian: Guardian.new(post_2.user)).posts) + .to eq([]) + + expect(Search.execute('longan', guardian: Guardian.new(post_2.user)).posts.sort) + .to eq([post, post_2]) + + expect(Search.execute('longan in:unseen', guardian: Guardian.new(post_2.user)).posts.sort) + .to eq([post, post_2]) + + expect(Search.execute('longan in:unseen', guardian: Guardian.new(post.user)).posts) + .to eq([post_2]) + + expect(Search.execute('longan IN:UNSEEN', guardian: Guardian.new(post.user)).posts) + .to eq([post_2]) + end + + it 'supports before and after filters' do + time = Time.zone.parse('2001-05-20 2:55') + freeze_time(time) + + post_1 = Fabricate(:post, raw: 'hi this is a test 123 123', created_at: time.months_ago(2)) + post_2 = Fabricate(:post, raw: 'boom boom shake the room test') + + expect(Search.execute('test before:1').posts).to contain_exactly(post_1) + expect(Search.execute('test before:2001-04-20').posts).to contain_exactly(post_1) + expect(Search.execute('test before:2001').posts).to eq([]) + expect(Search.execute('test after:2001').posts).to contain_exactly(post_1, post_2) + expect(Search.execute('test before:monday').posts).to contain_exactly(post_1) + expect(Search.execute('test after:jan').posts).to contain_exactly(post_1, post_2) + end + + it 'supports in:first, user:, @username' do + post_1 = Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic) + post_2 = Fabricate(:post, raw: 'boom boom shake the room test', topic: topic) + + expect(Search.execute('test in:first').posts).to contain_exactly(post_1) + expect(Search.execute('test IN:FIRST').posts).to contain_exactly(post_1) + + expect(Search.execute('boom').posts).to contain_exactly(post_2) + + expect(Search.execute('boom in:first').posts).to eq([]) + expect(Search.execute('boom f').posts).to eq([]) + + expect(Search.execute('123 in:first').posts).to contain_exactly(post_1) + expect(Search.execute('123 f').posts).to contain_exactly(post_1) + + expect(Search.execute('user:nobody').posts).to eq([]) + expect(Search.execute("user:#{post_1.user.username}").posts).to contain_exactly(post_1) + expect(Search.execute("user:#{post_1.user_id}").posts).to contain_exactly(post_1) + + expect(Search.execute("@#{post_1.user.username}").posts).to contain_exactly(post_1) + end + + context "searching for posts made by users of a group" do + fab!(:topic) { Fabricate(:topic, created_at: 3.months.ago) } + fab!(:user) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + fab!(:group) { Fabricate(:group, name: "Like_a_Boss").tap { |g| g.add(user) } } + fab!(:group_2) { Fabricate(:group).tap { |g| g.add(user_2) } } + let!(:post) { Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic, user: user) } + let!(:post_2) { Fabricate(:post, user: user_2) } + + it 'should not return any posts if group does not exist' do + group.update!( + visibility_level: Group.visibility_levels[:public], + members_visibility_level: Group.visibility_levels[:public] + ) + + expect(Search.execute('group:99999').posts).to eq([]) + end + + it 'should return the right posts for a public group' do + group.update!( + visibility_level: Group.visibility_levels[:public], + members_visibility_level: Group.visibility_levels[:public] + ) + + expect(Search.execute('group:like_a_boss').posts).to contain_exactly(post) + expect(Search.execute("group:#{group.id}").posts).to contain_exactly(post) + end + + it "should return the right posts for a public group with members' visibility restricted to logged on users" do + group.update!( + visibility_level: Group.visibility_levels[:public], + members_visibility_level: Group.visibility_levels[:logged_on_users] + ) + + expect(Search.execute("group:#{group.id}").posts).to eq([]) + expect(Search.execute("group:#{group.id}", guardian: Guardian.new(user_3)).posts).to contain_exactly(post) + end + + it "should return the right posts for a group with visibility restricted to logged on users with members' visibility restricted to members" do + group.update!( + visibility_level: Group.visibility_levels[:logged_on_users], + members_visibility_level: Group.visibility_levels[:members] + ) + + expect(Search.execute("group:#{group.id}").posts).to eq([]) + expect(Search.execute("group:#{group.id}", guardian: Guardian.new(user_3)).posts).to eq([]) + expect(Search.execute("group:#{group.id}", guardian: Guardian.new(user)).posts).to contain_exactly(post) + end + end + + it 'supports badge' do + topic = Fabricate(:topic, created_at: 3.months.ago) + post = Fabricate(:post, raw: 'hi this is a test 123 123', topic: topic) + + badge = Badge.create!(name: "Like a Boss", badge_type_id: 1) + UserBadge.create!(user_id: post.user_id, badge_id: badge.id, granted_at: 1.minute.ago, granted_by_id: -1) + + expect(Search.execute('badge:"like a boss"').posts.length).to eq(1) + expect(Search.execute('BADGE:"LIKE A BOSS"').posts.length).to eq(1) + expect(Search.execute('badge:"test"').posts.length).to eq(0) + end + + it 'can match exact phrases' do + post = Fabricate(:post, raw: %{this is a test post with 'a URL https://some.site.com/search?q=test.test.test some random text I have to add}) + post2 = Fabricate(:post, raw: 'test URL post with') + + expect(Search.execute("test post with 'a URL).posts").posts).to eq([post2, post]) + expect(Search.execute(%{"test post with 'a URL"}).posts).to eq([post]) + expect(Search.execute(%{"https://some.site.com/search?q=test.test.test"}).posts).to eq([post]) + expect(Search.execute(%{" with 'a URL https://some.site.com/search?q=test.test.test"}).posts).to eq([post]) + end + + it 'can search numbers correctly, and match exact phrases' do + post = Fabricate(:post, raw: '3.0 eta is in 2 days horrah') + post2 = Fabricate(:post, raw: '3.0 is eta in 2 days horrah') + + expect(Search.execute('3.0 eta').posts).to eq([post, post2]) + expect(Search.execute("'3.0 eta'").posts).to eq([post, post2]) + expect(Search.execute("\"3.0 eta\"").posts).to contain_exactly(post) + expect(Search.execute('"3.0, eta is"').posts).to eq([]) + end + + it 'can find by status' do + public_category = Fabricate(:category, read_restricted: false) + post = Fabricate(:post, raw: 'hi this is a test 123 123') + topic = post.topic + topic.update(category: public_category) + + private_category = Fabricate(:category, read_restricted: true) + post2 = Fabricate(:post, raw: 'hi this is another test 123 123') + second_topic = post2.topic + second_topic.update(category: private_category) + + _post3 = Fabricate(:post, raw: "another test!", user: topic.user, topic: second_topic) + + expect(Search.execute('test status:public').posts.length).to eq(1) + expect(Search.execute('test status:closed').posts.length).to eq(0) + expect(Search.execute('test status:open').posts.length).to eq(1) + expect(Search.execute('test STATUS:OPEN').posts.length).to eq(1) + expect(Search.execute('test posts_count:1').posts.length).to eq(1) + expect(Search.execute('test min_post_count:1').posts.length).to eq(1) + expect(Search.execute('test min_posts:1').posts.length).to eq(1) + expect(Search.execute('test max_posts:2').posts.length).to eq(1) + + topic.update(closed: true) + second_topic.update(category: public_category) + + expect(Search.execute('test status:public').posts.length).to eq(2) + expect(Search.execute('test status:closed').posts.length).to eq(1) + expect(Search.execute('status:closed').posts.length).to eq(1) + expect(Search.execute('test status:open').posts.length).to eq(1) + + topic.update(archived: true, closed: false) + second_topic.update(closed: true) + + expect(Search.execute('test status:archived').posts.length).to eq(1) + expect(Search.execute('test status:open').posts.length).to eq(0) + + expect(Search.execute('test status:noreplies').posts.length).to eq(1) + + expect(Search.execute('test in:likes', guardian: Guardian.new(topic.user)).posts.length).to eq(0) + + expect(Search.execute('test in:posted', guardian: Guardian.new(topic.user)).posts.length).to eq(2) + expect(Search.execute('test In:PoStEd', guardian: Guardian.new(topic.user)).posts.length).to eq(2) + + in_created = Search.execute('test in:created', guardian: Guardian.new(topic.user)).posts + created_by_user = Search.execute("test created:@#{topic.user.username}", guardian: Guardian.new(topic.user)).posts + expect(in_created.length).to eq(1) + expect(created_by_user.length).to eq(1) + expect(in_created).to eq(created_by_user) + + expect(Search.execute("test created:@#{second_topic.user.username}", guardian: Guardian.new(topic.user)).posts.length).to eq(1) + + new_user = Fabricate(:user) + expect(Search.execute("test created:@#{new_user.username}", guardian: Guardian.new(topic.user)).posts.length).to eq(0) + + TopicUser.change(topic.user.id, topic.id, notification_level: TopicUser.notification_levels[:tracking]) + expect(Search.execute('test in:watching', guardian: Guardian.new(topic.user)).posts.length).to eq(0) + expect(Search.execute('test in:tracking', guardian: Guardian.new(topic.user)).posts.length).to eq(1) + end + + it 'can find posts with images' do + post_uploaded = Fabricate(:post_with_uploaded_image) + Fabricate(:post) + + CookedPostProcessor.new(post_uploaded).update_post_image + + expect(Search.execute('with:images').posts.map(&:id)).to contain_exactly(post_uploaded.id) + end + + it 'can find by latest' do + topic1 = Fabricate(:topic, title: 'I do not like that Sam I am') + post1 = Fabricate(:post, topic: topic1, created_at: 10.minutes.ago) + post2 = Fabricate(:post, raw: 'that Sam I am, that Sam I am', created_at: 5.minutes.ago) + + expect(Search.execute('sam').posts.map(&:id)).to eq([post1.id, post2.id]) + expect(Search.execute('sam ORDER:LATEST').posts.map(&:id)).to eq([post2.id, post1.id]) + expect(Search.execute('sam l').posts.map(&:id)).to eq([post2.id, post1.id]) + expect(Search.execute('l sam').posts.map(&:id)).to eq([post2.id, post1.id]) + end + + it 'can order by topic creation' do + today = Date.today + yesterday = 1.day.ago + two_days_ago = 2.days.ago + category = Fabricate(:category_with_definition) + + old_topic = Fabricate(:topic, + title: 'First Topic, testing the created_at sort', + created_at: two_days_ago, + category: category + ) + + latest_topic = Fabricate(:topic, + title: 'Second Topic, testing the created_at sort', + created_at: yesterday, + category: category + ) + + old_relevant_topic_post = Fabricate(:post, + topic: old_topic, + created_at: yesterday, + raw: 'Relevant Relevant Topic' + ) + + latest_irrelevant_topic_post = Fabricate(:post, + topic: latest_topic, + created_at: today, + raw: 'Not Relevant' + ) + + # Expecting the default results + expect(Search.execute('Topic').posts.map(&:id)).to eq([ + old_relevant_topic_post.id, + latest_irrelevant_topic_post.id, + category.topic.first_post.id + ]) + + # Expecting the ordered by topic creation results + expect(Search.execute('Topic order:latest_topic').posts.map(&:id)).to eq([ + category.topic.first_post.id, + latest_irrelevant_topic_post.id, + old_relevant_topic_post.id + ]) + end + + it 'can order by topic views' do + topic = Fabricate(:topic, views: 1) + topic2 = Fabricate(:topic, views: 2) + post = Fabricate(:post, raw: 'Topic', topic: topic) + post2 = Fabricate(:post, raw: 'Topic', topic: topic2) + + expect(Search.execute('Topic order:views').posts.map(&:id)).to eq([ + post2.id, + post.id + ]) + end + + it 'can filter by topic views' do + topic = Fabricate(:topic, views: 100) + topic2 = Fabricate(:topic, views: 200) + post = Fabricate(:post, raw: 'Topic', topic: topic) + post2 = Fabricate(:post, raw: 'Topic', topic: topic2) + + expect(Search.execute('Topic min_views:150').posts.map(&:id)).to eq([post2.id]) + expect(Search.execute('Topic max_views:150').posts.map(&:id)).to eq([post.id]) + end + + it 'can search for terms with dots' do + post = Fabricate(:post, raw: 'Will.2000 Will.Bob.Bill...') + expect(Search.execute('bill').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('bob').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('2000').posts.map(&:id)).to eq([post.id]) + end + + it 'can search URLS correctly' do + post = Fabricate(:post, raw: 'i like http://wb.camra.org.uk/latest#test so yay') + + expect(Search.execute('http://wb.camra.org.uk/latest#test').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('camra').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('http://wb').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('wb.camra').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('wb.camra.org').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('org.uk').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('camra.org.uk').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('wb.camra.org.uk').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('wb.camra.org.uk/latest').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('/latest#test').posts.map(&:id)).to eq([post.id]) + end + + it 'supports category slug and tags' do + # main category + category = Fabricate(:category_with_definition, name: 'category 24', slug: 'cateGory-24') + topic = Fabricate(:topic, created_at: 3.months.ago, category: category) + post = Fabricate(:post, raw: 'Sams first post', topic: topic) + + expect(Search.execute('sams post #categoRy-24').posts.length).to eq(1) + expect(Search.execute("sams post category:#{category.id}").posts.length).to eq(1) + expect(Search.execute('sams post #categoRy-25').posts.length).to eq(0) + + sub_category = Fabricate(:category_with_definition, name: 'sub category', slug: 'sub-category', parent_category_id: category.id) + second_topic = Fabricate(:topic, created_at: 3.months.ago, category: sub_category) + Fabricate(:post, raw: 'sams second post', topic: second_topic) + + expect(Search.execute("sams post category:categoRY-24").posts.length).to eq(2) + expect(Search.execute("sams post category:=cAtegory-24").posts.length).to eq(1) + + expect(Search.execute("sams post #category-24").posts.length).to eq(2) + expect(Search.execute("sams post #=category-24").posts.length).to eq(1) + expect(Search.execute("sams post #sub-category").posts.length).to eq(1) + + expect(Search.execute("sams post #categoRY-24:SUB-category").posts.length) + .to eq(1) + + # tags + topic.tags = [Fabricate(:tag, name: 'alpha'), Fabricate(:tag, name: 'привет'), Fabricate(:tag, name: 'HeLlO')] + expect(Search.execute('this is a test #alpha').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('this is a test #привет').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('this is a test #hElLo').posts.map(&:id)).to eq([post.id]) + expect(Search.execute('this is a test #beta').posts.size).to eq(0) + end + + it 'supports sub-sub category slugs' do + + SiteSetting.max_category_nesting = 3 + + category = Fabricate(:category, name: 'top', slug: 'top') + sub = Fabricate(:category, name: 'middle', slug: 'middle', parent_category_id: category.id) + leaf = Fabricate(:category, name: 'leaf', slug: 'leaf', parent_category_id: sub.id) + + topic = Fabricate(:topic, created_at: 3.months.ago, category: leaf) + _post = Fabricate(:post, raw: 'Sams first post', topic: topic) + + expect(Search.execute('#Middle:leaf first post').posts.size).to eq(1) + end + + it 'correctly handles #symbol when no tag or category match' do + Fabricate(:post, raw: 'testing #1 #9998') + results = Search.new('testing #1').execute + expect(results.posts.length).to eq(1) + + results = Search.new('#9998').execute + expect(results.posts.length).to eq(1) + + results = Search.new('#777').execute + expect(results.posts.length).to eq(0) + + results = Search.new('xxx #:').execute + expect(results.posts.length).to eq(0) + end + + context 'tags' do + fab!(:tag1) { Fabricate(:tag, name: 'lunch') } + fab!(:tag2) { Fabricate(:tag, name: 'eggs') } + fab!(:tag3) { Fabricate(:tag, name: 'sandwiches') } + + fab!(:tag_group) do + group = TagGroup.create!(name: 'mid day') + TagGroupMembership.create!(tag_id: tag1.id, tag_group_id: group.id) + TagGroupMembership.create!(tag_id: tag3.id, tag_group_id: group.id) + group + end + + fab!(:topic1) { Fabricate(:topic, tags: [tag2, Fabricate(:tag)]) } + fab!(:topic2) { Fabricate(:topic, tags: [tag2]) } + fab!(:topic3) { Fabricate(:topic, tags: [tag1, tag2]) } + fab!(:topic4) { Fabricate(:topic, tags: [tag1, tag2, tag3]) } + fab!(:topic5) { Fabricate(:topic, tags: [tag2, tag3]) } + + def indexed_post(*args) + SearchIndexer.enable + Fabricate(:post, *args) + end + + fab!(:post1) { indexed_post(topic: topic1) } + fab!(:post2) { indexed_post(topic: topic2) } + fab!(:post3) { indexed_post(topic: topic3) } + fab!(:post4) { indexed_post(topic: topic4) } + fab!(:post5) { indexed_post(topic: topic5) } + + it 'can find posts by tag group' do + expect(Search.execute('#mid-day').posts.map(&:id)).to eq([ + post5, post4, post3 + ].map(&:id)) + end + + it 'can find posts with tag' do + post4 = Fabricate(:post, topic: topic3, raw: "It probably doesn't help that they're green...") + + expect(Search.execute('green tags:eggs').posts.map(&:id)).to eq([post4.id]) + expect(Search.execute('tags:plants').posts.size).to eq(0) + end + + it 'can find posts with non-latin tag' do + topic.tags = [Fabricate(:tag, name: 'さようなら')] + post = Fabricate(:post, raw: 'Testing post', topic: topic) + + expect(Search.execute('tags:さようなら').posts.map(&:id)).to eq([post.id]) + end + + it 'can find posts with thai tag' do + topic.tags = [Fabricate(:tag, name: 'เรซิ่น')] + post = Fabricate(:post, raw: 'Testing post', topic: topic) + + expect(Search.execute('tags:เรซิ่น').posts.map(&:id)).to eq([post.id]) + end + + it 'can find posts with any tag from multiple tags' do + expect(Search.execute('tags:eggs,lunch').posts.map(&:id).sort).to eq([post1.id, post2.id, post3.id, post4.id, post5.id].sort) + end + + it 'can find posts which contains all provided tags' do + expect(Search.execute('tags:lunch+eggs+sandwiches').posts.map(&:id)).to eq([post4.id].sort) + expect(Search.execute('tags:eggs+lunch+sandwiches').posts.map(&:id)).to eq([post4.id].sort) + end + + it 'can find posts which contains provided tags and does not contain selected ones' do + expect(Search.execute('tags:eggs -tags:lunch').posts.map(&:id)) + .to eq([post5, post2, post1].map(&:id)) + + expect(Search.execute('tags:eggs -tags:lunch+sandwiches').posts.map(&:id)) + .to eq([post5, post3, post2, post1].map(&:id)) + + expect(Search.execute('tags:eggs -tags:lunch,sandwiches').posts.map(&:id)) + .to eq([post2, post1].map(&:id)) + end + + it 'orders posts correctly when combining tags with categories or terms' do + cat1 = Fabricate(:category_with_definition, name: 'food') + topic6 = Fabricate(:topic, tags: [tag1, tag2], category: cat1) + topic7 = Fabricate(:topic, tags: [tag1, tag2, tag3], category: cat1) + post7 = Fabricate(:post, topic: topic6, raw: "Wakey, wakey, eggs and bakey.", like_count: 5, created_at: 2.minutes.ago) + post8 = Fabricate(:post, topic: topic7, raw: "Bakey, bakey, eggs to makey.", like_count: 2, created_at: 1.minute.ago) + + expect(Search.execute('bakey tags:lunch order:latest').posts.map(&:id)) + .to eq([post8.id, post7.id]) + + expect(Search.execute('#food tags:lunch order:latest').posts.map(&:id)) + .to eq([post8.id, post7.id]) + + expect(Search.execute('#food tags:lunch order:likes').posts.map(&:id)) + .to eq([post7.id, post8.id]) + end + + end + + it "can find posts which contains filetypes" do + post1 = Fabricate(:post, raw: "http://example.com/image.png") + + post2 = Fabricate(:post, + raw: "Discourse logo\n"\ + "http://example.com/logo.png\n"\ + "http://example.com/vector_image.svg" + ) + + post_with_upload = Fabricate(:post, uploads: [Fabricate(:upload)]) + Fabricate(:post) + + TopicLink.extract_from(post1) + TopicLink.extract_from(post2) + + expect(Search.execute('filetype:svg').posts).to eq([post2]) + + expect(Search.execute('filetype:png').posts.map(&:id)).to eq([ + post_with_upload, post2, post1 + ].map(&:id)) + + expect(Search.execute('logo filetype:png').posts).to eq([post2]) + end + end + + context '#ts_query' do + it 'can parse complex strings using ts_query helper' do + str = +" grigio:babel deprecated? " + str << "page page on Atmosphere](https://atmospherejs.com/grigio/babel)xxx: aaa.js:222 aaa'\"bbb" + + ts_query = Search.ts_query(term: str, ts_config: "simple") + expect { DB.exec(+"SELECT to_tsvector('bbb') @@ " << ts_query) }.to_not raise_error + + ts_query = Search.ts_query(term: "foo.bar/'&baz", ts_config: "simple") + expect { DB.exec(+"SELECT to_tsvector('bbb') @@ " << ts_query) }.to_not raise_error + expect(ts_query).to include("baz") + end + + it 'escapes the term correctly' do + expect(Search.ts_query(term: 'Title with trailing backslash\\')) + .to eq("TO_TSQUERY('english', '''Title with trailing backslash\\\\\\\\'':*')") + + expect(Search.ts_query(term: "Title with trailing quote'")) + .to eq("TO_TSQUERY('english', '''Title with trailing quote'''''':*')") + end + end + + context '#word_to_date' do + it 'parses relative dates correctly' do + time = Time.zone.parse('2001-02-20 2:55') + freeze_time(time) + + expect(Search.word_to_date('yesterday')).to eq(time.beginning_of_day.yesterday) + expect(Search.word_to_date('suNday')).to eq(Time.zone.parse('2001-02-18')) + expect(Search.word_to_date('thursday')).to eq(Time.zone.parse('2001-02-15')) + expect(Search.word_to_date('deCember')).to eq(Time.zone.parse('2000-12-01')) + expect(Search.word_to_date('deC')).to eq(Time.zone.parse('2000-12-01')) + expect(Search.word_to_date('january')).to eq(Time.zone.parse('2001-01-01')) + expect(Search.word_to_date('jan')).to eq(Time.zone.parse('2001-01-01')) + + expect(Search.word_to_date('100')).to eq(time.beginning_of_day.days_ago(100)) + + expect(Search.word_to_date('invalid')).to eq(nil) + end + + it 'parses absolute dates correctly' do + expect(Search.word_to_date('2001-1-20')).to eq(Time.zone.parse('2001-01-20')) + expect(Search.word_to_date('2030-10-2')).to eq(Time.zone.parse('2030-10-02')) + expect(Search.word_to_date('2030-10')).to eq(Time.zone.parse('2030-10-01')) + expect(Search.word_to_date('2030')).to eq(Time.zone.parse('2030-01-01')) + expect(Search.word_to_date('2030-01-32')).to eq(nil) + expect(Search.word_to_date('10000')).to eq(nil) + end + end + + context "#min_post_id" do + it "returns 0 when prefer_recent_posts is disabled" do + SiteSetting.search_prefer_recent_posts = false + expect(Search.min_post_id_no_cache).to eq(0) + end + + it "returns a value when prefer_recent_posts is enabled" do + SiteSetting.search_prefer_recent_posts = true + SiteSetting.search_recent_posts_size = 1 + + Fabricate(:post) + p2 = Fabricate(:post) + + expect(Search.min_post_id_no_cache).to eq(p2.id) + end + end + + context "search_log_id" do + it "returns an id when the search succeeds" do + s = Search.new( + 'indiana jones', + search_type: :header, + ip_address: '127.0.0.1' + ) + results = s.execute + expect(results.search_log_id).to be_present + end + + it "does not log search if search_type is not present" do + s = Search.new('foo bar', ip_address: '127.0.0.1') + results = s.execute + expect(results.search_log_id).not_to be_present + end + end + + context 'in:title' do + it 'allows for search in title' do + topic = Fabricate(:topic, title: 'I am testing a title search') + _post2 = Fabricate(:post, topic: topic, raw: 'this is the second post', post_number: 2) + post = Fabricate(:post, topic: topic, raw: 'this is the first post', post_number: 1) + + results = Search.execute('title in:title') + expect(results.posts.map(&:id)).to eq([post.id]) + + results = Search.execute('title iN:tItLe') + expect(results.posts.map(&:id)).to eq([post.id]) + + results = Search.execute('first in:title') + expect(results.posts).to eq([]) + end + + it 'works irrespective of the order' do + topic = Fabricate(:topic, title: "A topic about Discourse") + Fabricate(:post, topic: topic, raw: "This is another post") + topic2 = Fabricate(:topic, title: "This is another topic") + Fabricate(:post, topic: topic2, raw: "Discourse is awesome") + + results = Search.execute('Discourse in:title status:open') + expect(results.posts.length).to eq(1) + + results = Search.execute('in:title status:open Discourse') + expect(results.posts.length).to eq(1) + end + end + + context 'ignore_diacritics' do + before { SiteSetting.search_ignore_accents = true } + let!(:post1) { Fabricate(:post, raw: 'สวัสดี Rágis hello') } + + it ('allows strips correctly') do + results = Search.execute('hello', type_filter: 'topic') + expect(results.posts.length).to eq(1) + + results = Search.execute('ragis', type_filter: 'topic') + expect(results.posts.length).to eq(1) + + results = Search.execute('Rágis', type_filter: 'topic') + expect(results.posts.length).to eq(1) + + # TODO: this is a test we need to fix! + #expect(results.blurb(results.posts.first)).to include('Rágis') + + results = Search.execute('สวัสดี', type_filter: 'topic') + expect(results.posts.length).to eq(1) + end + end + + context 'include_diacritics' do + before { SiteSetting.search_ignore_accents = false } + let!(:post1) { Fabricate(:post, raw: 'สวัสดี Régis hello') } + + it ('allows strips correctly') do + results = Search.execute('hello', type_filter: 'topic') + expect(results.posts.length).to eq(1) + + results = Search.execute('regis', type_filter: 'topic') + expect(results.posts.length).to eq(0) + + results = Search.execute('Régis', type_filter: 'topic') + expect(results.posts.length).to eq(1) + + expect(results.blurb(results.posts.first)).to include('Régis') + + results = Search.execute('สวัสดี', type_filter: 'topic') + expect(results.posts.length).to eq(1) + end + end + + context 'pagination' do + let(:number_of_results) { 2 } + let!(:post1) { Fabricate(:post, raw: 'hello hello hello hello hello') } + let!(:post2) { Fabricate(:post, raw: 'hello hello hello hello') } + let!(:post3) { Fabricate(:post, raw: 'hello hello hello') } + let!(:post4) { Fabricate(:post, raw: 'hello hello') } + let!(:post5) { Fabricate(:post, raw: 'hello') } + before do + Search.stubs(:per_filter).returns(number_of_results) + end + + it 'returns more results flag' do + results = Search.execute('hello', type_filter: 'topic') + results2 = Search.execute('hello', type_filter: 'topic', page: 2) + + expect(results.posts.length).to eq(number_of_results) + expect(results.posts.map(&:id)).to eq([post1.id, post2.id]) + expect(results.more_full_page_results).to eq(true) + expect(results2.posts.length).to eq(number_of_results) + expect(results2.posts.map(&:id)).to eq([post3.id, post4.id]) + expect(results2.more_full_page_results).to eq(true) + end + + it 'correctly search with page parameter' do + search = Search.new('hello', type_filter: 'topic', page: 3) + results = search.execute + + expect(search.offset).to eq(2 * number_of_results) + expect(results.posts.length).to eq(1) + expect(results.posts).to eq([post5]) + expect(results.more_full_page_results).to eq(nil) + end + + end + + context 'in:tagged' do + it 'allows for searching by presence of any tags' do + topic = Fabricate(:topic, title: 'I am testing a tagged search') + _post = Fabricate(:post, topic: topic, raw: 'this is the first post') + tag = Fabricate(:tag) + _topic_tag = Fabricate(:topic_tag, topic: topic, tag: tag) + + results = Search.execute('in:untagged') + expect(results.posts.length).to eq(0) + + results = Search.execute('in:tagged') + expect(results.posts.length).to eq(1) + + results = Search.execute('In:TaGgEd') + expect(results.posts.length).to eq(1) + end + end + + context 'in:untagged' do + it 'allows for searching by presence of no tags' do + topic = Fabricate(:topic, title: 'I am testing a untagged search') + _post = Fabricate(:post, topic: topic, raw: 'this is the first post') + + results = Search.execute('iN:uNtAgGeD') + expect(results.posts.length).to eq(1) + + results = Search.execute('in:tagged') + expect(results.posts.length).to eq(0) + end + end + + context 'plugin extensions' do + let!(:post0) { Fabricate(:post, raw: 'this is the first post about advanced filter with length more than 50 chars') } + let!(:post1) { Fabricate(:post, raw: 'this is the second post about advanced filter') } + + it 'allows to define custom filter' do + expect(Search.new("advanced").execute.posts).to eq([post1, post0]) + Search.advanced_filter(/^min_chars:(\d+)$/) do |posts, match| + posts.where("(SELECT LENGTH(p2.raw) FROM posts p2 WHERE p2.id = posts.id) >= ?", match.to_i) + end + expect(Search.new("advanced min_chars:50").execute.posts).to eq([post0]) + end + + it 'allows to define custom order' do + expect(Search.new("advanced").execute.posts).to eq([post1, post0]) + + Search.advanced_order(:chars) do |posts| + posts.reorder("MAX(LENGTH(posts.raw)) DESC") + end + + expect(Search.new("advanced order:chars").execute.posts).to eq([post0, post1]) + end + end + + context 'exclude_topics filter' do + before { SiteSetting.tagging_enabled = true } + let!(:user) { Fabricate(:user) } + fab!(:group) { Fabricate(:group, name: 'bruce-world-fans') } + fab!(:topic) { Fabricate(:topic, title: 'Bruce topic not a result') } + + it 'works' do + category = Fabricate(:category_with_definition, name: 'bruceland', user: user) + tag = Fabricate(:tag, name: 'brucealicious') + + result = Search.execute('bruce', type_filter: 'exclude_topics') + + expect(result.users.map(&:id)).to contain_exactly(user.id) + + expect(result.categories.map(&:id)).to contain_exactly(category.id) + + expect(result.groups.map(&:id)).to contain_exactly(group.id) + + expect(result.tags.map(&:id)).to contain_exactly(tag.id) + + expect(result.posts.length).to eq(0) + end + + it 'does not fail when parsed term is empty' do + result = Search.execute('#cat ', type_filter: 'exclude_topics') + expect(result.categories.length).to eq(0) + end + end end