diff --git a/.rubocop.yml b/.rubocop.yml index 48b1f82..4411f96 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -22,3 +22,7 @@ Style/NumericLiterals: Style/BlockDelimiters: Exclude: - spec/**/* + +Lint/AmbiguousBlockAssociation: + Exclude: + - spec/**/* diff --git a/README.md b/README.md index fcbe52b..e82847c 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,10 @@ account or login locally: By the end of this section, you should be able to search Amazon products and add items to wishlists on your local machine (when your logged-in user is an -admin or site manager). +admin or site manager). *Note: if you're adding a new API endpoint, read more +[here][API client README].* + +[API client README]: lib/amazon_product_api/README.md **This step is only required for site managers and admins searching/adding Amazon products.** If your issue doesn't involve working with the Amazon diff --git a/app/jobs/item_sync_job.rb b/app/jobs/item_sync_job.rb new file mode 100644 index 0000000..eb21349 --- /dev/null +++ b/app/jobs/item_sync_job.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'amazon_product_api' + +# This job is responsible for syncing items with Amazon. +# +# Amazon changes prices, details, etc. pretty frequently, so this job will +# query the Amazon record associated with the ASIN and make any required local +# updates. +# +# Bang methods (ex. `#sync_batch!`) perform an HTTP request. +# +class ItemSyncJob < ApplicationJob + # Maximum number of ASINs in one request + BATCH_SIZE = AmazonProductAPI::ItemLookupEndpoint::ASIN_LIMIT + + queue_as :default + + def initialize + @client = AmazonProductAPI::HTTPClient.new + end + + # Syncs all items and writes the results to the log + def perform(*_args) + Rails.logger.info bold_green('Syncing all items') + sync_all! + Rails.logger.info bold_green('Done syncing!') + end + + private + + attr_reader :client + + # Syncs all database items with their Amazon sources + def sync_all! + # This is done in slices and batches to avoid Amazon rate limits + Item.all.each_slice(BATCH_SIZE * 3) do |batches| + batches.each_slice(BATCH_SIZE) { |batch| sync_batch! batch } + sleep 2.seconds unless Rails.env.test? + end + end + + # Syncs one batch of items with Amazon (up to the batch size limit) + def sync_batch!(items) + count = items.count + validate_batch_size(count) + + Rails.logger.info "Fetching #{count} items: #{items.map(&:asin).join(',')}" + + items_updates = get_updates_for! items + items_updates.map { |item, updates| update_item(item, updates) } + end + + # Returns pairs of items and their corresponding update hashes + def get_updates_for!(items) + query = client.item_lookup(*items.map(&:asin)) + amazon_items = query.response + updates = amazon_items.map(&:update_hash) + + items.zip(updates) + end + + def validate_batch_size(count) + return unless count > BATCH_SIZE + raise ArgumentError, + "Batch size too large: #{count}/#{BATCH_SIZE}" + end + + def update_item(item, update_hash) + Rails.logger.info green( + "Syncing item #{item.id}: (#{item.asin}) #{item.name.truncate(64)}" + ) + item.assign_attributes(update_hash) + return unless item.changed? + + Rails.logger.info bold_green("Changed:\n") + item.changes.pretty_inspect + item.save! + end + + # Some styles for log text. This is so minor that it's not worth + # bringing in a full library. + + def bold_green(text) + bold(green(text)) + end + + def green(text) + "\e[32m#{text}\e[0m" + end + + def bold(text) + "\e[1m#{text}\e[22m" + end +end diff --git a/lib/amazon_product_api/README.md b/lib/amazon_product_api/README.md new file mode 100644 index 0000000..1fa9cc8 --- /dev/null +++ b/lib/amazon_product_api/README.md @@ -0,0 +1,68 @@ +# Amazon Product Advertising API Client + +This folder contains the wrapper code for the Amazon Product Advertising API. +For more details on the API, check the [Amazon documentation]. + +[Amazon documentation]: http://docs.aws.amazon.com/AWSECommerceService/latest/DG/Welcome.html + +## Adding an Endpoint + +All endpoints should be subclassed from `AmazonProductAPI::Endpoint`. In order +to add a new endpoint, you'll need to modify the template below in a few ways: + + * Prepend any new attributes to the `#initialize` parameter list. Any + validations or processing can be done in `#initialize` as shown. Note: + setting `aws_credentials` is **required**! + + * Add endpoint-specific request parameters to `#request_params`. These can + be found in the Amazon API documentation. + + * Add any post-processing of the API response in `#process_response`. + + * Update the class name and comments. + +### Endpoint Template + +```ruby +require 'amazon_product_api/endpoint' + +module AmazonProductAPI + # Responsible for building and executing <...> + # + # + # + # Contains all specialization logic for this endpoint including request + # parameters, parameter validation, and response parsing. + class TemplateEndpoint < Endpoint + # Add any parameters you need for the specific endpoint. + # + # Make sure you set `@aws_credentials`-the query won't work without it! + def initialize(aws_credentials) + # Attribute validations + # raise InvalidQueryError, 'reason' if ... + + # Initialize attributes + @aws_credentials = aws_credentials + end + + private + + attr_accessor :aws_credentials # any other attrs + + # Add any post-processing of the response hash. + def process_response(response_hash) + ExampleResponse.new(response_hash).item + end + + # Include request parameters unique to this endpoint. + def request_params + { + # 'Operation' => 'ItemLookup', + # 'IdType' => 'ASIN', + # 'ItemId' => 'the item asin', + # ... + } + end + end +end +``` diff --git a/lib/amazon_product_api/endpoint.rb b/lib/amazon_product_api/endpoint.rb new file mode 100644 index 0000000..f094066 --- /dev/null +++ b/lib/amazon_product_api/endpoint.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module AmazonProductAPI + # Base representation of all Amazon Product Advertising API endpoints. + # + # http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\ + # CHAP_OperationListAlphabetical.html + # + # Any general logic relating to lookup, building the query string, + # authentication signatures, etc. should live in this class. Specializations + # (including specific request parameters and response parsing) should live in + # endpoint subclasses. + class Endpoint + require 'httparty' + require 'time' + require 'uri' + require 'openssl' + require 'base64' + + # The region you are interested in + ENDPOINT = 'webservices.amazon.com' + REQUEST_URI = '/onca/xml' + + # Generates the signed URL + def url + raise InvalidQueryError, 'Missing AWS credentials' unless aws_credentials + + "http://#{ENDPOINT}#{REQUEST_URI}" + # base + "?#{canonical_query_string}" + # query + "&Signature=#{uri_escape(signature)}" # signature + end + + # Sends the HTTP request + def get(http: HTTParty) + http.get(url) + end + + # Performs the search query and returns the processed response + def response(http: HTTParty, logger: Rails.logger) + response = parse_response get(http: http) + process_response(response) + end + + private + + attr_reader :aws_credentials + + # Takes the response hash and returns the processed API response + # + # This must be implemented for each individual endpoint. + def process_response(_response_hash) + raise NotImplementedError, 'Implement this method in your subclass.' + end + + # Returns a hash of request parameters unique to the endpoint + # + # This must be implemented for each individual endpoint. + def request_params + raise NotImplementedError, 'Implement this method in your subclass.' + end + + def params + params = request_params.merge( + 'Service' => 'AWSECommerceService', + 'AWSAccessKeyId' => aws_credentials.access_key, + 'AssociateTag' => aws_credentials.associate_tag + ) + + # Set current timestamp if not set + params['Timestamp'] ||= Time.now.gmtime.iso8601 + params + end + + def parse_response(response) + Hash.from_xml(response.body) + end + + # Generates the signature required by the Product Advertising API + def signature + Base64.encode64(digest_with_key(string_to_sign)).strip + end + + def string_to_sign + "GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}" + end + + def canonical_query_string + params.sort + .map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" } + .join('&') + end + + def digest_with_key(string) + OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), + aws_credentials.secret_key, + string) + end + + def uri_escape(phrase) + CGI.escape(phrase.to_s) + end + end +end diff --git a/lib/amazon_product_api/http_client.rb b/lib/amazon_product_api/http_client.rb index 427b228..c5b05f2 100644 --- a/lib/amazon_product_api/http_client.rb +++ b/lib/amazon_product_api/http_client.rb @@ -19,8 +19,8 @@ def item_search(query:, page: 1) ItemSearchEndpoint.new(query, page, aws_credentials) end - def item_lookup(asin) - ItemLookupEndpoint.new(asin, aws_credentials) + def item_lookup(*asin) + ItemLookupEndpoint.new(*asin, aws_credentials) end private diff --git a/lib/amazon_product_api/item_lookup_endpoint.rb b/lib/amazon_product_api/item_lookup_endpoint.rb index 087d597..ac6307a 100644 --- a/lib/amazon_product_api/item_lookup_endpoint.rb +++ b/lib/amazon_product_api/item_lookup_endpoint.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'amazon_product_api/endpoint' require 'amazon_product_api/lookup_response' module AmazonProductAPI @@ -7,92 +8,43 @@ module AmazonProductAPI # # http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemLookup.html # - # Any logic relating to lookup, building the query string, authentication - # signatures, etc. should live in this class. - class ItemLookupEndpoint - require 'httparty' - require 'time' - require 'uri' - require 'openssl' - require 'base64' + # Contains all specialization logic for this endpoint including request + # parameters, parameter validation, and response parsing. + class ItemLookupEndpoint < Endpoint + ASIN_LIMIT = 10 # max ASINs per request (from docs) - # The region you are interested in - ENDPOINT = 'webservices.amazon.com' - REQUEST_URI = '/onca/xml' + def initialize(*asins, aws_credentials) + validate_asin_count(asins) - attr_accessor :asin, :aws_credentials - - def initialize(asin, aws_credentials) - @asin = asin + @asins = asins @aws_credentials = aws_credentials end - # Generate the signed URL - def url - "http://#{ENDPOINT}#{REQUEST_URI}" + # base - "?#{canonical_query_string}" + # query - "&Signature=#{uri_escape(signature)}" # signature - end - - # Send the HTTP request - def get(http: HTTParty) - http.get(url) - end - - # Performs the search query and returns the resulting SearchResponse - def response(http: HTTParty, logger: Rails.logger) - response = parse_response get(http: http) - logger.debug(response) - LookupResponse.new(response).item - end - private - def parse_response(response) - Hash.from_xml(response.body) - end + attr_reader :asins, :aws_credentials - # Generate the signature required by the Product Advertising API - def signature - Base64.encode64(digest_with_key(string_to_sign)).strip + def validate_asin_count(asins) + return unless asins.count > ASIN_LIMIT + raise ArgumentError, + "Exceeded maximum ASIN limit: #{asins.length}/#{ASIN_LIMIT}" end - # Generate the string to be signed - def string_to_sign - "GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}" + def process_response(response_hash) + LookupResponse.new(response_hash).items end - # Generate the canonical query - def canonical_query_string - params.sort - .map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" } - .join('&') - end - - def params - params = { - 'Service' => 'AWSECommerceService', - 'AWSAccessKeyId' => aws_credentials.access_key, - 'AssociateTag' => aws_credentials.associate_tag, - # endpoint-specific + # Other request parameters for ItemLookup can be found here: + # + # http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\ + # ItemLookup.html#ItemLookup-rp + def request_params + { 'Operation' => 'ItemLookup', 'ResponseGroup' => 'ItemAttributes,Offers,Images', - 'ItemId' => asin.to_s + 'IdType' => 'ASIN', + 'ItemId' => asins.join(',') } - - # Set current timestamp if not set - params['Timestamp'] ||= Time.now.gmtime.iso8601 - params - end - - def digest_with_key(string) - OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), - aws_credentials.secret_key, - string) - end - - def uri_escape(phrase) - CGI.escape(phrase.to_s) end end end diff --git a/lib/amazon_product_api/item_search_endpoint.rb b/lib/amazon_product_api/item_search_endpoint.rb index 7230cfa..271d287 100644 --- a/lib/amazon_product_api/item_search_endpoint.rb +++ b/lib/amazon_product_api/item_search_endpoint.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'amazon_product_api/endpoint' require 'amazon_product_api/search_response' module AmazonProductAPI @@ -7,97 +8,37 @@ module AmazonProductAPI # # http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemSearch.html # - # Any logic relating to searching, building the query string, authentication - # signatures, etc. should live in this class. - class ItemSearchEndpoint - require 'httparty' - require 'time' - require 'uri' - require 'openssl' - require 'base64' - - # The region you are interested in - ENDPOINT = 'webservices.amazon.com' - REQUEST_URI = '/onca/xml' - - attr_accessor :query, :page, :aws_credentials - + # Contains all specialization logic for this endpoint including request + # parameters, parameter validation, and response parsing. + class ItemSearchEndpoint < Endpoint def initialize(query, page, aws_credentials) + raise InvalidQueryError, "Page can't be nil." if page.nil? + @query = query @page = page @aws_credentials = aws_credentials end - # Generate the signed URL - def url - raise InvalidQueryError unless query && page - - "http://#{ENDPOINT}#{REQUEST_URI}" + # base - "?#{canonical_query_string}" + # query - "&Signature=#{uri_escape(signature)}" # signature - end - - # Send the HTTP request - def get(http: HTTParty) - http.get(url) - end - - # Performs the search query and returns the resulting SearchResponse - def response(http: HTTParty, logger: Rails.logger) - response = parse_response get(http: http) - logger.debug response - SearchResponse.new response - end - private - def parse_response(response) - Hash.from_xml(response.body) - end - - # Generate the signature required by the Product Advertising API - def signature - Base64.encode64(digest_with_key(string_to_sign)).strip - end - - # Generate the string to be signed - def string_to_sign - "GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}" - end + attr_accessor :query, :page, :aws_credentials - # Generate the canonical query - def canonical_query_string - params.sort - .map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" } - .join('&') + def process_response(response_hash) + SearchResponse.new response_hash end - def params - params = { - 'Service' => 'AWSECommerceService', - 'AWSAccessKeyId' => aws_credentials.access_key, - 'AssociateTag' => aws_credentials.associate_tag, - # endpoint-specific + # Other request parameters for ItemLookup can be found here: + # + # http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\ + # ItemSearch.html#ItemSearch-rp + def request_params + { 'Operation' => 'ItemSearch', 'ResponseGroup' => 'ItemAttributes,Offers,Images', 'SearchIndex' => 'All', 'Keywords' => query.to_s, 'ItemPage' => page.to_s } - - # Set current timestamp if not set - params['Timestamp'] ||= Time.now.gmtime.iso8601 - params - end - - def digest_with_key(string) - OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), - aws_credentials.secret_key, - string) - end - - def uri_escape(phrase) - CGI.escape(phrase.to_s) end end end diff --git a/lib/amazon_product_api/lookup_response.rb b/lib/amazon_product_api/lookup_response.rb index 160ddca..03ecb2b 100644 --- a/lib/amazon_product_api/lookup_response.rb +++ b/lib/amazon_product_api/lookup_response.rb @@ -10,34 +10,34 @@ module AmazonProductAPI # file to touch if the API response changes. class LookupResponse def initialize(response_hash) - @hash = item_hash_for response_hash + @response_hash = response_hash end - def item(item_class: SearchItem) - item_class.new(**item_attrs) + def items(item_class: SearchItem) + item_hashes.map { |hash| item_class.new(**item_attrs_from(hash)) } end private - attr_reader :hash + attr_reader :response_hash - def item_attrs + def item_attrs_from(hash) { asin: hash['ASIN'], - price_cents: hash.dig('ItemAttributes', 'ListPrice', 'Amount').to_i, + detail_page_url: hash['DetailPageURL'], + title: hash['ItemAttributes']['Title'], + price_cents: hash['ItemAttributes']['ListPrice']['Amount'].to_i, - image_url: hash.dig('SmallImage', 'URL') || '', - image_width: hash.dig('SmallImage', 'Width') || '', - image_height: hash.dig('SmallImage', 'Height') || '', - - title: hash.dig('ItemAttributes', 'Title'), - detail_page_url: hash['DetailPageURL'] + image_url: hash.dig('SmallImage', 'URL') || '', + image_width: hash.dig('SmallImage', 'Width') || '', + image_height: hash.dig('SmallImage', 'Height') || '' } end - def item_hash_for(response_hash) - response_hash.dig('ItemLookupResponse', 'Items', 'Item') || [] + def item_hashes + items = response_hash.dig('ItemLookupResponse', 'Items', 'Item') || [] + items.is_a?(Array) ? items : [items] # wrap "naked" response end end end diff --git a/lib/tasks/items.rake b/lib/tasks/items.rake new file mode 100644 index 0000000..bf2bc8c --- /dev/null +++ b/lib/tasks/items.rake @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +namespace :items do + desc 'Check Amazon listings and update local items accordingly.' + task sync: :environment do + ItemSyncJob.perform_now + end +end diff --git a/spec/fixtures/files/amazon_dual_lookup_response.xml b/spec/fixtures/files/amazon_dual_lookup_response.xml new file mode 100644 index 0000000..7663bee --- /dev/null +++ b/spec/fixtures/files/amazon_dual_lookup_response.xml @@ -0,0 +1,5 @@ +
request_id0.0192698310000000
TrueASINB00TFT77ZSB005R46A6WItemAttributesOffersImagesAllB00TFT77ZShttps://www.amazon.com/Douglas-1713-Toys-Louie-Corgi/dp/B00TFT77ZS?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=165953&creativeASIN=B00TFT77ZSTechnical Detailshttps://www.amazon.com/Douglas-1713-Toys-Louie-Corgi/dp/tech-data/B00TFT77ZS?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZSAdd To Baby Registryhttps://www.amazon.com/gp/registry/baby/add-item.html?asin.0=B00TFT77ZS&SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZSAdd To Wedding Registryhttps://www.amazon.com/gp/registry/wedding/add-item.html?asin.0=B00TFT77ZS&SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZSAdd To Wishlisthttps://www.amazon.com/gp/registry/wishlist/add-item.html?asin.0=B00TFT77ZS&SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZSTell A Friendhttps://www.amazon.com/gp/pdp/taf/B00TFT77ZS?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZSAll Customer Reviewshttps://www.amazon.com/review/product/B00TFT77ZS?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZSAll Offershttps://www.amazon.com/gp/offer-listing/B00TFT77ZS?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZShttps://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL._SL75_.jpg7075https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL._SL160_.jpg149160https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL.jpg209225https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL._SL30_.jpg2830https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL._SL75_.jpg7075https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL._SL75_.jpg7075https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL._SL110_.jpg102110https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL._SL160_.jpg149160https://images-na.ssl-images-amazon.com/images/I/21GKf0-rrQL.jpg209225ToyDouglas1713E1713076754813527307675481352730793631101207Douglas04729840.25911350USD$13.50Douglas11883617131713135811692557911713ToyTOYS_AND_GAMESDouglasDouglasDouglas Toys Louie Corgi767548135273767548135273793631101207Too low to display3600011https://www.amazon.com/gp/offer-listing/B00TFT77ZS?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B00TFT77ZSNewfHCFxJv3MoTibA%2FRnHuS2QSX7ZZ2Z2nf1inD%2BM4YUJSghN6aWFfEtMoIB3dQLLTKsqy1OInZM15kgcgQ%2B8gL5D5vEzn%2B2IDlxGr6NV1GLz0kmt3F88uAtHVIIxmZNCFRUCkpw7l6qwMvCdDtceXu9fR6u2yj1M4h1300USD$13.0050USD$0.504Usually ships in 24 hoursnow0011B005R46A6WB076ZTWL2Fhttps://www.amazon.com/Go-Pet-Club-Furniture-Beige/dp/B005R46A6W?psc=1&SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=165953&creativeASIN=B005R46A6WTechnical Detailshttps://www.amazon.com/Go-Pet-Club-Furniture-Beige/dp/tech-data/B005R46A6W?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6WAdd To Baby Registryhttps://www.amazon.com/gp/registry/baby/add-item.html?asin.0=B005R46A6W&SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6WAdd To Wedding Registryhttps://www.amazon.com/gp/registry/wedding/add-item.html?asin.0=B005R46A6W&SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6WAdd To Wishlisthttps://www.amazon.com/gp/registry/wishlist/add-item.html?asin.0=B005R46A6W&SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6WTell A Friendhttps://www.amazon.com/gp/pdp/taf/B005R46A6W?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6WAll Customer Reviewshttps://www.amazon.com/review/product/B005R46A6W?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6WAll Offershttps://www.amazon.com/gp/offer-listing/B005R46A6W?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6Whttps://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL._SL75_.jpg7550https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL._SL160_.jpg160107https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL.jpg500334https://images-na.ssl-images-amazon.com/images/I/41on4bk9K9L._SL30_.jpg3024https://images-na.ssl-images-amazon.com/images/I/41on4bk9K9L._SL75_.jpg7559https://images-na.ssl-images-amazon.com/images/I/41on4bk9K9L._SL75_.jpg7559https://images-na.ssl-images-amazon.com/images/I/41on4bk9K9L._SL110_.jpg11086https://images-na.ssl-images-amazon.com/images/I/41on4bk9K9L._SL160_.jpg160125https://images-na.ssl-images-amazon.com/images/I/41on4bk9K9L.jpg500392https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL._SL30_.jpg3020https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL._SL75_.jpg7550https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL._SL75_.jpg7550https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL._SL110_.jpg11073https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL._SL160_.jpg160107https://images-na.ssl-images-amazon.com/images/I/411Z%2Bw4hc%2BL.jpg500334Misc.Go Pet ClubBeige0852438003074085243800307452 High Beige Cat TreeMaterial: Compressed wood, faux fur, sisal ropeFor medium size catAssembly instruction and tools included* Overall Size : 21"W x 21"L x 52"H +* Base Board Size : 19"W x 19"L +* Size of Condo : 12"W x 12"L x 10"H +* Top of 2 Perches : 11.25" Dia +* Number of Ladder : 15200210023002100EnglishUnknownThe buyer will pay for all shipping, so it would be best that they live in San Diego so that there are no fees. We would be willing to make accommodations for the buyer if they reside in San Diego.4647USD$46.47Go Pet Club IncF56F56111422130272019611F56Pet ProductsPET_SUPPLIES19900212Go Pet Club Inc3 LevelsGo Pet Club IncGo Pet Club Cat Tree Furniture Beige8524380030748524380030743485USD$34.851100011https://www.amazon.com/gp/offer-listing/B005R46A6W?SubscriptionId=aws_access_key&tag=aws_associates_tag&linkCode=xm2&camp=2025&creative=386001&creativeASIN=B005R46A6WNewfHCFxJv3MoTibA%2FRnHuS2U3IIQqEB1yK4w3NabhLJ7QjEdeGtGBTYT7pdkXEjnT64aEnVu%2FkHEC8d27uaypZpRRlhW2Un6rQ3Wt4SLNc3r24HUS8Ha7VBw%3D%3D3485USD$34.851162USD$11.6225Usually ships in 24 hoursnow0011
diff --git a/spec/jobs/item_sync_job_spec.rb b/spec/jobs/item_sync_job_spec.rb new file mode 100644 index 0000000..aaefd2a --- /dev/null +++ b/spec/jobs/item_sync_job_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +describe ItemSyncJob do + describe '#perform' do + context 'for zero items' do + it 'runs successfully' do + expect { subject.perform }.not_to raise_error + end + end + + context 'for one item', :external do + let!(:corgi) { create(:item, asin: 'corgi_asin') } + + context 'when an item is unchanged' do + before { subject.perform } + + it 'keeps the original item' do + expect { subject.perform }.not_to change { corgi.reload.attributes } + end + end + + context 'when an item is changed' do + it 'updates the item name' do + expect { subject.perform }.to change { corgi.reload.name } + end + + it 'updates the item price' do + expect { subject.perform }.to change { corgi.reload.price_cents } + end + + it 'updates the item url' do + expect { subject.perform }.to change { corgi.reload.amazon_url } + end + + it 'updates the item image url' do + expect { subject.perform }.to change { corgi.reload.image_url } + end + end + end + + context 'for multiple items', :external do + let!(:item_1) { create(:item, asin: 'corgi_asin') } + let!(:item_2) { create(:item, asin: 'corgi_asin2') } + + it 'updates the first item' do + expect { subject.perform }.to change { item_1.reload.attributes } + end + + it 'updates the second item' do + expect { subject.perform }.to change { item_2.reload.attributes } + end + end + end +end diff --git a/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb b/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb index c8c9288..d1d0b52 100644 --- a/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb +++ b/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb @@ -1,51 +1,90 @@ # frozen_string_literal: true require 'amazon_product_api/item_lookup_endpoint' +require 'support/helpers/amazon_helpers' describe AmazonProductAPI::ItemLookupEndpoint do - AWSTestCredentials = Struct.new(:access_key, :secret_key, :associate_tag) - let(:aws_credentials) { AWSTestCredentials.new('aws_access_key', 'aws_secret_key', 'aws_associates_tag') } - let(:query) { - AmazonProductAPI::ItemLookupEndpoint.new('corgi_asin', aws_credentials) - } - describe '#url' do - subject(:url) { query.url } - - it { should start_with 'http://webservices.amazon.com/onca/xml' } - it { should include 'AWSAccessKeyId=aws_access_key' } - it { should include 'AssociateTag=aws_associates_tag' } - it { should include 'Operation=ItemLookup' } - it { should include 'ItemId=corgi_asin' } - it { should include 'ResponseGroup=ItemAttributes%2COffers%2CImages' } - it { should include 'Service=AWSECommerceService' } - it { should include 'Timestamp=' } - it { should include 'Signature=' } - end + context 'with one asin' do + let(:query) { + AmazonProductAPI::ItemLookupEndpoint.new('corgi_asin', aws_credentials) + } + + describe '#url' do + subject(:url) { query.url } + + it { should start_with 'http://webservices.amazon.com/onca/xml' } + it { should include 'AWSAccessKeyId=aws_access_key' } + it { should include 'AssociateTag=aws_associates_tag' } + it { should include 'Operation=ItemLookup' } + it { should include 'ItemId=corgi_asin' } + it { should include 'ResponseGroup=ItemAttributes%2COffers%2CImages' } + it { should include 'Service=AWSECommerceService' } + it { should include 'Timestamp=' } + it { should include 'Signature=' } + end + + describe '#get' do + let(:http_double) { double('http') } + + it 'should make a `get` request to the specified http library' do + expect(http_double).to receive(:get).with(String) + query.get(http: http_double) + end + end + + describe '#response', :external do + subject(:items) { query.response } + let(:item) { items.first } - describe '#get' do - let(:http_double) { double('http') } + it 'should have one entry' do + expect(items.count).to eq 1 + end - it 'should make a `get` request to the specified http library' do - expect(http_double).to receive(:get).with(String) - query.get(http: http_double) + it 'should have the item information' do + expect(item.asin).to eq 'B00TFT77ZS' + expect(item.title).to eq 'Douglas Toys Louie Corgi' + expect(item.price_cents).to eq 1350 + end end end - describe '#response', :external do - subject { query.response } + context 'with multiple asins' do + let(:query) { + AmazonProductAPI::ItemLookupEndpoint.new('corgi_asin', + 'corgi_asin2', + aws_credentials) + } + + describe '#url' do + subject(:url) { query.url } + it { should include 'ItemId=corgi_asin%2Ccorgi_asin2' } + end + + describe '#response', :external do + subject(:items) { query.response } + + it 'should have two entries' do + expect(items.count).to eq 2 + end - it { should be_a AmazonProductAPI::SearchItem } + it 'should have the item information' do + expect(items.first.asin).to eq 'B00TFT77ZS' + expect(items.second.asin).to eq 'B005R46A6W' + end + end + end - it 'should have the item information' do - expect(subject.asin).to eq 'B00TFT77ZS' - expect(subject.title).to eq 'Douglas Toys Louie Corgi' - expect(subject.price_cents).to eq 1350 + context 'for more than ten asins' do + it 'raises an argument error' do + expect { + AmazonProductAPI::ItemLookupEndpoint.new(*('1'..'11'), aws_credentials) + }.to raise_error(ArgumentError) end end end diff --git a/spec/lib/amazon_product_api/item_search_endpoint_spec.rb b/spec/lib/amazon_product_api/item_search_endpoint_spec.rb index 16ea7c9..190169d 100644 --- a/spec/lib/amazon_product_api/item_search_endpoint_spec.rb +++ b/spec/lib/amazon_product_api/item_search_endpoint_spec.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true require 'amazon_product_api/item_search_endpoint' +require 'support/helpers/amazon_helpers' describe AmazonProductAPI::ItemSearchEndpoint do - AWSTestCredentials = Struct.new(:access_key, :secret_key, :associate_tag) - let(:aws_credentials) { AWSTestCredentials.new('aws_access_key', 'aws_secret_key', diff --git a/spec/support/helpers/amazon_helpers.rb b/spec/support/helpers/amazon_helpers.rb new file mode 100644 index 0000000..8d6b22c --- /dev/null +++ b/spec/support/helpers/amazon_helpers.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Mocks an AWS Credentials object +AWSTestCredentials = Struct.new(:access_key, + :secret_key, + :associate_tag) diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb index ce128c7..5e46fc8 100644 --- a/spec/support/webmock.rb +++ b/spec/support/webmock.rb @@ -7,17 +7,34 @@ RSpec.configure do |config| config.before(:each, :external) do # Item Search + search_response = file_fixture('amazon_corgi_search_response.xml').read stub_request(:get, 'webservices.amazon.com/onca/xml') .with(query: hash_including('Operation' => 'ItemSearch', 'Keywords' => 'corgi')) .to_return(body: search_response) - # Item Lookup + # Single Item Lookup + lookup_response = file_fixture('amazon_corgi_lookup_response.xml').read + + stub_request(:get, 'webservices.amazon.com/onca/xml') + .with(query: hash_including('Operation' => 'ItemLookup', + 'ItemId' => 'corgi_asin')) + .to_return(body: lookup_response) + stub_request(:get, 'webservices.amazon.com/onca/xml') .with(query: hash_including('Operation' => 'ItemLookup', - 'ItemId' => 'corgi_asin')) + 'ItemId' => 'corgi_asin2')) .to_return(body: lookup_response) + + # Dual Item Lookup + + dual_lookup_response = file_fixture('amazon_dual_lookup_response.xml').read + + stub_request(:get, 'webservices.amazon.com/onca/xml') + .with(query: hash_including('Operation' => 'ItemLookup', + 'ItemId' => 'corgi_asin,corgi_asin2')) + .to_return(body: dual_lookup_response) end end