(ns auto-ap.ssr.transaction.import-test (:require [auto-ap.datomic :refer [conn]] [auto-ap.integration.util :refer [admin-token setup-test-data test-bank-account test-client wrap-setup]] [auto-ap.ssr.transaction.import :as sut] [auto-ap.ssr.utils :refer [main-transformer]] [clojure.test :refer [deftest is testing use-fixtures]] [datomic.api :as dc] [malli.core :as mc] [slingshot.slingshot :refer [try+]])) (use-fixtures :each wrap-setup) (defn- seed-client! [] (setup-test-data [(test-client :db/id "import-client" :client/code "TEST" :client/locations ["DT"] :client/bank-accounts [(test-bank-account :db/id "import-ba" :bank-account/code "TEST-CHK")])])) (defn- txn-count [] (or (dc/q '[:find (count ?e) . :where [?e :transaction/id]] (dc/db conn)) 0)) (defn- import! [rows] (sut/import-transactions {:form-params {:table rows} :identity (admin-token)})) ;; ============================================================================= ;; Pure parsing — tsv->rows, vector->row, parse-form-schema ;; ============================================================================= (deftest tsv->rows-test (testing "Drops the header row and parses tab-separated columns" (let [tsv "Status\tDate\tDescription\nPOSTED\t01/15/2024\tCoffee" rows (sut/tsv->rows tsv)] (is (= 1 (count rows))) (is (= ["POSTED" "01/15/2024" "Coffee"] (first rows))))) (testing "Skips blank lines" (is (= 1 (count (sut/tsv->rows "h1\th2\nPOSTED\tx\n\t\n"))))) (testing "No-op on already-decoded data" (is (= [{:raw-date "x"}] (sut/tsv->rows [{:raw-date "x"}]))))) (deftest vector->row-test (testing "Maps the exact master positional columns" (let [row (sut/vector->row ["POSTED" "01/15/2024" "Coffee" "Food" "" "" "12.50" "" "" "" "" "" "TEST-CHK" "TEST"])] (is (= "POSTED" (:status row))) (is (= "01/15/2024" (:raw-date row))) (is (= "Coffee" (:description-original row))) (is (= "12.50" (:amount row))) (is (= "TEST-CHK" (:bank-account-code row))) (is (= "TEST" (:client-code row)))))) (deftest parse-form-schema-test (testing "Decodes a pasted Yodlee TSV string into row maps" (let [tsv (str "Status\tDate\tDescription\t\t\t\tAmount\t\t\t\t\t\tBank\tClient\n" "POSTED\t01/15/2024\tCoffee\tFood\t\t\t12.50\t\t\t\t\t\tTEST-CHK\tTEST") decoded (mc/decode sut/parse-form-schema {:table tsv} main-transformer)] (is (= 1 (count (:table decoded)))) (is (= "TEST-CHK" (:bank-account-code (first (:table decoded)))))))) ;; ============================================================================= ;; Validation — classify-table (hard errors + warnings, preserving master) ;; ============================================================================= (deftest classify-hard-errors-test (seed-client!) (testing "Unknown bank-account code is a hard error" (let [{:keys [form-errors has-errors?]} (sut/classify-table [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "NOPE"}])] (is has-errors?) (is (some (fn [[m _]] (re-find #"bank account" m)) (get-in form-errors [:table 0]))))) (testing "Unknown client fires independently when the bank account exists but is linked to no client" @(dc/transact conn [{:db/id "orphan-ba" :bank-account/code "ORPHAN-CHK" :bank-account/type :bank-account-type/check}]) (let [{:keys [form-errors has-errors?]} (sut/classify-table [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "ORPHAN-CHK"}]) msgs (map first (get-in form-errors [:table 0]))] (is has-errors?) (is (some #(re-find #"Cannot find client" %) msgs) "client-not-found error fires") (is (not (some #(re-find #"bank account by code" %) msgs)) "bank-account-not-found error does not fire because the bank account exists"))) (testing "Invalid date is a hard error" (let [{:keys [form-errors has-errors?]} (sut/classify-table [{:raw-date "not-a-date" :amount "1.00" :bank-account-code "TEST-CHK"}])] (is has-errors?) (is (some (fn [[m _]] (re-find #"(?i)mm/dd/yyyy|date" m)) (get-in form-errors [:table 0])))))) (deftest classify-clean-test (seed-client!) (testing "A fully valid row produces no errors or warnings" (let [{:keys [form-errors has-errors?]} (sut/classify-table [{:raw-date "01/15/2024" :description-original "Coffee" :amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}])] (is (not has-errors?)) (is (empty? (get-in form-errors [:table 0])))))) (deftest classify-not-ready-warning-test (testing "A date before the bank-account start-date is a (skippable) warning, not an error" (setup-test-data [(test-client :db/id "import-client" :client/code "TEST" :client/locations ["DT"] :client/bank-accounts [(test-bank-account :db/id "import-ba" :bank-account/code "TEST-CHK" :bank-account/start-date #inst "2030-01-01")])]) (let [{:keys [form-errors has-errors?]} (sut/classify-table [{:raw-date "01/15/2024" :description-original "Early" :amount "5.00" :bank-account-code "TEST-CHK" :client-code "TEST"}])] (is (not has-errors?) "warnings do not block") (is (some (fn [[_ s]] (= :warn s)) (get-in form-errors [:table 0])))))) ;; ============================================================================= ;; Import flow — import-transactions (engine reuse, block, idempotency, skip) ;; ============================================================================= (deftest import-clean-test (seed-client!) (testing "Clean rows import via the engine and persist" (let [stats (import! [{:raw-date "01/15/2024" :description-original "Coffee" :amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}])] (is (= 1 (:import-batch/imported stats))) (is (= 1 (txn-count)))))) (deftest import-blocks-on-hard-error-test (seed-client!) (testing "Any hard error blocks the whole batch — nothing is written" (is (= :blocked (try+ (import! [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "NOPE"}]) :did-not-throw (catch [:type :field-validation] _ :blocked)))) (is (= 0 (txn-count))))) (deftest import-idempotent-test (seed-client!) (testing "Re-importing the same paste is idempotent (extant), no duplicates" (let [row [{:raw-date "01/15/2024" :description-original "Coffee" :amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}]] (import! row) (let [stats (import! row)] (is (= 0 (:import-batch/imported stats))) (is (= 1 (:import-batch/extant stats))) (is (= 1 (txn-count))))))) (deftest import-skips-warning-rows-test (testing "Warning rows (not-ready) are skipped, not imported, without blocking" (setup-test-data [(test-client :db/id "import-client" :client/code "TEST" :client/locations ["DT"] :client/bank-accounts [(test-bank-account :db/id "import-ba" :bank-account/code "TEST-CHK" :bank-account/start-date #inst "2030-01-01")])]) (let [stats (import! [{:raw-date "01/15/2024" :description-original "Early" :amount "5.00" :bank-account-code "TEST-CHK" :client-code "TEST"}])] (is (= 0 (:import-batch/imported stats))) (is (= 1 (:import-batch/not-ready stats))) (is (= 0 (txn-count))))))