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