-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a generator for Google Merchant (XML)
As specified in https://support.google.com/merchants/answer/7052112. This also adds global configuration attributes to be used in the store feed. Co-Authored-By: Flavio Auciello <[email protected]> Co-Authored-By: Elia Schito <[email protected]>
- Loading branch information
1 parent
225063f
commit 4f2ce1f
Showing
5 changed files
with
281 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
# frozen_string_literal: true | ||
|
||
require 'builder' | ||
|
||
module SolidusFeeds | ||
module Generators | ||
# The GoogleMerchant XML feed as described in https://support.google.com/merchants/answer/7052112. | ||
class GoogleMerchant | ||
attr_accessor :products, :host | ||
|
||
def initialize(products, host:) | ||
self.products = products | ||
self.host = host | ||
end | ||
|
||
def call(io) | ||
builder = Builder::XmlMarkup.new(target: io, indent: 0) | ||
render_template(builder) | ||
end | ||
|
||
def render_template(xml) | ||
xml.rss version: "2.0", "xmlns:g" => "http://base.google.com/ns/1.0" do | ||
xml.channel do | ||
xml.title SolidusFeeds.title | ||
xml.link SolidusFeeds.link | ||
xml.description SolidusFeeds.description | ||
xml.language SolidusFeeds.language | ||
products.find_each do |product| | ||
xml.item do | ||
xml.tag! 'g:id', id(product) | ||
xml.tag! 'g:title', title(product) | ||
xml.tag! 'g:description', description(product) | ||
xml.tag! 'g:link', link(product) | ||
xml.tag! 'g:image_link', image_link(product) | ||
xml.tag! 'g:condition', condition(product) | ||
xml.tag! 'g:price', price(product) | ||
xml.tag! 'g:availability', availability(product) | ||
xml.tag! 'g:brand', brand(product) | ||
xml.tag! 'g:mpn', mpn(product) | ||
xml.tag! 'g:google_product_category', google_product_category_id(product) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
|
||
def id(product) | ||
product.id | ||
end | ||
|
||
def title(product) | ||
product.name | ||
end | ||
|
||
def description(product) | ||
product.description | ||
end | ||
|
||
def link(product) | ||
spree_routes.product_url(product, host: host) | ||
end | ||
|
||
def image_link(product) | ||
return unless product.images.any? | ||
|
||
attachment_url = product.images.first.attachment.url(:large) | ||
asset_host = ActionController::Base.asset_host | ||
|
||
URI.join(asset_host, attachment_url).to_s | ||
end | ||
|
||
# Must be "new", "refurbished", or "used". | ||
def condition(product) | ||
product.property("condition") || "new" | ||
end | ||
|
||
def price(product) | ||
Spree::Money.new(product.price).money.format(symbol: false, with_currency: true) | ||
end | ||
|
||
# Must be "in stock", "preorder" or "out of stock" | ||
def availability(product) | ||
product.master.in_stock? ? 'in stock' : 'out of stock' | ||
end | ||
|
||
def brand(product) | ||
product.property("brand") || SolidusFeeds.title | ||
end | ||
|
||
def mpn(product) | ||
product.master.sku | ||
end | ||
|
||
def google_product_category_id(product) | ||
# Must be selected from https://support.google.com/merchants/answer/1705911 | ||
product.property("google_product_category_id") | ||
end | ||
|
||
private | ||
|
||
def spree_routes | ||
@spree_routes ||= Spree::Core::Engine.routes.url_helpers | ||
end | ||
end | ||
end | ||
end |
132 changes: 132 additions & 0 deletions
132
spec/lib/solidus_feeds/generators/google_merchant_spec.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
# frozen_string_literal: true | ||
|
||
require 'spec_helper' | ||
require 'stringio' | ||
|
||
RSpec.describe SolidusFeeds::Generators::GoogleMerchant do | ||
subject(:generator) { described_class.new(products, host: 'https://example.com') } | ||
|
||
let(:products) do | ||
Spree::Product.where(id: [ | ||
create(:product, id: 123, slug: 'pro1', name: 'A product', price: 123.45, description: 'product description', sku: 'PRD1'), # rubocop:disable Layout/LineLength | ||
create(:product, id: 456, slug: 'pro2', name: 'Another product', price: 678.90, description: 'another product description', sku: 'PRD2'), # rubocop:disable Layout/LineLength | ||
]).order(:id) | ||
end | ||
let(:product) { products.first } | ||
let(:store) { instance_double(Spree::Store, name: "The Best Store", url: "example.com") } | ||
|
||
before { allow(Spree::Store).to receive(:default).and_return(store) } | ||
|
||
before(:each, with_images: true) do | ||
allow(ActionController::Base).to receive(:asset_host).and_return('https://assets.example.com') | ||
Spree::Image.create! viewable: products.first.master, attachment_file_name: 'foo.png', id: 234 | ||
end | ||
|
||
describe '#call', :with_images do | ||
describe 'generated XML' do | ||
def expect_xml_content(xpath) | ||
expect(xml.xpath(xpath, 'g' => "http://base.google.com/ns/1.0").text) | ||
end | ||
|
||
let(:xml) do | ||
io = StringIO.new | ||
generator.call(io) | ||
Nokogiri::XML(io.string) | ||
end | ||
|
||
it 'includes the tags with the expected values' do | ||
aggregate_failures do | ||
expect_xml_content('//channel/title').to eq('The Best Store') | ||
expect_xml_content('//channel/link').to eq('https://example.com') | ||
expect_xml_content('//channel/description').to eq('Find out about new products on https://example.com first!') | ||
expect_xml_content('//channel/language').to eq('en-us') | ||
expect_xml_content('//channel/item[1]/g:id').to eq('123') | ||
expect_xml_content('//channel/item[1]/g:title').to eq('A product') | ||
expect_xml_content('//channel/item[1]/g:description').to eq('product description') | ||
expect_xml_content('//channel/item[1]/g:link').to eq('https://example.com/products/pro1') | ||
expect_xml_content('//channel/item[1]/g:image_link').to eq("https://assets.example.com/spree/products/234/large/foo.png") # rubocop:disable Layout/LineLength | ||
expect_xml_content('//channel/item[1]/g:condition').to eq('new') | ||
expect_xml_content('//channel/item[1]/g:price').to eq('123.45 USD') | ||
expect_xml_content('//channel/item[1]/g:availability').to eq('out of stock') | ||
expect_xml_content('//channel/item[1]/g:brand').to eq('The Best Store') | ||
expect_xml_content('//channel/item[1]/g:mpn').to eq('PRD1') | ||
expect_xml_content('//channel/item[1]/g:google_product_category').to eq('') | ||
end | ||
end | ||
end | ||
end | ||
|
||
specify '#id' do | ||
product = instance_double(Spree::Product, id: 789) | ||
|
||
expect(generator.id(product)).to eq(789) | ||
end | ||
|
||
specify '#title' do | ||
product = instance_double(Spree::Product, name: "Foo Bar") | ||
|
||
expect(generator.title(product)).to eq("Foo Bar") | ||
end | ||
|
||
specify '#description' do | ||
product = instance_double(Spree::Product, description: "Foo Bar") | ||
|
||
expect(generator.description(product)).to eq("Foo Bar") | ||
end | ||
|
||
specify '#link' do | ||
product = instance_double(Spree::Product, to_param: "123-my-product") | ||
|
||
expect(generator.link(product)).to eq("https://example.com/products/123-my-product") | ||
end | ||
|
||
specify '#image_link', :with_images do | ||
expect(generator.image_link(products.first)).to eq("https://assets.example.com/spree/products/234/large/foo.png") | ||
expect(generator.image_link(products.second)).to eq(nil) | ||
end | ||
|
||
specify '#condition' do | ||
products.first.set_property("condition", "foo-bar") | ||
|
||
expect(generator.condition(product)).to eq("foo-bar") | ||
end | ||
|
||
specify '#price' do | ||
product = instance_double(Spree::Product, price: 321.45); | ||
|
||
expect(generator.price(product)).to eq("321.45 USD") | ||
end | ||
|
||
describe '#availability' do | ||
it 'is "in stock" when available' do | ||
allow(product.master).to receive(:in_stock?).and_return(true) | ||
|
||
expect(generator.availability(product)).to eq("in stock") | ||
end | ||
|
||
it 'is "out of stock" when unavailable' do | ||
allow(product.master).to receive(:in_stock?).and_return(false) | ||
|
||
expect(generator.availability(product)).to eq("out of stock") | ||
end | ||
end | ||
|
||
specify '#brand' do | ||
product.set_property("brand", "foo-bar") | ||
|
||
expect(generator.brand(product)).to eq("foo-bar") | ||
expect(generator.brand(products.second)).to eq("The Best Store") | ||
end | ||
|
||
specify '#mpn' do | ||
allow(product.master).to receive(:sku).and_return("FOOO_123_SKU") | ||
|
||
expect(generator.mpn(product)).to eq("FOOO_123_SKU") | ||
end | ||
|
||
specify '#google_product_category_id' do | ||
product.set_property("google_product_category_id", "123456") | ||
|
||
expect(generator.google_product_category_id(product)).to eq("123456") | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
# frozen_string_literal: true | ||
|
||
RSpec.describe SolidusFeeds do | ||
let(:store) { instance_double(Spree::Store, name: "The Best Store", url: "example.com") } | ||
|
||
before { allow(Spree::Store).to receive(:default).and_return(store) } | ||
|
||
specify '.title' do | ||
expect(described_class.title).to eq("The Best Store") | ||
end | ||
|
||
specify '.link' do | ||
expect(described_class.link).to eq("https://example.com") | ||
end | ||
|
||
specify '.description' do | ||
expect(described_class.description).to eq("Find out about new products on https://example.com first!") | ||
end | ||
|
||
specify '.language' do | ||
expect(described_class.language).to eq("en-us") | ||
end | ||
end |