From 490408683c38ef0f1b1f4e1ceb48f9c82911ae9b Mon Sep 17 00:00:00 2001 From: Wayne Weibel Date: Thu, 28 Mar 2024 10:06:20 -0400 Subject: [PATCH] Centralized Lighthouse BenefitsIntake API Service --- lib/lighthouse/benefits_intake/service.rb | 149 +++++++++ .../benefits_intake/service_spec.rb | 287 ++++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 lib/lighthouse/benefits_intake/service.rb create mode 100644 spec/lib/lighthouse/benefits_intake/service_spec.rb diff --git a/lib/lighthouse/benefits_intake/service.rb b/lib/lighthouse/benefits_intake/service.rb new file mode 100644 index 00000000000..6a9830f3866 --- /dev/null +++ b/lib/lighthouse/benefits_intake/service.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'common/client/base' +require 'lighthouse/benefits_intake/configuration' +require 'lighthouse/benefits_intake/metadata' + +module BenefitsIntake + ## + # Proxy Service for the Lighthouse Claims Intake API Service. + # We are using it here to submit claims that cannot be auto-established, + # via paper submission (electronic PDF submissiont to CMP) + # + # https://developer.va.gov/explore/api/benefits-intake/docs + # + class Service < Common::Client::Base + configuration BenefitsIntake::Configuration + + class InvalidDocumentError < StandardError; end + + STATSD_KEY_PREFIX = 'api.benefits_intake' + + attr_reader :location, :uuid + + ## + # Perform the upload to BenefitsIntake + # parameters should be run through validation functions first, to prevent downstream processing errors + # + # @param [Hash] metadata + # @param [String] document + # @param [Array] attachments; optional, default = [] + # @param [String] upload_url; optional, default = @location + # + def perform_upload(metadata:, document:, attachments: [], upload_url: nil) + upload_url, _uuid = request_upload unless upload_url + + meta_tmp = Common::FileHelpers.generate_temp_file(metadata.to_s, "#{STATSD_KEY_PREFIX}.#{@uuid}.metadata.json") + + params = {} + params[:metadata] = Faraday::UploadIO.new(meta_tmp, Mime[:json].to_s, 'metadata.json') + params[:content] = Faraday::UploadIO.new(document, Mime[:pdf].to_s, File.basename(document)) + attachments.each.with_index do |attachment, i| + params[:"attachment#{i + 1}"] = Faraday::UploadIO.new(attachment, Mime[:pdf].to_s, File.basename(attachment)) + end + + perform :put, upload_url, params, { 'Content-Type' => 'multipart/form-data' } + end + + ## + # Instantiates a new location and uuid for upload to BenefitsIntake + # + # @param [Boolean] refresh + # + def request_upload(refresh: false) + if !@uploads || refresh + @uploads = perform :post, 'uploads', {}, {} + + @location = @uploads.body.dig('data', 'attributes', 'location') + @uuid = @uploads.body.dig('data', 'id') + end + + [@location, @uuid] + end + + ## + # Get the status for a pervious upload + # + # @param [String] uuid + # + def get_status(uuid:) + headers = { 'accept' => Mime[:json].to_s } + perform :get, "uploads/#{uuid}", {}, headers + end + + ## + # Get the status for a set of prior uploads + # + # @param [Array] uuids + # + def bulk_status(uuids:) + headers = { 'Content-Type' => Mime[:json].to_s, 'accept' => Mime[:json].to_s } + data = { uuids: }.to_json + perform :post, 'uploads/report', data, headers + end + + ## + # Download a zip of 'what the server sees' for a previous upload + # + # @param [String] uuid + # + def download(uuid:) + headers = { 'accept' => Mime[:zip].to_s } + perform :get, "uploads/#{uuid}/download", {}, headers + end + + ## + # Validate the metadata satisfies BenefitsIntake specifications. + # @see BenefitsIntake::Metadata.validate + # + # @param [Hash] metadata + # + # @returns [Hash] validated and corrected metadata + # + def valid_metadata?(metadata:) + BenefitsIntake::Metadata.validate(metadata) + end + + ## + # Validate a file satisfies BenefitsIntake specifications. File must be a PDF. + # + # @param [String] document + # @param [Hash] headers; optional, default nil + # + def valid_document?(document:, headers: nil) + doc = File.read(document, mode: 'rb') + + doc_mime = Marcel::MimeType.for(doc) + raise TypeError, "Invalid Document MimeType: #{doc_mime}" if doc_mime != Mime[:pdf].to_s + + headers = (headers || {}).merge({ 'Content-Type': doc_mime }) + response = perform :post, 'uploads/validate_document', doc, headers + + raise InvalidDocumentError, "Invalid Document: #{response}" unless response.success? + + document + end + + ## + # Validate the upload meets BenefitsIntake specifications. + # + # @param [Hash] metadata + # @param [String] document + # @param [Array] attachments; optional, default [] + # @param [Hash] headers; optional, default nil + # + # @return [Hash] payload for upload + # + def valid_upload?(metadata:, document:, attachments: [], headers: nil) + { + metadata: valid_metadata?(metadata:), + document: valid_document?(document:, headers:), + attachments: attachments.map { |attachment| valid_document?(document: attachment, headers:) } + } + end + + # end Service + end + + # end BenefitsIntake +end diff --git a/spec/lib/lighthouse/benefits_intake/service_spec.rb b/spec/lib/lighthouse/benefits_intake/service_spec.rb new file mode 100644 index 00000000000..cb593e5f47b --- /dev/null +++ b/spec/lib/lighthouse/benefits_intake/service_spec.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'common/file_helpers' +require 'lighthouse/benefits_intake/service' + +RSpec.describe BenefitsIntake::Service do + let(:service) { BenefitsIntake::Service.new } + let(:metadata) do + { + 'veteranFirstName' => 'firstname', + 'veteranLastName' => 'lastname', + 'fileNumber' => '123456789', + 'zipCode' => '12345-5555', + 'source' => 'source', + 'docType' => 'doc_type', + 'businessLine' => 'BVA' + } + end + let(:upload) do + OpenStruct.new({ + body: { + 'data' => { + 'id' => 'uuid-for-the-upload', + 'attributes' => { + 'location' => 'upload-url-location' + } + } + } + }) + end + let(:mime_pdf) { Mime[:pdf].to_s } + let(:mime_json) { Mime[:json].to_s } + + before do + allow(service).to receive(:perform) + end + + describe '#perform_upload' do + let(:args) do + { + metadata: 'metadata', + document: 'file-path', + attachments: %w[attachment-path1 attachment-path2] + # upload_url: nil, # force call to #request_upload + } + end + + let(:expected_params) do + { + metadata: 'a-file-io-object', + content: 'a-file-io-object', + attachment1: 'a-file-io-object', + attachment2: 'a-file-io-object' + } + end + + let(:headers) { { 'Content-Type' => 'multipart/form-data' } } + + before do + service.instance_variable_set(:@uploads, true) + service.instance_variable_set(:@location, 'location') + service.instance_variable_set(:@uuid, 'uuid') + + allow(Common::FileHelpers).to receive(:generate_temp_file).and_return 'a-temp-file' + allow(Faraday::UploadIO).to receive(:new).and_return 'a-file-io-object' + end + + it 'performs the upload' do + expect(Common::FileHelpers).to( + receive(:generate_temp_file).once.with('metadata', 'api.benefits_intake.uuid.metadata.json') + ) + + expect(Faraday::UploadIO).to receive(:new).once.with('a-temp-file', mime_json, 'metadata.json') + expect(Faraday::UploadIO).to receive(:new).once.with('file-path', mime_pdf, 'file-path') + expect(Faraday::UploadIO).to receive(:new).once.with('attachment-path1', mime_pdf, 'attachment-path1') + expect(Faraday::UploadIO).to receive(:new).once.with('attachment-path2', mime_pdf, 'attachment-path2') + + expect(service).to receive(:perform).with(:put, 'location', expected_params, headers) + service.perform_upload(**args) + end + + it 'performs the upload to a different url' do + args[:upload_url] = 'another-location' + expect(service).not_to receive(:request_upload) + expect(service).to receive(:perform).with(:put, 'another-location', expected_params, headers) + service.perform_upload(**args) + end + end + + describe '#request_upload' do + it 'instantiates and returns location and uuid' do + allow(service).to receive(:perform).and_return(upload) + + expect(service).to receive(:perform).with(:post, 'uploads', {}, {}) + + location, uuid = service.request_upload + + expect(location).to eq('upload-url-location') + expect(uuid).to eq('uuid-for-the-upload') + expect(service.location).to eq(location) + expect(service.uuid).to eq(uuid) + end + + context 'existing instance variables' do + before do + service.instance_variable_set(:@uploads, true) + service.instance_variable_set(:@location, 'location') + service.instance_variable_set(:@uuid, 'uuid') + end + + it 'returns existing instance values' do + expect(service).not_to receive(:perform) + + location, uuid = service.request_upload + + expect(location).to eq('location') + expect(uuid).to eq('uuid') + end + + it 're-instantiates and return location and uuid' do + allow(service).to receive(:perform).and_return(upload) + + expect(service).to receive(:perform).with(:post, 'uploads', {}, {}) + + location, uuid = service.request_upload(refresh: true) + + expect(location).to eq('upload-url-location') + expect(uuid).to eq('uuid-for-the-upload') + expect(service.location).to eq(location) + expect(service.uuid).to eq(uuid) + end + end + end + + describe '#get_status' do + it 'gets an upload status' do + uuid = '12345TEST' + headers = { 'accept' => mime_json } + + expect(service).to receive(:perform).with(:get, "uploads/#{uuid}", {}, headers) + service.get_status(uuid:) + end + end + + describe '#bulk_status' do + it 'requests a status report' do + uuids = ['12345TEST', '6789FOO', 'BAR!'] + headers = { 'Content-Type' => mime_json, 'accept' => mime_json } + data = { uuids: }.to_json + + expect(service).to receive(:perform).with(:post, 'uploads/report', data, headers) + + service.bulk_status(uuids:) + end + end + + describe '#download' do + it 'gets the download' do + uuid = '12345TEST' + headers = { 'accept' => Mime[:zip].to_s } + + expect(service).to receive(:perform).with(:get, "uploads/#{uuid}/download", {}, headers) + service.download(uuid:) + end + end + + describe '#valid_metadata?' do + it 'returns valid metadata' do + data = service.valid_metadata?(metadata:) + expect(data).to eq(metadata) + end + + context 'invalid metadata' do + it 'errors on missing field' do + expect do + service.valid_metadata?(metadata: {}) + end.to raise_error(ArgumentError, 'veteran first name is missing') + end + + it 'errors on non-string field' do + expect do + service.valid_metadata?(metadata: { 'veteranFirstName' => 42 }) + end.to raise_error(ArgumentError, 'veteran first name is not a string') + end + + it 'errors on blank field' do + expect do + service.valid_metadata?(metadata: { 'veteranFirstName' => '' }) + end.to raise_error(ArgumentError, 'veteran first name is blank') + + expect do + service.valid_metadata?(metadata: { 'veteranFirstName' => ' ' }) + end.to raise_error(ArgumentError, 'veteran first name is blank') + + expect do + service.valid_metadata?(metadata: { 'veteranFirstName' => '23&_$!42' }) + end.to raise_error(ArgumentError, 'veteran first name is blank') + end + end + end + + describe '#valid_document?' do + let(:document) { 'fake-file-path' } + + context 'a valid file' do + before do + allow(File).to receive(:read).and_return('test-file-read') + allow(Marcel::MimeType).to receive(:for).and_return(mime_pdf) + allow(service).to receive(:perform).and_return OpenStruct.new({ success?: true }) + end + + it 'returns document path' do + expect(File).to receive(:read).once.with(document, mode: 'rb') + expect(Marcel::MimeType).to receive(:for).once.with('test-file-read') + expect(service).to receive(:perform).once.with(:post, 'uploads/validate_document', 'test-file-read', anything) + + expect(service.valid_document?(document:)).to eq(document) + end + end + + context 'an invalid file' do + it 'errors reading a missing file' do + expect do + service.valid_document?(document:) + end.to raise_error SystemCallError, /#{document}/ + end + + it 'errors if not a PDF' do + allow(File).to receive(:read).and_return('test-file-read') + allow(Marcel::MimeType).to receive(:for).and_return('not-a-pdf') + + expect do + service.valid_document?(document:) + end.to raise_error TypeError, 'Invalid Document MimeType: not-a-pdf' + end + + it 'errors on unsuccessful api validation' do + allow(File).to receive(:read).and_return('test-file-read') + allow(Marcel::MimeType).to receive(:for).and_return(mime_pdf) + allow(service).to receive(:perform).and_return OpenStruct.new({ success?: false }) + + expect do + service.valid_document?(document:) + end.to raise_error BenefitsIntake::Service::InvalidDocumentError, /Invalid Document/ + end + end + end + + describe '#valid_upload?' do + it 'returns valid upload parameters' do + allow(service).to receive(:valid_document?).and_return('valid-doc-path') + + # no attachments included + expected = { metadata:, document: 'valid-doc-path', attachments: [] } + expect(service).to receive(:valid_document?).once + + response = service.valid_upload?(metadata:, document: 'file-path') + expect(response).to eq(expected) + + # with 2 attachments + expected = { + metadata:, + document: 'valid-doc-path', + attachments: %w[valid-doc-path valid-doc-path] + } + expect(service).to receive(:valid_document?).exactly(3).times + + response = service.valid_upload?(metadata:, document: 'file-path', attachments: %w[1 2]) + expect(response).to eq(expected) + end + + it 'errors on bad metadata' do + expect do + service.valid_upload?(metadata: {}, document: 'file-path') + end.to raise_error ArgumentError + end + + it 'errors on bad file' do + expect do + service.valid_upload?(metadata:, document: 'file-path') + end.to raise_error SystemCallError + end + end + + # end RSpec.describe +end