module GdsApi::TestHelpers::PublishingApi

def assert_publishing_api(verb, url, attributes_or_matcher = nil, times = 1)

Parameters:
  • times (Integer) --
  • attributes_or_matcher (Object) --
  • url (String) --
  • verb (String) --
def assert_publishing_api(verb, url, attributes_or_matcher = nil, times = 1)
  matcher = if attributes_or_matcher.is_a?(Hash)
              request_json_matches(attributes_or_matcher)
            else
              attributes_or_matcher
            end
  if matcher
    assert_requested(verb, url, times: times, &matcher)
  else
    assert_requested(verb, url, times: times)
  end
end

def assert_publishing_api_discard_draft(content_id, attributes_or_matcher = nil, times = 1)

Parameters:
  • times (Integer) --
  • attributes_or_matcher (Object) --
  • content_id (UUID) --
def assert_publishing_api_discard_draft(content_id, attributes_or_matcher = nil, times = 1)
  url = PUBLISHING_API_V2_ENDPOINT + "/content/#{content_id}/discard-draft"
  assert_publishing_api(:post, url, attributes_or_matcher, times)
end

def assert_publishing_api_patch_links(content_id, attributes_or_matcher = nil, times = 1)

Parameters:
  • times (Integer) --
  • attributes_or_matcher (Object) --
  • content_id (UUID) --
def assert_publishing_api_patch_links(content_id, attributes_or_matcher = nil, times = 1)
  url = "#{PUBLISHING_API_V2_ENDPOINT}/links/#{content_id}"
  assert_publishing_api(:patch, url, attributes_or_matcher, times)
end

def assert_publishing_api_publish(content_id, attributes_or_matcher = nil, times = 1)

Parameters:
  • times (Integer) --
  • attributes_or_matcher (Object) --
  • content_id (UUID) --
def assert_publishing_api_publish(content_id, attributes_or_matcher = nil, times = 1)
  url = PUBLISHING_API_V2_ENDPOINT + "/content/#{content_id}/publish"
  assert_publishing_api(:post, url, attributes_or_matcher, times)
end

def assert_publishing_api_put(url, attributes_or_matcher = {}, times = 1)

def assert_publishing_api_put(url, attributes_or_matcher = {}, times = 1)
  matcher = if attributes_or_matcher.is_a?(Hash)
              attributes_or_matcher.empty? ? nil : request_json_matching(attributes_or_matcher)
            else
              attributes_or_matcher
            end
  if matcher
    assert_requested(:put, url, times: times, &matcher)
  else
    assert_requested(:put, url, times: times)
  end
end

def assert_publishing_api_put_content(content_id, attributes_or_matcher = nil, times = 1)

Parameters:
  • times (Integer) --
  • attributes_or_matcher (Object) --
  • content_id (UUID) --
def assert_publishing_api_put_content(content_id, attributes_or_matcher = nil, times = 1)
  url = "#{PUBLISHING_API_V2_ENDPOINT}/content/#{content_id}"
  assert_publishing_api(:put, url, attributes_or_matcher, times)
end

def assert_publishing_api_put_content_links_and_publish(body, content_id = nil, publish_body = nil)

Parameters:
  • publish_body (Hash) --
  • content_id (UUID) --
  • body (String) --
def assert_publishing_api_put_content_links_and_publish(body, content_id = nil, publish_body = nil)
  content_id ||= body[:content_id]
  if publish_body.nil?
    publish_body = { update_type: body.fetch(:update_type) }
    publish_body[:locale] = body[:locale] if body[:locale]
  end
  assert_publishing_api_put_content(content_id, body.except(:links))
  assert_publishing_api_patch_links(content_id, body.slice(:links)) unless body.slice(:links).empty?
  assert_publishing_api_publish(content_id, publish_body)
end

def assert_publishing_api_put_intent(base_path, attributes_or_matcher = {}, times = 1)

def assert_publishing_api_put_intent(base_path, attributes_or_matcher = {}, times = 1)
  url = "#{PUBLISHING_API_ENDPOINT}/publish-intent#{base_path}"
  assert_publishing_api_put(url, attributes_or_matcher, times)
end

def assert_publishing_api_unpublish(content_id, attributes_or_matcher = nil, times = 1)

Parameters:
  • times (Integer) --
  • attributes_or_matcher (Object) --
  • content_id (UUID) --
