From 9fb6524704605fba62cd6924d37454252c6272c2 Mon Sep 17 00:00:00 2001 From: Jon Allured Date: Sat, 17 Feb 2024 16:37:58 -0800 Subject: [PATCH] Admin creates csv upload --- .../admin/csv_uploads_controller.rb | 23 +++++++ app/jobs/parse_csv_upload_job.rb | 9 +++ app/models/csv_upload.rb | 8 +++ app/parsers/wells_fargo_parser.rb | 21 +++++++ app/views/admin/csv_uploads/new.html.haml | 8 +++ app/views/admin/csv_uploads/show.html.haml | 1 + config/routes.rb | 1 + spec/csv_files/empty.csv | 0 spec/csv_files/one_wf_transaction.csv | 1 + spec/jobs/parse_csv_upload_job_spec.rb | 41 ++++++++++++ spec/models/csv_upload_spec.rb | 20 ++++++ spec/parsers/wells_fargo_parser_spec.rb | 62 +++++++++++++++++++ spec/rails_helper.rb | 4 ++ .../admin_creates_csv_upload_spec.rb | 44 +++++++++++++ 14 files changed, 243 insertions(+) create mode 100644 app/controllers/admin/csv_uploads_controller.rb create mode 100644 app/jobs/parse_csv_upload_job.rb create mode 100644 app/parsers/wells_fargo_parser.rb create mode 100644 app/views/admin/csv_uploads/new.html.haml create mode 100644 app/views/admin/csv_uploads/show.html.haml create mode 100644 spec/csv_files/empty.csv create mode 100644 spec/csv_files/one_wf_transaction.csv create mode 100644 spec/jobs/parse_csv_upload_job_spec.rb create mode 100644 spec/parsers/wells_fargo_parser_spec.rb create mode 100644 spec/system/csv_uploads/admin_creates_csv_upload_spec.rb diff --git a/app/controllers/admin/csv_uploads_controller.rb b/app/controllers/admin/csv_uploads_controller.rb new file mode 100644 index 0000000..1c06b6b --- /dev/null +++ b/app/controllers/admin/csv_uploads_controller.rb @@ -0,0 +1,23 @@ +class Admin::CsvUploadsController < ApplicationController + expose(:csv_upload) + + def create + if csv_upload.save + ParseCsvUploadJob.perform_later(csv_upload.id) + flash.notice = "CSV Upload successfully created" + redirect_to admin_csv_upload_path(csv_upload) + else + flash.alert = csv_upload.errors.full_messages.join(",") + redirect_to new_admin_csv_upload_path + end + end + + private + + def csv_upload_params + safe_params = params.require(:csv_upload).permit(:parser_class_name) + safe_params[:original_filename] = params[:file].original_filename + safe_params[:data] = params[:file].read + safe_params + end +end diff --git a/app/jobs/parse_csv_upload_job.rb b/app/jobs/parse_csv_upload_job.rb new file mode 100644 index 0000000..f1cca3e --- /dev/null +++ b/app/jobs/parse_csv_upload_job.rb @@ -0,0 +1,9 @@ +class ParseCsvUploadJob < ApplicationJob + def perform(csv_upload_id) + csv_upload = CsvUpload.find_by(id: csv_upload_id) + return unless csv_upload + + parser = csv_upload.parser_class_name.constantize + parser.parse(csv_upload) + end +end diff --git a/app/models/csv_upload.rb b/app/models/csv_upload.rb index ae44476..149ee4e 100644 --- a/app/models/csv_upload.rb +++ b/app/models/csv_upload.rb @@ -1,3 +1,11 @@ +require "csv" + class CsvUpload < ApplicationRecord validates_presence_of :data, :original_filename, :parser_class_name + + def parsed_data + CSV.parse(data) + rescue CSV::MalformedCSVError + nil + end end diff --git a/app/parsers/wells_fargo_parser.rb b/app/parsers/wells_fargo_parser.rb new file mode 100644 index 0000000..30f084f --- /dev/null +++ b/app/parsers/wells_fargo_parser.rb @@ -0,0 +1,21 @@ +class WellsFargoParser + def self.parse(csv_upload) + table = csv_upload.parsed_data + return unless table + + wf_checking = FinancialAccount.wf_checking + + table.each do |row| + amount_cents = (row[1].to_r * 100).to_i + posted_on = Date.strptime(row[0], "%m/%d/%Y") + + attrs = { + amount_cents: amount_cents, + description: row[4], + posted_on: posted_on + } + + wf_checking.financial_transactions.create!(attrs) + end + end +end diff --git a/app/views/admin/csv_uploads/new.html.haml b/app/views/admin/csv_uploads/new.html.haml new file mode 100644 index 0000000..fc11f18 --- /dev/null +++ b/app/views/admin/csv_uploads/new.html.haml @@ -0,0 +1,8 @@ +%h1 New CSV Upload + += form_with model: [:admin, csv_upload], multipart: true do |form| + %select#csv_upload_parser_class_name(name="csv_upload[parser_class_name]" required="true") + %option(value="" disabled="true" selected hidden) pick parser + %option(value="WellsFargoParser") WellsFargoParser + %input#file_picker(required="true" type="file" name="file") + = form.button "create" diff --git a/app/views/admin/csv_uploads/show.html.haml b/app/views/admin/csv_uploads/show.html.haml new file mode 100644 index 0000000..0c4d096 --- /dev/null +++ b/app/views/admin/csv_uploads/show.html.haml @@ -0,0 +1 @@ +%h1 CSV Upload #{csv_upload.id} diff --git a/config/routes.rb b/config/routes.rb index dfecb28..47800ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,7 @@ namespace :admin do resources :books, only: %i[create edit new update] + resources :csv_uploads, only: %i[create new show] resources :gift_ideas resources :hooks, only: %i[create edit index] resources :post_bin_requests, only: %i[index show] diff --git a/spec/csv_files/empty.csv b/spec/csv_files/empty.csv new file mode 100644 index 0000000..e69de29 diff --git a/spec/csv_files/one_wf_transaction.csv b/spec/csv_files/one_wf_transaction.csv new file mode 100644 index 0000000..3f790a3 --- /dev/null +++ b/spec/csv_files/one_wf_transaction.csv @@ -0,0 +1 @@ +"12/29/2023","0.89","","","random fee" diff --git a/spec/jobs/parse_csv_upload_job_spec.rb b/spec/jobs/parse_csv_upload_job_spec.rb new file mode 100644 index 0000000..8450a98 --- /dev/null +++ b/spec/jobs/parse_csv_upload_job_spec.rb @@ -0,0 +1,41 @@ +require "rails_helper" + +describe ParseCsvUploadJob do + let(:parser_class_name) { "WellsFargoParser" } + + let(:csv_upload) do + FactoryBot.create(:csv_upload, parser_class_name: parser_class_name) + end + + let(:csv_upload_id) { csv_upload.id } + + context "with an invalid csv_upload_id" do + let(:csv_upload_id) { "invalid" } + + it "exits early" do + job = ParseCsvUploadJob.new + expect do + job.perform(csv_upload_id) + end.to_not raise_error + end + end + + context "with a CsvUpload that has an invalid parser_class_name" do + let(:parser_class_name) { "InvalidParser" } + + it "raises an error" do + job = ParseCsvUploadJob.new + expect do + job.perform(csv_upload_id) + end.to raise_error(NameError) + end + end + + context "with a valid CsvUpload" do + it "calls the parser with that CsvUpload" do + job = ParseCsvUploadJob.new + expect(WellsFargoParser).to receive(:parse).with(csv_upload) + job.perform(csv_upload_id) + end + end +end diff --git a/spec/models/csv_upload_spec.rb b/spec/models/csv_upload_spec.rb index 5717f0c..a54974f 100644 --- a/spec/models/csv_upload_spec.rb +++ b/spec/models/csv_upload_spec.rb @@ -21,4 +21,24 @@ end end end + + describe "#parsed_data" do + let(:csv_upload) { FactoryBot.create(:csv_upload, data: data) } + + context "with data that fails to parse" do + let(:data) { '"invalid' } + + it "returns nil" do + expect(csv_upload.parsed_data).to eq nil + end + end + + context "with data that parses" do + let(:data) { "abc,123,true" } + + it "returns that parsed data" do + expect(csv_upload.parsed_data).to eq [["abc", "123", "true"]] + end + end + end end diff --git a/spec/parsers/wells_fargo_parser_spec.rb b/spec/parsers/wells_fargo_parser_spec.rb new file mode 100644 index 0000000..03f0137 --- /dev/null +++ b/spec/parsers/wells_fargo_parser_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +describe WellsFargoParser do + describe ".parse" do + let!(:wf_checking) { FactoryBot.create(:wf_checking) } + let(:csv_upload) { FactoryBot.create(:csv_upload, data: data) } + + context "with nil parsed data" do + let(:data) { '"invalid' } + + it "exits early" do + expect do + WellsFargoParser.parse(csv_upload) + end.to_not raise_error + end + end + + context "with a properly formatted transaction" do + let(:data) { "12/29/2023,-0.89,,,random fee" } + + it "creates a transaction for the Wells Fargo Checking account" do + WellsFargoParser.parse(csv_upload) + expect(wf_checking.financial_transactions.count).to eq 1 + + financial_transaction = wf_checking.financial_transactions.last + expect(financial_transaction.amount_cents).to eq(-89) + expect(financial_transaction.description).to eq "random fee" + expect(financial_transaction.posted_on).to eq Date.parse("2023-12-29") + end + end + + context "with a few transactions" do + let(:data) do + <<~EOL + 12/01/2023,100.00,ignore,,check deposit + 12/07/2023,-77.77,,ignore,groceries + 12/29/2023,-0.89,,,random fee + EOL + end + + it "creates those transactions on the Wells Fargo Checking account" do + WellsFargoParser.parse(csv_upload) + + ordered_transactions = wf_checking.financial_transactions.order(:posted_on) + expect(ordered_transactions.count).to eq 3 + + amounts = ordered_transactions.pluck(:amount_cents) + expect(amounts).to eq [100_00, -77_77, -89] + + dates = ordered_transactions.pluck(:posted_on) + expect(dates.map(&:to_s)).to eq ["2023-12-01", "2023-12-07", "2023-12-29"] + + descriptions = ordered_transactions.pluck(:description) + expect(descriptions).to eq [ + "check deposit", + "groceries", + "random fee" + ] + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 3c95417..5aa869f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -16,6 +16,10 @@ config.infer_spec_type_from_file_location! config.use_transactional_fixtures = true + config.before do + ActiveJob::Base.queue_adapter = :test + end + config.before(:each, type: :system) do Selenium::WebDriver.logger.ignore(:deprecations) Capybara.server = :puma, {Silent: true} diff --git a/spec/system/csv_uploads/admin_creates_csv_upload_spec.rb b/spec/system/csv_uploads/admin_creates_csv_upload_spec.rb new file mode 100644 index 0000000..b8bf8e5 --- /dev/null +++ b/spec/system/csv_uploads/admin_creates_csv_upload_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +describe "Admin creates CsvUpload" do + include_context "admin password matches" + + scenario "without a parser" do + visit "/admin/csv_uploads/new" + click_on "create" + expect(CsvUpload.count).to eq 0 + select = page.find("#csv_upload_parser_class_name") + error_message = select.native.attribute("validationMessage") + expect(error_message).to eq "Please select an item in the list." + end + + scenario "without a file" do + visit "/admin/csv_uploads/new" + select "WellsFargoParser", from: "csv_upload_parser_class_name" + click_on "create" + expect(CsvUpload.count).to eq 0 + file_input = page.find("#file_picker") + error_message = file_input.native.attribute("validationMessage") + expect(error_message).to eq "Please select a file." + end + + scenario "with an empty file" do + visit "/admin/csv_uploads/new" + select "WellsFargoParser", from: "csv_upload_parser_class_name" + attach_file "file", "spec/csv_files/empty.csv" + click_on "create" + expect(CsvUpload.count).to eq 0 + expect(page).to have_content "Data can't be blank" + end + + scenario "with valid financial transactions" do + visit "/admin/csv_uploads/new" + select "WellsFargoParser", from: "csv_upload_parser_class_name" + attach_file "file", "spec/csv_files/one_wf_transaction.csv" + click_on "create" + expect(page).to have_content "CSV Upload successfully created" + csv_upload = CsvUpload.last + expect(page).to have_content "CSV Upload #{csv_upload.id}" + expect(ParseCsvUploadJob).to have_been_enqueued + end +end