diff --git a/app/commands/v2/put_content.rb b/app/commands/v2/put_content.rb index e8792b74d..42e8065ba 100644 --- a/app/commands/v2/put_content.rb +++ b/app/commands/v2/put_content.rb @@ -87,9 +87,9 @@ def check_update_type end def create_links(edition) - return if payload[:links].nil? + links = payload.fetch(:links, {}).merge({ embed: fetch_embedded_content(edition) }) - payload[:links].each do |link_type, target_link_ids| + links.each do |link_type, target_link_ids| edition.links.create!( target_link_ids.map.with_index do |target_link_id, i| { link_type:, target_content_id: target_link_id, position: i } @@ -98,6 +98,12 @@ def create_links(edition) end end + def fetch_embedded_content(edition) + return [] if edition[:details]["body"].nil? + + EmbeddedContentFinderService.new.fetch_linked_content_ids(edition[:details]["body"], edition.locale) + end + def create_redirect return unless payload[:base_path] diff --git a/app/presenters/content_embed_presenter.rb b/app/presenters/content_embed_presenter.rb new file mode 100644 index 000000000..7913b1025 --- /dev/null +++ b/app/presenters/content_embed_presenter.rb @@ -0,0 +1,54 @@ +module Presenters + class ContentEmbedPresenter + def initialize(edition) + @edition = edition + end + + def render_embedded_content(details) + return details if details[:body].nil? + + details[:body] = if details[:body].is_a?(Array) + details[:body].map do |content| + { + content_type: content[:content_type], + content: render_embedded_editions(content[:content]), + } + end + else + render_embedded_editions(details[:body]) + end + + details + end + + private + + def render_embedded_editions(content) + embedded_content_references = EmbeddedContentFinderService.new.find_content_references(content) + embedded_content_references_by_content_id = embedded_content_references.index_by(&:content_id) + + target_content_ids = @edition + .links + .where(link_type: "embed") + .pluck(:target_content_id) + + embedded_edition_ids = ::Queries::GetEditionIdsWithFallbacks.call( + target_content_ids, + locale_fallback_order: [@edition.locale, Edition::DEFAULT_LOCALE].uniq, + state_fallback_order: %w[published], + ) + + embedded_editions = Edition.where(id: embedded_edition_ids) + + embedded_editions.each do |embedded_edition| + embed_code = embedded_content_references_by_content_id[embedded_edition.content_id].embed_code + content = content.gsub( + embed_code, + embedded_edition.title, + ) + end + + content + end + end +end diff --git a/app/presenters/details_presenter.rb b/app/presenters/details_presenter.rb index 0d0b8be40..b4ef094f2 100644 --- a/app/presenters/details_presenter.rb +++ b/app/presenters/details_presenter.rb @@ -2,17 +2,19 @@ module Presenters class DetailsPresenter - attr_reader :content_item_details, :change_history_presenter + attr_reader :content_item_details, :change_history_presenter, :content_embed_presenter - def initialize(content_item_details, change_history_presenter) + def initialize(content_item_details, change_history_presenter, content_embed_presenter) @content_item_details = SymbolizeJSON.symbolize(content_item_details) @change_history_presenter = change_history_presenter + @content_embed_presenter = content_embed_presenter end def details @details ||= begin - updated = recursively_transform_govspeak(content_item_details) + updated = content_embed(content_item_details).presence || content_item_details + updated = recursively_transform_govspeak(updated) updated[:change_history] = change_history if change_history.present? updated end @@ -46,6 +48,10 @@ def recursively_transform_govspeak(obj) end end + def content_embed(content_item_details) + @content_embed ||= content_embed_presenter&.render_embedded_content(content_item_details) + end + def change_history @change_history ||= change_history_presenter&.change_history end diff --git a/app/presenters/edition_presenter.rb b/app/presenters/edition_presenter.rb index b06b6b577..cc33072df 100644 --- a/app/presenters/edition_presenter.rb +++ b/app/presenters/edition_presenter.rb @@ -114,9 +114,15 @@ def details_presenter @details_presenter ||= Presenters::DetailsPresenter.new( edition.to_h[:details], change_history_presenter, + content_embed_presenter, ) end + def content_embed_presenter + @content_embed_presenter ||= + Presenters::ContentEmbedPresenter.new(edition) + end + def change_history_presenter @change_history_presenter ||= Presenters::ChangeHistoryPresenter.new(edition) diff --git a/app/presenters/queries/expanded_link_set.rb b/app/presenters/queries/expanded_link_set.rb index 9734c235f..05c8f9c18 100644 --- a/app/presenters/queries/expanded_link_set.rb +++ b/app/presenters/queries/expanded_link_set.rb @@ -61,7 +61,7 @@ def present_expanded_link(link_hash) end if hash[:details] - hash[:details] = Presenters::DetailsPresenter.new(hash[:details], nil).details + hash[:details] = Presenters::DetailsPresenter.new(hash[:details], nil, content_embed_presenter(hash[:content_id], hash[:locale])).details end end end @@ -77,6 +77,11 @@ def available_translations def translations available_translations.translations end + + def content_embed_presenter(content_id, locale) + edition = Document.find_by(content_id:, locale:).live + Presenters::ContentEmbedPresenter.new(edition) if edition + end end end end diff --git a/app/services/embedded_content_finder_service.rb b/app/services/embedded_content_finder_service.rb new file mode 100644 index 000000000..6249c89dd --- /dev/null +++ b/app/services/embedded_content_finder_service.rb @@ -0,0 +1,45 @@ +class EmbeddedContentFinderService + ContentReference = Data.define(:document_type, :content_id, :embed_code) + + SUPPORTED_DOCUMENT_TYPES = %w[contact].freeze + UUID_REGEX = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/ + EMBED_REGEX = /({{embed:(#{SUPPORTED_DOCUMENT_TYPES.join('|')}):#{UUID_REGEX}}})/ + + def fetch_linked_content_ids(body, locale) + content_references = if body.is_a?(Array) + body.map { |hash| find_content_references(hash[:content]) }.flatten + else + find_content_references(body) + end + return [] if content_references.empty? + + check_all_references_exist(content_references, locale) + content_references.map(&:content_id) + end + + def find_content_references(body) + body.scan(EMBED_REGEX).map { |match| ContentReference.new(document_type: match[1], content_id: match[2], embed_code: match[0]) }.uniq + end + +private + + def check_all_references_exist(content_references, locale) + found_editions = live_editions(content_references, locale) + if found_editions.count != content_references.count + not_found_content_ids = content_references.map(&:content_id) - found_editions.map(&:content_id) + raise CommandError.new( + code: 422, + message: "Could not find any live editions in locale #{locale} for: #{not_found_content_ids.join(', ')}, ", + ) + end + end + + def live_editions(content_references, locale) + Edition.with_document.where( + state: "published", + content_store: "live", + document_type: content_references.map(&:document_type), + documents: { content_id: content_references.map(&:content_id), locale: }, + ) + end +end diff --git a/spec/integration/put_content/content_with_embedded_content_spec.rb b/spec/integration/put_content/content_with_embedded_content_spec.rb new file mode 100644 index 000000000..39ee3cfef --- /dev/null +++ b/spec/integration/put_content/content_with_embedded_content_spec.rb @@ -0,0 +1,106 @@ +RSpec.describe "PUT /v2/content when embedded content is provided" do + include_context "PutContent call" + + context "with embedded content" do + let(:first_contact) { create(:edition, state: "published", content_store: "live", document_type: "contact") } + let(:second_contact) { create(:edition, state: "published", content_store: "live", document_type: "contact") } + let(:document) { create(:document, content_id:) } + + before do + payload.merge!(document_type: "press_release", schema_name: "news_article", details: { body: "{{embed:contact:#{first_contact.document.content_id}}} {{embed:contact:#{second_contact.document.content_id}}}" }) + end + + it "should create links" do + expect { + put "/v2/content/#{content_id}", params: payload.to_json + }.to change(Link, :count).by(2) + + expect(Link.find_by(target_content_id: first_contact.content_id)).not_to be_nil + expect(Link.find_by(target_content_id: second_contact.content_id)).not_to be_nil + end + end + + context "without embedded content and embed links already existing on a draft edition" do + let(:contact) { create(:edition, state: "published", content_store: "live", document_type: "contact") } + let(:document) { create(:document, content_id:) } + let(:edition) { create(:edition, document:) } + + before do + stub_request(:put, %r{.*content-store.*/content/.*}) + edition.links.create!({ + link_type: "embed", + target_content_id: contact.content_id, + position: 0, + }) + payload.merge!(document_type: "press_release", schema_name: "news_article", details: { body: "no embed links" }) + end + + it "should remove embed links" do + expect { + put "/v2/content/#{content_id}", params: payload.to_json + }.to change(Link, :count).by(-1) + + expect(Link.find_by(target_content_id: contact.content_id)).to be_nil + end + end + + context "with different embedded content and embed links already existing on a draft edition" do + let(:first_contact) { create(:edition, state: "published", content_store: "live", document_type: "contact") } + let(:second_contact) { create(:edition, state: "published", content_store: "live", document_type: "contact") } + let(:document) { create(:document, content_id:) } + let(:edition) { create(:edition, document:) } + + before do + stub_request(:put, %r{.*content-store.*/content/.*}) + edition.links.create!({ + link_type: "embed", + target_content_id: first_contact.content_id, + position: 0, + }) + payload.merge!(document_type: "press_release", schema_name: "news_article", details: { body: "{{embed:contact:#{second_contact.document.content_id}}}" }) + end + + it "should replace the embed link" do + expect { + put "/v2/content/#{content_id}", params: payload.to_json + }.to change(Link, :count).by(0) + + expect(Link.find_by(target_content_id: first_contact.content_id)).to be_nil + expect(Link.find_by(target_content_id: second_contact.content_id)).not_to be_nil + end + end + + context "with embedded content that does not exist" do + let(:document) { create(:document, content_id:) } + let(:fake_content_id) { SecureRandom.uuid } + + before do + payload.merge!(document_type: "press_release", schema_name: "news_article", details: { body: "{{embed:contact:#{fake_content_id}}}" }) + end + + it "should return a 422 error" do + put "/v2/content/#{content_id}", params: payload.to_json + + expect(response).to be_unprocessable + expect(response.body).to match(/Could not find any live editions in locale en for: #{fake_content_id}/) + end + end + + context "with a mixture of embedded content that does and does not exist" do + let(:contact) { create(:edition, state: "published", content_store: "live", document_type: "contact") } + let(:document) { create(:document, content_id:) } + let(:first_fake_content_id) { SecureRandom.uuid } + let(:second_fake_content_id) { SecureRandom.uuid } + + before do + payload.merge!(document_type: "press_release", schema_name: "news_article", details: { body: "{{embed:contact:#{contact.document.content_id}}} {{embed:contact:#{first_fake_content_id}}} {{embed:contact:#{second_fake_content_id}}}" }) + end + + it "should return a 422 error" do + put "/v2/content/#{content_id}", params: payload.to_json + + expect(response).to be_unprocessable + expect(response.body).to match(/Could not find any live editions in locale en for: #{first_fake_content_id}, #{second_fake_content_id}/) + end + end +end diff --git a/spec/presenters/content_embed_presenter_spec.rb b/spec/presenters/content_embed_presenter_spec.rb new file mode 100644 index 000000000..22c4beda0 --- /dev/null +++ b/spec/presenters/content_embed_presenter_spec.rb @@ -0,0 +1,101 @@ +RSpec.describe Presenters::ContentEmbedPresenter do + let(:embedded_content_id) { SecureRandom.uuid } + let(:document) { create(:document) } + let(:edition) do + create( + :edition, + document:, + details: details.deep_stringify_keys, + links_hash: { + embed: [embedded_content_id], + }, + ) + end + let(:details) { {} } + + before do + embedded_document = create(:document, content_id: embedded_content_id) + create( + :edition, + document: embedded_document, + state: "published", + content_store: "live", + document_type: "contact", + title: "VALUE", + ) + end + + describe "#render_embedded_content" do + context "when body is a string" do + let(:details) { { body: "some string with a reference: {{embed:contact:#{embedded_content_id}}}" } } + + it "returns embedded content references with values from their editions" do + expect(described_class.new(edition).render_embedded_content(details)).to eq({ + body: "some string with a reference: VALUE", + }) + end + end + + context "when body is an array" do + let(:details) do + { body: [ + { content_type: "text/govspeak", content: "some string with a reference: {{embed:contact:#{embedded_content_id}}}" }, + { content_type: "text/html", content: "some string with a reference: {{embed:contact:#{embedded_content_id}}}" }, + ] } + end + + it "returns embedded content references with values from their editions" do + expect(described_class.new(edition).render_embedded_content(details)).to eq({ + body: [ + { content_type: "text/govspeak", content: "some string with a reference: VALUE" }, + { content_type: "text/html", content: "some string with a reference: VALUE" }, + ], + }) + end + end + + context "when the embedded content is available in multiple locales" do + let(:details) { { body: "some string with a reference: {{embed:contact:#{embedded_content_id}}}" } } + + before do + embedded_document = create(:document, content_id: embedded_content_id, locale: "cy") + create( + :edition, + document: embedded_document, + state: "published", + content_store: "live", + document_type: "contact", + title: "WELSH", + ) + end + + context "when the document is in the default language" do + it "returns embedded content references with values from the same language" do + expect(described_class.new(edition).render_embedded_content(details)).to eq({ + body: "some string with a reference: VALUE", + }) + end + end + + context "when the document is in an available locale" do + let(:document) { create(:document, locale: "cy") } + + it "returns embedded content references with values from the same language" do + expect(described_class.new(edition).render_embedded_content(details)).to eq({ + body: "some string with a reference: WELSH", + }) + end + end + + context "when the document is in an unavailable locale" do + let(:document) { create(:document, locale: "fr") } + + it "returns embedded content references with values from the default language" do + expect(described_class.new(edition).render_embedded_content(details)).to eq({ + body: "some string with a reference: VALUE", + }) + end + end + end + end +end diff --git a/spec/presenters/details_presenter_spec.rb b/spec/presenters/details_presenter_spec.rb index 8457c460e..c27a7d358 100644 --- a/spec/presenters/details_presenter_spec.rb +++ b/spec/presenters/details_presenter_spec.rb @@ -4,8 +4,12 @@ instance_double(Presenters::ChangeHistoryPresenter, change_history: []) end + let(:content_embed_presenter) do + instance_double(Presenters::ContentEmbedPresenter, render_embedded_content: nil) + end + subject do - described_class.new(edition_details, change_history_presenter).details + described_class.new(edition_details, change_history_presenter, content_embed_presenter).details end context "when we're passed details without a body" do @@ -179,5 +183,62 @@ expect(subject).to eq expected_details end end + + context "when we're passed a body with embedded content" do + let(:contact) do + create(:edition, state: "published", content_store: "live", document_type: "contact", title: "Some contact") + end + let(:content_embed_presenter) { Presenters::ContentEmbedPresenter.new(edition) } + let(:body) { "{{embed:contact:#{contact.document.content_id}}}" } + + context "when the body is not enumerable" do + let(:edition) { create(:edition, details: { body: }, links_hash: { embed: [contact.document.content_id] }) } + let(:edition_details) { edition.details } + let(:expected_details) do + { + body: contact.title, + } + end + + it "embeds the contact details" do + is_expected.to match(expected_details) + end + end + + context "when we're passed details with govspeak and HTML" do + let(:edition) do + create(:edition, + details: { body: [ + { content_type: "text/html", content: body }, + { content_type: "text/govspeak", content: body }, + ] }, + links_hash: { embed: [contact.document.content_id] }) + end + let(:edition_details) { edition.details } + let(:expected_details) do + { + body: [ + { content_type: "text/html", content: contact.title }, + { content_type: "text/govspeak", content: contact.title }, + ], + } + end + + it "embeds the contact details" do + is_expected.to match(expected_details) + end + end + + context "when we're passed an empty array" do + let(:edition) do + create(:edition, details: { body: [] }) + end + let(:edition_details) { edition.details } + + it "does not change anything in details" do + is_expected.to match(edition_details) + end + end + end end end diff --git a/spec/presenters/queries/expanded_link_set_spec.rb b/spec/presenters/queries/expanded_link_set_spec.rb index 9713f84bc..577d5274f 100644 --- a/spec/presenters/queries/expanded_link_set_spec.rb +++ b/spec/presenters/queries/expanded_link_set_spec.rb @@ -39,40 +39,85 @@ describe "details" do let(:c) { create_link_set } - before do - create_edition(a, "/a", document_type: "person") - create_edition( - b, - "/b", - document_type: "ministerial_role", - details: { - body: [ - { - content_type: "text/govspeak", - content: "Body", - }, - ], - }, - ) - create_edition(c, "/c", document_type: "role_appointment") + context "without embedded content in the body" do + before do + create_edition(a, "/a", document_type: "person") + create_edition( + b, + "/b", + document_type: "ministerial_role", + details: { + body: [ + { + content_type: "text/govspeak", + content: "Body", + }, + ], + }, + ) + create_edition(c, "/c", document_type: "role_appointment") + + create_link(c, a, "person") + create_link(c, b, "role") + end - create_link(c, a, "person") - create_link(c, b, "role") + it "recursively calls the details presenter and renders govspeak inside expanded links" do + b = expanded_links[:role_appointments].first + c = b[:links][:role].first + expect(c[:details][:body]).to match([ + { + content_type: "text/govspeak", + content: "Body", + }, + { + content_type: "text/html", + content: "
Body
\n", + }, + ]) + end end - it "recursively calls the details presenter and renders govspeak inside expanded links" do - b = expanded_links[:role_appointments].first - c = b[:links][:role].first - expect(c[:details][:body]).to match([ - { - content_type: "text/govspeak", - content: "Body", - }, - { - content_type: "text/html", - content: "Body
\n", - }, - ]) + context "with embedded content in the body" do + let(:contact) do + create(:edition, state: "published", content_store: "live", document_type: "contact", title: "Some contact") + end + + before do + create_edition(a, "/a", document_type: "person") + create_edition( + b, + "/b", + document_type: "ministerial_role", + details: { + body: [ + { + content_type: "text/govspeak", + content: "{{embed:contact:#{contact.document.content_id}}}", + }, + ], + }, + links_hash: { embed: [contact.document.content_id] }, + ) + create_edition(c, "/c", document_type: "role_appointment") + + create_link(c, a, "person") + create_link(c, b, "role") + end + + it "recursively calls the details presenter and embeds content inside expanded links" do + b = expanded_links[:role_appointments].first + c = b[:links][:role].first + expect(c[:details][:body]).to match([ + { + content_type: "text/govspeak", + content: "Some contact", + }, + { + content_type: "text/html", + content: "Some contact
\n", + }, + ]) + end end end end diff --git a/spec/services/embedded_content_finder_service_spec.rb b/spec/services/embedded_content_finder_service_spec.rb new file mode 100644 index 000000000..5c7dad095 --- /dev/null +++ b/spec/services/embedded_content_finder_service_spec.rb @@ -0,0 +1,67 @@ +RSpec.describe EmbeddedContentFinderService do + let(:contacts) do + [ + create(:edition, + state: "published", + document_type: "contact", + content_store: "live", + details: { title: "Some Title" }), + create(:edition, + state: "published", + document_type: "contact", + content_store: "live", + details: { title: "Some other Title" }), + ] + end + let(:draft_contact) do + create(:edition, + state: "draft", + document_type: "contact", + content_store: "live", + details: { title: "Some Title" }) + end + + describe ".fetch_linked_content_ids" do + it "returns an empty hash where there are no embeds" do + body = "Hello world!" + + links = EmbeddedContentFinderService.new.fetch_linked_content_ids(body, Edition::DEFAULT_LOCALE) + + expect(links).to eq([]) + end + + it "finds contact references" do + body = "{{embed:contact:#{contacts[0].content_id}}} {{embed:contact:#{contacts[1].content_id}}}" + + links = EmbeddedContentFinderService.new.fetch_linked_content_ids(body, Edition::DEFAULT_LOCALE) + + expect(links).to eq([contacts[0].content_id, contacts[1].content_id]) + end + + it "finds contact references when body is an array of hashes" do + body = [{ content: "{{embed:contact:#{contacts[0].content_id}}} {{embed:contact:#{contacts[1].content_id}}}" }] + + links = EmbeddedContentFinderService.new.fetch_linked_content_ids(body, Edition::DEFAULT_LOCALE) + + expect(links).to eq([contacts[0].content_id, contacts[1].content_id]) + end + + it "errors when given a content ID that has no live editions" do + body = "{{embed:contact:00000000-0000-0000-0000-000000000000}}" + + expect { EmbeddedContentFinderService.new.fetch_linked_content_ids(body, Edition::DEFAULT_LOCALE) }.to raise_error(CommandError) + end + + it "errors when given a content ID that is still draft" do + body = "{{embed:contact:#{draft_contact.content_id}}}" + + expect { EmbeddedContentFinderService.new.fetch_linked_content_ids(body, Edition::DEFAULT_LOCALE) }.to raise_error(CommandError) + end + + it "errors when given a live content ID that is not available in the current locale" do + body = "{{embed:contact:#{contacts[0].content_id}}}" + + expect { EmbeddedContentFinderService.new.fetch_linked_content_ids(body, "foo") }.to raise_error(CommandError) + end + end +end