test(invoice): add integration tests for invoice behaviors

- Fix schema ordering: move :journal-entry-line/running-balance to schema.edn
- Add invoice_behaviors_test.clj covering:
  - Permission gates (26.5, 26.6, 26.8)
  - Lock date blocking (27.1, 27.3)
  - New invoice validation (8.1, 8.5)
  - Edit invoice (11.1, 11.3)
  - Bulk edit (15.4)
  - Single/bulk void (16.3, 16.4, 17.1)
  - Unvoid restoring from history (18.1)
  - Undo autopay (19.1)
  - Invoice list filtering (2.6, 2.8, 2.10, 2.14)
  - Invoice list sorting (3.5, 3.7, 3.10)
  - Invoice list pagination (4.1, 4.3)
  - Legacy route redirects (28.1)
- Mock Solr in wrap-setup fixture to prevent Connection refused
- Fix setup-test-data to merge user-provided entities with defaults
- Fix InMemSolrClient.index_documents to handle entity IDs
- Fix ezcater_xls test to use dynamic entity IDs
- Update invoice.md behavior checklist with completed items
This commit is contained in:
2026-05-04 23:10:46 -07:00
parent da7897c0d6
commit ececdc8f5f
7 changed files with 697 additions and 81 deletions

View File

@@ -69,15 +69,15 @@ Every mutating operation checks:
| 2.3 | It should filter invoices by date range (invoice date) | Integration | [ ] |
| 2.4 | It should filter invoices by due date range | Integration | [ ] |
| 2.5 | It should filter invoices by amount range (min/max total) | Integration | [ ] |
| 2.6 | It should filter invoices by invoice number partial match | Integration | [ ] |
| 2.6 | It should filter invoices by invoice number partial match | Integration | [x] |
| 2.7 | It should filter invoices by check number | Integration | [ ] |
| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [ ] |
| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [x] |
| 2.9 | It should filter invoices by import status (pending/imported) | Integration | [ ] |
| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [ ] |
| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [x] |
| 2.11 | It should filter to invoices with scheduled payments | Integration | [ ] |
| 2.12 | It should filter to unresolved invoices (missing or unassigned expense accounts) | Integration | [ ] |
| 2.13 | It should filter by expense account location | Integration | [ ] |
| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [x] |
### Sorting Behaviors
@@ -87,20 +87,20 @@ Every mutating operation checks:
| 3.2 | It should sort by vendor name ascending/descending | Integration | [ ] |
| 3.3 | It should sort by description original ascending/descending | Integration | [ ] |
| 3.4 | It should sort by expense account location ascending/descending | Integration | [ ] |
| 3.5 | It should sort by invoice date ascending/descending | Integration | [ ] |
| 3.5 | It should sort by invoice date ascending/descending | Integration | [x] |
| 3.6 | It should sort by due date ascending/descending, with nulls last | Integration | [ ] |
| 3.7 | It should sort by invoice number ascending/descending | Integration | [ ] |
| 3.7 | It should sort by invoice number ascending/descending | Integration | [x] |
| 3.8 | It should sort by total amount ascending/descending | Integration | [ ] |
| 3.9 | It should sort by outstanding balance ascending/descending | Integration | [ ] |
| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [x] |
### Pagination Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 4.1 | It should display 25 invoices per page by default | Integration | [ ] |
| 4.1 | It should display 25 invoices per page by default | Integration | [x] |
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [ ] |
| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [x] |
### Selection Behaviors
@@ -140,11 +140,11 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [ ] |
| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [x] |
| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [x] |
| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [x] |
| 8.4 | It should suggest the vendor's default expense account | Unit | [x] |
| 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [ ] |
| 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [x] |
| 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] |
### Expense Accounts Step
@@ -174,12 +174,12 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 11.1 | It should allow editing unpaid and paid invoices | Integration | [ ] |
| 11.1 | It should allow editing unpaid and paid invoices | Integration | [x] |
| 11.2 | It should disable the vendor field when editing | UI | [ ] |
| 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [ ] |
| 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [x] |
| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [x] |
| 11.5 | Given the user saves changes, then the invoice row should update in place without a full page reload | UI | [ ] |
| 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [ ] |
| 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [x] |
---
@@ -224,7 +224,7 @@ Every mutating operation checks:
| 15.1 | It should allow selecting multiple invoices and opening the bulk edit wizard | UI | [ ] |
| 15.2 | It should allow adding expense account rows with account, location, and percentage | UI | [ ] |
| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [x] |
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [ ] |
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [x] |
| 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [x] |
@@ -235,9 +235,9 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 16.1 | It should show a confirmation modal with the count of invoices to void | UI | [ ] |
| 16.2 | It should require admin permission for bulk void operations | Integration | [ ] |
| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [ ] |
| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [ ] |
| 16.2 | It should require admin permission for bulk void operations | Integration | [x] |
| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [x] |
| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [x] |
| 16.5 | It should exclude invoices with linked non-cash payments | Integration | [ ] |
| 16.6 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
| 16.7 | Given successful voiding, then the table should refresh with a success notification | UI | [ ] |
@@ -248,9 +248,9 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 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 | Integration | [ ] |
| 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 | Integration | [x] |
| 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 | Integration | [ ] |
| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [ ] |
| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [x] |
| 17.4 | Given successful voiding, then the row should update in place with a "live-removed" animation | UI | [ ] |
---
@@ -259,7 +259,7 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 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 | Integration | [ ] |
| 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 | Integration | [x] |
| 18.2 | It should require edit permission and client access | Integration | [ ] |
| 18.3 | It should block unvoiding invoices with dates before the client's locked-until date | Integration | [ ] |
| 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] |
@@ -270,7 +270,7 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 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 | Integration | [ ] |
| 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 | Integration | [x] |
| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [x] |
| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [x] |
| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [x] |
@@ -351,18 +351,18 @@ Every mutating operation checks:
| 26.2 | It should block invoice editing for users without `:edit` permission | Integration | [ ] |
| 26.3 | It should block invoice voiding for users without `:delete` permission | Integration | [ ] |
| 26.4 | It should block invoice payment for users without `:pay` permission | Integration | [ ] |
| 26.5 | It should block bulk delete for non-admin users | Integration | [ ] |
| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [ ] |
| 26.5 | It should block bulk delete for non-admin users | Integration | [x] |
| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [x] |
| 26.7 | It should block import for users without `:import` permission | Integration | [ ] |
| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [ ] |
| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [x] |
### Lock Date Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [ ] |
| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [x] |
| 27.2 | It should block paying invoices dated before the client's locked-until date | Integration | [ ] |
| 27.3 | It should block voiding invoices dated before the client's locked-until date | Integration | [ ] |
| 27.3 | It should block voiding invoices dated before the client's locked-until date | Integration | [x] |
| 27.4 | It should block importing invoices dated before the client's locked-until date | Integration | [ ] |
| 27.5 | It should block approving imported invoices dated before the client's locked-until date | Integration | [ ] |
| 27.6 | It should filter out locked invoices from bulk operations | Integration | [ ] |
@@ -372,7 +372,7 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [ ] |
| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [x] |
---