def assert_publishing_api_unpublish(content_id, attributes_or_matcher = nil, times = 1)
  url = PUBLISHING_API_V2_ENDPOINT + "/content/#{content_id}/unpublish"
  assert_publishing_api(:post, url, attributes_or_matcher, times)
end

def content_item_for_publishing_api(base_path, publishing_app = "publisher")

def content_item_for_publishing_api(base_path, publishing_app = "publisher")
  content_item_for_base_path(base_path).merge("publishing_app" => publishing_app)
end

def deep_stringify_keys(hash)

def deep_stringify_keys(hash)
  deep_transform_keys(hash, &:to_s)
end

def deep_transform_keys(object, &block)

def deep_transform_keys(object, &block)
  case object
  when Hash
    object.each_with_object({}) do |(key, value), result|
      result[yield(key)] = deep_transform_keys(value, &block)
    end
  when Array
    object.map { |item| deep_transform_keys(item, &block) }
  else
    object
  end
end

def request_json_includes(required_attributes)

Get a request matcher that checks if a JSON request includes a set of attributes
def request_json_includes(required_attributes)
  lambda do |request|
    data = JSON.parse(request.body)
    deep_stringify_keys(required_attributes)
      .to_a.all? { |key, value| data[key] == value }
  end
end

def request_json_including(required_attributes)

def request_json_including(required_attributes)
  lambda do |request|
    data = JSON.parse(request.body)
    values_match_recursively(required_attributes, data)
  end
end

def request_json_matches(required_attributes)

Get a request matcher that checks if a JSON request matches a hash
def request_json_matches(required_attributes)
  lambda do |request|
    data = JSON.parse(request.body)
    deep_stringify_keys(required_attributes) == data
  end
end

def request_json_matching(required_attributes)

def request_json_matching(required_attributes)
  lambda do |request|
    data = JSON.parse(request.body)
    required_attributes.to_a.all? { |key, value| data[key.to_s] == value }
  end
end

def resource_not_found(content_id, type)

def resource_not_found(content_id, type)
  {
    error: {
      code: 404,
      message: "Could not find #{type} with content_id: #{content_id}",
    },
  }
end

def stub_any_publishing_api_call

