Compare commits
3 Commits
integreat-
...
b6649a3d1d
| Author | SHA1 | Date | |
|---|---|---|---|
| b6649a3d1d | |||
| 38ae6f460f | |||
| e156d8bfd8 |
@@ -455,6 +455,93 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
|
||||||
|
// click a rendered result. The vendor search is backed by Solr (unavailable in
|
||||||
|
// tests), so the result option is injected into the typeahead's Alpine
|
||||||
|
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||||
|
// search input firing a native `change` on blur, the `value = element` click
|
||||||
|
// handler, the Alpine reactivity, and the HTMX round-trip to
|
||||||
|
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
|
||||||
|
// regressed: a stale native `change` from the search input used to win the race
|
||||||
|
// and revert the vendor to its previous value.
|
||||||
|
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
||||||
|
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||||
|
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||||
|
|
||||||
|
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||||
|
await typeahead.locator('a[x-ref="input"]').click();
|
||||||
|
|
||||||
|
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||||
|
await search.waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
// Type under the 3-char search threshold so no Solr request fires and clears
|
||||||
|
// our injected option, while still dirtying the input so it fires a native
|
||||||
|
// `change` on blur -- the event that used to clobber the selection.
|
||||||
|
await search.fill('te');
|
||||||
|
|
||||||
|
// Inject a clickable result into the typeahead's Alpine state.
|
||||||
|
await typeahead.evaluate(
|
||||||
|
(el: HTMLElement, opt: { id: number; label: string }) => {
|
||||||
|
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||||
|
},
|
||||||
|
{ id: vendorId, label: vendorName }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the rendered option: fires the search input's native change (stale
|
||||||
|
// value) AND the synthetic change carrying the new value, then HTMX swaps.
|
||||||
|
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
|
||||||
|
|
||||||
|
await page.waitForResponse(
|
||||||
|
(response: any) =>
|
||||||
|
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens the edit modal and activates the Manual tab, waiting on the vendor
|
||||||
|
// typeahead rather than the account grid (which only exists in advanced mode).
|
||||||
|
async function openManualVendorSection(page: any, transactionIndex: number) {
|
||||||
|
await page.goto('/transaction2');
|
||||||
|
await page.waitForSelector('table tbody tr');
|
||||||
|
|
||||||
|
const editButton = page
|
||||||
|
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
|
||||||
|
.nth(transactionIndex);
|
||||||
|
await editButton.click();
|
||||||
|
|
||||||
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
|
await page.waitForSelector('#wizardmodal');
|
||||||
|
await page.click('button:has-text("Manual")');
|
||||||
|
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Transaction Edit Vendor Selection', () => {
|
||||||
|
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
|
||||||
|
await openManualVendorSection(page, 3);
|
||||||
|
|
||||||
|
const testInfo = await getTestInfo(page);
|
||||||
|
const vendorId: number = testInfo.accounts.vendor;
|
||||||
|
|
||||||
|
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
|
||||||
|
|
||||||
|
// The displayed vendor label must reflect the selection after the HTMX
|
||||||
|
// round-trip. Before the fix this reverted to blank because a stale
|
||||||
|
// `change` event submitted the previous vendor and its response won.
|
||||||
|
const label = page
|
||||||
|
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
||||||
|
.first();
|
||||||
|
await expect(label).toHaveText('Test Vendor');
|
||||||
|
|
||||||
|
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||||
|
const hidden = page
|
||||||
|
.locator(
|
||||||
|
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
await expect(hidden).toHaveValue(vendorId.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('Transaction Link Date Display', () => {
|
test.describe('Transaction Link Date Display', () => {
|
||||||
test('should show payment date when linking to payment', async ({ page }) => {
|
test('should show payment date when linking to payment', async ({ page }) => {
|
||||||
await openEditModalForTransaction(page, 'Transaction for payment link');
|
await openEditModalForTransaction(page, 'Transaction for payment link');
|
||||||
|
|||||||
@@ -80,9 +80,7 @@
|
|||||||
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
|
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
|
||||||
(let [preserved (transaction-nav-params request)]
|
(let [preserved (transaction-nav-params request)]
|
||||||
(hu/url (bidi/path-for ssr-routes/only-routes route)
|
(hu/url (bidi/path-for ssr-routes/only-routes route)
|
||||||
#_(if (or (:start-date preserved) (:end-date preserved))
|
{:date-range "month"})))
|
||||||
preserved
|
|
||||||
(merge default-params preserved)))))
|
|
||||||
|
|
||||||
(defn left-aside- [{:keys [nav page-specific]} & _]
|
(defn left-aside- [{:keys [nav page-specific]} & _]
|
||||||
[:aside {:id "left-nav",
|
[:aside {:id "left-nav",
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"x-ref" "hidden"
|
"x-ref" "hidden"
|
||||||
:type "hidden"
|
:type "hidden"
|
||||||
":value" "value.value"
|
":value" "value.value"
|
||||||
:x-init (hiccup/raw (str "$watch('value', v => $dispatch('change')); "))))]
|
:x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))]
|
||||||
[:div.flex.w-full.justify-items-stretch
|
[:div.flex.w-full.justify-items-stretch
|
||||||
[:span.flex-grow.text-left {"x-text" "value.label"}]
|
[:span.flex-grow.text-left {"x-text" "value.label"}]
|
||||||
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
|
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
|
||||||
@@ -104,9 +104,10 @@
|
|||||||
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||||
"x-model" "search"
|
"x-model" "search"
|
||||||
"placeholder" (:placeholder params)
|
"placeholder" (:placeholder params)
|
||||||
|
"@change.stop" ""
|
||||||
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
|
||||||
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
|
||||||
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()"
|
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
|
||||||
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
|
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
|
||||||
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
|
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
|
||||||
[:template {:x-for "(element, index) in elements"}
|
[:template {:x-for "(element, index) in elements"}
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
|
|
||||||
"@mouseover" "active = index"
|
"@mouseover" "active = index"
|
||||||
"@mouseout" "active = -1"
|
"@mouseout" "active = -1"
|
||||||
"@click.prevent" "value = element; tippy.hide(); $refs.input.focus()"
|
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
|
||||||
"x-html" "element.label"}]]]
|
"x-html" "element.label"}]]]
|
||||||
[:template {:x-if "elements.length == 0"}
|
[:template {:x-if "elements.length == 0"}
|
||||||
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
|
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
|
||||||
|
|||||||
@@ -514,6 +514,7 @@
|
|||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||||
:hx-target "#manual-coding-section"
|
:hx-target "#manual-coding-section"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
|
:hx-sync "this:replace"
|
||||||
:hx-include "closest form"}
|
:hx-include "closest form"}
|
||||||
(fc/with-field :transaction/vendor
|
(fc/with-field :transaction/vendor
|
||||||
(com/validated-field
|
(com/validated-field
|
||||||
@@ -882,9 +883,13 @@
|
|||||||
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
|
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
|
||||||
(mm/form-schema linear-wizard))
|
(mm/form-schema linear-wizard))
|
||||||
|
|
||||||
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
|
(render-step [this {{:keys [snapshot step-params] :as multi-form-state} :multi-form-state :as request}]
|
||||||
(let [tx-id (mm/get-mfs-field multi-form-state :db/id)
|
(let [tx-id (mm/get-mfs-field multi-form-state :db/id)
|
||||||
tx (d-transactions/get-by-id tx-id)]
|
tx (d-transactions/get-by-id tx-id)
|
||||||
|
;; Preserve explicit mode choice from step-params; only fall back to
|
||||||
|
;; row-count heuristic on initial load when no mode has been chosen.
|
||||||
|
mode (keyword (or (:mode step-params)
|
||||||
|
(name (manual-mode-initial snapshot))))]
|
||||||
(mm/default-render-step
|
(mm/default-render-step
|
||||||
linear-wizard this
|
linear-wizard this
|
||||||
:head [:div.p-2 "Edit Transaction"]
|
:head [:div.p-2 "Edit Transaction"]
|
||||||
@@ -950,7 +955,7 @@
|
|||||||
(transaction-rules-view request)]
|
(transaction-rules-view request)]
|
||||||
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
|
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
|
||||||
[:div {}
|
[:div {}
|
||||||
(manual-coding-section* (manual-mode-initial snapshot) request)
|
(manual-coding-section* mode request)
|
||||||
(fc/with-field :transaction/approval-status
|
(fc/with-field :transaction/approval-status
|
||||||
(com/validated-field
|
(com/validated-field
|
||||||
{:label "Status"
|
{:label "Status"
|
||||||
@@ -1429,10 +1434,13 @@
|
|||||||
(let [multi-form-state (:multi-form-state request)
|
(let [multi-form-state (:multi-form-state request)
|
||||||
snapshot (:snapshot multi-form-state)
|
snapshot (:snapshot multi-form-state)
|
||||||
step-params (:step-params multi-form-state)
|
step-params (:step-params multi-form-state)
|
||||||
mode (keyword (or (:mode step-params) "simple"))
|
mode (keyword (or (:mode step-params)
|
||||||
|
(get (:form-params request) "mode")
|
||||||
|
"simple"))
|
||||||
client-id (or (:transaction/client snapshot)
|
client-id (or (:transaction/client snapshot)
|
||||||
(-> request :entity :transaction/client :db/id))
|
(-> request :entity :transaction/client :db/id))
|
||||||
vendor-id (or (:transaction/vendor step-params)
|
vendor-id (or (:transaction/vendor step-params)
|
||||||
|
(->db-id (get step-params "transaction/vendor"))
|
||||||
(:transaction/vendor snapshot))
|
(:transaction/vendor snapshot))
|
||||||
total (Math/abs (or (-> request :entity :transaction/amount)
|
total (Math/abs (or (-> request :entity :transaction/amount)
|
||||||
(:transaction/amount snapshot)
|
(:transaction/amount snapshot)
|
||||||
@@ -1440,10 +1448,21 @@
|
|||||||
amount-mode (or (:amount-mode snapshot) "$")
|
amount-mode (or (:amount-mode snapshot) "$")
|
||||||
existing-accounts (or (seq (:transaction/accounts step-params))
|
existing-accounts (or (seq (:transaction/accounts step-params))
|
||||||
(seq (:transaction/accounts snapshot)))
|
(seq (:transaction/accounts snapshot)))
|
||||||
default-account (when (and (empty? existing-accounts) vendor-id client-id)
|
;; The form always submits an account row (even when empty with account=nil),
|
||||||
|
;; so we check if any row has a meaningful account ID.
|
||||||
|
has-meaningful-accounts? (some #(some? (:transaction-account/account %))
|
||||||
|
existing-accounts)
|
||||||
|
;; Simple mode: always populate vendor default (overwrite existing).
|
||||||
|
;; Advanced mode: populate only when 0 rows OR 1 empty row.
|
||||||
|
should-populate? (case mode
|
||||||
|
:simple true
|
||||||
|
:advanced (or (empty? existing-accounts)
|
||||||
|
(and (= 1 (count existing-accounts))
|
||||||
|
(not has-meaningful-accounts?))))
|
||||||
|
default-account (when (and should-populate? vendor-id client-id)
|
||||||
(vendor-default-account vendor-id client-id))
|
(vendor-default-account vendor-id client-id))
|
||||||
render-request
|
render-request
|
||||||
(if (and (empty? existing-accounts) vendor-id client-id)
|
(-> (if (and should-populate? vendor-id client-id)
|
||||||
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
|
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||||
:transaction-account/location (or (:account/location default-account) "Shared")
|
:transaction-account/location (or (:account/location default-account) "Shared")
|
||||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||||
@@ -1451,7 +1470,8 @@
|
|||||||
(-> request
|
(-> request
|
||||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
|
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
|
||||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||||
request)]
|
request)
|
||||||
|
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
|
||||||
(html-response
|
(html-response
|
||||||
(fc/start-form (:multi-form-state render-request) nil
|
(fc/start-form (:multi-form-state render-request) nil
|
||||||
(fc/with-field :step-params
|
(fc/with-field :step-params
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
[auto-ap.solr]
|
[auto-ap.solr]
|
||||||
[auto-ap.ssr.components.multi-modal :as mm]
|
[auto-ap.ssr.components.multi-modal :as mm]
|
||||||
[auto-ap.ssr.form-cursor :as fc]
|
[auto-ap.ssr.form-cursor :as fc]
|
||||||
[auto-ap.ssr.transaction.edit :refer [clientize-vendor
|
[auto-ap.ssr.transaction.edit
|
||||||
|
:refer [clientize-vendor
|
||||||
edit-vendor-changed-handler
|
edit-vendor-changed-handler
|
||||||
edit-wizard-toggle-mode-handler
|
edit-wizard-toggle-mode-handler
|
||||||
location-select*
|
location-select*
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
(is (re-find #"Test Account" body)
|
(is (re-find #"Test Account" body)
|
||||||
"Response should contain the vendor's default account name")))
|
"Response should contain the vendor's default account name")))
|
||||||
|
|
||||||
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account"
|
(testing "AC5: vendor selection in simple mode DOES overwrite already-set account"
|
||||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
:vendor/name "Test Vendor"}
|
:vendor/name "Test Vendor"}
|
||||||
{:db/id "account-id"
|
{:db/id "account-id"
|
||||||
@@ -126,9 +127,10 @@
|
|||||||
:transaction/client "client-id"}])
|
:transaction/client "client-id"}])
|
||||||
tx-id (tempid->id result "transaction-id")
|
tx-id (tempid->id result "transaction-id")
|
||||||
vendor-id (tempid->id result "vendor-id")
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
account-id (tempid->id result "account-id")
|
||||||
other-account-id (tempid->id result "other-account-id")
|
other-account-id (tempid->id result "other-account-id")
|
||||||
client-id (tempid->id result "client-id")
|
client-id (tempid->id result "client-id")
|
||||||
;; existing-accounts already set means vendor should NOT overwrite
|
;; existing-accounts already set — but simple mode should still overwrite
|
||||||
existing-accounts [{:db/id "row-id"
|
existing-accounts [{:db/id "row-id"
|
||||||
:transaction-account/account other-account-id
|
:transaction-account/account other-account-id
|
||||||
:transaction-account/location "DT"
|
:transaction-account/location "DT"
|
||||||
@@ -149,12 +151,12 @@
|
|||||||
;; The handler returns an html-response; verify the body is HTML
|
;; The handler returns an html-response; verify the body is HTML
|
||||||
(is (re-find #"manual-coding-section" body)
|
(is (re-find #"manual-coding-section" body)
|
||||||
"Response body should contain the manual-coding-section element")
|
"Response body should contain the manual-coding-section element")
|
||||||
;; The original account ID must still appear in the rendered HTML
|
;; The vendor's default account SHOULD appear (overwriting previous)
|
||||||
(is (re-find (re-pattern (str other-account-id)) body)
|
(is (re-find (re-pattern (str account-id)) body)
|
||||||
"Response should contain the original (pre-existing) account ID")
|
"Vendor change in simple mode should overwrite with vendor's default account")
|
||||||
;; The vendor's default account ID must NOT appear — it was not used
|
;; The previous account should NOT appear
|
||||||
(is (not (re-find (re-pattern (str (tempid->id result "account-id"))) body))
|
(is (not (re-find (re-pattern (str other-account-id)) body))
|
||||||
"Response should NOT contain the vendor's default account ID when existing account is set"))))
|
"Previous account should be replaced by vendor default"))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
;;; ---------------------------------------------------------------------------
|
||||||
;;; AC6: save round-trip — manual mode saves vendor + account to DB
|
;;; AC6: save round-trip — manual mode saves vendor + account to DB
|
||||||
@@ -934,3 +936,384 @@
|
|||||||
;; Should NOT show 'Switch to simple mode'
|
;; Should NOT show 'Switch to simple mode'
|
||||||
(is (not (re-find #"Switch to simple mode" html))
|
(is (not (re-find #"Switch to simple mode" html))
|
||||||
"AC20: Simple mode should NOT show 'Switch to simple mode' link"))))
|
"AC20: Simple mode should NOT show 'Switch to simple mode' link"))))
|
||||||
|
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
;;; Bug: vendor selection gets erased on vendor-changed HTMX response
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(deftest vendor-selection-preserved-in-htmx-response-test
|
||||||
|
(testing "BUG: vendor selection should be preserved when HTMX re-renders the edit form"
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Test Vendor"}
|
||||||
|
{:db/id "account-id"
|
||||||
|
:account/name "Existing Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "VENDORCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
account-id (tempid->id result "account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Simulate the request after middleware decoding.
|
||||||
|
;; In production, form values arrive as strings. The middleware decodes
|
||||||
|
;; step-params with keyword keys but leaves values as strings.
|
||||||
|
existing-accounts [{:db/id "row-1"
|
||||||
|
:transaction-account/account account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts existing-accounts}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
;; This is how the vendor ID arrives from the form:
|
||||||
|
;; as a string, not a long.
|
||||||
|
:transaction/vendor (str vendor-id)
|
||||||
|
:transaction/accounts existing-accounts})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
;; The handler should return a successful response with the vendor
|
||||||
|
;; preserved. Currently it crashes because the string vendor-id is
|
||||||
|
;; not converted to a long before being passed to Datomic.
|
||||||
|
response (try
|
||||||
|
(edit-vendor-changed-handler request)
|
||||||
|
(catch Exception e
|
||||||
|
{:error e}))]
|
||||||
|
(is (not (:error response))
|
||||||
|
(str "BUG: String vendor-id from form submission should be converted to long. "
|
||||||
|
"Server crashes with: " (some-> response :error ex-message)))
|
||||||
|
(when-not (:error response)
|
||||||
|
(is (= 200 (:status response))
|
||||||
|
"Response should be successful")
|
||||||
|
(is (re-find #"Test Vendor" (:body response))
|
||||||
|
"Vendor name should appear in the HTMX response")
|
||||||
|
(is (re-find (re-pattern (str vendor-id)) (:body response))
|
||||||
|
"Vendor ID should be preserved in the response HTML")))))
|
||||||
|
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
;;; Bug: vendor change does not populate account
|
||||||
|
;;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(deftest vendor-change-simple-mode-overwrites-test
|
||||||
|
(testing "BUG: vendor change in simple mode should overwrite existing account"
|
||||||
|
;; When a vendor is changed in simple mode, it should always populate
|
||||||
|
;; the vendor's default account, even if an account was already set.
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "existing-account-id"
|
||||||
|
:account/name "Previously Selected Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "VENDORCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
existing-account-id (tempid->id result "existing-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Simulate form state with an already-selected account (as the form submits)
|
||||||
|
existing-accounts [{:db/id "row-1"
|
||||||
|
:transaction-account/account existing-account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts existing-accounts}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts existing-accounts})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The vendor's default account SHOULD appear (overwriting the previous)
|
||||||
|
(is (re-find (re-pattern (str vendor-account-id)) body)
|
||||||
|
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
|
||||||
|
;; The previously selected account should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str existing-account-id)) body))
|
||||||
|
"Previously selected account should be replaced by vendor default")
|
||||||
|
(is (re-find #"Vendor Default Account" body)
|
||||||
|
"Vendor default account name should appear"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-advanced-mode-empty-row-test
|
||||||
|
(testing "BUG: vendor change in advanced mode should populate empty row"
|
||||||
|
;; In advanced mode with 1 empty row, changing vendor should populate it
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "ADVEMPTYCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Simulate advanced mode with 1 empty row (account=nil, as form submits)
|
||||||
|
empty-row [{:db/id "row-1"
|
||||||
|
:transaction-account/account nil
|
||||||
|
:transaction-account/location "Shared"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts empty-row}
|
||||||
|
[]
|
||||||
|
{:mode "advanced"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts empty-row})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The vendor's default account SHOULD appear in the row
|
||||||
|
(is (re-find (re-pattern (str vendor-account-id)) body)
|
||||||
|
"BUG: Vendor change in advanced mode with empty row should populate it")
|
||||||
|
(is (re-find #"Vendor Default Account" body)
|
||||||
|
"Vendor default account name should appear in the row"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-advanced-mode-filled-row-test
|
||||||
|
(testing "AC15b: vendor change in advanced mode with filled row should NOT overwrite"
|
||||||
|
;; In advanced mode with 1 row that already has an account selected,
|
||||||
|
;; changing vendor should NOT overwrite it
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "existing-account-id"
|
||||||
|
:account/name "Manually Selected Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "ADVFILLEDCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
existing-account-id (tempid->id result "existing-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Advanced mode with 1 row that already has an account
|
||||||
|
filled-row [{:db/id "row-1"
|
||||||
|
:transaction-account/account existing-account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts filled-row}
|
||||||
|
[]
|
||||||
|
{:mode "advanced"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts filled-row})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The existing account should still be there
|
||||||
|
(is (re-find (re-pattern (str existing-account-id)) body)
|
||||||
|
"Existing account should remain when vendor changes in advanced mode with filled row")
|
||||||
|
;; The vendor's default account should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str vendor-account-id)) body))
|
||||||
|
"Vendor default should NOT overwrite filled row in advanced mode"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-advanced-mode-two-rows-test
|
||||||
|
(testing "AC15c: vendor change in advanced mode with 2+ rows should NOT modify any"
|
||||||
|
;; In advanced mode with 2 or more rows, vendor change should not touch any row
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendor With Default"}
|
||||||
|
{:db/id "vendor-account-id"
|
||||||
|
:account/name "Vendor Default Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "vendor-account-id"}
|
||||||
|
{:db/id "account-1"
|
||||||
|
:account/name "Account One"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "account-2"
|
||||||
|
:account/name "Account Two"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "ADVTWOROWCL"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
vendor-account-id (tempid->id result "vendor-account-id")
|
||||||
|
account-1 (tempid->id result "account-1")
|
||||||
|
account-2 (tempid->id result "account-2")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; Advanced mode with 2 rows
|
||||||
|
two-rows [{:db/id "row-1"
|
||||||
|
:transaction-account/account account-1
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 50.0}
|
||||||
|
{:db/id "row-2"
|
||||||
|
:transaction-account/account account-2
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 50.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts two-rows}
|
||||||
|
[]
|
||||||
|
{:mode "advanced"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts two-rows})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; Both existing accounts should remain
|
||||||
|
(is (re-find (re-pattern (str account-1)) body)
|
||||||
|
"First row account should remain")
|
||||||
|
(is (re-find (re-pattern (str account-2)) body)
|
||||||
|
"Second row account should remain")
|
||||||
|
;; Vendor default should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str vendor-account-id)) body))
|
||||||
|
"Vendor default should NOT modify rows when 2+ exist"))))
|
||||||
|
|
||||||
|
(deftest vendor-change-client-specific-override-test
|
||||||
|
(testing "BUG: vendor change should use client-specific account override if present"
|
||||||
|
;; When a vendor has a client-specific account override, changing vendor
|
||||||
|
;; should populate the client-specific account, not the global default.
|
||||||
|
(let [result @(dc/transact conn [{:db/id "global-account-id"
|
||||||
|
:account/name "Global Default"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-specific-account-id"
|
||||||
|
:account/name "Client Specific Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "CLIOVERRIDE"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/name "Clientized Vendor"
|
||||||
|
:vendor/default-account "global-account-id"
|
||||||
|
:vendor/account-overrides [{:vendor-account-override/client "client-id"
|
||||||
|
:vendor-account-override/account "client-specific-account-id"}]}])
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
global-account-id (tempid->id result "global-account-id")
|
||||||
|
client-specific-account-id (tempid->id result "client-specific-account-id")
|
||||||
|
;; Simple mode with empty account row
|
||||||
|
empty-row [{:db/id "row-1"
|
||||||
|
:transaction-account/account nil
|
||||||
|
:transaction-account/location "Shared"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id 999999
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts empty-row}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts empty-row})
|
||||||
|
:entity {:db/id 999999
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The client-specific account should appear, not the global default
|
||||||
|
(is (re-find (re-pattern (str client-specific-account-id)) body)
|
||||||
|
"BUG: Vendor change should populate client-specific account override")
|
||||||
|
(is (re-find #"Client Specific Account" body)
|
||||||
|
"Client-specific account name should appear")
|
||||||
|
;; The global default should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str global-account-id)) body))
|
||||||
|
"Global vendor default should NOT appear when client override exists"))))
|
||||||
|
|
||||||
|
;;; Update AC5: simple mode SHOULD overwrite existing accounts
|
||||||
|
(deftest vendor-change-simple-mode-overwrites-ac5-test
|
||||||
|
(testing "AC5 UPDATED: vendor selection in simple mode DOES overwrite already-set account"
|
||||||
|
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||||
|
:vendor/name "Test Vendor"}
|
||||||
|
{:db/id "account-id"
|
||||||
|
:account/name "Test Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "vendor-id"
|
||||||
|
:vendor/default-account "account-id"}
|
||||||
|
{:db/id "other-account-id"
|
||||||
|
:account/name "Other Account"
|
||||||
|
:account/type :account-type/expense}
|
||||||
|
{:db/id "client-id"
|
||||||
|
:client/code "TESTCL2"
|
||||||
|
:client/locations ["DT"]}
|
||||||
|
{:db/id "transaction-id"
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/date #inst "2023-01-01"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/client "client-id"}])
|
||||||
|
tx-id (tempid->id result "transaction-id")
|
||||||
|
vendor-id (tempid->id result "vendor-id")
|
||||||
|
account-id (tempid->id result "account-id")
|
||||||
|
other-account-id (tempid->id result "other-account-id")
|
||||||
|
client-id (tempid->id result "client-id")
|
||||||
|
;; existing-accounts already set — but simple mode should still overwrite
|
||||||
|
existing-accounts [{:db/id "row-id"
|
||||||
|
:transaction-account/account other-account-id
|
||||||
|
:transaction-account/location "DT"
|
||||||
|
:transaction-account/amount 100.0}]
|
||||||
|
request {:multi-form-state (mm/->MultiStepFormState
|
||||||
|
{:db/id tx-id
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/accounts existing-accounts}
|
||||||
|
[]
|
||||||
|
{:mode "simple"
|
||||||
|
:transaction/vendor vendor-id
|
||||||
|
:transaction/accounts existing-accounts})
|
||||||
|
:entity {:db/id tx-id
|
||||||
|
:transaction/client {:db/id client-id}
|
||||||
|
:transaction/amount 100.0}}
|
||||||
|
response (edit-vendor-changed-handler request)
|
||||||
|
body (:body response)]
|
||||||
|
;; The handler returns an html-response; verify the body is HTML
|
||||||
|
(is (re-find #"manual-coding-section" body)
|
||||||
|
"Response body should contain the manual-coding-section element")
|
||||||
|
;; The vendor's default account SHOULD appear (overwriting previous)
|
||||||
|
(is (re-find (re-pattern (str account-id)) body)
|
||||||
|
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
|
||||||
|
;; The previous account should NOT appear
|
||||||
|
(is (not (re-find (re-pattern (str other-account-id)) body))
|
||||||
|
"Previous account should be replaced by vendor default"))))
|
||||||
|
|||||||
Reference in New Issue
Block a user