(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"])))))