Stub any request to the publishing API
def stub_any_publishing_api_call
  stub_request(:any, %r{\A#{PUBLISHING_API_ENDPOINT}})
end

def stub_any_publishing_api_call_to_return_not_found

Stub any request to the publishing API to return a 404 response
def stub_any_publishing_api_call_to_return_not_found
  stub_request(:any, %r{\A#{PUBLISHING_API_ENDPOINT}})
    .to_return(status: 404, headers: { "Content-Type" => "application/json; charset=utf-8" })
end

def stub_any_publishing_api_discard_draft

Stub any POST /v2/content/*/discard-draft request
def stub_any_publishing_api_discard_draft
  stub_request(:post, %r{\A#{PUBLISHING_API_V2_ENDPOINT}/content/.*/discard-draft})
end

def stub_any_publishing_api_patch_links

Stub any PATCH /v2/links/* request
def stub_any_publishing_api_patch_links
  stub_request(:patch, %r{\A#{PUBLISHING_API_V2_ENDPOINT}/links/})
end

def stub_any_publishing_api_path_reservation

stub_any_publishing_api_path_reservation
@example

Stub all PUT /paths/:base_path requests
def stub_any_publishing_api_path_reservation
  stub_request(:put, %r{\A#{PUBLISHING_API_ENDPOINT}/paths/}).to_return do |request|
    base_path = request.uri.path.sub(%r{\A/paths}, "")
    body = JSON.parse(request.body).merge(base_path: base_path)
    {
      status: 200,
      headers: { content_type: "application/json" },
      body: body.to_json,
    }
  end
end

def stub_any_publishing_api_publish

Stub any POST /v2/content/*/publish request
def stub_any_publishing_api_publish
  stub_request(:post, %r{\A#{PUBLISHING_API_V2_ENDPOINT}/content/.*/publish})
end

def stub_any_publishing_api_put_content

Stub any PUT /v2/content/* request
def stub_any_publishing_api_put_content
  stub_request(:put, %r{\A#{PUBLISHING_API_V2_ENDPOINT}/content/})
end

def stub_any_publishing_api_put_intent

def stub_any_publishing_api_put_intent
  stub_request(:put, %r{\A#{PUBLISHING_API_ENDPOINT}/publish-intent})
end

def stub_any_publishing_api_republish

Stub any POST /v2/content/*/publish request
def stub_any_publishing_api_republish
  stub_request(:post, %r{\A#{PUBLISHING_API_V2_ENDPOINT}/content/.*/republish})
end

def stub_any_publishing_api_unpublish

Stub any POST /v2/content/*/unpublish request
def stub_any_publishing_api_unpublish
  stub_request(:post, %r{\A#{PUBLISHING_API_V2_ENDPOINT}/content/.*/unpublish})
end

def stub_any_publishing_api_unreserve_path

def stub_any_publishing_api_unreserve_path
  stub_request(:delete, %r{\A#{PUBLISHING_API_ENDPOINT}/paths/})
end

def stub_publishing_api_destroy_intent(base_path)

def stub_publishing_api_destroy_intent(base_path)
  url = "#{PUBLISHING_API_ENDPOINT}/publish-intent#{base_path}"
  stub_request(:delete, url).to_return(status: 200, body: "{}", headers: { "Content-Type" => "application/json; charset=utf-8" })
end

def stub_publishing_api_discard_draft(content_id)

Parameters:
  • content_id (UUID) --
def stub_publishing_api_discard_draft(content_id)
  url = PUBLISHING_API_V2_ENDPOINT + "/content/#{content_id}/discard-draft"
  stub_request(:post, url).to_return(status: 200, headers: { "Content-Type" => "application/json; charset=utf-8" })
end

def stub_publishing_api_does_not_have_item(content_id, params = {})

Parameters:
  • content_id (UUID) --
def stub_publishing_api_does_not_have_item(content_id, params = {})
  url = "#{PUBLISHING_API_V2_ENDPOINT}/content/#{content_id}"
  stub_request(:get, url)
    .with(query: hash_including(params))
    .to_return(status: 404, body: resource_not_found(content_id, "content item").to_json, headers: {})
end

def stub_publishing_api_does_not_have_links(content_id)

Parameters:
  • content_id (UUID) --
def stub_publishing_api_does_not_have_links(content_id)
  url = "#{PUBLISHING_API_V2_ENDPOINT}/links/#{content_id}"
  stub_request(:get, url).to_return(status: 404, body: resource_not_found(content_id, "link set").to_json, headers: {})
end

def stub_publishing_api_get_editions(editions, params = {})

Parameters:
  • params (Hash) --
  • items (Array) --
def stub_publishing_api_get_editions(editions, params = {})
  url = "#{PUBLISHING_API_V2_ENDPOINT}/editions"
  results = editions.map do |edition|
    next edition unless params[:fields]
    edition.select { |k| params[:fields].include?(k) }
  end
  per_page = (params[:per_page] || 100).to_i
  results = results.take(per_page)
  body = {
    results: results,
    links: [
      { rel: "self", href: "#{PUBLISHING_API_V2_ENDPOINT}/editions" },
    ],
  }
  stub_request(:get, url)
    .with(query: params)
    .to_return(status: 200, body: body.to_json, headers: {})
end

def stub_publishing_api_has_content(items, params = {})

Parameters:
  • params (Hash) --
  • items (Array) --
def stub_publishing_api_has_content(items, params = {})
  url = "#{PUBLISHING_API_V2_ENDPOINT}/content"
  if params.respond_to? :fetch
    per_page = params.fetch(:per_page, 50)
    page = params.fetch(:page, 1)
  else
    per_page = 50
    page = 1
  end
  start_position = (page - 1) * per_page
  page_items = items.slice(start_position, per_page) || []
  number_of_pages =
    if items.count < per_page
      1
    else
      (items.count / per_page.to_f).ceil
    end
  body = {
    results: page_items,
    total: items.count,
    pages: number_of_pages,
    current_page: page,
  }
  stub_request(:get, url)
    .with(query: params)
    .to_return(status: 200, body: body.to_json, headers: {})
end

def stub_publishing_api_has_expanded_links(links, with_drafts: true, generate: false)

Parameters:
  • links (Hash) -- the structure of the links hash
def stub_publishing_api_has_expanded_links(links, with_drafts: true, generate: false)
  links = deep_transform_keys(links, &:to_sym)
  request_params = {}
  request_params["with_drafts"] = false unless with_drafts
  request_params["generate"] = true if generate
  url = "#{PUBLISHING_API_V2_ENDPOINT}/expanded-links/#{links[:content_id]}"
  stub_request(:get, url)
    .with(query: request_params)
    .to_return(status: 200, body: links.to_json, headers: {})
end

def stub_publishing_api_has_fields_for_document(document_type, items, fields)

publishing_api_has_content allows for flexible passing in of arguments, please use instead
This method has been refactored into publishing_api_has_content (above)
def stub_publishing_api_has_fields_for_document(document_type, items, fields)
  body = Array(items).map do |item|
    deep_stringify_keys(item).slice(*fields)
  end
  query_params = fields.map do |f|
    "&fields%5B%5D=#{f}"
  end
  url = PUBLISHING_API_V2_ENDPOINT + "/content?document_type=#{document_type}#{query_params.join('')}"
  stub_request(:get, url).to_return(status: 200, body: { results: body }.to_json, headers: {})
end

def stub_publishing_api_has_item(item, params = {})

Parameters:
  • item (Hash) --
def stub_publishing_api_has_item(item, params = {})
  item = deep_transform_keys(item, &:to_sym)
  url = "#{PUBLISHING_API_V2_ENDPOINT}/content/#{item[:content_id]}"
  stub_request(:get, url)
    .with(query: hash_including(params))
    .to_return(status: 200, body: item.to_json, headers: {})
end

def stub_publishing_api_has_item_in_sequence(content_id, items)

Parameters:
  • items (Array) --
def stub_publishing_api_has_item_in_sequence(content_id, items)
  items = items.each { |item| deep_transform_keys(item, &:to_sym) }
  url = "#{PUBLISHING_API_V2_ENDPOINT}/content/#{content_id}"
  calls = -1
  stub_request(:get, url).to_return do |_request|
    calls += 1
    item = items[calls] || items.last
    { status: 200, body: item.to_json, headers: {} }
  end
end

def stub_publishing_api_has_linkables(linkables, document_type:)

Parameters:
  • linkables (Array) --
def stub_publishing_api_has_linkables(linkables, document_type:)
  url = PUBLISHING_API_V2_ENDPOINT + "/linkables?document_type=#{document_type}"
  stub_request(:get, url).to_return(status: 200, body: linkables.to_json, headers: {})
end

def stub_publishing_api_has_linked_items(items, params = {})

Parameters:
  • params (Hash) -- A hash of parameters
  • items (Array) -- The linked items we wish to return
def stub_publishing_api_has_linked_items(items, params = {})
  content_id = params.fetch(:content_id)
  link_type = params.fetch(:link_type)
  fields = params.fetch(:fields, %w[base_path content_id document_type title])
  url = PUBLISHING_API_V2_ENDPOINT + "/linked/#{content_id}"
  request_parmeters = {
    "fields" => fields,
    "link_type" => link_type,
  }
  stub_request(:get, url)
    .with(query: request_parmeters)
    .and_return(
      body: items.to_json,
      status: 200,
    )
end

def stub_publishing_api_has_links(links)

Parameters:
  • links (Hash) -- the structure of the links hash
def stub_publishing_api_has_links(links)
  links = deep_transform_keys(links, &:to_sym)
  url = "#{PUBLISHING_API_V2_ENDPOINT}/links/#{links[:content_id]}"
  stub_request(:get, url).to_return(status: 200, body: links.to_json, headers: {})
end

def stub_publishing_api_has_links_for_content_ids(links)

Parameters:
  • links (Hash) -- the links for each content id
def stub_publishing_api_has_links_for_content_ids(links)
  url = "#{PUBLISHING_API_V2_ENDPOINT}/links/by-content-id"
  stub_request(:post, url).with(body: { content_ids: links.keys }).to_return(status: 200, body: links.to_json, headers: {})
end

def stub_publishing_api_has_lookups(lookup_hash)

Parameters:
  • lookup_hash (Hash) -- Hash with base_path as key, content_id as value.
def stub_publishing_api_has_lookups(lookup_hash)
  url = "#{PUBLISHING_API_ENDPOINT}/lookup-by-base-path"
  stub_request(:post, url).to_return(body: lookup_hash.to_json)
end

def stub_publishing_api_has_path_reservation_for(path, publishing_app)

Parameters:
  • publishing_app (String) --
  • base_path (String) --
def stub_publishing_api_has_path_reservation_for(path, publishing_app)
  message = "#{path} is already reserved by #{publishing_app}"
  error = { code: 422,
            message: "Base path #{message}",
            fields: { base_path: [message] } }
  stub_request(:put, "#{PUBLISHING_API_ENDPOINT}/paths#{path}")
            .to_return(status: 422,
                       headers: { content_type: "application/json" },
                       body: { error: error }.to_json)
  stub_request(:put, "#{PUBLISHING_API_ENDPOINT}/paths#{path}")
    .with(body: { "publishing_app" => publishing_app })
    .to_return(status: 200,
               headers: { content_type: "application/json" },
               body: { publishing_app: publishing_app, base_path: path }.to_json)
end

def stub_publishing_api_isnt_available

Stub any request to the publishing API to return a 503 response
def stub_publishing_api_isnt_available
  stub_request(:any, /#{PUBLISHING_API_ENDPOINT}\/.*/).to_return(status: 503)
end

def stub_publishing_api_patch(*args)

def stub_publishing_api_patch(*args)
  stub_publishing_api_postlike_call(:patch, *args)
end

def stub_publishing_api_patch_links(content_id, body)

Parameters:
  • body (String) --
  • content_id (UUID) --
def stub_publishing_api_patch_links(content_id, body)
  stub_publishing_api_patch(content_id, body, "/links")
end

def stub_publishing_api_patch_links_conflict(content_id, body)

Parameters:
  • body (String) --
  • content_id (UUID) --
def stub_publishing_api_patch_links_conflict(content_id, body)
  previous_version = JSON.parse(body.to_json)["previous_version"]
  override_response_hash = { status: 409, body: version_conflict(previous_version) }
  stub_publishing_api_patch(content_id, body, "/links", override_response_hash)
end

def stub_publishing_api_path_reservation(base_path, params = {})

Parameters:
  • params (Hash) --
  • base_path (String) --
def stub_publishing_api_path_reservation(base_path, params = {})
  url = PUBLISHING_API_ENDPOINT + "/paths#{base_path}"
  response = {
    status: 200,
    headers: { content_type: "application/json" },
    body: params.merge(base_path: base_path).to_json,
  }
  stub_request(:put, url).with(body: params).to_return(response)
end

def stub_publishing_api_postlike_call(method, content_id, body, resource_path, override_response_hash = {})

def stub_publishing_api_postlike_call(method, content_id, body, resource_path, override_response_hash = {})
  response_hash = { status: 200, body: "{}", headers: { "Content-Type" => "application/json; charset=utf-8" } }
  response_hash.merge!(override_response_hash)
  response_hash[:body] = response_hash[:body].to_json if response_hash[:body].is_a?(Hash)
  url = "#{PUBLISHING_API_V2_ENDPOINT}#{resource_path}/#{content_id}"
  stub_request(method, url).with(body: body).to_return(response_hash)
end

def stub_publishing_api_publish(content_id, body, response_hash = {})

Parameters:
  • response_hash (Hash) --
  • body (String) --
  • content_id (UUID) --
def stub_publishing_api_publish(content_id, body, response_hash = {})
  url = PUBLISHING_API_V2_ENDPOINT + "/content/#{content_id}/publish"
  response = {
    status: 200,
    body: "{}",
    headers: { "Content-Type" => "application/json; charset=utf-8" },
  }.merge(response_hash)
  stub_request(:post, url).with(body: body).to_return(response)
end

def stub_publishing_api_put(*args)

def stub_publishing_api_put(*args)
  stub_publishing_api_postlike_call(:put, *args)
end

def stub_publishing_api_put_content(content_id, body, response_hash = {})

Parameters:
  • response_hash (Hash) --
  • body (String) --
  • content_id (UUID) --
def stub_publishing_api_put_content(content_id, body, response_hash = {})
  stub_publishing_api_put(content_id, body, "/content", response_hash)
end

def stub_publishing_api_put_content_links_and_publish(body, content_id = nil, publish_body = nil)

Parameters:
  • publish_body (Hash) --
  • content_id (UUID) --
  • body (String) --
def stub_publishing_api_put_content_links_and_publish(body, content_id = nil, publish_body = nil)
  content_id ||= body[:content_id]
  if publish_body.nil?
    publish_body = { update_type: body.fetch(:update_type) }
    publish_body[:locale] = body[:locale] if body[:locale]
  end
  stubs = []
  stubs << stub_publishing_api_put_content(content_id, body.except(:links))
  stubs << stub_publishing_api_patch_links(content_id, body.slice(:links)) unless body.slice(:links).empty?
  stubs << stub_publishing_api_publish(content_id, publish_body)
  stubs
end

def stub_publishing_api_put_intent(base_path, params = {})

Parameters:
  • params (Hash) --
  • base_path (String) --
def stub_publishing_api_put_intent(base_path, params = {})
  url = PUBLISHING_API_ENDPOINT + "/publish-intent#{base_path}"
  body = params.is_a?(String) ? params : params.to_json
  response = {
    status: 200,
    headers: { content_type: "application/json" },
    body: body,
  }
  stub_request(:put, url).with(body: params).to_return(response)
end

def stub_publishing_api_republish(content_id, body = {}, response_hash = {})

Parameters:
  • response_hash (Hash) --
  • body (String) --
  • content_id (UUID) --
def stub_publishing_api_republish(content_id, body = {}, response_hash = {})
  url = PUBLISHING_API_V2_ENDPOINT + "/content/#{content_id}/republish"
  response = {
    status: 200,
    body: "{}",
    headers: { "Content-Type" => "application/json; charset=utf-8" },
  }.merge(response_hash)
  stub_request(:post, url).with(body: body).to_return(response)
end

def stub_publishing_api_returns_path_reservation_validation_error_for(base_path, error_fields = {})

Parameters:
  • error_fields (Hash) --
  • base_path (String) --
def stub_publishing_api_returns_path_reservation_validation_error_for(base_path, error_fields = {})
  error_fields = { "base_path" => ["Computer says no"] } if error_fields.empty?
  message = "#{error_fields.keys.first.to_s.capitalize.gsub(/_/, ' ')} #{error_fields.values.flatten.first}"
  error = { code: 422, message: message, fields: error_fields }
  stub_request(:put, "#{PUBLISHING_API_ENDPOINT}/paths#{base_path}")
    .to_return(status: 422,
               headers: { content_type: "application/json" },
               body: { error: error }.to_json)
end

def stub_publishing_api_unpublish(content_id, params, response_hash = {})

Parameters:
  • body (String) --
  • params (Hash) --
  • content_id (UUID) --
def stub_publishing_api_unpublish(content_id, params, response_hash = {})
  url = PUBLISHING_API_V2_ENDPOINT + "/content/#{content_id}/unpublish"
  response = {
    status: 200,
    body: "{}",
    headers: { "Content-Type" => "application/json; charset=utf-8" },
  }.merge(response_hash)
  stub_request(:post, url).with(params).to_return(response)
end

def stub_publishing_api_unreserve_path(base_path, publishing_app = /.*/)

def stub_publishing_api_unreserve_path(base_path, publishing_app = /.*/)
  stub_publishing_api_unreserve_path_with_code(base_path, publishing_app, 200)
end

def stub_publishing_api_unreserve_path_invalid(base_path, publishing_app = /.*/)

def stub_publishing_api_unreserve_path_invalid(base_path, publishing_app = /.*/)
  stub_publishing_api_unreserve_path_with_code(base_path, publishing_app, 422)
end

def stub_publishing_api_unreserve_path_not_found(base_path, publishing_app = /.*/)

def stub_publishing_api_unreserve_path_not_found(base_path, publishing_app = /.*/)
  stub_publishing_api_unreserve_path_with_code(base_path, publishing_app, 404)
end

def stub_publishing_api_unreserve_path_with_code(base_path, publishing_app, code)

def stub_publishing_api_unreserve_path_with_code(base_path, publishing_app, code)
  url = "#{PUBLISHING_API_ENDPOINT}/paths#{base_path}"
  body = { publishing_app: publishing_app }
  stub_request(:delete, url).with(body: body).to_return(status: code, body: "{}", headers: { "Content-Type" => "application/json; charset=utf-8" })
end

def values_match_recursively(expected_value, actual_value)

def values_match_recursively(expected_value, actual_value)
  case expected_value
  when Hash
    return false unless actual_value.is_a?(Hash)
    expected_value.all? do |expected_sub_key, expected_sub_value|
      actual_value.key?(expected_sub_key.to_s) &&
        values_match_recursively(expected_sub_value, actual_value[expected_sub_key.to_s])
    end
  when Array
    return false unless actual_value.is_a?(Array)
    return false unless actual_value.size == expected_value.size
    expected_value.each.with_index.all? do |expected_sub_value, i|
      values_match_recursively(expected_sub_value, actual_value[i])
    end
  else
    expected_value == actual_value
  end
end

def version_conflict(expected_version, actual_version = expected_version + 1)

def version_conflict(expected_version, actual_version = expected_version + 1)
  {
    error: {
      code: 409,
      message: "A lock-version conflict occurred. The `previous_version` you've sent (#{expected_version}) is not the same as the current lock version of the edition (#{actual_version}).",
      fields: { previous_version: ["does not match"] },
    },
  }
end