diff --git a/.github/workflows/ams-ci.yml b/.github/workflows/ams-ci.yml new file mode 100644 index 000000000..5154297d5 --- /dev/null +++ b/.github/workflows/ams-ci.yml @@ -0,0 +1,50 @@ +name: CI RSpec Tests + +on: [push, pull_request] + +jobs: + tests: + name: CI + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install Redis + run: sudo apt-get install -y redis-tools redis-server + + - name: Install libcurl4-openssl-dev for Curb Gem + run: sudo apt-get install libcurl4-openssl-dev + + - name: Setup Ruby and Install RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.5.3 + bundler-cache: true + + - name: Install JDK + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: '11' + + - name: Install Node + shell: bash -l -eo pipefail {0} + run: nvm install 12.9.0 + + - name: Install Chrome Browser + run: google-chrome-stable --headless --disable-gpu --no-sandbox --remote-debugging-port=9222 http://localhost & + + - name: Prepare Test Environment + run: | + cp config/travis/solr_wrapper_test.yml config/solr_wrapper_test.yml + cp config/travis/fcrepo_wrapper_test.yml config/fcrepo_wrapper_test.yml + export DISPLAY=:99.0 + RAILS_ENV=test bundle exec rake db:environment:set db:create db:migrate --trace + RAILS_ENV=test npm install yarn + RAILS_ENV=test yarn --ignore-engines install + RAILS_ENV=test bundle exec rake webpacker:compile + + - name: Run Rspec specs using CI config + run: bundle exec rake ci diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b531be476..000000000 --- a/.travis.yml +++ /dev/null @@ -1,59 +0,0 @@ -dist: xenial -language: ruby -services: -- redis-server -- xvfb -jdk: -- openjdk11 -rvm: -- 2.5.3 -addons: - chrome: stable -cache: - bundler: true - directories: - - dep_cache -before_install: -- google-chrome-stable --headless --disable-gpu --no-sandbox --remote-debugging-port=9222 - http://localhost & -- mkdir -p dep_cache -- ls -l dep_cache -- cp config/travis/solr_wrapper_test.yml config/solr_wrapper_test.yml -- cp config/travis/fcrepo_wrapper_test.yml config/fcrepo_wrapper_test.yml -- gem update bundler -- gem update --system 3.0.6 -- nvm install 12.9.0 -before_script: -- export DISPLAY=:99.0 -- RAILS_ENV=test bundle exec rake db:environment:set db:create db:migrate --trace -- RAILS_ENV=test npm install yarn -- RAILS_ENV=test yarn --ignore-engines install -- RAILS_ENV=test bundle exec rake webpacker:compile -script: -- bundle exec rake ci -deploy: -- provider: codedeploy - access_key_id: AKIAR3SRUQECDKWELTGU - secret_access_key: - secure: "vAmLUYYon/glBfFog8+xyChUMidy+5GciyNSfuNpkF+dWH+r5FYmMpA9hbfcTZhNrM9XSZxjf+heVltkz/nTNwb+U7Q35e1k1KR4NO/juf8+VNk+jwK9oQESoSeEU+Pplkl7sUCCZikqPO07aYtIPzCJy8pt2hUsA9EzxPny6vWPSZiGxghxcCqZIHmuiJJFg39Pnl8P4R8MM3EqGr3qExCEtapO4ca4s+JVr8dvhiJCUac7e0rWHSTTzGh1qTyOk1d4T6tU4edJZXc8BFzsZ4O/GETzGfA8bYiaHfizAQNcwpcQ9cP66k+XQkQ+CYRUlHlNbq6JGItkJTUGkY/MHhs3jIy58iWKVEg1dyPsOsjWs6aL4UA52Uo/uV3TPsB/GHbC52gVonzz7pqNeNPoS46RHO2ekqlerkeE488uuM0fGwamQ3JhfDwKBklZcGjzo/sxFD8IKEAieGikcuO3fYIhijhuCBUuOyGt/bCMfMh3rStKGeDRVfzCFHi+WNQY1FtSqqh6P+PNfrR0Y5j2cwKF/6cUFN0iFMZ+LWeEmduTtRDaG1tCyZ1UKRetG69zpWgVn4tmMrVdDOTyyHoE2BrD8D4qft1+690Lvji+6y5g3vqM9sl1aPZO4t21BxKSwwcNLmS/fZo114YpU9Eu9xA3ly7h36OYaei5gh1Z4jc=" - revision_type: github - application: ams-production-restored - deployment_group: ams-production-restored-DG - region: us-east-1 - on: - branch: main - ruby: 2.5.3 -- provider: codedeploy - access_key_id: AKIAR3SRUQECDKWELTGU - secret_access_key: - secure: "vAmLUYYon/glBfFog8+xyChUMidy+5GciyNSfuNpkF+dWH+r5FYmMpA9hbfcTZhNrM9XSZxjf+heVltkz/nTNwb+U7Q35e1k1KR4NO/juf8+VNk+jwK9oQESoSeEU+Pplkl7sUCCZikqPO07aYtIPzCJy8pt2hUsA9EzxPny6vWPSZiGxghxcCqZIHmuiJJFg39Pnl8P4R8MM3EqGr3qExCEtapO4ca4s+JVr8dvhiJCUac7e0rWHSTTzGh1qTyOk1d4T6tU4edJZXc8BFzsZ4O/GETzGfA8bYiaHfizAQNcwpcQ9cP66k+XQkQ+CYRUlHlNbq6JGItkJTUGkY/MHhs3jIy58iWKVEg1dyPsOsjWs6aL4UA52Uo/uV3TPsB/GHbC52gVonzz7pqNeNPoS46RHO2ekqlerkeE488uuM0fGwamQ3JhfDwKBklZcGjzo/sxFD8IKEAieGikcuO3fYIhijhuCBUuOyGt/bCMfMh3rStKGeDRVfzCFHi+WNQY1FtSqqh6P+PNfrR0Y5j2cwKF/6cUFN0iFMZ+LWeEmduTtRDaG1tCyZ1UKRetG69zpWgVn4tmMrVdDOTyyHoE2BrD8D4qft1+690Lvji+6y5g3vqM9sl1aPZO4t21BxKSwwcNLmS/fZo114YpU9Eu9xA3ly7h36OYaei5gh1Z4jc=" - revision_type: github - application: ams-demo-restore1 - deployment_group: ams-demo-restore1-DG - region: us-east-1 - on: - branch: develop - ruby: 2.5.3 -env: - matrix: - secure: bhFHxEHJJKvXc1rXvhx6ip9anTD9vEZSUO+rkXDN3M2HOV3wco2Dt8HH+7gy1fS3A8l/5+VB1LQ0vwRzykQlGARuGIFFd9y9VaPsdAdjqJbTeD6Neb4SHFu7pOEbhfCfdkU/wOLTn1HQ46bl0u33E3fFeVLRyN1vyIuvYW3o9ZHpfhni8enGC9UbQt65DHUVSUCgynutKIWK/lIiiIzxrOhySjQN3u05/W38o1nwsQLi3pWjj20SLD7U42VPK72TzIqkfs4LPcOSb9we/EMdWhIcrfqRZrC/bbVXB/56Un4ZUF/83y0dQJoglcHB7S+rRCGSx48b2ZtojG6B2vdJ96fNuDePf1YhTkolt9VxDL70AZdIiszADSPYJY4OgI4bUInl2BQvxueXQqoZjLkXSxLdHTD5ImZwfYioV3qgmdWXKdmxc6+MRlOznKXE1oHJqCtnwFC47BN4gq7VZoQHiQdpx4BMOWF13b6qGtO8pJK59bGDQPSO+eskBpZfghad3aMJ9c8+FESkDN9la8HXxlwyZDVNpysVLFFZqFcQlWQ/NG3r/e/NhmAfs3uyqi+bC4dsgg4MInfNQnzErshpfbvTXFJ29cKYnWCY9cWD0zLau2VehXHUxdaBoPaBONM8K73i3aqv/2YMZwSYAy/1egDslliOdyDb9ACKkKt/g9w= diff --git a/Gemfile b/Gemfile index 4fc7f06ba..93a9a0681 100644 --- a/Gemfile +++ b/Gemfile @@ -92,7 +92,8 @@ gem 'bootstrap-multiselect-rails' gem 'hyrax-batch_ingest', git: 'https://github.com/samvera-labs/hyrax-batch_ingest' gem 'pbcore', '~> 0.3.0' gem 'curb' -gem 'sony_ci_api', '~> 0.2.1' +# gem 'sony_ci_api', '~> 0.2.1' +gem 'sony_ci_api', github: 'WGBH-MLA/sony_ci_api_rewrite', branch: 'v0.1' # gem 'hyrax-iiif_av', '>= 0.2.0' # gem 'hyrax-iiif_av', github: 'samvera-labs/hyrax-iiif_av', branch: 'hyrax_master' gem 'webpacker' diff --git a/Gemfile.lock b/Gemfile.lock index 5fbeb3b30..2333f74c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,6 +25,15 @@ GIT rails (>= 5.1.6) rdf (>= 2.0.2, < 4.0) simple_form +GIT + remote: https://github.com/WGBH-MLA/sony_ci_api_rewrite.git + revision: f98576c7060e11cc50da0f67cf4642d2b4372517 + branch: v0.1 + specs: + sony_ci_api (0.1.0) + activesupport + faraday (~> 0.12) + faraday_middleware GIT remote: https://github.com/samvera-labs/hyrax-batch_ingest @@ -1001,7 +1010,7 @@ GEM temple (0.8.2) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - thor (0.20.3) + thor (1.1.0) thread_safe (0.3.6) tilt (2.0.10) tinymce-rails (4.9.11) @@ -1114,7 +1123,8 @@ DEPENDENCIES sidekiq simple_form (= 5.0.0) solr_wrapper (~> 2.1) - sony_ci_api (~> 0.2.1) + sony_ci_api! + sqlite3 (= 1.3.13) turbolinks (~> 5) uglifier (>= 1.3.0) web-console (>= 3.3.0) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index d8217c3be..30e0244cf 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,7 +10,6 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // -//= //= require turbolinks // // Required by Blacklight diff --git a/app/assets/javascripts/sony_ci/find_media.coffee b/app/assets/javascripts/sony_ci/find_media.coffee new file mode 100644 index 000000000..eaec78297 --- /dev/null +++ b/app/assets/javascripts/sony_ci/find_media.coffee @@ -0,0 +1,103 @@ +class FindSonyCiMediaBehavior + # Select for the search button + searchButtonSelector: '#find_sony_ci_media #search' + # Selector for the div that displays the feedback messages. + feedbackSelector: '#find_sony_ci_media #feedback' + # Selector for the text inputs that have the Sony Ci IDs. + sonyCiIdInputSelector: 'input.asset_sonyci_id' + # Selector for the button to add new Sony Ci IDs + addNewSonyCiIdButtonSelector: '.form-group.asset_sonyci_id button.add' + + constructor: (@query) -> + + # searchHandler - search Sony Ci for records matching the query and provide + # feedback to the user. + searchHandler: (event) => + event.preventDefault() + @giveFeedback('searching...') + $.ajax( + context: this, + url: "/sony_ci/api/find_media", + data: { query: @query } + ).done ( response ) -> + @giveFeedback(response.length + ' records found') + if response.length > 0 + @addFoundRecords(response) + + # fetchFilenameHandler - fetches the filename from Sony Ci given a Sony Ci ID + # from an input field. + fetchFilenameHandler: -> + $.ajax( + url: "/sony_ci/api/get_filename", + context: this, + data: { sony_ci_id: $(this).val() } + ).done(( response ) -> + $(this).parent().find('.sony_ci_filename').text(response['name']) + ).fail(( response ) -> + $(this).parent().find('.sony_ci_filename').text("Could not find Sony Ci record") + ) + + # Adds a message to give the user feedback on what's happening. + # The element is hidden at first, so set the text and reveal it. + giveFeedback: (msg) => + $(@feedbackSelector).text(msg).show() + + addFoundRecords: (records) => + # Map the sonyci_id text inputs to their values. + existingSonyCiIds = $(@sonyCiIdInputSelector).map (_, element) -> + $(element).val() + + # Map the found records to just the Sony Ci IDs. + # This is not a jQuery.map function, so the index is the 2nd arg instead of + # the first, like in the map function above. + foundSonyCiIds = records.map (record, _) -> + record['id'] + + # Subtract the existing Sony Ci Ids from the found Sony Ci IDs. + newSonyCiIds = $(foundSonyCiIds).not(existingSonyCiIds).get(); + + # For each of the new found Sony Ci IDs... + newSonyCiIds.forEach (sonyCiId, index) => + + # Insert the found Sony Ci ID into the last text input and trigger the \ + # change() event, because just setting val(x) won't do it. + $(@sonyCiIdInputSelector).last().val(sonyCiId).change() + + # If we have more Sony Ci IDs to add + if newSonyCiIds.length > index + # Add another Sony Ci ID field it by clicking the "Add another..." + # button. + $(@addNewSonyCiIdButtonSelector).click() + + # Hyrax will simply copy and append the last element, but we don't want + # values for Sony Ci ID or Filename there, so clear them out. + $(@sonyCiIdInputSelector).last().val('') + $('.sony_ci_filename').last().text('') + + # Finally, add the handler to the change() event of the input. + $(@sonyCiIdInputSelector).last().change @fetchFilenameHandler + + # apply - Attaches handlers to events. + apply: -> + # Attach the search handler to the click event of the search button. + $(@searchButtonSelector).click @searchHandler + # Attach the fetchFilenameHanlder to the change event of the inputs. + $(@sonyCiIdInputSelector).change @fetchFilenameHandler + +# When the page loads... +# NOTE: could not get $(document).on('turbolinks:load') to work on initial page +# load; reverting to $(document).ready, which seems to work more consistently. +$(document).ready -> + # This regex matches the 3rd URL segment which should be the GUID. + guid_query_str = window.location.href.match(/concern\/assets\/(.*)\//)[1] + + # Create the behavior object, passing in the GUID as the query string. + # NOTE: Sony Ci API has a 20 character limit on it's search terms, so let's + # just pass in the last 20 characters, which will be more unique than the 1st + # 20 chars due to the common prefix of "cpb-aacip-". Supposedly, Michael Potts + # from Sony Ci said that quoted search queries have no such limit, but I could + # not get that to work, nor is it mentioned in the Ci API docs anywhere. + behavior = new FindSonyCiMediaBehavior(guid_query_str.substr(-20)) + + # apply the behavior + behavior.apply() diff --git a/app/controllers/api/assets_controller.rb b/app/controllers/api/assets_controller.rb new file mode 100644 index 000000000..ae394eb9b --- /dev/null +++ b/app/controllers/api/assets_controller.rb @@ -0,0 +1,38 @@ +module API + class AssetsController < APIController + # Authenticate user before all actions. + # NOTE: For Basic HTTP auth to work: + # * the `http_authenticatable` config option for Devise must be set to true + # (see config/initializers/devise.rb). + # * The Authorization request header must be set to "Basic {cred}" where + # {cred} is the base64 encoded username:password. + # TODO: Move authn into base APIController class and make modifications so + # that the SonyCi::APIController will work with authn, which needs to be + # done. + before_action do + authenticate_user! + end + + + def show + respond_to do |format| + format.json { render json: pbcore_json } + format.xml { render xml: pbcore_xml } + end + end + + private + + def pbcore_json + @pbcore_json ||= Hash.from_xml(pbcore_xml).to_json + end + + def pbcore_xml + @pbcore_xml ||= solr_doc.export_as_pbcore + end + + def solr_doc + @solr_doc ||= SolrDocument.find(params[:id]) + end + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 5908a91df..748eba396 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -1,3 +1,15 @@ class APIController < ActionController::API + # Gives us respond_to in controller actions which we use to respond with + # JSON or PBCore XML. + include ActionController::MimeResponds + # Common API features here, e.g. auth. + rescue_from ActiveFedora::ObjectNotFoundError, with: :not_found + + private + + def not_found(error) + # TODO: render errors in the proper format: xml or json. + render text: "Not Found", status: 404 + end end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 8e589819d..832c92366 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -1,32 +1,68 @@ require 'sony_ci_api' class MediaController < ApplicationController + + rescue_from StandardError, with: :error_response_default + rescue_from Blacklight::Exceptions::RecordNotFound, with: :error_response_404 + rescue_from SonyCiApi::HttpError, with: :error_response_from_sony_ci + def show - if solr_document - if can? :show, solr_document - redirect_to download_url - else - head :forbidden - end - else - head :not_found - end + head(:forbidden) and return unless can? :show, solr_document + head(:not_found) and return unless download_url + redirect_to download_url and return end private + # Returns the download_url (aka the 'location') of the media for the Solr + # document's Sony Ci ID def download_url - @download_url ||= ci.download(solr_document['sonyci_id_ssim'][(params['part'] || 0).to_i]) + sony_ci_response['location'] + end + + # Fetches the response from the actual Sony Ci api. + def sony_ci_response + @sony_ci_response ||= ci.asset_download(sony_ci_id) + end + + # Returns the Sony Ci ID from the list of Sony Ci IDs in the multi-valued + # sonyci_id_ssim Solr field; nil if the sonyci_id_ssim field is empty. + def sony_ci_id + sony_ci_ids = solr_document['sonyci_id_ssim'] + if sony_ci_ids.present? + # `part` defaults to 0 if `params['part']` is nil. + part = params['part'].to_i + sony_ci_ids[part] + end end def ci - credentials = YAML.load(ERB.new(File.read('config/ci.yml')).result) - @ci ||= SonyCiBasic.new(credentials:credentials) + @ci ||= SonyCiApi::Client.new('config/ci.yml') end def solr_document - @solr_document ||= ActiveFedora::Base.find(params['id']).to_solr - rescue ActiveFedora::ObjectNotFoundError - nil + @solr_document ||= SolrDocument.find(params['id']) + end + + ################ + # Error handling + ################ + def error_response_default(error) + log_error error + head :internal_server_error + end + + def error_response_404(error) + log_error error + head :not_found + end + + def error_response_from_sony_ci(error) + log_error error + head error.http_status + end + + def log_error(error) + Rails.logger.error(error.class) { error.message } end end diff --git a/app/controllers/sony_ci/api_controller.rb b/app/controllers/sony_ci/api_controller.rb new file mode 100644 index 000000000..1b043622d --- /dev/null +++ b/app/controllers/sony_ci/api_controller.rb @@ -0,0 +1,93 @@ +require 'sony_ci_api' + +module SonyCi + class APIController < ::APIController + + respond_to :json + + # Specify error handlers for different kinds of errors. NOTE: for *all* + # endpoints, we *always* want to respond with JSON and an appropriate HTTP + # error, regardless of success or error. We *never* want to accidentally + # render HTML, or anything other than JSON. This is the contract with + # consumers, and if we violate it we'll break JS elsewhere. In order to + # guarantee this, each error handler must have additional rescue clauses + # to catch any additional exceptions, and render as JSON. + rescue_from StandardError, with: :handle_error + rescue_from SonyCiApi::Error, with: :handle_sony_ci_api_error + rescue_from SonyCiApi::HttpError, with: :handle_sony_ci_api_http_error + rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing_error + + # Returns a list of Sony Ci records that result from searching for + # params[:query]. + def find_media + result = sony_ci_api.workspace_search( + query: permitted_params.require(:query), + fields: return_fields, + kind: "Asset" + ) + render json: result + end + + # Returns a JSON object with the ID and Name of the Sony Ci Record if found + # from params[:sony_ci_id] + def get_filename + sony_ci_id = params.require(:sony_ci_id) + result = sony_ci_api.asset(sony_ci_id) + render json: { 'sony_ci_id' => sony_ci_id, 'name' => result['name'] } + end + + private + + # Memoized accessor for the SonyCiApi::Client instance. + def sony_ci_api + @sony_ci_api ||= SonyCiApi::Client.new('config/ci.yml') + end + + # Sony Ci will return 'id', 'name', and 'kind' plus any other fields + # specified in a comma-separated list in :fields param. An empty string + # for the :fields param will return all fields. If :fields is nil, + # specify 'createdOn' as an additional default field. + def return_fields + params[:fields] || 'createdOn' + end + + def permitted_params + params.permit(:query, :fields) + end + + # Renders generic JSON error object with 500 status. + def render_basic_error(error) + render json: { "error" => error.class.to_s, "error_message" => error.message }, status: 500 + end + + # Default error handler. + def handle_error(error) + render_basic_error(error) + rescue => secondary_error + render_basic_error(secondary_error) + end + + # Error handler for SonyCiApi::Error errors. + # Renders JSON from the error object with a default 500 status. + def handle_sony_ci_api_error(sony_ci_api_error) + render json: sony_ci_api_error.to_h, status: 500 + rescue => secondary_error + render_basic_error(secondary_error) + end + + # Error handler for SonyCiApi::HttpError errors. + # Renders JSON from the error object, with a status also from the error + # object, which will be something between 400 and 599. + def handle_sony_ci_api_http_error(sony_ci_api_http_error) + render json: sony_ci_api_http_error.to_h, status: sony_ci_api_http_error.http_status + rescue => secondary_error + render_basic_error(secondary_error) + end + + def handle_parameter_missing_error(parameter_missing_error) + render json: { "error" => "Missing Parameter", "error_message" => parameter_missing_error.message }, status: 400 + rescue => secondary_error + render_basic_error(secondary_error) + end + end +end diff --git a/app/controllers/sony_ci/webhook_logs_controller.rb b/app/controllers/sony_ci/webhook_logs_controller.rb index 11150f06c..91feb296b 100644 --- a/app/controllers/sony_ci/webhook_logs_controller.rb +++ b/app/controllers/sony_ci/webhook_logs_controller.rb @@ -1,25 +1,75 @@ class SonyCi::WebhookLogsController < ApplicationController - before_action :set_sony_ci_webhook_log, only: [:show ] + + before_action(only: :index) do + @pagination = Pagination.new( + total: SonyCi::WebhookLog.count, + page: params.fetch('page', 1), + per_page: params.fetch('per_page', 50) + ) + end # GET /sony_ci/webhook_logs # GET /sony_ci/webhook_logs.json def index - @sony_ci_webhook_logs = SonyCi::WebhookLog.all + @presenters = sony_ci_webhook_logs.map do |sony_ci_webhook_log| + SonyCi::WebhookLogPresenter.new(sony_ci_webhook_log) + end end # GET /sony_ci/webhook_logs/1 # GET /sony_ci/webhook_logs/1.json def show + respond_to do |format| + format.html do + @presenter = SonyCi::WebhookLogPresenter.new(sony_ci_webhook_log) + end + format.json + end end private - # Use callbacks to share common setup or constraints between actions. - def set_sony_ci_webhook_log - @sony_ci_webhook_log = SonyCi::WebhookLog.find(params[:id]) + def sony_ci_webhook_log + @sony_ci_webhook_log ||= SonyCi::WebhookLog.find(params[:id]) + end + + def sony_ci_webhook_logs + @sony_ci_webhook_logs ||= SonyCi::WebhookLog.all.order(sort_order).limit(per_page).offset(offset) + end + + def sort_order + { created_at: :desc } + end + + def per_page + params.fetch(:per_page, 50).to_i + end + + def offset + [0, page.to_i - 1].max * per_page + end + + def page + params.fetch(:page, 1) end - # Only allow a list of trusted parameters through. - def sony_ci_webhook_log_params - params.fetch(:sony_ci_webhook_log, {}) + class Pagination + attr_reader :total, :page, :per_page + def initialize(total:, page: 1, per_page: 50) + @total, @page, @per_page = total.to_i, page.to_i, per_page.to_i + end + + def showing + "#{lower_bound} - #{upper_bound}" + end + + private + + def lower_bound + ((page - 1) * per_page) + 1 + end + + def upper_bound + [ ( page * per_page ), total ].min + end end end diff --git a/app/controllers/sony_ci/webhooks_controller.rb b/app/controllers/sony_ci/webhooks_controller.rb index b2f44ad73..20f526757 100644 --- a/app/controllers/sony_ci/webhooks_controller.rb +++ b/app/controllers/sony_ci/webhooks_controller.rb @@ -1,5 +1,5 @@ module SonyCi - class WebhooksController < APIController + class WebhooksController < ::APIController after_action :create_webhook_log rescue_from StandardError do |error| @@ -16,16 +16,29 @@ class WebhooksController < APIController end def save_sony_ci_id - asset_admin_data_from_sony_ci_filename.update!( sonyci_id: [ sony_ci_id ] ) - render status: 200, json: { message: "success" } + asset.admin_data.update!( sonyci_id: [ sony_ci_id ] ) + # Re-save the Asset to re-index it. + # TODO: Is there a faster way to save the Sony Ci ID to the AdminData and + # re-index the Asset? + asset.save! + render status: 200, + json: { + message: "success", + guid: asset.id, + sony_ci_id: sony_ci_id + } end private - def asset_admin_data_from_sony_ci_filename - Asset.find(guid_from_sony_ci_filename).admin_data + def asset + @asset ||= Asset.find(guid_from_sony_ci_filename) end + # Returns the assumed GUID from the Sony Ci Filename. + # This assumes a naming convention of {GUID}.ext, where the GUID is the + # string before the first dot. If this convention is not followed, then + # this feature will not work. def guid_from_sony_ci_filename sony_ci_filename.sub(/\..*/, '') unless sony_ci_filename.empty? end @@ -44,9 +57,12 @@ def sony_ci_id def create_webhook_log(error: nil) webhook_log.response_headers = response.headers.to_h webhook_log.response_body = response_json + webhook_log.response_status = response.status if error webhook_log.error = error.class webhook_log.error_message = error.message + else + webhook_log.guids = [ guid_from_sony_ci_filename ] end webhook_log.save! end diff --git a/app/input/sony_ci_id_input.rb b/app/input/sony_ci_id_input.rb new file mode 100644 index 000000000..0095e1e27 --- /dev/null +++ b/app/input/sony_ci_id_input.rb @@ -0,0 +1,16 @@ +class SonyCiIdInput < MultiValueInput + def build_field(value, index) + super + sony_ci_filename_html(value).to_s.html_safe + end + + private + + def sony_ci_filename_html(sony_ci_id) + # NOTE: object.model.admin_data will be FALSE if this is a new record so + # we need to guard against that. + filename = if object.model.admin_data.present? + object.model.admin_data.sonyci_records&.fetch(sony_ci_id, nil)&.fetch('name', nil) + end + "