View File

@@ -1,10 +1,4 @@
[{:db/valueType :db.type/double,
:db/cardinality :db.cardinality/one,
:db/noHistory true
:db/doc "The cached running balance for the account this line item is for",
:db/ident :journal-entry-line/running-balance,
}
{:db/valueType :db.type/boolean,
[{:db/valueType :db.type/boolean,
:db/cardinality :db.cardinality/one,
:db/noHistory true
:db/doc "Whether or not this journal entry line is dirty and needs to recalculate balances",

View File

@@ -905,6 +905,11 @@
:db/cardinality #:db{:ident :db.cardinality/one},
:db/doc "The client for the journal entry line",
:db/ident :journal-entry-line/client}
{:db/valueType #:db{:ident :db.type/double},
:db/cardinality #:db{:ident :db.cardinality/one},
:db/doc "The cached running balance for the account this line item is for",
:db/ident :journal-entry-line/running-balance,
:db/noHistory true}
{:db/valueType :db.type/tuple
:db/tupleAttrs [:journal-entry-line/client
:journal-entry-line/account

View File

@@ -213,8 +213,9 @@
(fn [data-set]
(reduce
(fn [data-set x]
(let [thing (datomic->solr x)]
(update data-set index conj [(str/join " " (vals x)) thing])))
(if-let [thing (datomic->solr x)]
(update data-set index conj [(str/join " " (map str (vals thing))) thing])
data-set))
data-set
xs)))
nil)

View File

@@ -0,0 +1,606 @@
(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"])))))

View File

@@ -34,18 +34,18 @@
:client test-client
:location "DT"
:external-id
"ezcater/charge/17592186045501-DT-ZA2-320-0"
(str "ezcater/charge/" test-client "-DT-ZA2-320-0")
:processor :ccp-processor/ezcater
:total 516.12
:tip 0.0}]
:client test-client
:tip 0.0
:tax 37.12
:external-id "ezcater/order/17592186045501-DT-ZA2-320"
:external-id (str "ezcater/order/" test-client "-DT-ZA2-320")
:total 516.12
:line-items
[#:order-line-item{:external-id
"ezcater/order/17592186045501-DT-ZA2-320-0"
(str "ezcater/order/" test-client "-DT-ZA2-320-0")
:item-name "EZCater Catering"
:category "EZCater Catering"
:discount 0.0

View File

@@ -5,7 +5,8 @@
(defn wrap-setup
[f]
(with-redefs [auto-ap.datomic/uri "datomic:mem://test"]
(with-redefs [auto-ap.datomic/uri "datomic:mem://test"
auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))]
(dc/create-database auto-ap.datomic/uri)
(with-redefs [auto-ap.datomic/conn (dc/connect auto-ap.datomic/uri)]
(transact-schema conn)
@@ -28,6 +29,13 @@
:user/name "TEST USER"
:user/clients [{:db/id client-id}]}))
(defn user-token-no-access []
{:user "TEST USER"
:exp (time/plus (time/now) (time/days 1))
:user/role "user"
:user/name "TEST USER"
:user/clients []})
@@ -101,8 +109,7 @@
(dissoc x :id))
(defn setup-test-data [data]
(:tempids @(dc/transact conn (into data
[(test-account :db/id "test-account-id")
(let [defaults [(test-account :db/id "test-account-id")
(test-client :db/id "test-client-id"
:client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")])
(test-vendor :db/id "test-vendor-id")
@@ -110,7 +117,10 @@
:account/name "Accounts Payable"
:db/ident :account/accounts-payable
:account/numeric-code 21000
:account/account-set "default"}]))))
:account/account-set "default"}]
user-ids (set (keep :db/id data))
merged (into [] (concat data (remove #(user-ids (:db/id %)) defaults)))]
(:tempids @(dc/transact conn merged))))
(defn apply-tx [data]
(:db-after @(dc/transact conn data)))