Files
integreat/e2e/invoice-new.spec.ts
Bryce 5502f4c4a2 refactor(ssr): Phase 8 — migrate New/Edit Invoice onto the engine (conditional :next)
The hardest modal in the app: one wizard that both creates and edits invoices,
with a conditional middle step (basic-details → [accounts] → next-steps, where
the expense-accounts step is skipped on the default-accounts path). Migrated off
mm/* + form-cursor + the EDN snapshot onto the session-backed engine (wizard2).

Finding: the OLD basic-details "Save" was broken. It hx-puts /invoice/new/navigate,
whose `[:to {:optional true} …]` query-schema 500s on empty query-params — Ring's
wrap-params yields {} for a no-query PUT, and main-transformer's parse-empty-as-nil
decodes {} → nil, which the bare [:map] rejects. Production uses the identical
wrap-params, so it was broken there too. So e2e/invoice-new.spec.ts is an ACCEPTANCE
gate (red on the old code, green on the engine, whose submit is a POST with no
query-schema): the migration fixes a latent bug. Create semantics (default → vendor
default account, location-spread; customize → posted grid; edit → prefill + updated
row) were pinned at the REPL.

What changed:
- defrecord 4 → 0 (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps), mm/ 0,
  fc/ cursor refs 0, step-params[…] field names 0.
- Conditional `:next` `(if (= :customize …) :accounts :done)` replaces mm/CustomNext +
  the broken 308-to-submit. Dual-purpose new+edit = one :init-fn branching on a route
  :db/id; create-wizard! seeds :init-data as per-step step-data so edit opens populated.
- The broken new-wizard-navigate route is deleted; the genuine async helpers
  (account-prediction, due/scheduled-payment-date, location-select, expense total/balance,
  add-row) remain but read the posted flat form (+ ws/get-all for the cross-step total).
- next-steps becomes the done-fn's returned modal (Pay now / Add another / Close).
- Dates ride as java.util.Date (#inst) in step-data so it's EDN-safe across the
  non-terminal step (clj-time DateTimes break the cookie store).

Verification: full e2e suite 61/61 (58 prior + 3 new); maybe-spread-locations unit
test 6/6; create semantics + edit prefill confirmed at the REPL. Skill fed
(scorecard Phase 8, gotchas {}→nil 500 + #inst dates, form-vs-wizard conditional
:next + dual-purpose).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:05:01 -07:00

102 lines
4.9 KiB
TypeScript

import { test, expect } from '@playwright/test';
// Acceptance spec for the New Invoice wizard (basic-details -> [accounts] -> next-steps),
// the dual-purpose new+edit wizard with a CONDITIONAL middle step: basic-details creates
// straight away when customize-accounts = :default (the vendor's default expense account),
// and routes through the expense-accounts grid when :customize.
//
// NOTE: the pre-migration `mm` flow's basic-details "Save" was broken in this harness (and
// prod): the button PUT /invoice/new/navigate, whose `:to` query-schema 500s on empty
// query-params (the {}->nil main-transformer quirk). So this is an ACCEPTANCE gate -- red on
// the old code, green on the engine (whose submit is a POST with no query-schema). The seed
// exposes client TEST + vendor "Test Vendor" (default account "Test Account") via /test-info.
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
async function seedIds(page: any): Promise<{ client: number; vendor: number }> {
const info = await (await page.request.get('/test-info')).json();
return { client: info.clientIds.test, vendor: info.accounts.vendor };
}
// Open the wizard from the invoice list (so htmx/alpine are present -- opening the modal
// fragment directly would submit natively).
async function openNewWizard(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto('/invoice');
await page.waitForSelector('#entity-table tbody tr');
await page.locator('button:has-text("New invoice")').first().click();
await page.waitForSelector('#wizard-form');
await page.waitForTimeout(400);
}
// The vendor field is a typeahead whose hidden input posts invoice/vendor; set it the way a
// dropdown pick would land (the value the form submits).
async function setVendor(page: any, vendorId: number) {
await page.evaluate((id: number) => {
const hidden = document.querySelector('input[name="invoice/vendor"]') as HTMLInputElement;
hidden.value = String(id);
hidden.dispatchEvent(new Event('change', { bubbles: true }));
}, vendorId);
}
// The customize-accounts radio lives in an async fragment loaded on the "bryce" event (fired
// when the Alpine vendorId changes). Trigger that htmx load explicitly after setting vendor.
async function loadPrediction(page: any) {
await page.evaluate(() => {
const el = document.querySelector('#expense-account-prediction [hx-put], #expense-account-prediction[hx-put]');
// @ts-ignore
if (el && window.htmx) window.htmx.trigger(el, 'bryce');
});
await page.waitForTimeout(600);
}
const save = (page: any) => page.locator('#wizard-form button:has-text("Save")').first().click();
test.describe.configure({ mode: 'serial' });
test.describe('New Invoice wizard (acceptance)', () => {
test('basic-details renders the invoice fields', async ({ page }) => {
await openNewWizard(page);
const form = page.locator('#wizard-form');
await expect(form).toContainText('New invoice');
await expect(form).toContainText('Vendor');
await expect(form).toContainText('Date');
await expect(form).toContainText('Invoice Number');
await expect(form).toContainText('Total');
await expect(form.locator('input[name="invoice/total"]')).toBeVisible();
});
test('default-accounts path creates the invoice and offers to pay it now', async ({ page }) => {
const { vendor } = await seedIds(page);
await openNewWizard(page);
await setVendor(page, vendor);
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1001');
await page.locator('input[name="invoice/total"]').fill('212.44');
await page.waitForTimeout(200);
await save(page);
await page.waitForTimeout(1200);
// the next-steps modal (done-fn output) -- no accounts step on the default path
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
});
test('customize-accounts path routes through the expense-accounts grid then creates', async ({ page }) => {
const { vendor } = await seedIds(page);
await openNewWizard(page);
await setVendor(page, vendor);
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1002');
await page.locator('input[name="invoice/total"]').fill('300.00');
await loadPrediction(page);
// pick "Customize accounts" (the radio in the async fragment)
await page.locator('input[name="customize-accounts"][value="customize"]').first().check();
await page.waitForTimeout(150);
await save(page);
await page.waitForTimeout(1000);
// the expense-accounts step: a grid prefilled with the vendor's default account + total
const form = page.locator('#wizard-form');
await expect(form).toContainText('Invoice accounts');
await expect(form).toContainText('INVOICE TOTAL');
await save(page); // accounts -> done
await page.waitForTimeout(1200);
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
});
});