Filename: #{filename}

" + end +end diff --git a/app/models/admin_data.rb b/app/models/admin_data.rb index 08f26e92b..d72d88142 100644 --- a/app/models/admin_data.rb +++ b/app/models/admin_data.rb @@ -58,4 +58,23 @@ def asset(refresh: false) @asset_error = error nil end + + # Returns a hash of Sony Ci records fetched from the Sony Ci API, keyed + # by the Sony Ci ID. + def sonyci_records + # NOTE: sonyci_id (although singular) is actually a serialized array. + # We do hit the API per sonyci_id here, but over 99% of the time, there will + # be only one, and when there's more, there are not a whole bunch. + @sonyci_records ||= {}.tap do |hash| + sonyci_id.each { |id| hash[id] = sony_ci_api.asset(id) } + end + rescue => e + Rails.logger.error "Could not retrieve records from Sony Ci API.\n " \ + "#{e.class}: #{e.message}" + nil + end + + def sony_ci_api + @sony_ci_api ||= SonyCiApi::Client.new('config/ci.yml') + end end diff --git a/app/models/asset.rb b/app/models/asset.rb index 39b5d94a4..78175b8bc 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb @@ -298,6 +298,10 @@ def canonical_meta_tag canonical_meta_tag ||= find_annotation_attribute("canonical_meta_tag") end + def proxy_start_time + proxy_start_time ||= find_annotation_attribute("proxy_start_time") + end + def find_annotation_attribute(attribute) if admin_data.annotations.select { |a| a.annotation_type == attribute }.present? return admin_data.annotations.select { |a| a.annotation_type == attribute }.map(&:value) diff --git a/app/models/solr_document.rb b/app/models/solr_document.rb index a5565e111..79d44701b 100644 --- a/app/models/solr_document.rb +++ b/app/models/solr_document.rb @@ -520,6 +520,10 @@ def md5 self[Solrizer.solr_name('md5', :symbol)] end + def proxy_start_time + self[Solrizer.solr_name('proxy_start_time','ssim')] + end + def all_members(only: [], exclude: []) # Fetch members recursively and memoize. Subtract self from the list. @all_members ||= SolrDocument.get_members(self) - [ self ] diff --git a/app/models/sony_ci/webhook_log.rb b/app/models/sony_ci/webhook_log.rb index 8a401d8df..dcc8f48dc 100644 --- a/app/models/sony_ci/webhook_log.rb +++ b/app/models/sony_ci/webhook_log.rb @@ -1,6 +1,11 @@ class SonyCi::WebhookLog < ApplicationRecord - serialize :request_header, JSON + serialize :request_headers, JSON serialize :request_body, JSON - serialize :response_header, JSON + serialize :response_headers, JSON serialize :response_body, JSON + serialize :guids, Array + + validates :url, presence: true + validates :action, presence: true + validates :response_status, inclusion: { in: 200..599 } end diff --git a/app/presenters/sony_ci/webhook_log_presenter.rb b/app/presenters/sony_ci/webhook_log_presenter.rb new file mode 100644 index 000000000..72e2e8f3d --- /dev/null +++ b/app/presenters/sony_ci/webhook_log_presenter.rb @@ -0,0 +1,69 @@ +module SonyCi + class WebhookLogPresenter + + DATETIME_FORMAT = '%m/%d/%Y %I:%M:%S %P' + + attr_reader :webhook_log + + delegate :id, :url, :error, :error_message, :status, :guids, + to: :webhook_log + + def initialize(webhook_log) + raise ArgumentError, "expected first parameter to be a " \ + "SonyCi::WebhookLog but #{webhook_log.class} was " \ + "given" unless webhook_log.is_a? SonyCi::WebhookLog + @webhook_log = webhook_log + end + + def status + webhook_log.error ? "Fail" : "Success" + end + + def created_at + webhook_log.created_at.strftime(DATETIME_FORMAT) + end + + def action + WebhookLogPresenter.actions[webhook_log.action] || "None" + end + + def request_headers + return "None" unless webhook_log.request_headers + http_headers(webhook_log.request_headers) + end + + def request_body + return "None" unless webhook_log.request_body + JSON.pretty_generate(webhook_log.request_body) + end + + def response_headers + return "None" unless webhook_log.response_headers + http_headers(webhook_log.response_headers) + end + + def response_body + return "None" unless webhook_log.response_body + JSON.pretty_generate(webhook_log.response_body) + end + + private + + def http_headers(headers_hash) + headers_hash.map { |header, val| + "#{header}: #{val}" + }.join("\n") + end + + + class << self + # Returns a mapping of recognized actions from SonyCi::WebhookController + # to display text. + def actions + { + 'save_sony_ci_id' => "Link Asset to Sony Ci Media" + } + end + end + end +end diff --git a/app/services/ams/media_download/media_download_service.rb b/app/services/ams/media_download/media_download_service.rb index 93c82189e..a9b8a4f8d 100644 --- a/app/services/ams/media_download/media_download_service.rb +++ b/app/services/ams/media_download/media_download_service.rb @@ -34,8 +34,7 @@ def self.cleanup_temp_file(temp_file: nil) private def ci - credentials = YAML.load(ERB.new(File.read('config/ci.yml')).result) - @ci ||= SonyCiBasic.new(credentials:credentials) + @ci ||= SonyCiApi::Client.new('config/ci.yml') end def process_download @@ -82,7 +81,7 @@ def delete_media_files(array_of_files) end def get_sonyci_file_location(sonyci_id) - ci.download(sonyci_id) + ci.asset_download(sonyci_id)['location'] end def parse_sonyci_file_name(location) diff --git a/app/views/records/edit_fields/_default.html.erb b/app/views/records/edit_fields/_default.html.erb index 0137cbc34..3e4ee560e 100644 --- a/app/views/records/edit_fields/_default.html.erb +++ b/app/views/records/edit_fields/_default.html.erb @@ -4,4 +4,4 @@ <%= f.input key, required: f.object.required?(key), as: :hidden %> <% else %> <%= f.input key, required: f.object.required?(key), disabled:f.object.respond_to?(:disabled?) && f.object.disabled?(key), readonly:f.object.respond_to?(:readonly?) && f.object.readonly?(key) %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/records/edit_fields/_md5.html.erb b/app/views/records/edit_fields/_md5.html.erb index e5f4febeb..0ed671be8 100644 --- a/app/views/records/edit_fields/_md5.html.erb +++ b/app/views/records/edit_fields/_md5.html.erb @@ -1,2 +1 @@ - <%= f.input :md5, label: 'md5' ,required: f.object.required?(key), disabled:f.object.respond_to?(:disabled?) && f.object.disabled?(key) %> diff --git a/app/views/records/edit_fields/_sonyci_id.html.erb b/app/views/records/edit_fields/_sonyci_id.html.erb new file mode 100644 index 000000000..fc2eb7b08 --- /dev/null +++ b/app/views/records/edit_fields/_sonyci_id.html.erb @@ -0,0 +1,17 @@ +<%= javascript_include_tag "sony_ci/find_media" %> +<% asset_id = f.object.model.id %> +<% if asset_id %> +
+ Find all Sony Ci files matching <%= asset_id %> + +
+<% end %> + +<%= + f.input 'sonyci_id', as: :sony_ci_id, + input_html: { class: 'form-control' }, + wrapper_html: { class: 'multi_value' }, + disabled: f.object.respond_to?(:disabled?) && f.object.disabled?(key), + readonly: f.object.respond_to?(:readonly?) && f.object.readonly?(key), + required: f.object.required?(key) +%> diff --git a/app/views/sony_ci/webhook_logs/index.html.erb b/app/views/sony_ci/webhook_logs/index.html.erb index 77fc3af58..646f20fab 100644 --- a/app/views/sony_ci/webhook_logs/index.html.erb +++ b/app/views/sony_ci/webhook_logs/index.html.erb @@ -1,18 +1,74 @@ + +

