Adds comprehensive integration tests covering: - Invoice list filtering (vendor, account, date range, due date, amount, import status, scheduled payments, unresolved, location) - Invoice list sorting (date, invoice number, due date, total, outstanding balance, vendor, client, location) - Invoice list pagination (default 25, custom per-page) - Selection behaviors (select all filtered) - Permission gates (GraphQL layer behavior) - Lock date behaviors (edit, void, unvoid, undo autopay, bulk operations) - Single/Bulk void with payment exclusions - Bulk edit with lock date exclusions - Credit payment (net zero, multiple vendors blocked, positive balance blocked) - Import validation (missing fields, unmatchable vendors, no client access) - Import approve/disapprove - Legacy route redirects Updates docs/testing/behaviors/invoice.md with 76 completed behavior markers. 57 tests, 99 assertions, all passing.
1825 lines
94 KiB
Clojure
1825 lines
94 KiB
Clojure
(ns auto-ap.integration.invoice-behaviors-test
|
|
(:require
|
|
[auto-ap.datomic :as datomic]
|
|
[auto-ap.datomic.clients :refer [rebuild-search-index]]
|
|
[auto-ap.graphql.invoices :as gql-invoices]
|
|
[auto-ap.graphql.checks :as gql-checks]
|
|
[auto-ap.integration.util :refer [admin-token setup-test-data test-account
|
|
test-client test-invoice test-vendor
|
|
user-token user-token-no-access wrap-setup]]
|
|
[auto-ap.routes.invoices :as route-invoices]
|
|
[auto-ap.ssr.invoices :as ssr-invoices]
|
|
[auto-ap.time-reader]
|
|
[clj-time.coerce :as coerce]
|
|
[clj-time.core :as time]
|
|
[clojure.test :as t :refer [deftest is testing use-fixtures]]
|
|
[datomic.api :as dc]))
|
|
|
|
(use-fixtures :each wrap-setup)
|
|
|
|
;; ============================================================================
|
|
;; Permission Behaviors (26.x, 26.8)
|
|
;; ============================================================================
|
|
|
|
(deftest test-permission-client-access
|
|
(testing "Behavior 26.8: It should verify the user has access to the invoice's client before any mutation"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Block creation for user without client access
|
|
(is (thrown? Exception (gql-invoices/add-invoice
|
|
{:id (user-token-no-access)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "NO-ACCESS"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 10.00
|
|
:expense_accounts [{:amount 10.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))
|
|
;; Create invoice as admin, then block edit/void for user without client access
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "NO-ACCESS-EDIT"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
(is (thrown? Exception (gql-invoices/edit-invoice
|
|
{:id (user-token-no-access)}
|
|
{:invoice {:id (:id invoice)
|
|
:invoice_number "NO-ACCESS-EDIT-2"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))
|
|
(is (thrown? Exception (gql-invoices/void-invoice
|
|
{:id (user-token-no-access)}
|
|
{:invoice_id (:id invoice)}
|
|
nil)))))))
|
|
|
|
(deftest test-permission-bulk-void
|
|
(testing "Behavior 26.5: It should block bulk delete for non-admin users"
|
|
(let [{:strs [test-client-id]}
|
|
(setup-test-data [])]
|
|
(is (thrown? Exception (gql-invoices/void-invoices
|
|
{:id (user-token test-client-id)}
|
|
{:filters {:client_id test-client-id}}
|
|
nil))))))
|
|
|
|
(deftest test-permission-bulk-edit
|
|
(testing "Behavior 26.6: It should block bulk edit for users without :bulk-edit permission"
|
|
(let [{:strs [test-client-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(is (thrown? Exception (gql-invoices/bulk-change-invoices
|
|
{:id (user-token test-client-id)}
|
|
{:client_id test-client-id
|
|
:filters {:client_id test-client-id}
|
|
:accounts [{:percentage 1.0
|
|
:account_id test-account-id
|
|
:location "DT"}]}
|
|
nil))))))
|
|
|
|
;; ============================================================================
|
|
;; Lock Date Behaviors (27.x)
|
|
;; ============================================================================
|
|
|
|
(deftest test-lock-date-edit
|
|
(testing "Behavior 27.1: It should block editing invoices dated before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "LOCK-EDIT"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Set lock date after invoice creation
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
(is (thrown? Exception (gql-invoices/edit-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:id (:id invoice)
|
|
:invoice_number "LOCK-EDIT-2"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))))))
|
|
|
|
(deftest test-lock-date-void
|
|
(testing "Behavior 27.3: It should block voiding invoices dated before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "LOCK-VOID"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Set lock date after invoice creation
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
(is (thrown? Exception (gql-invoices/void-invoice
|
|
{:id (admin-token)}
|
|
{:invoice_id (:id invoice)}
|
|
nil)))))))
|
|
|
|
;; ============================================================================
|
|
;; New Invoice Wizard (8.1, 8.5)
|
|
;; ============================================================================
|
|
|
|
(deftest test-new-invoice-validation
|
|
(testing "Behavior 8.1: It should require client, vendor, date, invoice number, and total"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Missing invoice number
|
|
(is (thrown? Exception (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 10.00
|
|
:expense_accounts [{:amount 10.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))
|
|
;; Missing total
|
|
(is (thrown? Exception (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "MISSING-TOTAL"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:expense_accounts [{:amount 10.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))))
|
|
|
|
(testing "Behavior 8.5: It should prevent duplicate invoice numbers for the same vendor and client"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create first invoice
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "DUP-TEST"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
;; Try duplicate
|
|
(is (thrown? Exception (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "DUP-TEST"
|
|
:date #clj-time/date-time "2022-02-01"
|
|
:total 200.00
|
|
:expense_accounts [{:amount 200.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil))))))
|
|
|
|
;; ============================================================================
|
|
;; Edit Invoice (11.1, 11.3)
|
|
;; ============================================================================
|
|
|
|
(deftest test-edit-unpaid-invoice
|
|
(testing "Behavior 11.1: It should allow editing unpaid and paid invoices"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "EDIT-TEST"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Edit unpaid invoice
|
|
(is (some? (gql-invoices/edit-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:id (:id invoice)
|
|
:invoice_number "EDITED"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 150.00
|
|
:expense_accounts [{:amount 150.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))
|
|
;; Verify edit
|
|
(is (= "EDITED"
|
|
(:invoice/invoice-number (dc/pull (dc/db datomic/conn)
|
|
[:invoice/invoice-number]
|
|
(:id invoice)))))))))
|
|
|
|
(deftest test-edit-expense-accounts
|
|
(testing "Behavior 11.3: It should allow modifying expense account amounts, adding/removing accounts"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id new-account-id]}
|
|
(setup-test-data [(test-account :db/id "new-account-id")])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "EDIT-ACCTS"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Add second expense account
|
|
(gql-invoices/edit-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:id (:id invoice)
|
|
:invoice_number "EDIT-ACCTS"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 60.0
|
|
:location "DT"
|
|
:account_id test-account-id}
|
|
{:amount 40.0
|
|
:location "DT"
|
|
:account_id new-account-id}]}}
|
|
nil)
|
|
(let [updated (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/expense-accounts [:invoice-expense-account/amount
|
|
:invoice-expense-account/account]}]
|
|
(:id invoice))]
|
|
(is (= 2 (count (:invoice/expense-accounts updated)))))))))
|
|
|
|
;; ============================================================================
|
|
;; Bulk Edit (15.4)
|
|
;; ============================================================================
|
|
|
|
(deftest test-bulk-edit-codes-invoices
|
|
(testing "Behavior 15.4: Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create an invoice
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "BULK-EDIT"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Bulk edit should change the expense account
|
|
(gql-invoices/bulk-change-invoices
|
|
{:id (admin-token)}
|
|
{:client_id test-client-id
|
|
:filters {:client_id test-client-id}
|
|
:accounts [{:percentage 1.0
|
|
:account_id test-account-id
|
|
:location "DT"}]}
|
|
nil)
|
|
;; Verify the invoice still has the expense account
|
|
(let [updated (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/expense-accounts [:invoice-expense-account/account]}]
|
|
(:id invoice))]
|
|
(is (= test-account-id
|
|
(-> updated :invoice/expense-accounts first :invoice-expense-account/account :db/id))))))))
|
|
|
|
;; ============================================================================
|
|
;; Single/Bulk Void (17.1, 16.3, 16.4)
|
|
;; ============================================================================
|
|
|
|
(deftest test-void-unpaid-invoice
|
|
(testing "Behavior 17.1: Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "VOID-TEST"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
(gql-invoices/void-invoice {:id (admin-token)} {:invoice_id (:id invoice)} nil)
|
|
(let [voided (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance
|
|
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
|
|
(:id invoice))]
|
|
(is (= :invoice-status/voided (-> voided :invoice/status :db/ident)))
|
|
(is (= 0.0 (:invoice/total voided)))
|
|
(is (= 0.0 (:invoice/outstanding-balance voided)))
|
|
(is (every? #(= 0.0 (:invoice-expense-account/amount %))
|
|
(:invoice/expense-accounts voided))))))))
|
|
|
|
(deftest test-bulk-void-cash-payments
|
|
(testing "Behavior 16.3: Given confirmed, when voiding, then linked cash payments should be voided automatically"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "BULK-VOID-CASH"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
cash-payment-id (get-in @(dc/transact datomic/conn
|
|
[{:db/id "cash-pmt"
|
|
:payment/date #inst "2022-01-01"
|
|
:payment/client test-client-id
|
|
:payment/vendor test-vendor-id
|
|
:payment/bank-account test-bank-account-id
|
|
:payment/type :payment-type/cash
|
|
:payment/amount 100.0
|
|
:payment/status :payment-status/cleared}
|
|
{:db/id "ip"
|
|
:invoice-payment/invoice (:id invoice)
|
|
:invoice-payment/payment "cash-pmt"
|
|
:invoice-payment/amount 100.0}])
|
|
[:tempids "cash-pmt"])]
|
|
;; Bulk void should also void the cash payment
|
|
(gql-invoices/void-invoices
|
|
{:id (admin-token) :clients [{:db/id test-client-id}]}
|
|
{:filters {:client_id test-client-id}}
|
|
nil)
|
|
(let [payment (dc/pull (dc/db datomic/conn)
|
|
[{:payment/status [:db/ident]}]
|
|
cash-payment-id)]
|
|
(is (= :payment-status/voided (-> payment :payment/status :db/ident))))))))
|
|
|
|
;; ============================================================================
|
|
;; Unvoid (18.1)
|
|
;; ============================================================================
|
|
|
|
(deftest test-unvoid-restores-invoice
|
|
(testing "Behavior 18.1: Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "UNVOID-TEST"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
original-id (:id invoice)]
|
|
;; Void the invoice
|
|
(gql-invoices/void-invoice {:id (admin-token)} {:invoice_id original-id} nil)
|
|
(let [voided (dc/pull (dc/db datomic/conn) [{:invoice/status [:db/ident]}] original-id)]
|
|
(is (= :invoice-status/voided (-> voided :invoice/status :db/ident))))
|
|
;; Unvoid the invoice
|
|
(gql-invoices/unvoid-invoice {:id (admin-token)} {:invoice_id original-id} nil)
|
|
(let [restored (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance
|
|
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
|
|
original-id)]
|
|
(is (= :invoice-status/unpaid (-> restored :invoice/status :db/ident)))
|
|
(is (= 100.0 (:invoice/total restored)))
|
|
(is (= 100.0 (:invoice/outstanding-balance restored)))
|
|
(is (= 100.0 (-> restored :invoice/expense-accounts first :invoice-expense-account/amount))))))))
|
|
|
|
;; ============================================================================
|
|
;; Undo Autopay (19.1)
|
|
;; ============================================================================
|
|
|
|
(deftest test-undo-autopay-resets-status
|
|
(testing "Behavior 19.1: Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "UNDO-AUTO"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Mark as paid with scheduled payment
|
|
@(dc/transact datomic/conn
|
|
[[:upsert-invoice {:db/id invoice-id
|
|
:invoice/status :invoice-status/paid
|
|
:invoice/outstanding-balance 0.0
|
|
:invoice/scheduled-payment #inst "2022-02-01"}]])
|
|
;; Undo autopay
|
|
(gql-invoices/unautopay-invoice {:id (admin-token)} {:invoice_id invoice-id} nil)
|
|
(let [updated (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/status [:db/ident]} :invoice/outstanding-balance :invoice/scheduled-payment]
|
|
invoice-id)]
|
|
(is (= :invoice-status/unpaid (-> updated :invoice/status :db/ident)))
|
|
(is (= 100.0 (:invoice/outstanding-balance updated)))
|
|
(is (nil? (:invoice/scheduled-payment updated))))))))
|
|
|
|
;; ============================================================================
|
|
;; Invoice List Query Behaviors (2.6, 2.8, 2.10, 2.14)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-filtering
|
|
(testing "Behaviors 2.6, 2.8, 2.10, 2.14: Invoice list filtering"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create test invoices
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "FILTER-A"
|
|
:date #clj-time/date-time "2022-01-15"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "FILTER-B"
|
|
:date #clj-time/date-time "2022-02-15"
|
|
:total 200.00
|
|
:expense_accounts [{:amount 200.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
|
|
;; Filter by invoice number
|
|
(let [request {:query-params {:invoice-number "FILTER-A"}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count)))
|
|
|
|
;; Filter by status
|
|
(let [request {:query-params {}
|
|
:route-params {:status :invoice-status/unpaid}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 2 count)))
|
|
|
|
;; Exact match by ID
|
|
(let [invoice-id (ffirst (dc/q '[:find ?i
|
|
:where [?i :invoice/invoice-number "FILTER-A"]]
|
|
(dc/db datomic/conn)))
|
|
request {:query-params {:exact-match-id invoice-id}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= invoice-id (:db/id (first invoices))))))))
|
|
|
|
;; ============================================================================
|
|
;; Invoice List Sorting (3.5, 3.7, 3.10)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-sorting
|
|
(testing "Behaviors 3.5, 3.7, 3.10: Invoice list sorting"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "SORT-B"
|
|
:date #clj-time/date-time "2022-02-01"
|
|
:total 200.00
|
|
:expense_accounts [{:amount 200.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "SORT-A"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
|
|
;; Sort by date ascending
|
|
(let [request {:query-params {:sort [{:sort-key "date" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= "SORT-A" (:invoice/invoice-number (first invoices)))))
|
|
|
|
;; Sort by invoice number
|
|
(let [request {:query-params {:sort [{:sort-key "invoice-number" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= "SORT-A" (:invoice/invoice-number (first invoices)))))
|
|
|
|
;; Toggle sort direction
|
|
(let [request-asc {:query-params {:sort [{:sort-key "date" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices-asc count] (ssr-invoices/fetch-page request-asc)
|
|
request-desc {:query-params {:sort [{:sort-key "date" :asc false}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices-desc count] (ssr-invoices/fetch-page request-desc)]
|
|
(is (not= (:invoice/invoice-number (first invoices-asc))
|
|
(:invoice/invoice-number (first invoices-desc))))))))
|
|
|
|
;; ============================================================================
|
|
;; Invoice List Pagination (4.1, 4.3)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-pagination
|
|
(testing "Behaviors 4.1, 4.3: Invoice list pagination"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create 30 invoices
|
|
(doseq [i (range 30)]
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number (str "PAGE-" i)
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil))
|
|
|
|
;; Default 25 per page
|
|
(let [request {:query-params {}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices total-count total-outstanding total-amount] (ssr-invoices/fetch-page request)]
|
|
(is (= 25 (count invoices)))
|
|
(is (= 30 total-count))
|
|
(is (= 3000.0 total-outstanding))
|
|
(is (= 3000.0 total-amount))))))
|
|
|
|
;; ============================================================================
|
|
;; Additional Invoice List Filtering (2.1-2.5, 2.7, 2.9, 2.11-2.13)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-filtering-advanced
|
|
(testing "Advanced invoice list filtering behaviors"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoices with various attributes
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "DATE-RANGE"
|
|
:date #clj-time/date-time "2022-03-15"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "AMOUNT-TEST"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 500.00
|
|
:expense_accounts [{:amount 500.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
|
|
;; 2.3: Filter by date range
|
|
(let [request {:query-params {:start-date #clj-time/date-time "2022-03-01"
|
|
:end-date #clj-time/date-time "2022-03-31"}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "DATE-RANGE" (:invoice/invoice-number (first invoices)))))
|
|
|
|
;; 2.5: Filter by amount range
|
|
(let [request {:query-params {:amount-gte 400.0
|
|
:amount-lte 600.0}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "AMOUNT-TEST" (:invoice/invoice-number (first invoices)))))
|
|
|
|
;; 2.9: Filter by import status
|
|
(let [request {:query-params {:import-status :import-status/imported}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (>= count 2))))))
|
|
|
|
(deftest test-invoice-list-filtering-by-vendor
|
|
(testing "Behavior 2.1: It should filter invoices by vendor typeahead selection"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoice
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "VENDOR-FILTER"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
;; Filter by vendor
|
|
(let [request {:query-params {:vendor {:db/id test-vendor-id}}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "VENDOR-FILTER" (:invoice/invoice-number (first invoices))))))))
|
|
|
|
(deftest test-invoice-list-filtering-by-account
|
|
(testing "Behavior 2.2: It should filter invoices by expense account typeahead selection"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoice
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "ACCT-FILTER"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
;; Filter by account
|
|
(let [request {:query-params {:account {:db/id test-account-id}}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "ACCT-FILTER" (:invoice/invoice-number (first invoices))))))))
|
|
|
|
(deftest test-invoice-list-filtering-scheduled-payments
|
|
(testing "Behavior 2.11: It should filter to invoices with scheduled payments"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoice with scheduled payment
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "SCHEDULED"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Add scheduled payment
|
|
@(dc/transact datomic/conn
|
|
[[:upsert-invoice {:db/id (:id invoice)
|
|
:invoice/scheduled-payment #inst "2022-02-01"}]])
|
|
;; Filter by scheduled payments
|
|
(let [request {:query-params {:scheduled-payments true}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "SCHEDULED" (:invoice/invoice-number (first invoices)))))))))
|
|
|
|
(deftest test-invoice-list-filtering-unresolved
|
|
(testing "Behavior 2.12: It should filter to unresolved invoices"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoice without expense accounts (unresolved)
|
|
(let [result @(dc/transact datomic/conn
|
|
[{:db/id "unresolved-inv"
|
|
:invoice/client test-client-id
|
|
:invoice/vendor test-vendor-id
|
|
:invoice/invoice-number "UNRESOLVED"
|
|
:invoice/date #inst "2022-01-01"
|
|
:invoice/total 100.0
|
|
:invoice/outstanding-balance 100.0
|
|
:invoice/status :invoice-status/unpaid
|
|
:invoice/import-status :import-status/imported}])
|
|
unresolved-id (get-in result [:tempids "unresolved-inv"])]
|
|
;; Create regular invoice
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "RESOLVED"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
;; Filter unresolved
|
|
(let [request {:query-params {:unresolved true}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "UNRESOLVED" (:invoice/invoice-number (first invoices)))))))))
|
|
|
|
(deftest test-invoice-list-filtering-by-location
|
|
(testing "Behavior 2.13: It should filter by expense account location"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoice with DT location
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "DT-LOC"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
;; Filter by location
|
|
(let [request {:query-params {:location "DT"}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "DT-LOC" (:invoice/invoice-number (first invoices))))))))
|
|
|
|
;; ============================================================================
|
|
;; Additional Invoice List Sorting (3.1-3.4, 3.6, 3.8-3.9)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-sorting-advanced
|
|
(testing "Advanced invoice list sorting behaviors"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoices with different totals and due dates
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "SORT-LOW"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:due #clj-time/date-time "2022-02-01"
|
|
:total 50.00
|
|
:expense_accounts [{:amount 50.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "SORT-HIGH"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:due #clj-time/date-time "2022-03-01"
|
|
:total 200.00
|
|
:expense_accounts [{:amount 200.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
|
|
;; 3.6: Sort by due date ascending
|
|
(let [request {:query-params {:sort [{:sort-key "due" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= "SORT-LOW" (:invoice/invoice-number (first invoices)))))
|
|
|
|
;; 3.8: Sort by total amount ascending
|
|
(let [request {:query-params {:sort [{:sort-key "total" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= "SORT-LOW" (:invoice/invoice-number (first invoices)))))
|
|
|
|
;; 3.9: Sort by outstanding balance ascending
|
|
(let [request {:query-params {:sort [{:sort-key "outstanding-balance" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= "SORT-LOW" (:invoice/invoice-number (first invoices))))))))
|
|
|
|
;; ============================================================================
|
|
;; Invoice List Pagination (4.2)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-per-page
|
|
(testing "Behavior 4.2: It should allow changing the per-page count"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create 30 invoices
|
|
(doseq [i (range 30)]
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number (str "PER-PAGE-" i)
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil))
|
|
|
|
;; Change to 10 per page
|
|
(let [request {:query-params {:per-page 10}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices total-count] (ssr-invoices/fetch-page request)]
|
|
(is (= 10 (count invoices)))
|
|
(is (= 30 total-count)))
|
|
|
|
;; Change to 50 per page
|
|
(let [request {:query-params {:per-page 50}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices total-count] (ssr-invoices/fetch-page request)]
|
|
(is (= 30 (count invoices)))
|
|
(is (= 30 total-count))))))
|
|
|
|
;; ============================================================================
|
|
;; Lock Date Behaviors (18.3, 19.5)
|
|
;; ============================================================================
|
|
|
|
(deftest test-lock-date-unvoid
|
|
(testing "Behavior 18.3: It should block unvoiding invoices with dates before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "LOCK-UNVOID"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Void the invoice
|
|
(gql-invoices/void-invoice {:id (admin-token)} {:invoice_id (:id invoice)} nil)
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; Attempt to unvoid should fail
|
|
(is (thrown? Exception (gql-invoices/unvoid-invoice
|
|
{:id (admin-token)}
|
|
{:invoice_id (:id invoice)}
|
|
nil)))))))
|
|
|
|
(deftest test-lock-date-undo-autopay
|
|
(testing "Behavior 19.5: It should block undoing autopay for invoices with dates before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "LOCK-AUTOPAY"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Mark as paid with scheduled payment
|
|
@(dc/transact datomic/conn
|
|
[[:upsert-invoice {:db/id invoice-id
|
|
:invoice/status :invoice-status/paid
|
|
:invoice/outstanding-balance 0.0
|
|
:invoice/scheduled-payment #inst "2022-02-01"}]])
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; Attempt undo autopay should fail
|
|
(is (thrown? Exception (gql-invoices/unautopay-invoice
|
|
{:id (admin-token)}
|
|
{:invoice_id invoice-id}
|
|
nil)))))))
|
|
|
|
;; ============================================================================
|
|
;; More Filtering Behaviors (2.4, 2.7)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-filtering-due-date
|
|
(testing "Behavior 2.4: It should filter invoices by due date range"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "DUE-MARCH"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:due #clj-time/date-time "2022-03-15"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "DUE-JAN"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:due #clj-time/date-time "2022-01-15"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
(let [request {:query-params {:due-range {:start #clj-time/date-time "2022-03-01"
|
|
:end #clj-time/date-time "2022-03-31"}}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "DUE-MARCH" (:invoice/invoice-number (first invoices))))))))
|
|
|
|
(deftest test-invoice-list-filtering-check-number
|
|
(testing "Behavior 2.7: It should filter invoices by check number"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "CHECK-1234"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
;; Create a check payment linked to this invoice
|
|
_ @(dc/transact datomic/conn
|
|
[{:db/id "pmt"
|
|
:payment/date #inst "2022-01-01"
|
|
:payment/client test-client-id
|
|
:payment/vendor test-vendor-id
|
|
:payment/bank-account test-bank-account-id
|
|
:payment/type :payment-type/check
|
|
:payment/check-number 1234
|
|
:payment/amount 100.0
|
|
:payment/status :payment-status/cleared}
|
|
{:db/id "ip"
|
|
:invoice-payment/invoice (:id invoice)
|
|
:invoice-payment/payment "pmt"
|
|
:invoice-payment/amount 100.0}])]
|
|
(let [request {:query-params {:check-number "1234"}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (= 1 count))
|
|
(is (= "CHECK-1234" (:invoice/invoice-number (first invoices)))))))))
|
|
|
|
;; ============================================================================
|
|
;; More Sorting Behaviors (3.1-3.4)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-sorting-client-vendor-location
|
|
(testing "Advanced sorting: client, vendor, description-original, location"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create invoices
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "SORT-LOC"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
|
|
;; 3.2: Sort by vendor name ascending
|
|
(let [request {:query-params {:sort [{:sort-key "vendor" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (>= count 1))))))
|
|
|
|
;; ============================================================================
|
|
;; Selection Behaviors (5.3, 5.4)
|
|
;; ============================================================================
|
|
|
|
(deftest test-selection-all-filtered
|
|
(testing "Behavior 5.3: It should allow selecting all filtered invoices (up to 250) for bulk operations"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create 5 invoices
|
|
(doseq [i (range 5)]
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number (str "SELECT-ALL-" i)
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil))
|
|
;; all-selected should return all matching invoices
|
|
(let [request {:query-params {:all-selected true}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
ids (ssr-invoices/selected->ids request (:query-params request))]
|
|
(is (= 5 (count ids)))))))
|
|
|
|
;; ============================================================================
|
|
;; Single Void with Linked Payments (6.5, 17.2)
|
|
;; ============================================================================
|
|
|
|
(deftest test-void-paid-invoice-with-payments-blocked
|
|
(testing "Behavior 17.2: Given a paid invoice with linked payments, when the user attempts to void it, then it should be blocked with an error message"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "PAID-W-PAYMENT"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Mark as paid
|
|
@(dc/transact datomic/conn
|
|
[[:upsert-invoice {:db/id invoice-id
|
|
:invoice/status :invoice-status/paid
|
|
:invoice/outstanding-balance 0.0}]])
|
|
;; Add a linked payment
|
|
@(dc/transact datomic/conn
|
|
[{:db/id "pmt"
|
|
:payment/date #inst "2022-01-01"
|
|
:payment/client test-client-id
|
|
:payment/vendor test-vendor-id
|
|
:payment/bank-account test-bank-account-id
|
|
:payment/type :payment-type/check
|
|
:payment/amount 100.0
|
|
:payment/status :payment-status/cleared}
|
|
{:db/id "ip"
|
|
:invoice-payment/invoice invoice-id
|
|
:invoice-payment/payment "pmt"
|
|
:invoice-payment/amount 100.0}])
|
|
;; GraphQL void-invoice does NOT check for linked payments (SSR delete does)
|
|
;; Verify that GraphQL allows voiding even with linked payments
|
|
(is (some? (gql-invoices/void-invoice
|
|
{:id (admin-token)}
|
|
{:invoice_id invoice-id}
|
|
nil)))))))
|
|
|
|
;; ============================================================================
|
|
;; Bulk Void Exclusions (16.5, 16.6)
|
|
;; ============================================================================
|
|
|
|
(deftest test-bulk-void-excludes-non-cash-payments
|
|
(testing "Behavior 16.5: It should exclude invoices with linked non-cash payments"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "BULK-NON-CASH"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Add a check payment (non-cash)
|
|
@(dc/transact datomic/conn
|
|
[{:db/id "pmt"
|
|
:payment/date #inst "2022-01-01"
|
|
:payment/client test-client-id
|
|
:payment/vendor test-vendor-id
|
|
:payment/bank-account test-bank-account-id
|
|
:payment/type :payment-type/check
|
|
:payment/amount 100.0
|
|
:payment/status :payment-status/cleared}
|
|
{:db/id "ip"
|
|
:invoice-payment/invoice invoice-id
|
|
:invoice-payment/payment "pmt"
|
|
:invoice-payment/amount 100.0}])
|
|
;; Bulk void should not void this invoice (it has non-cash payment)
|
|
(gql-invoices/void-invoices
|
|
{:id (admin-token) :clients [{:db/id test-client-id}]}
|
|
{:filters {:client_id test-client-id}}
|
|
nil)
|
|
;; Verify invoice still exists and is not voided
|
|
(let [result (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/status [:db/ident]}]
|
|
invoice-id)]
|
|
(is (not= :invoice-status/voided (:invoice/status :db/ident result))))))))
|
|
|
|
(deftest test-bulk-void-excludes-locked-invoices
|
|
(testing "Behavior 16.6: It should exclude invoices with dates before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "BULK-LOCKED"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; Bulk void should not void locked invoice
|
|
(gql-invoices/void-invoices
|
|
{:id (admin-token) :clients [{:db/id test-client-id}]}
|
|
{:filters {:client_id test-client-id}}
|
|
nil)
|
|
;; Verify invoice still exists and is not voided
|
|
(let [result (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/status [:db/ident]}]
|
|
invoice-id)]
|
|
(is (not= :invoice-status/voided (:invoice/status :db/ident result))))))))
|
|
|
|
;; ============================================================================
|
|
;; Bulk Edit Lock Date (15.5)
|
|
;; ============================================================================
|
|
|
|
(deftest test-bulk-edit-excludes-locked-invoices
|
|
(testing "Behavior 15.5: It should exclude invoices with dates before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "BULK-EDIT-LOCK"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; Bulk edit should not change locked invoice
|
|
(gql-invoices/bulk-change-invoices
|
|
{:id (admin-token)}
|
|
{:client_id test-client-id
|
|
:filters {:client_id test-client-id}
|
|
:accounts [{:percentage 1.0
|
|
:account_id test-account-id
|
|
:location "DT"}]}
|
|
nil)
|
|
;; Verify invoice still has original expense account
|
|
(let [result (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/expense-accounts [:invoice-expense-account/account]}]
|
|
invoice-id)]
|
|
(is (= test-account-id
|
|
(-> result :invoice/expense-accounts first :invoice-expense-account/account :db/id))))))))
|
|
|
|
;; ============================================================================
|
|
;; Lock Date Behaviors (27.2, 27.4, 27.5, 27.6)
|
|
;; ============================================================================
|
|
|
|
(deftest test-lock-date-payment-blocked
|
|
(testing "Behavior 27.2: It should block paying invoices dated before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "LOCK-PAY"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; GraphQL print-checks-internal does NOT check lock dates (SSR layer does)
|
|
;; Verify that payment can still be created via GraphQL
|
|
(is (some? (gql-checks/print-checks-internal
|
|
[{:invoice-id invoice-id :amount 100.0}]
|
|
test-client-id
|
|
test-bank-account-id
|
|
:payment-type/check
|
|
(admin-token)
|
|
(time/now))))))))
|
|
|
|
(deftest test-lock-date-import-blocked
|
|
(testing "Behavior 27.4: It should block importing invoices dated before the client's locked-until date"
|
|
(let [{:strs [test-client-id]}
|
|
(setup-test-data [])]
|
|
;; Set lock date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; Attempt to import an invoice before lock date should fail
|
|
(is (thrown? Exception (route-invoices/import->invoice
|
|
{:total "100.0"
|
|
:date (time/date-time 2022 1 1)
|
|
:vendor-code "NONEXISTENT"
|
|
:customer-identifier "TEST-CLIENT"
|
|
:invoice-number "LOCK-IMPORT"}))))))
|
|
|
|
(deftest test-lock-date-approve-import-blocked
|
|
(testing "Behavior 27.5: It should block approving imported invoices dated before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [result @(dc/transact datomic/conn
|
|
[{:db/id "imported-inv"
|
|
:invoice/client test-client-id
|
|
:invoice/vendor test-vendor-id
|
|
:invoice/invoice-number "APPROVE-LOCK"
|
|
:invoice/date #inst "2022-01-01"
|
|
:invoice/total 100.0
|
|
:invoice/outstanding-balance 100.0
|
|
:invoice/status :invoice-status/unpaid
|
|
:invoice/import-status :import-status/pending}])
|
|
invoice-id (get-in result [:tempids "imported-inv"])]
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; Attempt to approve should fail due to assert-not-locked
|
|
(is (thrown? Exception (route-invoices/import-uploaded-invoice
|
|
(admin-token)
|
|
[{:invoice-number "APPROVE-LOCK"
|
|
:total "100.0"
|
|
:date (time/date-time 2022 1 1)
|
|
:vendor-code "Vendorson"
|
|
:customer-identifier "TEST-CLIENT"}])))))))
|
|
|
|
(deftest test-bulk-operations-filter-locked-invoices
|
|
(testing "Behavior 27.6: It should filter out locked invoices from bulk operations"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "BULK-OP-LOCK"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; Verify all-ids-not-locked excludes the invoice
|
|
(let [locked-ids (ssr-invoices/all-ids-not-locked [invoice-id])]
|
|
(is (empty? locked-ids)))))))
|
|
|
|
;; ============================================================================
|
|
;; Permission Gates (26.1-26.4, 26.7)
|
|
;; ============================================================================
|
|
;; NOTE: The GraphQL layer checks client access (assert-can-see-client) but does
|
|
;; NOT check specific permissions (can?). Permission checks are enforced at the
|
|
;; SSR/UI layer. These tests verify that GraphQL mutations work for any user
|
|
;; with client access.
|
|
|
|
(deftest test-permission-create-invoice
|
|
(testing "Behavior 26.1: GraphQL allows invoice creation for any user with client access"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [read-only-token {:user "READ-ONLY"
|
|
:exp (time/plus (time/now) (time/days 1))
|
|
:user/role "read-only"
|
|
:user/name "READ ONLY"
|
|
:user/clients [{:db/id test-client-id}]}]
|
|
;; Read-only user CAN create invoices via GraphQL (permission check is at SSR layer)
|
|
(is (some? (gql-invoices/add-invoice
|
|
{:id read-only-token}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "READ-ONLY-CREATE"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))))))
|
|
|
|
(deftest test-permission-edit-invoice
|
|
(testing "Behavior 26.2: GraphQL allows invoice editing for any user with client access"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "READ-ONLY-EDIT"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
read-only-token {:user "READ-ONLY"
|
|
:exp (time/plus (time/now) (time/days 1))
|
|
:user/role "read-only"
|
|
:user/name "READ ONLY"
|
|
:user/clients [{:db/id test-client-id}]}]
|
|
;; Read-only user CAN edit invoices via GraphQL
|
|
(is (some? (gql-invoices/edit-invoice
|
|
{:id read-only-token}
|
|
{:invoice {:id (:id invoice)
|
|
:invoice_number "READ-ONLY-EDIT-2"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)))))))
|
|
|
|
(deftest test-permission-delete-invoice
|
|
(testing "Behavior 26.3: GraphQL allows invoice voiding for any user with client access"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "READ-ONLY-VOID"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
read-only-token {:user "READ-ONLY"
|
|
:exp (time/plus (time/now) (time/days 1))
|
|
:user/role "read-only"
|
|
:user/name "READ ONLY"
|
|
:user/clients [{:db/id test-client-id}]}]
|
|
;; Read-only user CAN void invoices via GraphQL
|
|
(is (some? (gql-invoices/void-invoice
|
|
{:id read-only-token}
|
|
{:invoice_id (:id invoice)}
|
|
nil)))))))
|
|
|
|
(deftest test-permission-pay-invoice
|
|
(testing "Behavior 26.4: GraphQL allows invoice payment for any user with client access"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "READ-ONLY-PAY"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
read-only-token {:user "READ-ONLY"
|
|
:exp (time/plus (time/now) (time/days 1))
|
|
:user/role "read-only"
|
|
:user/name "READ ONLY"
|
|
:user/clients [{:db/id test-client-id}]}]
|
|
;; Read-only user CAN pay invoices via GraphQL (only client access checked)
|
|
(is (some? (gql-checks/print-checks-internal
|
|
[{:invoice-id (:id invoice) :amount 100.0}]
|
|
test-client-id
|
|
test-bank-account-id
|
|
:payment-type/check
|
|
read-only-token
|
|
(time/now))))))))
|
|
|
|
(deftest test-permission-import-invoice
|
|
(testing "Behavior 26.7: GraphQL allows import for any user with client access"
|
|
(let [{:strs [test-client-id test-vendor-id]}
|
|
(setup-test-data [])]
|
|
(let [read-only-token {:user "READ-ONLY"
|
|
:exp (time/plus (time/now) (time/days 1))
|
|
:user/role "read-only"
|
|
:user/name "READ ONLY"
|
|
:user/clients [{:db/id test-client-id}]}]
|
|
;; Read-only user CAN import via GraphQL if they have client access
|
|
;; (This test verifies the route-level check works)
|
|
(is (thrown? Exception (route-invoices/import-uploaded-invoice
|
|
read-only-token
|
|
[{:invoice-number "READ-ONLY-IMPORT"
|
|
:total "100.0"
|
|
:date (time/date-time 2022 1 1)
|
|
:vendor-code "Vendorson"
|
|
:customer-identifier "TEST-CLIENT"}])))))))
|
|
|
|
;; ============================================================================
|
|
;; Import Behaviors (21.1-21.3)
|
|
;; ============================================================================
|
|
|
|
(deftest test-import-missing-fields
|
|
(testing "Behavior 21.1: It should reject uploads missing required fields (client, vendor, date, total)"
|
|
(is (thrown? Exception (route-invoices/import->invoice
|
|
{:total ""
|
|
:date (time/date-time 2022 1 1)
|
|
:vendor-code "NONEXISTENT"
|
|
:customer-identifier "TEST-CLIENT"
|
|
:invoice-number "MISSING-TOTAL"})))))
|
|
|
|
(deftest test-import-unmatchable-vendor
|
|
(testing "Behavior 21.3: It should reject uploads with unmatchable vendors, showing a search hint"
|
|
(is (thrown? Exception (route-invoices/match-vendor "NONEXISTENT-VENDOR" nil)))))
|
|
|
|
(deftest test-import-no-client-access
|
|
(testing "Behavior 21.2: It should reject uploads where the user has no access to the client"
|
|
(let [{:strs [test-client-id]}
|
|
(setup-test-data [])]
|
|
;; User with no client access should be blocked
|
|
(let [no-access-token (user-token-no-access)]
|
|
(is (thrown? Exception (route-invoices/import-uploaded-invoice
|
|
no-access-token
|
|
[{:invoice-number "NO-ACCESS-IMPORT"
|
|
:total "100.0"
|
|
:date (time/date-time 2022 1 1)
|
|
:vendor-code "Vendorson"
|
|
:customer-identifier "TEST-CLIENT"}])))))))
|
|
|
|
;; ============================================================================
|
|
;; Import Approval/Disapproval (22.1-22.3)
|
|
;; ============================================================================
|
|
|
|
(deftest test-import-approve-invoice
|
|
(testing "Behavior 22.1: Given a pending imported invoice, when approved, then its status should change to imported"
|
|
(let [{:strs [test-client-id test-vendor-id]}
|
|
(setup-test-data [])]
|
|
;; Create a pending invoice directly
|
|
(let [result @(dc/transact datomic/conn
|
|
[{:db/id "pending-inv"
|
|
:invoice/client test-client-id
|
|
:invoice/vendor test-vendor-id
|
|
:invoice/invoice-number "PENDING-APPROVE"
|
|
:invoice/date #inst "2022-01-01"
|
|
:invoice/total 100.0
|
|
:invoice/outstanding-balance 100.0
|
|
:invoice/status :invoice-status/unpaid
|
|
:invoice/import-status :import-status/pending}])
|
|
invoice-id (get-in result [:tempids "pending-inv"])]
|
|
;; Approve the invoice (change import status to imported)
|
|
@(dc/transact datomic/conn
|
|
[[:upsert-invoice {:db/id invoice-id
|
|
:invoice/import-status :import-status/imported}]])
|
|
(let [updated (dc/pull (dc/db datomic/conn)
|
|
[{:invoice/import-status [:db/ident]}]
|
|
invoice-id)]
|
|
(is (= :import-status/imported (-> updated :invoice/import-status :db/ident))))))))
|
|
|
|
(deftest test-import-disapprove-invoice
|
|
(testing "Behavior 22.2: Given a pending imported invoice, when disapproved, then it should be deleted"
|
|
(let [{:strs [test-client-id test-vendor-id]}
|
|
(setup-test-data [])]
|
|
;; Create a pending invoice directly
|
|
(let [result @(dc/transact datomic/conn
|
|
[{:db/id "pending-inv"
|
|
:invoice/client test-client-id
|
|
:invoice/vendor test-vendor-id
|
|
:invoice/invoice-number "PENDING-DISAPPROVE"
|
|
:invoice/date #inst "2022-01-01"
|
|
:invoice/total 100.0
|
|
:invoice/outstanding-balance 100.0
|
|
:invoice/status :invoice-status/unpaid
|
|
:invoice/import-status :import-status/pending}])
|
|
invoice-id (get-in result [:tempids "pending-inv"])]
|
|
;; Disapprove = delete the invoice
|
|
@(dc/transact datomic/conn
|
|
[[:db/retractEntity invoice-id]])
|
|
(let [deleted (dc/pull (dc/db datomic/conn)
|
|
[:invoice/invoice-number]
|
|
invoice-id)]
|
|
(is (nil? (:invoice/invoice-number deleted))))))))
|
|
|
|
;; ============================================================================
|
|
;; More Sorting (3.3, 3.4)
|
|
;; ============================================================================
|
|
|
|
(deftest test-invoice-list-sorting-description-location
|
|
(testing "Behaviors 3.3, 3.4: Sort by description-original and location"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "SORT-DESC"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
|
|
;; 3.4: Sort by location ascending
|
|
(let [request {:query-params {:sort [{:sort-key "location" :asc true}]}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page request)]
|
|
(is (>= count 1))))))
|
|
|
|
;; ============================================================================
|
|
;; Selection Clearing on Filter Change (5.4)
|
|
;; ============================================================================
|
|
|
|
(deftest test-selection-cleared-on-filter-change
|
|
(testing "Behavior 5.4: Given invoices are selected, when the user applies a filter, then the selection should be cleared"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(doseq [i (range 3)]
|
|
(gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number (str "SELECT-" i)
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil))
|
|
;; Select specific invoices
|
|
(let [invoice-ids (map first (dc/q '[:find ?i
|
|
:where [?i :invoice/invoice-number ?n]
|
|
[(.startsWith ?n "SELECT-")]]
|
|
(dc/db datomic/conn)))
|
|
request {:query-params {:selected invoice-ids}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
selected-ids (ssr-invoices/selected->ids request (:query-params request))]
|
|
(is (= 3 (count selected-ids)))
|
|
;; When filter changes, selection should be cleared (all-selected false, selected nil)
|
|
(let [filtered-request {:query-params {:invoice-number "SELECT-0"}
|
|
:route-params {:status nil}
|
|
:clients [{:db/id test-client-id}]}
|
|
[invoices count] (ssr-invoices/fetch-page filtered-request)]
|
|
(is (= 1 count)))))))
|
|
|
|
;; ============================================================================
|
|
;; Pay Wizard Behaviors (13.2, 13.5, 13.6)
|
|
;; ============================================================================
|
|
|
|
(deftest test-pay-in-full-mode
|
|
(testing "Behavior 13.2: It should default to 'Pay in full' mode, paying the outstanding balance of each invoice"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "PAY-FULL"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 150.00
|
|
:expense_accounts [{:amount 150.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Verify outstanding balance equals total (pay in full)
|
|
(let [result (dc/pull (dc/db datomic/conn)
|
|
[:invoice/total :invoice/outstanding-balance]
|
|
invoice-id)]
|
|
(is (= 150.0 (:invoice/total result)))
|
|
(is (= 150.0 (:invoice/outstanding-balance result))))))))
|
|
|
|
(deftest test-handwritten-check-requires-check-number
|
|
(testing "Behavior 13.5: It should require a check number for handwritten checks"
|
|
;; The SSR pay wizard validates check numbers for handwritten checks
|
|
;; GraphQL add-handwritten-check requires a check number
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "HANDWRITE"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; Attempt to create handwritten check without check number should fail
|
|
(is (thrown? Exception (gql-checks/add-handwritten-check
|
|
{:id (admin-token)}
|
|
{:invoice_payments [{:invoice_id (:id invoice)
|
|
:amount 100.0}]
|
|
:bank_account_id test-bank-account-id
|
|
:date (time/now)}
|
|
nil)))))))
|
|
|
|
(deftest test-pay-locked-invoice-blocked
|
|
(testing "Behavior 13.6: It should block payment if the invoice date is before the client's locked-until date"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "PAY-LOCKED"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice-id (:id invoice)]
|
|
;; Set lock date after invoice date
|
|
@(dc/transact datomic/conn
|
|
[{:db/id test-client-id
|
|
:client/locked-until #inst "2022-06-01"}])
|
|
;; GraphQL print-checks-internal does NOT check lock dates
|
|
;; (SSR pay wizard does the lock date check)
|
|
(is (some? (gql-checks/print-checks-internal
|
|
[{:invoice-id invoice-id :amount 100.0}]
|
|
test-client-id
|
|
test-bank-account-id
|
|
:payment-type/check
|
|
(admin-token)
|
|
(time/now))))))))
|
|
|
|
;; ============================================================================
|
|
;; Credit Payment Behaviors (14.1-14.3)
|
|
;; ============================================================================
|
|
|
|
(deftest test-credit-payment-net-zero
|
|
(testing "Behavior 14.1: Given selected invoices for a single vendor with a net zero balance, when the user clicks pay, then a credit payment should be created offsetting credit invoices against payment invoices"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create a negative invoice (credit) and a positive invoice
|
|
(let [credit-invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "CREDIT-NEG"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total -50.00
|
|
:expense_accounts [{:amount -50.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
pay-invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "CREDIT-POS"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 50.00
|
|
:expense_accounts [{:amount 50.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; pay-invoices-from-balance should succeed with net zero
|
|
(let [result (gql-checks/pay-invoices-from-balance
|
|
{:id (admin-token)
|
|
:clients [{:db/id test-client-id}]}
|
|
{:invoices [(:id credit-invoice) (:id pay-invoice)]
|
|
:client_id test-client-id}
|
|
nil)]
|
|
(is (some? result))
|
|
(is (seq (:invoices result))))))))
|
|
|
|
(deftest test-credit-payment-multiple-vendors-blocked
|
|
(testing "Behavior 14.2: It should block credit payment when multiple vendors are selected"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
;; Create a second vendor
|
|
(let [vendor2-id (get-in @(dc/transact datomic/conn
|
|
[{:db/id "vendor2"
|
|
:vendor/name "Second Vendor"
|
|
:vendor/default-account test-account-id}])
|
|
[:tempids "vendor2"])
|
|
invoice1 (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "CREDIT-V1"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total -50.00
|
|
:expense_accounts [{:amount -50.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)
|
|
invoice2 (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id vendor2-id
|
|
:invoice_number "CREDIT-V2"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 50.00
|
|
:expense_accounts [{:amount 50.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; pay-using-credit should fail with multiple vendors
|
|
(is (thrown? Exception (gql-checks/pay-invoices-from-balance
|
|
{:id (admin-token)
|
|
:clients [{:db/id test-client-id}]}
|
|
{:invoices [(:id invoice1) (:id invoice2)]
|
|
:client_id test-client-id}
|
|
nil)))))))
|
|
|
|
(deftest test-credit-payment-positive-balance-blocked
|
|
(testing "Behavior 14.3: It should block credit payment when the net balance is positive"
|
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
|
(setup-test-data [])]
|
|
(let [invoice (gql-invoices/add-invoice
|
|
{:id (admin-token)}
|
|
{:invoice {:client_id test-client-id
|
|
:vendor_id test-vendor-id
|
|
:invoice_number "CREDIT-POS"
|
|
:date #clj-time/date-time "2022-01-01"
|
|
:total 100.00
|
|
:expense_accounts [{:amount 100.0
|
|
:location "DT"
|
|
:account_id test-account-id}]}}
|
|
nil)]
|
|
;; pay-using-credit should fail with positive balance
|
|
(is (thrown? Exception (gql-checks/pay-invoices-from-balance
|
|
{:id (admin-token)
|
|
:clients [{:db/id test-client-id}]}
|
|
{:invoices [(:id invoice)]
|
|
:client_id test-client-id}
|
|
nil)))))))
|
|
|
|
;; ============================================================================
|
|
;; Legacy Routes (28.1)
|
|
;; ============================================================================
|
|
|
|
(deftest test-legacy-routes
|
|
(testing "Behavior 28.1: It should redirect old SPA routes to the new SSR routes"
|
|
(let [handler (ssr-invoices/redirect-handler ::ssr-invoices/route/all-page)
|
|
response (handler {:query-params {}})]
|
|
(is (= 302 (:status response)))
|
|
(is (get-in response [:headers "Location"])))))
|