From 3489fb19d5bc5b60dcacc47e1a9100b665bbdf12 Mon Sep 17 00:00:00 2001 From: Bryce Date: Sat, 31 Jan 2026 10:13:06 -0800 Subject: [PATCH] clauding --- .claude/settings.json | 5 + .claude/skills/testing-conventions/SKILL.md | 247 ++++++++ project.clj | 1 + test/clj/auto_ap/ssr/admin/accounts_test.clj | 221 +++++++ .../ssr/invoice/new_invoice_wizard_test.clj | 7 +- test/clj/auto_ap/ssr/ledger_test.clj | 559 ++++++++++++++++++ 6 files changed, 1035 insertions(+), 5 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .claude/skills/testing-conventions/SKILL.md create mode 100644 test/clj/auto_ap/ssr/admin/accounts_test.clj create mode 100644 test/clj/auto_ap/ssr/ledger_test.clj diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..1bcd18fa --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "playwright@claude-plugins-official": true + } +} diff --git a/.claude/skills/testing-conventions/SKILL.md b/.claude/skills/testing-conventions/SKILL.md new file mode 100644 index 00000000..62fda6b3 --- /dev/null +++ b/.claude/skills/testing-conventions/SKILL.md @@ -0,0 +1,247 @@ +--- +name: testing-conventions +description: Describe the way that tests should be authored, conventions, tools, helpers, superceding any conventions found in existing tests. +--- + +# Testing Conventions Skill + +This skill documents the testing conventions for `test/clj/auto_ap/`. + +## Test Focus: User-Observable Behavior + +**Primary rule**: Test user-observable behavior. If an endpoint or function makes a database change, verify the change by querying the database directly rather than asserting on markup. + +**other rules**: +1. Don't test the means of doing work. For example, if there is a middleware that makes something available on a request, don't bother testing that wrapper. +2. prefer :refer testing imports, rather than :as reference +3. Prefer structured edits from clojure-mcp + +### When to Assert on Database State + +When testing an endpoint that modifies data: +1. Verify the database change by querying the entity directly +2. Use `dc/pull` or `dc/q` to verify the data was stored correctly + +```clojure +;; CORRECT: Verify the database change directly +(deftest test-create-transaction + (let [result @(post-create-transaction {:amount 100.0})] + (let [entity (dc/pull (dc/db conn) [:db/id :transaction/amount] (:transaction/id result))] + (is (= 100.0 (:transaction/amount entity)))))) + +;; CORRECT: Verify response status and headers +(is (= 201 (:status response))) +(is (= "application/json" (get-in response [:headers "content-type"]))) + +;; CORRECT: Check for expected text content +(is (re-find #"Transaction created" (get-in response [:body "message"]))) +``` + +### When Markup Testing is Acceptable + +Markup testing (HTML/SSR response bodies) is acceptable when: +- Validating response status codes and headers +- Checking for presence/absence of specific text strings +- Verifying small, expected elements within the markup +- Testing SSR component rendering + +```clojure +;; ACCEPTABLE: Response codes and headers +(is (= 200 (:status response))) +(is (= "application/json" (get-in response [:headers "content-type"]))) + +;; ACCEPTABLE: Text content within markup +(is (re-find #"Transaction found" response-body)) + +;; ACCEPTABLE: Small element checks +(is (re-find #">Amount: \$100\.00<" response-body)) +``` + +### When to Avoid Markup Testing + +Do not use markup assertions for: +- Verifying complex data structures (use database queries instead) +- Complex nested content that's easier to query +- Business logic verification (test behavior, not presentation) + +## Database Setup + +All tests in `test/clj/auto_ap/` use a shared database fixture (`wrap-setup`) that: +1. Creates a temporary in-memory Datomic database (`datomic:mem://test`) +2. Loads the full schema from `io/resources/schema.edn` +3. Installs custom Datomic functions from `io/resources/functions.edn` +4. Cleans up the database after each test + +## Using the Fixture + +```clojure +(ns my-test + (:require + [auto-ap.integration.util :refer [wrap-setup]] + [clojure.test :as t])) + +(use-fixtures :each wrap-setup) + +(deftest my-test + ;; tests here can access the test database + ) +``` + +## Helper Functions + +`test/clj/auto_ap/integration/util.clj` provides helper functions for creating test data: + +### Identity Helpers + +```clojure +;; Add a unique string to avoid collisions +(str "CLIENT" (rand-int 100000)) +(str "INVOICE " (rand-int 1000000)) +``` + +### Test Entity Builders + +```clojure +;; Client +(test-client + [:db/id "client-id" + :client/code "CLIENT123" + :client/locations ["DT" "MH"] + :client/bank-accounts [:bank-account-id]]) + +;; Vendor +(test-vendor + [:db/id "vendor-id" + :vendor/name "Vendorson" + :vendor/default-account "test-account-id"]) + +;; Bank Account +(test-bank-account + [:db/id "bank-account-id" + :bank-account/code "TEST-BANK-123" + :bank-account/type :bank-account-type/check]) + +;; Transaction +(test-transaction + [:db/id "transaction-id" + :transaction/date #inst "2022-01-01" + :transaction/client "test-client-id" + :transaction/bank-account "test-bank-account-id" + :transaction/id (str (java.util.UUID/randomUUID)) + :transaction/amount 100.0 + :transaction/description-original "original description"]) + +;; Payment +(test-payment + [:db/id "test-payment-id" + :payment/date #inst "2022-01-01" + :payment/client "test-client-id" + :payment/bank-account "test-bank-account-id" + :payment/type :payment-type/check + :payment/vendor "test-vendor-id" + :payment/amount 100.0]) + +;; Invoice +(test-invoice + [:db/id "test-invoice-id" + :invoice/date #inst "2022-01-01" + :invoice/client "test-client-id" + :invoice/status :invoice-status/unpaid + :invoice/import-status :import-status/imported + :invoice/total 100.0 + :invoice/outstanding-balance 100.00 + :invoice/vendor "test-vendor-id" + :invoice/invoice-number "INVOICE 123456" + :invoice/expense-accounts + [{:invoice-expense-account/account "test-account-id" + :invoice-expense-account/amount 100.0 + :invoice-expense-account/location "DT"}]]) + +;; Account +(test-account + [:db/id "account-id" + :account/name "Account" + :account/type :account-type/asset]) +``` + +### Common Data Setup (`setup-test-data`) + +Creates a minimal but complete dataset for testing: + +```clojure +(defn setup-test-data [data] + (:tempids @(dc/transact conn (into data + [(test-account :db/id "test-account-id") + (test-client :db/id "test-client-id" + :client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")]) + (test-vendor :db/id "test-vendor-id") + {:db/id "accounts-payable-id" + :account/name "Accounts Payable" + :db/ident :account/accounts-payable + :account/numeric-code 21000 + :account/account-set "default"}])))) +``` + +Use like: +```clojure +(let [{:strs [test-client-id test-bank-account-id test-vendor-id]} (setup-test-data [])] + ...) +``` + +### Token Helpers + +```clojure +;; Admin token +(admin-token) + +;; User token (optionally scoped to specific client) +(user-token) ; Default: client-id 1 +(user-token client-id) ; Scoped to specific client +``` + +## Example Usage + +```clojure +(ns my-test + (:require + [clojure.test :as t] + [auto-ap.datomic :refer [conn]] + [auto-ap.integration.util :refer [wrap-setup admin-token setup-test-data test-transaction]])) + +(use-fixtures :each wrap-setup) + +(deftest test-transaction-import + (testing "Should import a transaction" + (let [{:strs [client-id bank-account-id]} (setup-test-data []) + tx-result @(dc/transact conn + [(test-transaction + {:db/id "test-tx-id" + :transaction/client client-id + :transaction/bank-account bank-account-id + :transaction/amount 50.0})])] + (is (= 1 (count (:tx-data tx-result)))) + ;; Verify by querying the database, not markup + (let [entity (dc/pull (dc/db conn) [:transaction/amount] (:db/id tx-result))] + (is (= 50.0 (:transaction/amount entity))))))) +``` + +## Note on Temp IDs + +Test data often uses string-based temp IDs like `"client-id"`, `"bank-account-id"`, etc. When transacting, the returned `:tempids` map maps these symbolic IDs to Datomic's internal entity IDs: + +```clojure +(let [{:strs [client-id bank-account-id]} (:tempids @(dc/transact conn txes))] + ...) +``` + +## Memory Database + +All tests use `datomic:mem://test` - an in-memory database. This ensures: +- Tests are fast +- Tests don't interfere with each other +- No setup required to run tests locally + +The database is automatically deleted after each test completes. + +# running tests +use leiningen to run tests diff --git a/project.clj b/project.clj index 778e429f..c83eaa89 100644 --- a/project.clj +++ b/project.clj @@ -110,6 +110,7 @@ [commons-codec "1.12"]] :plugins [[lein-ring "0.9.7"] #_[lein-cljsbuild "1.1.5"] + [dev.weavejester/lein-cljfmt "0.15.6"] [lein-ancient "0.6.15"]] :clean-targets ^{:protect false} ["resources/public/js/compiled" "target"] #_#_:ring {:handler auto-ap.handler/app} diff --git a/test/clj/auto_ap/ssr/admin/accounts_test.clj b/test/clj/auto_ap/ssr/admin/accounts_test.clj new file mode 100644 index 00000000..de7d0e09 --- /dev/null +++ b/test/clj/auto_ap/ssr/admin/accounts_test.clj @@ -0,0 +1,221 @@ +(ns auto-ap.ssr.admin.accounts-test + (:require + [datomic.api :as dc :refer [q]] + [auto-ap.datomic :refer [conn]] + [auto-ap.integration.util :refer [wrap-setup test-account + admin-token user-token + setup-test-data test-client]] + [auto-ap.ssr.admin.accounts :as sut] + [clojure.test :refer [deftest is testing use-fixtures]] + [malli.core :as mc])) + +(use-fixtures :each wrap-setup) + +;; ============================================================================= +;; Schemas +;; ============================================================================= + +(deftest query-schema-test + (testing "Should validate correct query params" + (is (mc/validate sut/query-schema + {:date-range {:start-date #inst "2021-01-01" :end-date #inst "2021-12-31"} + :type "asset" + :name "Cash" + :code 11101 + :page 1 + :per-page 50 + :sort "name" + :sort-dir "asc"}))) + (testing "Should allow optional fields" + (is (mc/validate sut/query-schema + {:page 1 + :per-page 50}))) + (testing "Should reject invalid type" + (is (not (mc/validate sut/query-schema + {:type "invalid-type"}))))) + +(deftest form-schema-test + (testing "Should validate valid account form" + (is (mc/validate sut/form-schema + {:db/id "new" + :account/numeric-code 11101 + :account/name "Test Account" + :account/type :account-type/asset + :account/location "DT" + :account/invoice-allowance :allowance/allowed + :account/vendor-allowance :allowance/allowed + :account/applicability :account-applicability/default}))) + (testing "Should allow updating existing account" + (is (mc/validate sut/form-schema + {:db/id 123 + :account/numeric-code 11102 + :account/name "Updated Account" + :account/type :account-type/liability}))) + (testing "Should require name" + (is (not (mc/validate sut/form-schema + {:account/numeric-code 11101})))) + (testing "Should reject invalid type" + (is (not (mc/validate sut/form-schema + {:account/numeric-code 11101 + :account/name "Test" + :account/type :invalid-type})))) + (testing "Should accept client overrides" + (is (mc/validate sut/form-schema + {:account/numeric-code 11101 + :account/name "Test" + :account/client-overrides + [{:account-client-override/client 1 + :account-client-override/name "Override"}]})))) + +;; ============================================================================= +;; Fetch IDs +;; ============================================================================= + +(deftest fetch-ids-by-type-test + (testing "Should filter by account type" + (let [_ (setup-test-data [(test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Asset Account" + :account/type :account-type/asset) + (test-account :db/id "acc-2" + :account/numeric-code 11102 + :account/name "Liability Account" + :account/type :account-type/liability)])] + (let [results (dc/q '[:find ?e ?n ?nc + :in $ + :where [?e :account/name ?n] + [?e :account/numeric-code ?nc]] + (dc/db conn))] + (is (= 2 (count results))))))) + +;; ============================================================================= +;; Fetch Page +;; ============================================================================= + +(deftest fetch-page-test + (testing "Should return empty results when no accounts exist" + (let [db (dc/db conn) + [_ count] (sut/fetch-page {:query-params {}})] + (is (= 0 count)) + (is (empty? _))))) + +(deftest fetch-page-with-name-filter-test + (testing "Should filter by name (case-insensitive)" + (let [_ (setup-test-data [(test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Asset Account") + (test-account :db/id "acc-2" + :account/numeric-code 11102 + :account/name "Liability Account")])] + (let [[results count] (sut/fetch-page + {:query-params {:name "asset"}})] + (is (= 1 count)) + (is (= 1 (count results))) + (is (= "Asset Account" (:account/name (first results)))))))) + +(deftest fetch-page-with-code-filter-test + (testing "Should filter by numeric code" + (let [_ (setup-test-data [(test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Account 1") + (test-account :db/id "acc-2" + :account/numeric-code 11102 + :account/name "Account 2")])] + (let [[results count] (sut/fetch-page + {:query-params {:code 11102}})] + (is (= 1 count)) + (is (= 1 (count results))) + (is (= "Account 2" (:account/name (first results)))))))) + +(deftest fetch-page-with-type-filter-test + (testing "Should filter by account type" + (let [_ (setup-test-data [(test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Asset Account" + :account/type :account-type/asset) + (test-account :db/id "acc-2" + :account/numeric-code 11102 + :account/name "Liability Account" + :account/type :account-type/liability)])] + (let [[results count] (sut/fetch-page + {:query-params {:type "liability"}})] + (is (= 1 count)) + (is (= 1 (count results))) + (is (= "Liability Account" (:account/name (first results)))))))) + +(deftest fetch-page-pagination-test + (testing "Should apply pagination" + (let [_ (setup-test-data + (for [i (range 15)] + (test-account :db/id (str "acc-" i) + :account/numeric-code (+ 11100 i) + :account/name (str "Account " i))))] + (let [[results count] (sut/fetch-page + {:query-params {:page 1 :per-page 10}})] + (is (= 10 count)) + (is (= 10 (count results))) + (is (= "Account 0" (:account/name (first results)))) + (is (= "Account 9" (:account/name (last results)))))))) + +(deftest fetch-page-sort-test + (testing "Should sort by name ascending" + (let [_ (setup-test-data + [(test-account :db/id "acc-3" + :account/numeric-code 11103 + :account/name "Charlie") + (test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Alpha") + (test-account :db/id "acc-2" + :account/numeric-code 11102 + :account/name "Beta")])] + (let [[results count] (sut/fetch-page + {:query-params {:sort "name" :sort-dir "asc"}})] + (is (= 3 count)) + (is (= "Alpha" (:account/name (first results)))) + (is (= "Charlie" (:account/name (last results))))))) + + (testing "Should sort by code ascending" + (let [_ (setup-test-data + [(test-account :db/id "acc-2" + :account/numeric-code 11102 + :account/name "Beta") + (test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Alpha")])] + (let [[results count] (sut/fetch-page + {:query-params {:sort "code" :sort-dir "asc"}})] + (is (= 2 count)) + (is (= 11101 (:account/numeric-code (first results)))) + (is (= 11102 (:account/numeric-code (last results)))))))) + +(deftest fetch-page-combine-filters-test + (testing "Should combine name and type filters" + (let [_ (setup-test-data + [(test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Cash Asset" + :account/type :account-type/asset) + (test-account :db/id "acc-2" + :account/numeric-code 11102 + :account/name "Cash Liability" + :account/type :account-type/liability) + (test-account :db/id "acc-3" + :account/numeric-code 11103 + :account/name "Bank Asset" + :account/type :account-type/asset)])] + (let [[results count] (sut/fetch-page + {:query-params {:name "cash" :type "asset"}})] + (is (= 1 count)) + (is (= 1 (count results))) + (is (= "Cash Asset" (:account/name (first results)))))))) + +(deftest fetch-page-mixed-case-name-filter-test + (testing "Should handle case-insensitive name filtering" + (let [_ (setup-test-data [(test-account :db/id "acc-1" + :account/numeric-code 11101 + :account/name "Cash Account")])] + (let [[results count] (sut/fetch-page + {:query-params {:name "CASH"}})] + (is (= 1 count)) + (is (= 1 (count results))))))) diff --git a/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj b/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj index 13d0b673..0aa0894c 100644 --- a/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj +++ b/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj @@ -2,7 +2,6 @@ (:require [clojure.test :refer [deftest testing is]] [auto-ap.ssr.invoice.new-invoice-wizard :as sut9])) - (deftest maybe-spread-locations-test (testing "Shared amount correctly spread across multiple locations" (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0 @@ -30,8 +29,6 @@ :invoice-expense-account/location "Location 2"}] (map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result)))))) - - (testing "Shared amount correctly spread with leftovers" (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0 :invoice-expense-account/location "Shared"}] @@ -77,14 +74,14 @@ {:invoice-expense-account/amount -50.66 :invoice-expense-account/location "Location 2"}] (map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result)))))) - + (testing "Leftovers should not exceed a single cent" (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount -100 :invoice-expense-account/location "Shared"} {:invoice-expense-account/amount -5 :invoice-expense-account/location "Shared"}] :invoice/total -101} - result (sut9/maybe-spread-locations invoice ["Location 1" ])] + result (sut9/maybe-spread-locations invoice ["Location 1"])] (is (= [{:invoice-expense-account/amount -100.0 :invoice-expense-account/location "Location 1"} diff --git a/test/clj/auto_ap/ssr/ledger_test.clj b/test/clj/auto_ap/ssr/ledger_test.clj new file mode 100644 index 00000000..81fc001c --- /dev/null +++ b/test/clj/auto_ap/ssr/ledger_test.clj @@ -0,0 +1,559 @@ +(ns auto-ap.ssr.ledger-test + (:require + [auto-ap.datomic :refer [conn audit-transact transact-schema install-functions]] + [auto-ap.datomic.accounts :as a] + [auto-ap.integration.util :refer [wrap-setup test-client test-vendor test-bank-account test-account + setup-test-data admin-token]] + [auto-ap.ssr.ledger :as sut] + [auto-ap.ssr.utils :refer [main-transformer]] + [auto-ap.ssr.ledger.common :as common] + [clojure.test :refer [deftest is testing use-fixtures]] + [clj-time.core :as t] + [clj-time.coerce :as coerce] + [clojure.data.csv :as csv] + [clojure.string :as str] + [datomic.api :as dc] + [malli.core :as mc])) + +(use-fixtures :each wrap-setup) + +;; ============================================================================= +;; Pure Functions - trim-header, tsv->import-data, line->id, entry-errors, +;; flatten-errors, entry-error-types +;; ============================================================================= + +(deftest trim-header-test + (testing "Should trim header with expected keys - returns rest when header matches" + (is (= [["data1" "data2" "data3"]] + (sut/trim-header [["id" "client" "date"] ["data1" "data2" "data3"]])))) + (testing "Should trim header when all columns match" + (is (= [["1" "TEST" "2021-01-01" "100.0"]] + (sut/trim-header [["id" "client" "date" "debit"] ["1" "TEST" "2021-01-01" "100.0"]])))) + (testing "Should not trim header when no columns match" + (is (= [["random" "header" "here"]] + (sut/trim-header [["random" "header" "here"]]))))) + +(deftest tsv->import-data-test + (testing "Should parse TSV data from string" + (let [data "external-id\tclient-code\tdate\n1\tTEST\t2021-01-01" + result (sut/tsv->import-data data)] + (is (vector? result)) + (is (= 1 (count result))) + ;; tsv->import-data returns vectors of vectors, not maps + (is (= "1" (first (first result)))) + (is (= "TEST" (second (first result)))))) + (testing "Should return parsed vector when data is already vector" + (let [data [{:external-id "1" :client-code "TEST"}] + result (sut/tsv->import-data data)] + (is (= 1 (count result))) + (is (= {:external-id "1" :client-code "TEST"} (first result)))))) + +(deftest line->id-test + (testing "Should create unique id from source, client-code, and external-id" + (is (= "TEST-source-123" + (sut/line->id {:source "source" + :client-code "TEST" + :external-id "123"})))) + (testing "Should produce consistent id for same inputs" + (let [id1 (sut/line->id {:source "source" :client-code "TEST" :external-id "123"}) + id2 (sut/line->id {:source "source" :client-code "TEST" :external-id "123"})] + (is (= id1 id2))))) + +(deftest entry-errors-test + (testing "Should return entry errors concatenated with line-item errors" + (let [entry {:errors [["client not found" :error]] + :line-items [{:errors [["invalid account" :error]]} + {:errors [["amount is zero" :warn]]}]} + errors (sut/entry-errors entry)] + (is (= 3 (count errors))) + (is (contains? (set errors) ["client not found" :error])) + (is (contains? (set errors) ["invalid account" :error])) + (is (contains? (set errors) ["amount is zero" :warn]))))) + +(deftest flatten-errors-test + (testing "Should flatten entry and line-item errors into flat list" + (let [entries [{:errors [["entry error" :error]] + :indices [0 1] + :line-items [{:errors [["line error" :error]] :index 0}]} + {:errors [["entry warning" :warn]] + :indices [2] + :line-items [{:errors [["line warning" :warn]] :index 1}]}] + errors (sut/flatten-errors entries)] + (is (= 5 (count errors))) + ;; Check that all expected errors are present in the result + (is (some #(= [[:table 0] "entry error" :error] %) errors)) + (is (some #(= [[:table 1] "entry error" :error] %) errors)) + (is (some #(= [[:table 0] "line error" :error] %) errors)) + (is (some #(= [[:table 2] "entry warning" :warn] %) errors)) + (is (some #(= [[:table 1] "line warning" :warn] %) errors)))) + (testing "Should return empty sequence for no errors" + (is (empty? (sut/flatten-errors []))))) + +(deftest entry-error-types-test + (testing "Should return set of error types from entry errors" + (let [entry {:errors [["client not found" :error] + ["date is invalid" :error] + ["warn message" :warn]]} + types (sut/entry-error-types entry)] + (is (= #{:error :warn} types))))) + +;; ============================================================================= +;; Validation - add-errors +;; ============================================================================= + +(deftest add-errors-test + (testing "Should add error when client not found" + (let [entry {:client-code "NONEXISTENT" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :line-items [{:account-code "1100" :debit 100.0 :credit 0.0}]} + result (sut/add-errors entry + {} ; all-vendors + #{} ; all-accounts + {} ; client-locked-lookup (empty = client not found) + {} ; all-client-bank-accounts + {})] ; all-client-locations + (is (= ["Client 'NONEXISTENT' not found." :error] + (first (:errors result)))))) + + (testing "Should add error when vendor not found" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "CLIENT123" + :client/locations ["HQ"])]) + entry {:client-code "CLIENT123" + :vendor-name "NONEXISTENT" + :date (coerce/to-date-time #inst "2021-01-01") + :line-items [{:account-code "1100" :debit 100.0 :credit 0.0 :location "HQ"}]} + result (sut/add-errors entry + {} ; all-vendors - vendor not present + #{"1100"} ; all-accounts + {"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup + {} ; all-client-bank-accounts + {"CLIENT123" #{"HQ"}})] ; all-client-locations + (is (= ["Vendor 'NONEXISTENT' not found." :error] + (first (:errors result)))))) + + (testing "Should add error when client data is locked" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "LOCKED" + :client/locked-until #inst "2099-12-31")]) + entry {:client-code "LOCKED" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2020-01-01") ; before locked date + :line-items [{:account-code "1100" :debit 100.0 :credit 0.0}]} + result (sut/add-errors entry + {"Vendor" {:db/id "vendor-1"}} ; all-vendors + #{"1100"} ; all-accounts + {"LOCKED" #inst "2099-12-31"} ; client-locked-lookup + {} ; all-client-bank-accounts + {})] ; all-client-locations + (is (some #(str/includes? (first %) "locked") (:errors result))))) + + (testing "Should add error when debit and credit don't balance" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "CLIENT123" + :client/locations ["HQ"])]) + entry {:client-code "CLIENT123" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :amount 150.0 + :line-items [{:account-code "1100" :debit 100.0 :credit 0.0 :location "HQ"} + {:account-code "1101" :debit 0.0 :credit 50.0 :location "HQ"}]} + result (sut/add-errors entry + {"Vendor" {:db/id "vendor-1"}} ; all-vendors + #{"1100" "1101"} ; all-accounts + {"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup + {} ; all-client-bank-accounts + {"CLIENT123" #{"HQ"}})] ; all-client-locations + (is (some #(str/includes? (first %) "do not add up") (:errors result))))) + + (testing "Should add warning when line-item amount is zero or negative" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "CLIENT123" + :client/locations ["HQ"])]) + entry {:client-code "CLIENT123" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :amount 100.0 + :line-items [{:account-code "1100" :debit 0.0 :credit 0.0 :location "HQ"}]} + result (sut/add-errors entry + {"Vendor" {:db/id "vendor-1"}} ; all-vendors + #{"1100"} ; all-accounts + {"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup + {} ; all-client-bank-accounts + {"CLIENT123" #{"HQ"}})] ; all-client-locations + ;; The entry itself has a warning about $0 total, check line item errors + (is (some #(= ["Line item amount 0.0 must be greater than 0." :warn] %) + (mapcat :errors (:line-items result)))))) + + (testing "Should add error when account not found" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "CLIENT123" + :client/locations ["HQ"])]) + entry {:client-code "CLIENT123" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :line-items [{:account-code 99999 :debit 100.0 :credit 0.0 :location "HQ"}]} + result (sut/add-errors entry + {"Vendor" {:db/id "vendor-1"}} ; all-vendors + #{"1100"} ; all-accounts - 99999 not present + {"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup + {} ; all-client-bank-accounts + {"CLIENT123" #{"HQ"}})] ; all-client-locations + (is (some #(str/includes? (first %) "Account '99999' not found") + (mapcat :errors (:line-items result)))))) + + (testing "Should add error when bank account not found" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "CLIENT123" + :client/locations ["HQ"] + :client/bank-accounts [(test-bank-account :db/id "bank-1" + :bank-account/code "CLIENT123-999")])]) + entry {:client-code "CLIENT123" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :line-items [{:account-code "CLIENT123-123" :debit 100.0 :credit 0.0 :location "HQ"}]} + result (sut/add-errors entry + {"Vendor" {:db/id "vendor-1"}} ; all-vendors + #{} ; all-accounts + {"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup + {"CLIENT123" #{"CLIENT123-999"}} ; all-client-bank-accounts - 123 not present + {"CLIENT123" #{"HQ"}})] ; all-client-locations + (is (some #(str/includes? (first %) "Bank Account 'CLIENT123-123' not found") + (mapcat :errors (:line-items result)))))) + + (testing "Should add error when location not found" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "CLIENT123" + :client/locations ["HQ"])]) + entry {:client-code "CLIENT123" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :line-items [{:account-code "1100" :debit 100.0 :credit 0.0 + :location "XX"}]} ; XX is not a valid location + result (sut/add-errors entry + {"Vendor" {:db/id "vendor-1"}} ; all-vendors + #{"1100"} ; all-accounts + {"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup + {} ; all-client-bank-accounts + {"CLIENT123" #{"HQ"}})] ; all-client-locations - XX not present + (is (some #(str/includes? (first %) "Location 'XX' not found") + (mapcat :errors (:line-items result)))))) + + (testing "Should pass through when all validations pass" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "CLIENT123" + :client/locations ["HQ"]) + {:db/id "pass-account-1100" + :account/numeric-code 1100 + :account/account-set "default" + :account/name "Cash"}]) + entry {:client-code "CLIENT123" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :amount 100.0 + :line-items [{:account-code 1100 :debit 100.0 :credit 100.0 :location "HQ"}]} + result (sut/add-errors entry + {"Vendor" {:db/id "vendor-1"}} ; all-vendors + #{"1100"} ; all-accounts + {"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup + {} ; all-client-bank-accounts + {"CLIENT123" #{"HQ"}})] ; all-client-locations + (is (empty? (:errors result))) + (is (empty? (mapcat :errors (:line-items result))))))) + +;; ============================================================================= +;; Transaction Building - entry->tx +;; ============================================================================= + +(deftest entry->tx-test + (testing "Should create upsert transaction for valid entry" + (let [vendors {"Vendor" {:db/id "vendor-1" :vendor/name "Vendor"}} + entry {:source "manual" + :client-code "CLIENT123" + :external-id "ext-1" + :vendor-name "Vendor" + :date (coerce/to-date-time #inst "2021-01-01") + :amount 100.0 + :note "Test note" + :line-items [{:account-code "1100" :debit 100.0 :credit 0.0 + :location "HQ"}]} + tx (sut/entry->tx entry vendors)] + (is (= :upsert-ledger (first tx))) + (is (= "manual" (get-in (second tx) [:journal-entry/source]))) + (is (= [:client/code "CLIENT123"] (get-in (second tx) [:journal-entry/client]))) + (is (= "ext-1" (get-in (second tx) [:journal-entry/external-id]))) + (is (= 100.0 (get-in (second tx) [:journal-entry/amount]))) + (is (= "Test note" (get-in (second tx) [:journal-entry/note]))) + (is (true? (get-in (second tx) [:journal-entry/cleared]))) + (is (= 1 (count (get-in (second tx) [:journal-entry/line-items])))) + (is (= "HQ" (get-in (second tx) [:journal-entry/line-items 0 :journal-entry-line/location]))) + (is (= 100.0 (get-in (second tx) [:journal-entry/line-items 0 :journal-entry-line/debit]))) + (is (= [:bank-account/code "1100"] (get-in (second tx) [:journal-entry/line-items 0 :journal-entry-line/account]))))) + + (testing "Should use bank-account code when no matching account" + (let [vendors {"Vendor" {:db/id "vendor-1"}} + entry {:client-code "TEST" + :external-id "ext-2" + :vendor-name "Vendor" + :amount 50.0 + :line-items [{:account-code "TEST-BANK" :debit 50.0 :credit 0.0 + :location "HQ"}]} + tx (sut/entry->tx entry vendors)] + (is (-> tx + last + :journal-entry/line-items + (-> first + (get :journal-entry-line/account) + (= [:bank-account/code "TEST-BANK"])))))) + + (testing "Should skip zero debit" + (let [vendors {"Vendor" {:db/id "vendor-1"}} + entry {:client-code "TEST" + :external-id "ext-3" + :vendor-name "Vendor" + :amount 100.0 + :line-items [{:account-code "1100" :debit 0.0 :credit 100.0 + :location "HQ"}]} + tx (sut/entry->tx entry vendors)] + (is (empty? (filter #(= 0.0 (:journal-entry-line/debit %)) + (-> tx last :journal-entry/line-items)))))) + + (testing "Should skip zero credit" + (let [vendors {"Vendor" {:db/id "vendor-1"}} + entry {:client-code "TEST" + :external-id "ext-4" + :vendor-name "Vendor" + :amount 100.0 + :line-items [{:account-code "1100" :debit 100.0 :credit 0.0 + :location "HQ"}]} + tx (sut/entry->tx entry vendors)] + (is (empty? (filter #(= 0.0 (:journal-entry-line/credit %)) + (-> tx last :journal-entry/line-items))))))) + +;; ============================================================================= +;; Import Flow - table->entries, import-ledger +;; ============================================================================= + +(deftest table->entries-test + (testing "Should group lines with same line->id and add errors" + (let [_ (setup-test-data [(test-client :db/id "client-1" + :client/code "TEST" + :client/locations ["HQ"])]) + table [{:source "manual" + :client-code "TEST" + :external-id "ext-1" + :date (coerce/to-date-time #inst "2021-01-01") + :vendor-name "Vendor" + :debit 100.0 + :credit 0.0 + :account-code "1100" + :location "HQ"} + {:source "manual" + :client-code "TEST" + :external-id "ext-1" + :date (coerce/to-date-time #inst "2021-01-01") + :vendor-name "Vendor" + :debit 50.0 + :credit 150.0 + :account-code "1101" + :location "HQ"}] + vendors {"Vendor" {:db/id "vendor-1"}} + entries (sut/table->entries table + vendors + #{"1100" "1101"} ; all-accounts + {"TEST" #inst "2000-01-01"} ; client-locked-lookup + {"TEST" #{}} ; all-client-bank-accounts + {"TEST" #{"HQ"}})] ; all-client-locations + (is (= 1 (count entries))) + (is (= "TEST" (:client-code (first entries)))) + (is (= 2 (count (:line-items (first entries)))))))) + +(deftest import-ledger-test + (testing "Should upsert hidden vendors and create transactions" + (let [_ (setup-test-data [(test-client :db/id "import-client-1" + :client/code "IMPORT-TEST" + :client/locations ["HQ"]) + {:db/id "import-account-1100" + :account/numeric-code 1100 + :account/account-set "default" + :account/name "Cash"}]) + form-params {:table [{:source "manual" + :client-code "IMPORT-TEST" + :external-id "ext-import-unique-1" + :date (coerce/to-date-time #inst "2021-01-01") + :vendor-name "New Vendor Import Unique" + :debit 100.0 + :credit 100.0 + :account-code 1100 + :location "HQ"}]} + admin-identity (admin-token) + result (sut/import-ledger {:form-params form-params + :identity admin-identity}) + db-after (dc/db conn)] + (is (= 1 (:successful result))) + ;; Verify vendor was upserted as hidden + (let [vendor-id (dc/q '[:find ?e . + :where [?e :vendor/name "New Vendor Import Unique"]] + db-after)] + (is vendor-id))))) + +(deftest import-ledger-with-errors-test + (testing "Should throw exception when entries have errors - client not found" + (let [form-params {:table [{:source "manual" + :client-code "NONEXISTENT-ERR" + :external-id "ext-err-1" + :date (coerce/to-date-time #inst "2021-01-01") + :vendor-name "Vendor" + :debit 100.0 + :credit 0.0 + :account-code 1100 + :location "HQ"}]} + admin-identity (admin-token)] + (is (thrown? Exception (sut/import-ledger {:form-params form-params + :identity admin-identity}))))) + + (testing "Should produce form-errors for invalid account entries" + (let [_ (setup-test-data [(test-client :db/id "err-client-1" + :client/code "ERR-TEST" + :client/locations ["HQ"]) + (test-vendor :db/id "err-vendor-1" + :vendor/name "Err Vendor")]) + form-params {:table [{:source "manual" + :client-code "ERR-TEST" + :external-id "ext-err-2" + :date (coerce/to-date-time #inst "2021-01-01") + :vendor-name "Err Vendor" + :debit 100.0 + :credit 100.0 + :account-code 99999 + :location "HQ"}]} + admin-identity (admin-token)] + (is (thrown? Exception (sut/import-ledger {:form-params form-params + :identity admin-identity})))))) + +(deftest import-ledger-with-warnings-test + (testing "Should ignore entries with only warnings" + (let [_ (setup-test-data [(test-client :db/id "warn-client-1" + :client/code "WARN-CLIENT" + :client/locations ["HQ"]) + (test-vendor :db/id "warn-vendor-1" + :vendor/name "Warn Vendor") + {:db/id "warn-account-1100" + :account/numeric-code 1100 + :account/account-set "default" + :account/name "Cash"}]) + client-code "WARN-CLIENT" + form-params {:table [{:source "manual" + :client-code client-code + :external-id "ext-warn-unique-1" + :date (coerce/to-date-time #inst "2021-01-01") + :vendor-name "Warn Vendor" + :debit 0.0 + :credit 0.0 + :account-code 1100 + :location "HQ"}]} + admin-identity (admin-token) + result (sut/import-ledger {:form-params form-params + :identity admin-identity})] + ;; Entries with only warnings are ignored, not imported + (is (= 0 (:successful result))) + (is (= 1 (:ignored result)))))) + +;; ============================================================================= +;; Selected IDs - selected->ids +;; ============================================================================= + +(deftest selected->ids-test + (testing "Should return all ids when all-selected is true" + (with-redefs [common/fetch-ids (constantly {:ids ["id1" "id2" "id3"]})] + (let [request {:query-params {:all-selected true :start 0 :per-page 10}} + result (sut/selected->ids request {:all-selected true})] + (is (= #{"id1" "id2" "id3"} (set result)))))) + + (testing "Should return selected ids when all-selected is false" + (is (= #{"id1" "id2"} (set (sut/selected->ids {} {:all-selected false + :selected ["id1" "id2"]}))))) + + (testing "Should return empty set when no selection" + (is (empty? (sut/selected->ids {} {}))))) + +;; ============================================================================= +;; Bank Account Middleware +;; ============================================================================= + +(deftest wrap-ensure-bank-account-belongs-test + (testing "Should remove bank-account when client not present" + (let [handler (fn [req] req) + wrapped (sut/wrap-ensure-bank-account-belongs handler) + request {:query-params {:bank-account {:db/id "bank-1"}} + :identity (admin-token)}] + (is (nil? (-> (wrapped request) :query-params :bank-account))))) + + (testing "Should remove bank-account when client doesn't own it" + (let [handler (fn [req] req) + wrapped (sut/wrap-ensure-bank-account-belongs handler) + request {:query-params {:bank-account {:db/id "bank-1"}} + :client {:client/bank-accounts []} + :identity (admin-token)}] + (is (nil? (-> (wrapped request) :query-params :bank-account))))) + + (testing "Should keep bank-account when client owns it" + (let [handler (fn [req] req) + wrapped (sut/wrap-ensure-bank-account-belongs handler) + request {:query-params {:bank-account {:db/id "bank-1"}} + :client {:client/bank-accounts [{:db/id "bank-1"}]} + :identity (admin-token)}] + (is (= "bank-1" (-> (wrapped request) :query-params :bank-account :db/id)))))) + +(deftest parse-form-schema-test + (testing "Should parse valid TSV data" + (let [tsv "id\tclient\tsource\tvendor\tdate\taccount\tlocation\tdebit\tcredit\n1\tTEST\tmanual\tVendor\t2021-01-01\t1100\tHQ\t100.0\t0.0" + result (mc/decode sut/parse-form-schema + {:table tsv} + main-transformer)] + (is (vector? (:table result))) + (is (= 1 (count (:table result)))))) + (testing "Should not validate for invalid date" + ;; mc/decode doesn't throw - it returns invalid data that fails validation + ;; Need to add a data row (header gets trimmed), so use two rows + (let [result (mc/decode sut/parse-form-schema + {:table "id\tclient\tsource\tvendor\tdate\taccount\tlocation\tdebit\tcredit\n1\tTEST\tmanual\tVendor\tinvalid-date\t1100\tHQ\t100.0\t0.0"} + main-transformer)] + ;; The result should fail validation due to invalid date + (is (not (mc/validate sut/parse-form-schema result)))))) + +;; ============================================================================= +;; Helper Functions +;; ============================================================================= + +(deftest delete-invoice-test + (testing "Should throw notification exception when trying to void a paid invoice" + (is (thrown? Exception (sut/delete {:entity {:invoice/status :invoice-status/paid + :invoice/payments []} + :identity (admin-token)})))) + + (testing "Should throw notification exception when trying to void invoice with payments" + (is (thrown? Exception (sut/delete {:entity {:invoice/status :invoice-status/unpaid + :invoice/payments [{:payment/status :payment-status/cleared}]} + :identity (admin-token)})))) + + (testing "Should void unpaid invoice with no payments" + (let [tempids (setup-test-data [(test-client :db/id "del-client-1" + :client/code "TESTCLIENT-DEL")]) + client-id (get tempids "del-client-1")] + (with-redefs [auto-ap.graphql.utils/assert-can-see-client (fn [_ _] true)] + (let [temp-id (str (java.util.UUID/randomUUID)) + tx-result @(dc/transact conn [{:db/id temp-id + :invoice/status :invoice-status/unpaid + :invoice/total 100.0 + :invoice/outstanding-balance 100.0 + :invoice/client client-id}]) + invoice-id (get-in tx-result [:tempids temp-id])] + (let [response (sut/delete {:entity {:db/id invoice-id + :invoice/status :invoice-status/unpaid + :invoice/payments [] + :invoice/client {:db/id client-id}} + :identity (admin-token)})] + (is (= (format "#entity-table tr[data-id=\"%d\"]" invoice-id) + (get-in response [:headers "hx-retarget"])))))))))