mirror of
https://github.com/discourse/discourse.git
synced 2026-08-15 14:06:35 +08:00
Adds a site setting to allow multiple solutions to be accepted in the solved plugin. Behavior without the setting enabled should remain as before these changes. ### Model/DB changes Previously, solutions were stored in a `SolvedTopic` model backed by the `discourse_solved_solved_topic` table, which pointed to a topic id, a post id, an accepting user id, and optionally a timer id for a timer to auto-close the topic after a set amount of time if configured. Now, the `SolvedTopic` model drops the post id and accepting user id, but still points to the topic id and the timer id (there will only be one timer per topic), and adds a has_many relationship to a new `TopicAnswer` model. The `TopicAnswer` model backed by the `discourse_solved_topic_answer` table points to a `SolvedTopic` id, a post id and accepting user id, with one `TopicAnswer` for each accepted solution post. References to `topic.accepted_answer` or `topic.solved.accepted_post` have been changed to `topic.accepted_answers.first` or `topic.solved.topic_answers.first.post`, etc. Updated all specs/tests and added new cases for the multiple solutions mode. Several references in other build-in plugins are also fixed in this PR. ### Allow multiple solutions mode These changes add the site setting `solved_allow_multiple_solutions` which is disabled by default. When disabled, behavior should be as it was before these changes. Users can not accept multiple solutions for a single topic. If a post in the topic is already marked as a solution, then the "Solution" button is hidden, and if a user still accepts a different solution then the original solution will be unaccepted. When enabled, users can accept more than one solution for each topic. Even if a solution is already accepted, other posts still show the "Solution" button. Users can still unaccept any accepted solutions. If a topic has multiple solutions, each one is displayed as a summary under the OP using the existing component to represent the summary. For displaying the solved checkmark icon, filtering, etc, a topic is considered solved if it has at least one solution. ### UI Added a PostExcerptAccordion component which displays post excerpts in an accordion style with a header and expandable/collapsible items, and that component is used to render the solution post(s) under the OP. The first item is displayed expanded while the others are collapsed, by default. The expanded items are truncated based on a number of lines calculated from the `solved_quote_length` site setting, with a "read more" link to view the full post. Without multiple solutions allowed: <img width="1606" height="1046" alt="image" src="https://github.com/user-attachments/assets/5d6229f2-9052-473a-8953-574667761544" /> With multiple solutions allowed: <img width="1594" height="1254" alt="image" src="https://github.com/user-attachments/assets/6e7b7ac5-23fa-4b0e-b6d6-b8a53cdf2756" /> With multiple solutions, after expanding each: <img width="932" height="1000" alt="image" src="https://github.com/user-attachments/assets/2a8842b6-040d-44d8-bad4-f61d819789ef" /> ### QAPage schema The QAPage schema metadata is updated to include multiple accepted solutions, which is allowed according to [google's QAPage doc](https://developers.google.com/search/docs/appearance/structured-data/qapage#question) --------- Co-authored-by: discourse-patch-triage[bot] <272280883+discourse-patch-triage[bot]@users.noreply.github.com> Co-authored-by: Manuel Kostka <manuel@discourse.org>
387 lines
16 KiB
Ruby
Vendored
387 lines
16 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe TopicsController do
|
|
let(:p1) { Fabricate(:post, like_count: 1) }
|
|
let(:topic) { p1.topic }
|
|
let(:p2) { Fabricate(:post, like_count: 2, topic:, user: Fabricate(:user)) }
|
|
|
|
def answer_json(post)
|
|
'{"@type":"Answer","author":{"@type":"Person","name":"%{username2}","url":"%{user2_url}"},"dateModified":"%{answer_modified}","datePublished":"%{answered_at}","text":"%{answer_text}","upvoteCount":%{answer_likes},"url":"%{answer_url}"}' %
|
|
{
|
|
answer_text: post.excerpt,
|
|
answer_likes: post.like_count,
|
|
answered_at: post.created_at.as_json,
|
|
answer_modified: (post.last_version_at || post.created_at).as_json,
|
|
answer_url: post.full_url,
|
|
username2: post.user&.username,
|
|
user2_url: post.user&.full_url,
|
|
}
|
|
end
|
|
|
|
def p2_single_answer_json
|
|
',"acceptedAnswer":[%{answer_json}]' % { answer_json: answer_json(p2) }
|
|
end
|
|
|
|
def expected_schema_json(answer_json, answer_count = 1)
|
|
'<script type="application/ld+json">{"@context":"http://schema.org","@type":"QAPage","name":"%{title}","datePublished":"%{created_at}","mainEntity":{"@type":"Question","answerCount":%{answer_count},"author":{"@type":"Person","name":"%{username1}","url":"%{user1_url}"},"dateModified":"%{question_modified}","datePublished":"%{created_at}","name":"%{title}","text":"%{question_text}","upvoteCount":%{question_likes}%{answer_json}}}</script>' %
|
|
# rubocop:enable Layout/LineLength
|
|
{
|
|
answer_count:,
|
|
title: topic.title,
|
|
question_text: p1.excerpt,
|
|
question_likes: p1.like_count,
|
|
created_at: topic.created_at.as_json,
|
|
question_modified: (p1.last_version_at || p1.created_at).as_json,
|
|
username1: topic.user&.username,
|
|
user1_url: topic.user&.full_url,
|
|
answer_json:,
|
|
}
|
|
end
|
|
|
|
context "with solved enabled on every topic" do
|
|
before { SiteSetting.allow_solved_on_all_topics = true }
|
|
|
|
it "should not include schema information for single-post topics without answers" do
|
|
get "/t/#{topic.slug}/#{topic.id}"
|
|
|
|
expect(response.body).not_to include("QAPage")
|
|
end
|
|
|
|
it "should include correct schema information when topic has an accepted answer" do
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}"
|
|
|
|
expect(response.body).to include(expected_schema_json(p2_single_answer_json))
|
|
end
|
|
|
|
it "should include quoted content in schema information" do
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
post = topic.first_post
|
|
post.raw = "[quote]This is a quoted text.[/quote]"
|
|
post.save!
|
|
post.rebake!
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}"
|
|
|
|
expect(response.body).to include('"text":"This is a quoted text."')
|
|
end
|
|
|
|
it "should include user name in output with the corresponding site setting" do
|
|
SiteSetting.display_name_on_posts = true
|
|
SiteSetting.show_who_marked_solved = true
|
|
accepter = Fabricate(:user)
|
|
Fabricate(:solved_topic, topic:, topic_answer: Fabricate(:topic_answer, post: p2, accepter:))
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}.json"
|
|
|
|
expect(response.parsed_body["accepted_answers"][0]["name"]).to eq(p2.user.name)
|
|
expect(response.parsed_body["accepted_answers"][0]["username"]).to eq(p2.user.username)
|
|
expect(response.parsed_body["accepted_answers"][0]["accepter_name"]).to eq(accepter.name)
|
|
expect(response.parsed_body["accepted_answers"][0]["accepter_username"]).to eq(
|
|
accepter.username,
|
|
)
|
|
|
|
SiteSetting.show_who_marked_solved = false
|
|
get "/t/#{topic.slug}/#{topic.id}.json"
|
|
expect(response.parsed_body["accepted_answers"][0]["accepter_name"]).to eq(nil)
|
|
expect(response.parsed_body["accepted_answers"][0]["accepter_username"]).to eq(nil)
|
|
|
|
# enable_names is default ON, this ensures disabling it also disables names here
|
|
SiteSetting.enable_names = false
|
|
get "/t/#{topic.slug}/#{topic.id}.json"
|
|
expect(response.parsed_body["accepted_answers"][0]["name"]).to eq(nil)
|
|
expect(response.parsed_body["accepted_answers"][0]["accepter_name"]).to eq(nil)
|
|
end
|
|
|
|
it "should not include user name when site setting is disabled" do
|
|
SiteSetting.display_name_on_posts = false
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}.json"
|
|
|
|
expect(response.parsed_body["accepted_answers"][0]["name"]).to eq(nil)
|
|
expect(response.parsed_body["accepted_answers"][0]["username"]).to eq(p2.user.username)
|
|
end
|
|
|
|
describe "with multiple solutions enabled" do
|
|
before { SiteSetting.solved_allow_multiple_solutions = true }
|
|
|
|
describe "when topic has two accepted answers and one not accepted" do
|
|
let(:p3) { Fabricate(:post, topic:) }
|
|
let(:p4) { Fabricate(:post, topic:) }
|
|
let(:solved_topic) { Fabricate(:solved_topic, topic:) }
|
|
|
|
before do
|
|
Fabricate(:topic_answer, solved_topic:, post: p2)
|
|
Fabricate(:topic_answer, solved_topic:, post: p3)
|
|
p4
|
|
end
|
|
|
|
it "should include correct schema information " do
|
|
get "/t/#{topic.slug}/#{topic.id}"
|
|
|
|
two_accepted_answers_json =
|
|
',"acceptedAnswer":[%{answer_p2},%{answer_p3}]' %
|
|
{ answer_p2: answer_json(p2), answer_p3: answer_json(p3) }
|
|
suggested_answer_json =
|
|
',"suggestedAnswer":[%{answer_p4}]' % { answer_p4: answer_json(p4) }
|
|
|
|
answers_json = "#{two_accepted_answers_json}#{suggested_answer_json}"
|
|
|
|
expect(response.body).to include(expected_schema_json(answers_json, 3))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "crawler schema modifiers" do
|
|
let(:crawler_env) { { "HTTP_USER_AGENT" => "Googlebot" } }
|
|
|
|
before { SiteSetting.allow_solved_on_all_topics = true }
|
|
|
|
def parsed_crawler_body
|
|
Nokogiri::HTML5.fragment(response.body)
|
|
end
|
|
|
|
it "uses Question schema instead of DiscussionForumPosting when topic has replies" do
|
|
Fabricate(:post, topic:, user: Fabricate(:user))
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="Question"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="DiscussionForumPosting"]').size).to eq(0)
|
|
end
|
|
|
|
it "keeps DiscussionForumPosting for single-post topics" do
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="DiscussionForumPosting"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="QAPage"]').size).to eq(0)
|
|
end
|
|
|
|
it "keeps DiscussionForumPosting when only replies are hidden" do
|
|
Fabricate(:post, topic:, hidden: true)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="DiscussionForumPosting"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="QAPage"]').size).to eq(0)
|
|
end
|
|
|
|
it "keeps DiscussionForumPosting when only replies are small action posts" do
|
|
Fabricate(:post, topic:, post_type: Post.types[:small_action])
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="DiscussionForumPosting"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="QAPage"]').size).to eq(0)
|
|
end
|
|
|
|
it "emits valid QAPage microdata with all required schema.org properties" do
|
|
p3 = Fabricate(:post, topic:, user: Fabricate(:user))
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
qa_page = doc.at_css('[itemtype*="QAPage"]')
|
|
expect(qa_page).to be_present
|
|
expect(qa_page.at_css('> [itemprop="name"]')["content"]).to eq(topic.title)
|
|
|
|
question = doc.at_css('[itemtype*="Question"]')
|
|
expect(question).to be_present
|
|
expect(question.at_css('[itemprop="name"]')["content"]).to eq(topic.title)
|
|
expect(question.at_css('[itemprop="datePublished"]')["content"]).to be_present
|
|
expect(question.at_css('[itemprop="answerCount"]')["content"]).to eq("2")
|
|
expect(question.at_css('[itemprop="upvoteCount"]')["content"]).to eq(p1.like_count.to_s)
|
|
expect(question.at_css('[itemprop="text"]')).to be_present
|
|
expect(question.at_css('[itemprop="author"] [itemprop="name"]')).to be_present
|
|
|
|
accepted = doc.at_css("#post_#{p2.post_number}")
|
|
expect(accepted["itemprop"]).to eq("acceptedAnswer")
|
|
expect(accepted["itemtype"]).to include("Answer")
|
|
expect(accepted.at_css('[itemprop="text"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="datePublished"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="author"] [itemprop="name"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="author"] [itemprop="url"]')["content"]).to include(
|
|
p2.user.username,
|
|
)
|
|
expect(accepted.at_css('[itemprop="upvoteCount"]')["content"]).to eq(p2.like_count.to_s)
|
|
accepted_urls = accepted.css('meta[itemprop="url"]').map { |el| el["content"] }
|
|
expect(accepted_urls).to include(p2.full_url)
|
|
|
|
suggested = doc.at_css("#post_#{p3.post_number}")
|
|
expect(suggested["itemprop"]).to eq("suggestedAnswer")
|
|
expect(suggested["itemtype"]).to include("Answer")
|
|
expect(suggested.at_css('[itemprop="text"]')).to be_present
|
|
expect(suggested.at_css('[itemprop="datePublished"]')).to be_present
|
|
expect(suggested.at_css('[itemprop="author"] [itemprop="name"]')).to be_present
|
|
expect(suggested.at_css('[itemprop="author"] [itemprop="url"]')["content"]).to include(
|
|
p3.user.username,
|
|
)
|
|
expect(suggested.at_css('[itemprop="upvoteCount"]')["content"]).to eq(p3.like_count.to_s)
|
|
suggested_urls = suggested.css('meta[itemprop="url"]').map { |el| el["content"] }
|
|
expect(suggested_urls).to include(p3.full_url)
|
|
end
|
|
|
|
it "does not leak microdata from ineligible posts into the Question scope" do
|
|
ineligible =
|
|
Fabricate(:post, topic:, user: Fabricate(:user), post_type: Post.types[:moderator_action])
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
question = doc.at_css('[itemtype*="Question"]')
|
|
ineligible_node = doc.at_css("#post_#{ineligible.post_number}")
|
|
|
|
expect(ineligible_node).to be_present
|
|
expect(question.xpath('./*[@itemprop="datePublished"]').size).to eq(1)
|
|
expect(ineligible_node.css("[itemprop]")).to be_empty
|
|
end
|
|
|
|
describe "with multiple solutions enabled" do
|
|
let(:p3) { Fabricate(:post, topic:, cooked: "p3 cooked") }
|
|
let(:p4) { Fabricate(:post, topic:, cooked: "p4 cooked") }
|
|
let(:solved_topic) { Fabricate(:solved_topic, topic:) }
|
|
|
|
before do
|
|
SiteSetting.solved_allow_multiple_solutions = true
|
|
Fabricate(:topic_answer, solved_topic:, post: p2)
|
|
Fabricate(:topic_answer, solved_topic:, post: p3)
|
|
p4
|
|
end
|
|
|
|
it "should include two acceptedAnswers and a suggestedAnswer in qaschema" do
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
qa_page = doc.at_css('[itemtype*="QAPage"]')
|
|
expect(qa_page).to be_present
|
|
expect(qa_page.at_css('> [itemprop="name"]')["content"]).to eq(topic.title)
|
|
|
|
question = doc.at_css('[itemtype*="Question"]')
|
|
expect(question).to be_present
|
|
expect(question.at_css('[itemprop="name"]')["content"]).to eq(topic.title)
|
|
expect(question.at_css('[itemprop="datePublished"]')["content"]).to be_present
|
|
expect(question.at_css('[itemprop="answerCount"]')["content"]).to eq("3")
|
|
expect(question.at_css('[itemprop="upvoteCount"]')["content"]).to eq(p1.like_count.to_s)
|
|
expect(question.at_css('[itemprop="text"]')).to be_present
|
|
expect(question.at_css('[itemprop="author"] [itemprop="name"]')).to be_present
|
|
|
|
accepted = doc.at_css("#post_#{p2.post_number}")
|
|
expect(accepted["itemprop"]).to eq("acceptedAnswer")
|
|
expect(accepted["itemtype"]).to include("Answer")
|
|
expect(accepted.at_css('[itemprop="text"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="datePublished"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="author"] [itemprop="name"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="author"] [itemprop="url"]')["content"]).to include(
|
|
p2.user.username,
|
|
)
|
|
expect(accepted.at_css('[itemprop="upvoteCount"]')["content"]).to eq(p2.like_count.to_s)
|
|
accepted_urls = accepted.css('meta[itemprop="url"]').map { |el| el["content"] }
|
|
expect(accepted_urls).to include(p2.full_url)
|
|
|
|
accepted = doc.at_css("#post_#{p3.post_number}")
|
|
expect(accepted["itemprop"]).to eq("acceptedAnswer")
|
|
expect(accepted["itemtype"]).to include("Answer")
|
|
expect(accepted.at_css('[itemprop="text"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="datePublished"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="author"] [itemprop="name"]')).to be_present
|
|
expect(accepted.at_css('[itemprop="author"] [itemprop="url"]')["content"]).to include(
|
|
p3.user.username,
|
|
)
|
|
expect(accepted.at_css('[itemprop="upvoteCount"]')["content"]).to eq(p3.like_count.to_s)
|
|
accepted_urls = accepted.css('meta[itemprop="url"]').map { |el| el["content"] }
|
|
expect(accepted_urls).to include(p3.full_url)
|
|
|
|
suggested = doc.at_css("#post_#{p4.post_number}")
|
|
expect(suggested["itemprop"]).to eq("suggestedAnswer")
|
|
expect(suggested["itemtype"]).to include("Answer")
|
|
expect(suggested.at_css('[itemprop="text"]')).to be_present
|
|
expect(suggested.at_css('[itemprop="datePublished"]')).to be_present
|
|
expect(suggested.at_css('[itemprop="author"] [itemprop="name"]')).to be_present
|
|
expect(suggested.at_css('[itemprop="author"] [itemprop="url"]')["content"]).to include(
|
|
p4.user.username,
|
|
)
|
|
expect(suggested.at_css('[itemprop="upvoteCount"]')["content"]).to eq(p4.like_count.to_s)
|
|
suggested_urls = suggested.css('meta[itemprop="url"]').map { |el| el["content"] }
|
|
expect(suggested_urls).to include(p4.full_url)
|
|
end
|
|
end
|
|
|
|
it "does not modify schema for topics without solved enabled" do
|
|
SiteSetting.allow_solved_on_all_topics = false
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="DiscussionForumPosting"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="Question"]').size).to eq(0)
|
|
end
|
|
|
|
it "does not emit QAPage schema when schema markup is set to never" do
|
|
SiteSetting.solved_add_schema_markup = "never"
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="DiscussionForumPosting"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="QAPage"]').size).to eq(0)
|
|
expect(doc.css('[itemtype*="Question"]').size).to eq(0)
|
|
end
|
|
|
|
it "does not emit QAPage schema when set to 'answered only' without an accepted answer" do
|
|
SiteSetting.solved_add_schema_markup = "answered only"
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="DiscussionForumPosting"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="QAPage"]').size).to eq(0)
|
|
end
|
|
|
|
it "emits QAPage schema when set to 'answered only' with an accepted answer" do
|
|
SiteSetting.solved_add_schema_markup = "answered only"
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}", env: crawler_env
|
|
doc = parsed_crawler_body
|
|
|
|
expect(doc.css('[itemtype*="QAPage"]').size).to eq(1)
|
|
expect(doc.css('[itemtype*="Question"]').size).to eq(1)
|
|
end
|
|
end
|
|
|
|
context "with solved enabled for topics with specific tags" do
|
|
let(:tag) { Fabricate(:tag) }
|
|
|
|
before { SiteSetting.enable_solved_tags = tag.name }
|
|
|
|
it "includes the correct schema information" do
|
|
DiscourseTagging.add_or_create_tags_by_name(topic, [tag.name])
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}"
|
|
|
|
expect(response.body).to include(expected_schema_json(p2_single_answer_json))
|
|
end
|
|
|
|
it "doesn't include solved schema information when the topic has a different tag" do
|
|
another_tag = Fabricate(:tag)
|
|
|
|
DiscourseTagging.add_or_create_tags_by_name(topic, [another_tag.name])
|
|
Fabricate(:solved_topic, topic:, answer_post: p2)
|
|
|
|
get "/t/#{topic.slug}/#{topic.id}"
|
|
|
|
expect(response.body).not_to include(expected_schema_json(p2_single_answer_json))
|
|
end
|
|
end
|
|
end
|