<%= notice %>

Sony Ci Webhook Logs

- +<% if @pagination %> +
+

Showing <%= @pagination.showing %> of <%= @pagination.total %>

+

Pages: + <% ( @pagination.total / @pagination.per_page + 1 ).times do |page| %> + <%= link_to (page + 1).to_i, sony_ci_webhook_logs_path( page: page + 1, per_page: @pagination.per_page ) %> + <% end %> +

+
+<% end %> + +
- + + + + - <% @sony_ci_webhook_logs.each do |sony_ci_webhook_log| %> - - + <% @presenters.each_with_index do |presenter, i| %> + "> + + + + <% end %> diff --git a/app/views/sony_ci/webhook_logs/show.html.erb b/app/views/sony_ci/webhook_logs/show.html.erb index 8d82107d2..5c461ca22 100644 --- a/app/views/sony_ci/webhook_logs/show.html.erb +++ b/app/views/sony_ci/webhook_logs/show.html.erb @@ -1,3 +1,51 @@ + +

<%= notice %>

-<%= link_to 'Back', sony_ci_webhook_logs_path %> +
+
Action
+
<%= @presenter.action %>
+ +
Status
+
<%= @presenter.status %>
+ +
Date
+
<%= @presenter.created_at %>
+ + <% if @presenter.error %> +
Error
+
<%= @presenter.error || "None" %>
+ +
Error Message
+
<%= @presenter.error_message || "None" %>
+ <% end %> + +
URL
+
<%= @presenter.url || "Unavailable" %>
+ +
Request Headers
+
+
<%= @presenter.request_headers || "None" %>
+
+ +
Request Body
+
+
<%= @presenter.request_body || "None" %>
+
+ +
Response Headers
+
+
<%= @presenter.response_headers || "None" %>
+
+ +
Response Body
+
+
<%= @presenter.response_body || "None" %>
+
+
+ +<%= link_to 'View All', sony_ci_webhook_logs_path %> diff --git a/config/authorities/annotation_types.yml b/config/authorities/annotation_types.yml index c2d825c2f..660cbf75d 100644 --- a/config/authorities/annotation_types.yml +++ b/config/authorities/annotation_types.yml @@ -36,4 +36,6 @@ terms: - id: transcript_url term: Transcript URL - id: transcript_source - term: Transcript Source \ No newline at end of file + term: Transcript Source + - id: proxy_start_time + term: Proxy Start Time diff --git a/config/authorities/format.yml b/config/authorities/format.yml index bf4efe45e..069015896 100644 --- a/config/authorities/format.yml +++ b/config/authorities/format.yml @@ -138,7 +138,7 @@ terms: - id: 22mm film term: 22mm film - id: 28mm film - - term: 28mm film + term: 28mm film - id: 35mm film term: 35mm film - id: 70mm film @@ -181,8 +181,8 @@ terms: Betamax: Super - id: Blu-ray disc term: Blu-ray disc - - id: Cartivision - term: Cartivision + - id: Cartrivision + term: Cartrivision - id: CD term: CD - id: D1 diff --git a/config/authorities/topics.yml b/config/authorities/topics.yml index 32701ed1c..d33fa24b3 100644 --- a/config/authorities/topics.yml +++ b/config/authorities/topics.yml @@ -69,8 +69,8 @@ terms: term: News - id: Parenting term: Parenting - - id: Perfoming Arts - term: Perfoming Arts + - id: Performing Arts + term: Performing Arts - id: Philosophy term: Philosophy - id: Politics and Government diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index b3e9d6dad..980166766 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -12,4 +12,4 @@ # application.js, application.css, and all non-JS/CSS in the app/assets # folder are already added. # Rails.application.config.assets.precompile += %w( admin.js admin.css ) -Rails.application.config.assets.precompile += %w( work_actions.js work_show/work_show.css ) +Rails.application.config.assets.precompile += %w( work_actions.js work_show/work_show.css sony_ci/find_media.coffee ) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 3ada7e643..994ee151f 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -68,7 +68,7 @@ # given strategies, for example, `config.http_authenticatable = [:database]` will # enable it only for database authentication. The supported strategies are: # :database = Support basic authentication with authentication key + password - # config.http_authenticatable = false + config.http_authenticatable = true # If 401 status code should be returned for AJAX requests. True by default. # config.http_authenticatable_on_xhr = true diff --git a/config/routes.rb b/config/routes.rb index 9837e03aa..f148daa61 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,11 +1,8 @@ Rails.application.routes.draw do - -if ENV['SETTINGS__BULKRAX__ENABLED'] == 'true' - mount Bulkrax::Engine, at: '/' -end - namespace :sony_ci do - resources :webhook_logs, only: [ :index, :show ] + if ENV['SETTINGS__BULKRAX__ENABLED'] == 'true' + mount Bulkrax::Engine, at: '/' end + mount Hyrax::BatchIngest::Engine, at: '/' require 'sidekiq/web' authenticate :user, lambda { |u| u.admin? } do @@ -68,8 +65,22 @@ resources 'audits', only: [:new, :create] post "/audits/new" => "audits#create" + # Routes under /sony_ci/* namespace :sony_ci do - post '/webhooks/save_sony_ci_id', controller: 'webhooks', action: :save_sony_ci_id + # Routes for the Webhook logs. + resources :webhook_logs, only: [ :index, :show ] + + # Define routes that receive requests from Sony Ci webhooks. + post '/webhooks/save_sony_ci_id', controller: 'webhooks', + action: :save_sony_ci_id + + # Define routes for making customized requests to the Sony Ci API + get '/api/find_media', controller: 'api', action: :find_media, defaults: { format: :json } + get '/api/get_filename', controller: 'api', action: :get_filename, defaults: { format: :json } + end + + namespace :api do + resources :assets, only: [:show], defaults: { format: :json } end diff --git a/db/migrate/20210917032608_add_response_status_and_guids_to_sony_ci_webhook_logs.rb b/db/migrate/20210917032608_add_response_status_and_guids_to_sony_ci_webhook_logs.rb new file mode 100644 index 000000000..d122f215a --- /dev/null +++ b/db/migrate/20210917032608_add_response_status_and_guids_to_sony_ci_webhook_logs.rb @@ -0,0 +1,10 @@ +class AddResponseStatusAndGuidsToSonyCiWebhookLogs < ActiveRecord::Migration[5.1] + def change + change_table(:sony_ci_webhook_logs) do |t| + t.string :guids + t.integer :response_status + + t.index :guids + end + end +end diff --git a/db/schema.test.rb b/db/schema.test.rb index e69172c30..423af4832 100644 --- a/db/schema.test.rb +++ b/db/schema.test.rb @@ -645,6 +645,9 @@ t.string "error_message" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "guids" + t.integer "response_status" + t.index ["guids"], name: "index_sony_ci_webhook_logs_on_guids" end create_table "tinymce_assets", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=latin1" do |t| diff --git a/spec/controllers/api/assets_controller_spec.rb b/spec/controllers/api/assets_controller_spec.rb new file mode 100644 index 000000000..855c92cf8 --- /dev/null +++ b/spec/controllers/api/assets_controller_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe API::AssetsController, controller: true do + describe 'GET /api/assets/{id}' do + let(:password) { "abc123" } + let(:user) { create(:user, password: password) } + let(:encoded_username_and_password) { Base64.encode64("#{request_username}:#{request_password}").strip } + let(:format) { :json } + + before do + request.headers['Authorization'] = "Basic #{encoded_username_and_password}" + get :show, params: { id: asset_id }, format: format + end + + context 'when username is wrong,' do + let(:request_password) { password } + let(:request_username) { 'wrong username' } + let(:asset_id) { 'anything' } + it 'returns a 401' do + expect(response.status).to eq 401 + end + end + + context 'when password is wrong,' do + let(:request_username) { user.user_key } + let(:request_password) { 'wrong password' } + let(:asset_id) { 'anything' } + it 'returns a 401' do + expect(response.status).to eq 401 + end + end + + context 'when username and password are correct,' do + let(:request_username) { user.user_key} + let(:request_password) { password } + + context 'when an Asset exists' do + let(:asset) { create(:asset) } + let(:asset_id) { asset.id } + let(:pbcore_xml) { SolrDocument.find(asset_id).export_as_pbcore } + + context 'when the format is .json' do + let(:format) { :json } + let(:pbcore_json) { Hash.from_xml(pbcore_xml).to_json } + + it 'responds with a 200 status' do + expect(response.status).to eq 200 + end + + it 'response with the JSON for an Asset' do + expect(response.body).to eq pbcore_json + end + end + + context 'when the format is .xml' do + let(:format) { :xml } + + it 'responds with a 200 status' do + expect(response.status).to eq 200 + end + + it 'responds with the PBCore XML for an Asset' do + pbcore_xml = SolrDocument.find(asset.id).export_as_pbcore + expect(response.body).to eq pbcore_xml + end + end + end + end + end +end diff --git a/spec/controllers/media_controller_spec.rb b/spec/controllers/media_controller_spec.rb index 75f329c8f..880d54ca4 100644 --- a/spec/controllers/media_controller_spec.rb +++ b/spec/controllers/media_controller_spec.rb @@ -6,19 +6,23 @@ let(:id) { '1234' } let(:sony_ci_id) { '4567' } let(:fake_sony_ci_url) { "https://fakesonyci.com/download/#{sony_ci_id}"} - let(:fake_sony_ci_api) { instance_double("SonyCiBasic") } + let(:fake_sony_ci_api) { instance_double(SonyCiApi::Client) } + let(:fake_sony_ci_response) { + { 'id' => sony_ci_id, 'location' => fake_sony_ci_url } + } let(:fake_solr_document) { { 'sonyci_id_ssim' => [sony_ci_id] } } before do - allow(fake_sony_ci_api).to receive(:download).with(sony_ci_id).and_return(fake_sony_ci_url) + allow(fake_sony_ci_api).to receive(:asset_download).with(sony_ci_id).and_return(fake_sony_ci_response) allow(controller).to receive(:ci).and_return(fake_sony_ci_api) - allow(controller).to receive(:solr_document).and_return(fake_solr_document) + allow(SolrDocument).to receive(:find).with(id).and_return(fake_solr_document) allow(controller).to receive(:can?).with(:show, fake_solr_document).and_return(true) end context 'when no Solr document is found for the given :id' do - # Pretend like the controller failed to find the Solr document. - before { allow(controller).to receive(:solr_document).and_return(nil) } + before do + allow(SolrDocument).to receive(:find).with(id).and_raise(Blacklight::Exceptions::RecordNotFound) + end it 'a 404 HTTP status is returned' do get :show, params: { id: id } expect(response).to have_http_status 404 @@ -29,9 +33,22 @@ context 'and when user has permission to view the file' do it 'the SonyCiApi is used to fetch the media url' do get :show, params: { id: id } - expect(fake_sony_ci_api).to have_received(:download).with(sony_ci_id) + expect(fake_sony_ci_api).to have_received(:asset_download).with(sony_ci_id) expect(response).to redirect_to fake_sony_ci_url end + + context 'but the Sony Ci Api responds with any SonyCiApi::HttpError' do + let(:http_status) { rand(400..599) } + before do + allow(fake_sony_ci_api).to receive(:asset_download).and_raise(SonyCiApi::HttpError) + allow_any_instance_of(SonyCiApi::HttpError).to receive(:http_status).and_return(http_status) + end + it 'returns no response and status from SonyCiApi::HttpError#http_status' do + get :show, params: { id: id } + expect(response).to have_http_status http_status + expect(response.body).to be_empty + end + end end context 'and when user does NOT have permission to view the file' do @@ -40,6 +57,7 @@ it 'a 403 HTTP status is returned' do get :show, params: { id: id } expect(response).to have_http_status 403 + expect(response.body).to be_empty end end end diff --git a/spec/controllers/sony_ci/api_controller_spec.rb b/spec/controllers/sony_ci/api_controller_spec.rb new file mode 100644 index 000000000..e953ad2c0 --- /dev/null +++ b/spec/controllers/sony_ci/api_controller_spec.rb @@ -0,0 +1,137 @@ +require 'rails_helper' + +# TODO: move this. +RSpec.configure do |config| + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end +end + + +RSpec.describe SonyCi::APIController do + # Defind params to use in GET request. + let(:params) { { query: "foo" } } + let(:mock_sony_ci_api) { instance_double(SonyCiApi::Client) } + let(:response_body) { JSON.parse(response.body) } + + before do + # Use a mock Sony Ci API in the controller. + allow(controller).to receive(:sony_ci_api).and_return(mock_sony_ci_api) + end + + # All methods in SonyCi::APIController should catch errors raised by + # SonyCiApi::Client and render JSON with the error info, and respond with + # the proper HTTP status. + shared_examples 'error responses' do |ams_endpoint:, params:| + # This context is needed to isolate the `before` callback to specs within + # the shared examples. + context 'when errors are raised' do + let(:error_class) { StandardError } + let(:error_msg) { "bad things man, bad things" } + + before do + # Force SonyCiApi::Client to raise error_class when any method is called. + SonyCiApi::Client.instance_methods(false).each do |instance_method| + allow(mock_sony_ci_api).to receive(instance_method).with(any_args).and_raise( + error_class, + error_msg + ) + end + end + + it 'responds with a 500 status and error info' do + # make a request to ams_endpoint (param for the shared examples) + get ams_endpoint, params: params + expect(response_body['error']).to eq "StandardError" + expect(response_body['error_message']).to eq error_msg + expect(response.status).to eq 500 + end + + + context 'when a SonyCiApi::Error is raised' do + let(:error_class) { SonyCiApi::Error } + + it 'responds with a 500 http status and JSON object containing the error info' do + # make a request to ams_endpoint with params + get ams_endpoint, params: params + expect(response_body['error']).to eq "SonyCiApi::Error" + expect(response_body['error_message']).to eq error_msg + expect(response.status).to eq 500 + end + end + + context 'when a SonyCiApi::HttpError is raised' do + let(:error_class) { SonyCiApi::HttpError } + let(:status) { rand(400..599) } + + before do + allow_any_instance_of(SonyCiApi::HttpError).to receive(:http_status).and_return(status) + end + + it 'responds with the HTTP status from the error instance' do + # make a request to ams_endpoint with params + get ams_endpoint, params: params + expect(response_body['error']).to eq "SonyCiApi::HttpError" + expect(response_body['error_message']).to eq error_msg + expect(response.status).to eq status + end + end + end + end + + describe 'GET find_media' do + before do + # the find_media action calls SonyCiApi::Client#workspace_search, so mock + # that here as well. + allow(mock_sony_ci_api).to receive(:workspace_search).with( + hash_including(params) + ) + # Call the action under test. + get :find_media, params: params + end + + context 'with :query param that returns results from Sony Ci API' do + it 'returns a 200 ' do + expect(response.status).to eq 200 + end + end + + # Run the 'error responses' shared specs for this endpoint. + include_examples 'error responses', ams_endpoint: :find_media, params: { query: 'foo' } + end + + describe 'GET get_filename' do + let(:params) { { sony_ci_id: '123' } } + let(:mock_response) { + { 'sony_ci_id' => params[:sony_ci_id], 'name' => 'foo.mp4' } + } + + before do + allow(mock_sony_ci_api).to receive(:asset).with(params[:sony_ci_id]).and_return( + mock_response + ) + end + + # Only use response_body after the request has been made, otherwise it won't + # have the real response in it. + let(:response_body) { JSON.parse(response.body) } + + it 'gets the filename for a given Sony Ci ID' do + get :get_filename, params: params + expect(response_body).to eq mock_response + expect(response.status).to eq 200 + end + + context 'when missing the :sony_ci_id param' do + let(:params) { {} } + + it 'returns a JSON for 400 Bad Request and says which param is missing' do + get :get_filename, params: params + expect(response_body['error_message']).to include 'sony_ci_id' + expect(response.status).to eq 400 + end + end + + include_examples 'error responses', ams_endpoint: :get_filename, params: { sony_ci_id: '123' } + end +end diff --git a/spec/controllers/sony_ci/webhook_logs_controller_spec.rb b/spec/controllers/sony_ci/webhook_logs_controller_spec.rb index bb7a787d4..e8b6c00f3 100644 --- a/spec/controllers/sony_ci/webhook_logs_controller_spec.rb +++ b/spec/controllers/sony_ci/webhook_logs_controller_spec.rb @@ -1,58 +1,22 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe SonyCi::WebhookLogsController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # SonyCi::WebhookLog. As you add validations to SonyCi::WebhookLog, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip("Add a hash of attributes valid for your model") - } - - let(:invalid_attributes) { - skip("Add a hash of attributes invalid for your model") - } - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # SonyCi::WebhookLogsController. Be sure to keep this updated too. - let(:valid_session) { {} } + render_views describe "GET #index" do it "returns a success response" do - SonyCi::WebhookLog.create! valid_attributes - get :index, params: {}, session: valid_session expect(response).to be_successful end end describe "GET #show" do + let(:webhook_log) { create(:sony_ci_webhook_log) } it "returns a success response" do - webhook_log = SonyCi::WebhookLog.create! valid_attributes - get :show, params: {id: webhook_log.to_param}, session: valid_session + get :show, params: { id: webhook_log.to_param } + + + expect(response).to be_successful end end diff --git a/spec/controllers/sony_ci/webhooks_controller_spec.rb b/spec/controllers/sony_ci/webhooks_controller_spec.rb index daa8fc0dd..defa296a8 100644 --- a/spec/controllers/sony_ci/webhooks_controller_spec.rb +++ b/spec/controllers/sony_ci/webhooks_controller_spec.rb @@ -1,24 +1,18 @@ require 'rails_helper' RSpec.describe SonyCi::WebhooksController do - - # non-memoized shortcut to random hex string - def randhex(len=32) - len.times.map { rand(15).to_s(16) } - end - describe 'POST save_sony_ci_id' do - let(:sony_ci_id) { randhex } + let(:sony_ci_id) { Faker::Number.hexadecimal(16) } let(:asset) { create(:asset) } let(:sony_ci_filename) { "#{asset.id}.mp4" } let(:request_body) { { - "id" => randhex, + "id" => Faker::Number.hexadecimal(16), "type" => "AssetProcessingFinished", "createdOn" => Time.now.utc.iso8601, "createdBy" => { - "id" => randhex, + "id" => Faker::Number.hexadecimal(16), "name" => "John Smith", "email" => "johnsmith@example.com" }, @@ -42,23 +36,27 @@ def randhex(len=32) after do expect(latest_webhook_log.request_body).to eq request_body expect(latest_webhook_log.response_body).to eq response_body + expect(latest_webhook_log.response_status).to eq 200 end it 'returns a 200 ' \ - 'and returns a success message ' \ - 'and saves the Sony Ci ID to the Asset ' \ - 'and creates a WebhookRequest record for logging' do + 'and returns a success message, ' \ + 'and saves the Sony Ci ID to the Asset, ' \ + 'and creates a WebhookLog record for logging containing the GUID' do expect(response.status).to eq 200 expect(response_body['message']).to match /success/ expect(asset.admin_data.reload.sonyci_id).to eq [ sony_ci_id ] + expect(latest_webhook_log.guids).to eq [ asset.id ] end context 'when the uploaded filename does not resolve to an Asset' do let(:sony_ci_filename) { "does-not-match-asset.mp4" } - it 'returns a 200 to avoid Sony Ci retries' \ - 'and returns an error message in the body' do + it 'responds with a 200 to avoid Sony Ci retries, ' \ + 'and responds with the error message, ' \ + 'and does NOT save the GUID to the WebhookLog record' do expect(response.status).to eq 200 expect(response_body['error']).not_to be_empty + expect(latest_webhook_log.guids).to be_empty end end end diff --git a/spec/factories/sony_ci/webhook_log.rb b/spec/factories/sony_ci/webhook_log.rb new file mode 100644 index 000000000..fb209f1ac --- /dev/null +++ b/spec/factories/sony_ci/webhook_log.rb @@ -0,0 +1,53 @@ +FactoryBot.define do + factory :sony_ci_webhook_log, class: 'SonyCi::WebhookLog' do + url { Faker::Internet.url } + action { SonyCi::WebhooksController.action_methods.to_a.sample } + request_headers { { 'Content-Type' => 'application/json' } } + request_body { + { + "id": "6vzdrfgzff0teglw", + "type": "AssetProcessingFinished", + "createdOn": "2017-01-02T00:00:00.000Z", + "createdBy": { + "id": "c460dfc1447f4240b14b2f32ce8d4a5f", + "name": "John Smith", + "email": "johnsmith@example.com" + }, + "assets": [ + { + "id": "kayc4skb5dkk49k7", + "name": "Movie.mov" + } + ] + } + } + + # Response headers should always include Content-type of application/json. + response_headers { { "Content-Type" => "application/json" } } + + # NOTE: the response status does not necesarily mean that no error occurred. + # Rather, returning a 2xx status tells Sony Ci not to try the request again + # which is what we want in nearly every use case. + response_status { 200 } + + # Randomly assing an error. + error { [true, false].sample ? "FakeError" : nil } + + # Almost always, there is only 1 GUID, but need to allow for multiple. + guids { [ "cpb-aacip-#{Faker::Number.hexadecimal(11)}" ] } + + after(:build) do |webhook_log| + if webhook_log.error? + # Add an error message if we have an error. + webhook_log.error_message = Faker::Lorem.sentence if webhook_log.error? + # TODO: This should match what is actually returned by + # SonyCi::WebhooksController for the given error. + webhook_log.response_body = { "error" => webhook_log.error_message } + else + # TODO: This should match what is actually returned by + # SonyCi::WebhooksController for the given action. + webhook_log.response_body = { "success" => true } + end + end + end +end diff --git a/spec/factories/sony_ci_webhook_logs.rb b/spec/factories/sony_ci_webhook_logs.rb deleted file mode 100644 index 7d33ac262..000000000 --- a/spec/factories/sony_ci_webhook_logs.rb +++ /dev/null @@ -1,5 +0,0 @@ -FactoryBot.define do - factory :sony_ci_webhook_log, class: 'SonyCi::WebhookLog' do - - end -end diff --git a/spec/features/update_admin_data_spec.rb b/spec/features/update_admin_data_spec.rb index 8587c7743..455e13d0d 100644 --- a/spec/features/update_admin_data_spec.rb +++ b/spec/features/update_admin_data_spec.rb @@ -3,7 +3,7 @@ # This is really a test of work done in the AssetActor # Actor classes are very hard to test due to attempting to mock the entire environment, # so this indirectly and imperfectly tests our saving expections -RSpec.feature 'Update AdminData', js: true, asset_form_helpers: true, clean: true do +RSpec.feature 'Update AdminData', asset_form_helpers: true, clean: true do context 'Create adminset, create asset' do let(:admin_user) { create :admin_user } let(:admin_set_id) { AdminSet.find_or_create_default_admin_set_id } @@ -11,12 +11,24 @@ let!(:workflow) { Sipity::Workflow.create!(active: true, name: 'test-workflow', permission_template: permission_template) } let!(:admindata) { create(:admin_data, :empty)} let!(:asset) { FactoryBot.create(:asset, with_admin_data: admindata.gid) } + let(:fake_sonyci_id) { rand(999999) } + let(:fake_sonyci_records) { + { fake_sonyci_id: { 'id' => fake_sonyci_id, 'name' => 'foo' } } + } let(:admin_data_string_attributes) { { - "Sony's Ci ID" => "1a2b3c4d5e" + "Sony's Ci ID" => fake_sonyci_id } } + before do + # It's not apparent, but this is required to avoid errors. + # When the Sony Ci ID input is rendered, AdminData#sonyci_records is + # called. which calls SonyCiApi::Client#asset to fetch the records. + # NOTE: confusingly, the Sony Ci API refers to the records as 'assets'. + allow_any_instance_of(SonyCiApi::Client).to receive(:asset).with(fake_sonyci_id.to_s).and_return(fake_sonyci_records) + end + scenario 'Update AdminData on Asset' do Sipity::WorkflowAction.create!(name: 'submit', workflow: workflow) Hyrax::PermissionTemplateAccess.create!( diff --git a/spec/models/asset_spec.rb b/spec/models/asset_spec.rb index fefe1e1c4..133bac2c3 100644 --- a/spec/models/asset_spec.rb +++ b/spec/models/asset_spec.rb @@ -6,7 +6,6 @@ let(:asset) { build(:asset) } context 'properties' do - subject { build :asset } it { is_expected.to have_property(:bulkrax_identifier).with_predicate("http://ams2.wgbh-mla.org/resource#bulkraxIdentifier") } @@ -45,16 +44,33 @@ it { is_expected.to have_property(:producing_organization).with_predicate(::RDF::Vocab::DC11.creator) } end - context "admin_data_gid" do + context "with AdminData" do let(:admin_data) { FactoryBot.create(:admin_data) } - let(:asset) { FactoryBot.build(:asset, with_admin_data:admin_data.gid) } - it "has admin_data_gid" do - expect(asset).to have_property(:admin_data_gid).with_predicate(/pbcore.org#hasAAPBAdminData/) - expect(asset.admin_data_gid).to eq(admin_data.gid) + let(:annotation) { FactoryBot.create(:annotation, admin_data_id: admin_data.id)} + let(:asset) { FactoryBot.build(:asset, with_admin_data: admin_data.gid) } + + describe ".admin_data_gid" do + it 'returns the expected AdminData' do + expect(asset).to have_property(:admin_data_gid).with_predicate(/pbcore.org#hasAAPBAdminData/) + expect(asset.admin_data_gid).to eq(admin_data.gid) + end + + it "returns ActiveRecord::RecordNotFound if cannot find admin_data for the gid" do + gid = 'gid://ams/admindata/999' + expect { asset.admin_data_gid = gid }.to raise_error(ActiveRecord::RecordNotFound, "Couldn't find AdminData matching GID #{gid}") + end end - it "has throws ActiveRecord::RecordNotFound if cannot find admin_data for the gid" do - gid = 'gid://ams/admindata/999' - expect { asset.admin_data_gid = gid }.to raise_error(ActiveRecord::RecordNotFound, "Couldn't find AdminData matching GID #{gid}") + + describe ".find_admin_data_attribute" do + it 'returns the expected value' do + expect(asset.find_admin_data_attribute("sonyci_id")).to eq(admin_data.sonyci_id) + end + end + + describe '.find_annotation_attribute' do + it 'returns the expected value' do + expect(asset.find_annotation_attribute(annotation.annotation_type)).to eq([annotation.value]) + end end end diff --git a/spec/models/sony_ci/webhook_log_spec.rb b/spec/models/sony_ci/webhook_log_spec.rb index 502a0fb82..bfbc4544e 100644 --- a/spec/models/sony_ci/webhook_log_spec.rb +++ b/spec/models/sony_ci/webhook_log_spec.rb @@ -1,5 +1,42 @@ require 'rails_helper' RSpec.describe SonyCi::WebhookLog, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'validation' do + subject { build(:sony_ci_webhook_log) } + context 'when all fields are valid' do + it { is_expected.to be_valid } + end + + context 'when response_status not a number between 200 and 599' do + before { subject.response_status = 'something bogus' } + it { is_expected.to_not be_valid } + end + + context 'when URL is empty' do + it 'is invalid' do + expect(build(:sony_ci_webhook_log, url: nil)).to_not be_valid + expect(build(:sony_ci_webhook_log, url: '')).to_not be_valid + end + end + + context 'when URL is empty string' do + before { subject.url = '' } + it { is_expected.to_not be_valid } + end + + context 'when action is empty' do + it 'is invalid' do + expect(build(:sony_ci_webhook_log, action: nil)).to_not be_valid + expect(build(:sony_ci_webhook_log, action: '')).to_not be_valid + end + end + end + + describe '#guids' do + let(:guids) { [ 'cpb-aacip-1234', 'cpb-aacip-5678'] } + let(:webhook_log) { create(:sony_ci_webhook_log, guids: guids) } + it 'stores a list of GUIDs as a serialized array' do + expect(SonyCi::WebhookLog.find(webhook_log.id).guids).to eq guids + end + end end diff --git a/spec/presenters/sony_ci/webhook_log_presenter_spec.rb b/spec/presenters/sony_ci/webhook_log_presenter_spec.rb new file mode 100644 index 000000000..0505d0ef1 --- /dev/null +++ b/spec/presenters/sony_ci/webhook_log_presenter_spec.rb @@ -0,0 +1,44 @@ +# Generated via +# `rails generate hyrax:work Asset` +require 'rails_helper' + +RSpec.describe SonyCi::WebhookLogPresenter do + + let(:webhook_log) { + create(:sony_ci_webhook_log, action: 'save_sony_ci_id') + } + let(:presenter) do + described_class.new(webhook_log) + end + + describe '#created_at' do + it 'returns the created_at timestamp field formatted to mm/dd/yyyy hh:mm:ss am/pm' do + expected_date_str = webhook_log.created_at.strftime(described_class::DATETIME_FORMAT) + expect(presenter.created_at).to eq expected_date_str + end + end + + describe '#action' do + it 'returns a user-friendly name of the action performed' do + expect(presenter.action).to eq 'Link Asset to Sony Ci Media' + end + end + + describe '#status' do + context 'when there is an error present' do + let(:webhook_log) { create(:sony_ci_webhook_log, error: "Some error") } + it 'returns "Fail"' do + expect(presenter.status).to eq "Fail" + end + end + end + + describe '#status' do + context 'when there is not an error present' do + let(:webhook_log) { create(:sony_ci_webhook_log, error: nil) } + it 'returns "Success"' do + expect(presenter.status).to eq "Success" + end + end + end +end diff --git a/spec/services/ams/media_download/media_download_service_spec.rb b/spec/services/ams/media_download/media_download_service_spec.rb index 7249d3ded..c914da041 100644 --- a/spec/services/ams/media_download/media_download_service_spec.rb +++ b/spec/services/ams/media_download/media_download_service_spec.rb @@ -5,10 +5,19 @@ let(:admin_data) { create(:admin_data, :one_sony_ci_id) } let(:asset) { create(:asset, with_admin_data: admin_data.gid) } - let(:fake_sony_ci_url) { "https://fake_sony_ci_url/cifiles/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4?response-content-disposition=attachment%3bfilename%3d%22cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4"} + # Pared down response from Sony Ci. For this spec, we just really need the + # 'id' and 'location'. + let(:fake_sony_ci_api_result) { + { + 'id' => sonyci_id, + 'location' => "https://fake_sony_ci_url/cifiles/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4?response-content-disposition=attachment%3bfilename%3d%22cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4" + } + } + # let(:fake_sony_ci_url) { "https://fake_sony_ci_url/cifiles/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4?response-content-disposition=attachment%3bfilename%3d%22cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4"} let(:spec_media_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4' ) } - let(:fake_sony_ci_api) { instance_double("SonyCiBasic") } + let(:fake_sony_ci_api) { instance_double(SonyCiApi::Client) } let(:solr_doc) { SolrDocument.new(asset.to_solr) } + let(:sonyci_id) { solr_doc['sonyci_id_ssim'].first } let(:service) do described_class.new(solr_document: solr_doc) @@ -17,7 +26,7 @@ before do allow(service).to receive(:ci).and_return(fake_sony_ci_api) allow(service).to receive(:generate_sonyci_file_path).with('cpb-aacip-15-hd7np1wp4c__barcode163700_.h264.mp4').and_return(spec_media_file_path) - allow(service).to receive(:download_media_file).with(spec_media_file_path, fake_sony_ci_url) + allow(service).to receive(:download_media_file).with(spec_media_file_path, fake_sony_ci_api_result['location']) allow(service).to receive(:delete_media_files) end @@ -25,7 +34,7 @@ context "with a single Sony Ci ID" do context "during a successful download" do before do - allow(fake_sony_ci_api).to receive(:download).with(/Sony-\d{1}/).and_return(fake_sony_ci_url) + allow(fake_sony_ci_api).to receive(:asset_download).with(/Sony-\d{1}/).and_return(fake_sony_ci_api_result) end it "returns the expected Success object" do @@ -39,7 +48,7 @@ context "during an unsuccessful download" do before do - allow(fake_sony_ci_api).to receive(:download).with(/Sony-\d{1}/).and_raise(RuntimeError.new("NO VIDEO!!!")) + allow(fake_sony_ci_api).to receive(:asset_download).with(sonyci_id).and_raise(RuntimeError.new("NO VIDEO!!!")) end it "returns the expected Failure object" do diff --git a/spec/views/sony_ci/webhook_logs/index.html.erb_spec.rb b/spec/views/sony_ci/webhook_logs/index.html.erb_spec.rb index 25a3ab6e6..77146bc36 100644 --- a/spec/views/sony_ci/webhook_logs/index.html.erb_spec.rb +++ b/spec/views/sony_ci/webhook_logs/index.html.erb_spec.rb @@ -1,14 +1,26 @@ require 'rails_helper' RSpec.describe "sony_ci/webhook_logs/index", type: :view do + let(:webhook_logs) { create_list(:sony_ci_webhook_log, rand(3..7)) } + let(:presenters) { + webhook_logs.map { |webhook_log| + SonyCi::WebhookLogPresenter.new(webhook_log) + } + } + before(:each) do - assign(:sony_ci_webhook_logs, [ - SonyCi::WebhookLog.create!(), - SonyCi::WebhookLog.create!() - ]) + assign(:presenters, presenters) + render end it "renders a list of sony_ci/webhook_logs" do - render + presenters.each do |presenter| + expect(rendered).to include presenter.created_at + expect(rendered).to include presenter.action + expect(rendered).to include presenter.status + presenter.guids.each do |guid| + expect(rendered).to include guid + end + end end end diff --git a/spec/views/sony_ci/webhook_logs/show.html.erb_spec.rb b/spec/views/sony_ci/webhook_logs/show.html.erb_spec.rb index 0ccae64ef..e590d32b8 100644 --- a/spec/views/sony_ci/webhook_logs/show.html.erb_spec.rb +++ b/spec/views/sony_ci/webhook_logs/show.html.erb_spec.rb @@ -1,11 +1,17 @@ require 'rails_helper' RSpec.describe "sony_ci/webhook_logs/show", type: :view do + let(:webhook_log) { create(:sony_ci_webhook_log) } + let(:presenter) { SonyCi::WebhookLogPresenter.new(webhook_log) } before(:each) do - @sony_ci_webhook_log = assign(:sony_ci_webhook_log, SonyCi::WebhookLog.create!()) + assign(:presenter, presenter) + render end - it "renders attributes in

" do - render + it 'displays the Date, Action, URL, Request Headers, Request Body, ' \ + 'Response Headers, Response Body, and the Error, if present' do + expect(rendered).to include presenter.created_at + expect(rendered).to include presenter.action + expect(rendered).to include presenter.url end end

Date/TimeActionStatusGUIDs
<%= link_to 'Show', sony_ci_webhook_log %>
<%= presenter.created_at %> + <%= link_to presenter.action, sony_ci_webhook_log_path(presenter.id) %> + <%= presenter.status %> +
    + <% presenter.guids.each_with_index do |guid, i| %> +
  • <%= link_to guid, hyrax_asset_path(guid) %>
  • + <% end %> +
+