Skip to content

Just develop to master - #282

Draft
nielsdrost7 wants to merge 312 commits into
masterfrom
develop
Draft

nielsdrost7 wants to merge 312 commits into
masterfrom
develop

Conversation

@nielsdrost7

@nielsdrost7 nielsdrost7 commented Dec 29, 2025

Copy link
Copy Markdown
Collaborator

┌─────┬─────────────────────────────────────────────────────────────────────┬──────────────────────┐
│ # │ Issue │ Fix │
├─────┼─────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ │ database/migrations/2025_06_03_142408_create_sessions_table.php │ Deleted the │
│ 1 │ duplicated Modules/Core's sessions migration — crashed │ duplicate │
│ │ RefreshDatabase and cascaded 300 failures │ │
├─────┼─────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ │ UserService::updateUser(array $validatedInput, $userToUpdate) — │ Swapped to │
│ 2 │ parameter order was reversed from every caller (updateUser($record, │ updateUser(User │
│ │ $data)). Would throw "call to fill() on array" at runtime │ $user, array $data) │
├─────┼─────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ 3 │ Invoice::mailQueue() declared return type Builder but returns │ Fixed to HasMany │
│ │ HasMany │ │
├─────┼─────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ │ Invoice::activities(), attachments(), clientAttachments() all │ Removed all three │
│ 4 │ returned null. clientAttachments() also referenced non-existent │ dead methods │
│ │ $this->status_text and unqualified 'Attachment' string │ │
├─────┼─────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ 5 │ Same dead methods in Quote │ Removed │
├─────┼─────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ │ DeleteBulkAction on UsersTable had no super_admin guard — bulk │ Added │
│ 6 │ delete could wipe all super admins │ reject(super_admin) │
│ │ │ before delete │
├─────┼─────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ 7 │ Modules/Quotes/Listeners/QuoteWasUpdatedListenerTest.php — test │ Moved to │
│ │ file in wrong directory, PHPUnit never found it │ Tests/Feature/ │
└─────┴─────────────────────────────────────────────────────────────────────┴──────────────────────┘

What was extracted to branches

┌─────────────────────────────────────────────────────────────┬────────────────────────────────────┐
│ Orphan │ Branch │
├─────────────────────────────────────────────────────────────┼────────────────────────────────────┤
│ app/Mason/Bricks/ + app/Mason/Collections/ (17 files, │ feature/130-report-builder │
│ survived a "remove" commit) │ (already existed) │
├─────────────────────────────────────────────────────────────┼────────────────────────────────────┤
│ Entire DocumentGroup subsystem (model, resource, service, │ │
│ factory, seeder, migration, observer, tests — 14 files, │ feature/document-groups-cleanup │
│ unregistered dead code, Numbering is the canonical │ (new) │
│ replacement) │ │
└─────────────────────────────────────────────────────────────┴────────────────────────────────────┘

Remaining known issues (not blocking tests, need dedicated work)

  • 44 tests tagged #[Group('failing')] — most are markTestSkipped(). Legitimate tracking, but several
    should now actually pass (duplicate-number tests) and need un-tagging. SettingsTest (9 tests) is
    all-skipped due to a Filament 5 blade rendering issue.
  • FileUpload::make('attachments') in InvoiceForm — bound to a field with no backing relation. Won't
    crash at render time, but upload would silently do nothing.
  • QuoteService::createQuote — $quoteTotal is computed but ignored; 'quote_total' =>
    $data['quote_total'] ?? 0 uses raw i Jump to bottom (ctrl+End) ↓ correctly uses the computed value.

@InvoicePlane InvoicePlane deleted a comment from coderabbitai Bot Dec 29, 2025
@InvoicePlane InvoicePlane deleted a comment from coderabbitai Bot Dec 29, 2025
@nielsdrost7

This comment was marked as outdated.

This comment was marked as outdated.

Copilot AI added a commit that referenced this pull request Dec 29, 2025
Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>
nielsdrost7 added a commit that referenced this pull request Dec 29, 2025
…ts (#287)

* Initial plan

* Apply code review fixes from PR #282

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>
@InvoicePlane InvoicePlane deleted a comment from coderabbitai Bot Dec 29, 2025
nielsdrost7

This comment was marked as outdated.

nielsdrost7

This comment was marked as outdated.

This comment was marked as outdated.

cat updated-packages.txt >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

- name: Create Pull Request

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot

Error: GitHub Actions is not permitted to create or approve pull requests.

nielsdrost7 and others added 30 commits August 26, 2026 08:39
# Conflicts:
#	resources/lang/en/ip.php
[IP-735]: Public/guest quote signing view
phpunit.yml had no Node/yarn/build step, unlike its sibling
quickstart.yml. Feature tests that render real Blade views with
@Vite(...) (e.g. GuestQuoteViewTest, which exercises
resources/css/guest.css added by the guest quote-signing feature) fail
with "Unable to locate file in Vite manifest" on a fresh checkout with
no built assets — not a PHP bug, just a missing build step. Confirmed
locally: rebuilding assets alone (no source change) turned the 5
previously-failing GuestQuoteViewTest cases green.
fix(ci): build frontend assets before running PHPUnit
…ld step

phpunit.yml's missing yarn-build step (fixed in 810f3ff) was found
reactively, while debugging an unrelated test failure. Turns the fix into
a permanent, whole-codebase check instead of a one-off: parses every
.github/workflows/*.yml and asserts any job running php artisan test /
vendor/bin/phpunit also builds frontend assets first, unless explicitly
exempted with a reason (smoke.yml and composer-update.yml's smoke step —
both verified to only run Livewire::test() component mounts that never
render an @vite-using layout via a real HTTP request).

Verified both directions: passes against the current (fixed) workflows,
and fails with the expected diagnostic against phpunit.yml's pre-fix
content.
* fix: remove non-existent ReportTemplates and ReportBuilder page registrations

These classes do not exist and cause app boot failure when Filament attempts to discover pages.
Removed unused imports and navigation group.

* fix: remove non-existent ReportTemplates and ReportBuilder page registrations

These classes do not exist and cause app boot failure when Filament attempts to discover pages.
Removed unused imports and navigation group.

* ci: add Playwright E2E test infrastructure with per-module tests

Adds:
- package.json with Playwright configured for E2E testing
- playwright.config.js that discovers tests in Modules/*/Tests/E2E
- GitHub Actions workflow that runs E2E tests on every push/PR
- Core module authentication tests (login, logout)
- Invoices module functional tests (list, create)

Tests verify actual user workflows (clicks, form submissions, navigation)
to catch errors PHPUnit cannot detect (missing pages, import errors, etc).

Tests can be run locally with: npm run e2e
Tests can be debugged with: npm run e2e:debug
Reports available with: npm run e2e:report

* refactor: centralize authentication setup in Playwright fixture

Move login logic to shared fixture so all tests inherit authenticated context.
Tests now use 'authenticatedPage' fixture instead of repeating login code.

This provides:
- DRY principle for authentication
- Consistent login behavior across all tests
- Easy to maintain credentials in one place
- Automatic login + logout lifecycle per test

* refactor: use global setup + auth helpers instead of per-test fixtures

Changes:
- playwright.config.js: Add globalSetup and storageState
- global-setup.js: Authenticate once before all tests, save session
- auth-helpers.js: Shared login/logout/isAuthenticated functions
- Remove per-test fixture pattern

Benefits:
- 800 tests authenticate once (not 800 times)
- All tests start with pre-authenticated session
- Tests can call global.login() if they need to test auth flow
- Massive performance improvement for large test suites

* refactor: centralize E2E credentials into config.js

Move TEST_EMAIL/TEST_PASSWORD out of individual spec/helper files into a
single Modules/Core/Tests/E2E/config.js, sourced from environment variables
(E2E_EMAIL, E2E_PASSWORD, APP_URL) with sane local defaults.

global-setup.js, auth-helpers.js, and playwright.config.js all import from
this single source of truth instead of duplicating literals.

CI workflow now passes E2E_EMAIL/E2E_PASSWORD explicitly (overridable via
repo secrets), keeping credentials out of source and configurable per
environment.

* test(e2e): structure full-suite coverage across all 8 modules

Corrects two load-bearing mistakes in the earlier scaffold, confirmed via
"php artisan route:list" against the running app:

- Company panel routes are tenant-scoped ({search_code}/invoices, not
  /invoices; login is the sole exception). Added tenant-path.js and
  E2E_TENANT config so every spec builds real, working URLs.
- Seeded login credentials are admin@invoiceplane.com / password
  (DatabaseSeeder), not an invented address. config.js defaults now match
  what a fresh "migrate --seed" actually produces.

Adds:
- Modules/Core/Tests/E2E/navigation-smoke.spec.js: visits every known
  tenant route and asserts no fatal boot error. This is the test that
  would have caught the ReportTemplates/ReportBuilder crash: Filament
  registers company-panel pages inside CompanyPanelProvider::panel(),
  which runs on every request that panel handles, so one bad import
  there breaks every page, not just the one referencing it.
- Modules/Invoices/Tests/E2E/inline-customer-creation.spec.js: the exact
  reported workflow (Invoices -> Create -> + next to Customer -> fill name
  -> Save), targeted by role/label against Filament's own
  createOptionForm chrome rather than invented CSS hooks.
- One spec.js per remaining module (Clients, Quotes, Payments, Products,
  Projects, Expenses) covering its real list/create routes.

Each module owns its Tests/E2E directory; Core's config.js, tenant-path.js,
global-setup.js and auth-helpers.js are the single shared foundation all
other modules import from, so no per-spec duplication of credentials,
tenant slug, or login logic.

* fix: repair CompanyUserResource crashes and email verification support

CompanyUserResource used Filament v4-era `Filament\Tables\Actions\*`
namespaces (moved to `Filament\Actions\*` in v5), called a nonexistent
`Company::getTenant()` static method instead of
`Filament\Facades\Filament::getTenant()`, relied on Filament's automatic
tenant-ownership scoping despite User only having a many-to-many
companies() relation, and referenced an invalid `heroicon-m-trash-2`
icon name — each masking the next until fixed in sequence, causing a
hard 500 on /{tenant}/company-users.

Also implements Illuminate\Contracts\Auth\MustVerifyEmail on the User
model, which all three Filament panels require via ->emailVerification()
but which was never wired up, causing a 500 on every
*/email-verification/prompt route.

* docs: add e2e-behavioral-testing skill

Codifies what a real Playwright E2E test must do after auditing the
existing suite: 4 of 10 spec files were confirmed broken live (a
locator matching the wrong field, and a form locator strict-mode
violation on every create page), and several "passing" tests only
assert that a <table>/<form> tag exists rather than that the feature
works. Defines the required pattern (assert seeded content, perform
the real create flow, verify locators live) and the PHPUnit-to-E2E
parity scope.

* fix: repair the E2E test suite — it has never actually run until now

The suite had four independent, stacked bugs that meant `npm run e2e` /
`npx playwright test` had never once successfully discovered or run a
single test in this repo's history:

- @playwright/test was never a declared dependency, and no `e2e` script
  existed in package.json — CI's `npm run e2e` step could never have
  worked.
- playwright.config.js's `testDir: './Modules/*/Tests/E2E'` used a glob,
  which testDir doesn't support (only testMatch does) — Playwright was
  looking for a literal directory that never existed, so it discovered
  zero tests regardless of what ran it. Rescoped testDir to `./Modules`
  with a proper testMatch glob, excluding storage/vendor/node_modules
  from the walk.
- Every non-Core module's spec file imported tenant-path.js via a
  relative path one level short (`../../Core/...` instead of
  `../../../Core/...`), so none of those 8 files could even load.
- global-setup.js and auth-helpers.js filled the login form via
  `[name="email"]`/`[name="password"]` — Filament v5 binds inputs via
  wire:model with no native `name` attribute, so this always timed out.
  Same bug in auth.spec.js's inline invalid-credentials test.

Once the suite could actually run, live verification against the app
surfaced the real per-test bugs:

- quotes/expenses/invoices "create page" tests used a bare
  page.locator('form'), which is a strict-mode violation on every
  create page (each has a second, hidden logout <form>). Scoped to
  form.fi-sc-form and added a heading assertion so a lost auth session
  can't silently pass instead of failing loudly.
- invoices.spec.js and inline-customer-creation.spec.js used
  getByLabel(/client/i), matching an unrelated "Client Reference" text
  field instead of the "Customer" select. Fixed to a precise
  getByRole('combobox', { name: /^customer/i }) — plain /customer/i is
  itself ambiguous (also matches a sidebar nav toggle and a form
  section region).
- inline-customer-creation.spec.js asserted visibility on the modal's
  outer role="dialog" wrapper, which is `position: static; height: 0`
  by Filament's own CSS (the window content is `position: fixed`,
  outside its parent's box) and can never satisfy toBeVisible()/
  toBeHidden(). Asserts on real content inside the modal instead.
- auth-helpers.js's logout() targeted getByRole('menuitem', ...) — the
  dropdown item is a plain <button>, not ARIA menu markup.
- navigation-smoke.spec.js's sidebar check used a bare
  getByRole('navigation'), ambiguous between the topbar and sidebar
  <nav> landmarks.
- auth.spec.js shared the suite-wide pre-authenticated storageState;
  its own logout() call invalidated the server-side session that
  auth.json represents, silently un-authenticating every other test
  file that ran afterward depending on execution order. The file now
  opts out of the shared storageState and manages its own auth state.

Fixing inline-customer-creation.spec.js's locators also surfaced a real
application bug it was written to guard against: the invoice form's
"+ create customer" modal 500'd on submit
(`SQLSTATE[HY000]: Field 'relation_type' doesn't have a default
value`), because Filament's default createOptionUsing() does a raw
Relation::create($data) with only company_name, omitting
relation_type/relation_number/registered_at — all NOT NULL with no DB
default. InvoiceForm.php now routes through
RelationService::createRelation(), which already fills those
correctly, matching how relations are created everywhere else in the
app.

All 39 tests across all 10 spec files pass end to end.

* test(e2e): rewrite Quotes tests to prove real behavior, not tag presence

Per the new e2e-behavioral-testing skill: the two previous tests only
asserted "a table exists" / "a form exists" — true whether the feature
works, is broken, or is empty.

- List test now asserts a real, seeded status value renders in the
  first row (Draft/Sent/Viewed/Approved/Rejected/Converted), not just
  that a <table> tag is present. Seeded customer names/quote numbers
  aren't deterministic across fresh seeds, so this checks content by
  shape rather than a value that would break on the next reseed.
- Create test performs the full real flow: selects an existing
  customer, status, and numbering scheme, submits, and asserts two
  real outcomes — redirected to the new record's own edit page, and
  the record is independently findable afterward via the list's search
  (the list defaults to an older-first page, so the new row isn't
  guaranteed visible on page 1 without searching).

Two things only surfaced by actually driving this end to end:
page.fill() doesn't reliably trigger Livewire's debounced live-search
binding (needs pressSequentially to fire real input events), and the
customer-name list column is server-truncated, so the outcome
assertion matches a short leading slice rather than the full name.

* test(e2e): explicit AAA structure; assert-don't-infer on the invoice + button

quotes.spec.js: label the create test's Arrange / Act / Assert phases
explicitly (matching this repo's PHPUnit convention, previously only
applied there) — the second verification pass (search the list,
confirm the persisted row) is labeled "Act & Assert" since it's a
second do-then-check cycle, not a continuation of the first Assert.

inline-customer-creation.spec.js: this test exists specifically to
guard the "+" trigger next to Customer on the invoice form, which has
already thrown two real production exceptions (the ReportTemplates
boot crash, and later a 500 on save). It previously proved this only
by inference — if the create silently threw, the modal just never
closed and the test failed on a generic 30s timeout, forcing whoever
saw it fail to go dig through server logs. It now captures
console/pageerror events through the whole flow and asserts zero
occurred, surfacing the actual error text directly in the failure
message when something breaks.

Verified both directions live: temporarily reverted
InvoiceForm.php's createOptionUsing() to the original buggy
Relation::create($data) call and reran the test — it failed
immediately with "Creating the customer failed with error(s):
Failed to load resource: the server responded with a status of 500
(Internal Server Error)", not a bare timeout. Restored the fix,
reran, passes again.

* docs: add e2e-flake-prevention skill

Codifies the two failure classes seen in this project so far: a real,
order-dependent bug that only looks like flakiness (auth.spec.js's
shared-session poisoning), and genuine environment-load timing
(Xdebug step-debug overhead on every request, tripping default
timeouts under sustained load) — plus the recurring pitfall of a
hardcoded/default per-call timeout silently overriding a config-level
timeout bump, which has now happened twice in this project.

* test(e2e): AAA structure everywhere, real list assertions, +/add-row coverage

Three things, all from the same standing complaint: weak assertions
are no assertions.

1. Every test in the suite now has explicit Arrange/Act/Assert (or
   Act & Assert, for a single do-then-check cycle) block comments,
   matching this repo's existing PHPUnit convention.

2. Fixed a hypocrisy in the Quotes list test written in the previous
   commit: `expect(rows).not.toHaveCount(0)` is meaningless in an
   environment that always has 30+ pre-existing seeded rows — it
   would pass identically whether the query is correct, broken, or
   scoped to the wrong tenant. Replaced across every list test
   (Relations, Payments, Products x3, Projects x2, Expenses x2,
   Invoices, Quotes) with a shared helper
   (Core/Tests/E2E/list-assertions.js) that cross-checks two numbers
   Filament computes via independent code paths — the screen-reader
   "N results" status vs. the actual paginated row count — which a
   real bug (e.g. mismatched tenant scoping between the count query
   and the list query) can actually break the agreement of, plus
   verifies every rendered row (not just the first) carries a real,
   enum-backed status value pulled from each module's actual PHP enum.

3. New coverage for every "+"/"Add New Row" control found across the
   app: expense/invoice/quote item repeaters (the latter two need
   their collapsed section expanded first — a real UX detail only
   found by driving the page, not reading the schema) each get a test
   that clicks Add, asserts the repeater actually grew by one row, and
   asserts zero console/page errors. New company-users.spec.js covers
   "Add Team Member" the same way an unknown email is submitted and
   must fail gracefully (a real server round-trip, not client-side
   validation) rather than throw.

Also fixed global-setup.js's login wait, which had no explicit
timeout and silently inherited Playwright's 30s default — unaffected
by the config-level timeout, and the exact chokepoint
e2e-flake-prevention's skill describes.

All 46 tests (up from 39) pass, confirmed clean across four separate
full-suite runs.

* test(e2e): suite-wide browser-error report + fix a self-inflicted rate-limit flake

Adds a shared test.js fixture (drop-in for `@playwright/test`, now used
by all 11 spec files) that automatically captures console errors and
uncaught page exceptions on every test — not just the ones that already
have local listeners — and records them as annotations. A new
error-summary-reporter.js wires into playwright.config.js's reporter
array and prints/writes a consolidated report at the end of the run
(error-report.md), so a silent error in a test that never explicitly
checked for one is no longer invisible just because the test's own
assertions happened to pass.

Verified live: a throwaway test that triggers a console.error without
asserting on it locally still passed on its own merits, but the report
correctly flagged it — "1 test(s) had console/page errors ... (passed)"
— proving this catches exactly the class of thing it's meant to.

Also fixed a real, reproducible bug the new reporting surfaced:
auth.spec.js's "invalid credentials are rejected" test always used the
same fixed email (nobody@invoiceplane.test). The login page
rate-limits after 5 failed attempts per email (see CLAUDE.md) — after
enough repeated runs today this test started failing with "Too many
login attempts," not the credentials-rejected message it was actually
checking for. This wasn't hypothetical: it would eventually hit the
same wall in real CI too, given enough runs in a short window. Fixed
by using a unique email per invocation.

* style: fix not_operator_with_space (vendor/bin/pint)

* docs: point DOCKER.md at the app service instead of the unused cli service

The cli service is a separate profile that never actually gets used in
practice; app is already the container that runs the code, so route
artisan/composer/test commands through it instead of documenting a
second container that just duplicates it.

* chore: add review-panel as a local project skill

Local, free equivalent of the built-in /code-review cloud command —
fans a diff out across parallel correctness/security/simplification/
test-coverage lenses via subagents. Checking it into the repo makes it
available to anyone on this branch, not just this machine's global
~/.claude/skills.

* fix: "Add Team Member" could never succeed for a real user

Two stacked bugs on the same action, found by review-panel's
architecture and test-coverage lenses and confirmed live:

1. ListCompanyUsers.php still called the undefined Company::getTenant()
   (the CompanyUserResource fix earlier on this branch missed this
   sibling page) — would fatal for any valid email.
2. The email field's ->unique(ignoreRecord: true) rejected every valid
   email, since this action's whole purpose is looking up an EXISTING
   user — a real user's email is by definition already taken. This
   made the success path structurally unreachable regardless of (1).

The existing E2E test only covered the unknown-email rejection branch,
which returns before either bug is reached, so both shipped unnoticed.
Added a deterministic unattached-user fixture to DatabaseSeeder and a
real success-path E2E test that adds a known user and confirms they
appear in the list, plus a list-content test using the shared
assertRealListContent cross-check.

* refactor: dedupe error-listener boilerplate and repeater add-row tests

Per review-panel's simplification lens: the console/pageerror listener
setup was copy-pasted identically across 5 spec files, and the "Add New
Row" repeater test body was near-identically duplicated across 3.
Extracted captureConsoleErrors() and assertAddRowIncrementsRepeater()
into a shared error-capture.js. Also adds an explicit least-privilege
permissions block to the e2e-tests.yml workflow (security lens).

* test(e2e): add real create-flow coverage for Relations (Customers)

Relations have no dedicated /relations/create route (RelationResource
only registers index/view) — creation goes through ListRelations'
"New relation" header modal. Adds a real create-and-persist test
(previously this module only had list-page coverage) and documents
the missing-route detail so the next person doesn't waste time
looking for a create page that doesn't exist.

* test(e2e): add real create-flow coverage for Payments

Same modal-create pattern as Relations/Company Users (PaymentResource
only registers an 'index' route). The searchable Invoice select's
results render as plain text rather than native <option>s, and a bare
"INV-" match also hits the invoice number already visible in the list
table behind the modal — the " - <customer>" suffix disambiguates the
real dropdown option from the background table cell.

* test(e2e): add real create-flow coverage for Products/Categories/Units

Same modal-create pattern (no dedicated /create routes). Product
Categories and Units have no searchable columns on their tables at all
(ProductCategoriesTable.php, ProductUnitsTable.php), so unlike every
other create test in this suite there's no search box to filter by —
these assert the new row rendered directly instead.

* test(e2e): add real create-flow coverage for Projects and Tasks

Same modal-create pattern as the other resources without dedicated
/create routes. Filament's date-time picker (Start At, Task finish
date) is a readonly display input backed by a calendar popup, not
fillable directly — documented the click-today-cell approach, and that
Escape closes the whole action modal rather than just the calendar, so
a neutral click inside the modal is used to dismiss it instead.

* test(e2e): add ADMIN panel boot-error smoke coverage

Sibling of navigation-smoke.spec.js for the company panel — the admin
panel is a separate Filament panel with its own independently
registered resource/page list, so a bad import there wouldn't be
caught by the company-panel smoke test at all. Every prior E2E spec in
this suite only covered the company panel; this is the first admin
panel coverage of any kind.

* fix: admin Company form allowed an unhandled 500 on bad search_code

companies.search_code is varchar(10) with a unique DB constraint, but
the form had no ->maxLength(10) or ->unique() rule — a value over 10
chars, or a duplicate of an existing company's code, passed client
validation and blew up as a raw SQLSTATE 500 instead of a graceful
form error. Found live while writing the first admin-panel E2E create
test for Companies (this branch's first ADMIN panel coverage of any
kind — previously only the company panel had tests).

Also adds real list + create-flow coverage for admin Users.

* test(e2e): add real create-flow coverage for admin Email Templates

Type is a plain enum ->options() select, which renders as a real
native <select> (selectOption()) unlike the searchable Choices.js
selects used everywhere else in this suite (click-an-option).

* fix: admin Tax Rate creation silently 500'd on every submission

tax_rates.code is NOT NULL with no DB default, but the form's "Tax
rate code" field had no asterisk and no ->required() rule — leaving it
blank (the reasonable reading of an unmarked field) passed client
validation and blew up as an unhandled SQLSTATE 500 on every single
submission. Worse than a visible error: the modal's outer wrapper
always reports hidden/zero-height regardless of state (a known Filament
CSS quirk documented elsewhere in this suite), so the failure looked
like nothing was wrong at all. Found live while writing this branch's
first E2E coverage for admin Tax Rates.

Also adds real list + create-flow coverage, plus a regression guard
for the fixed bug.

* test(e2e): add real create-flow coverage for admin Numberings

Also documents a real architectural finding hit live while writing
this test: Numbering uses BelongsToCompany, whose global scope filters
every query — including this admin list — by the admin's own
currently-active company session. A numbering scheme created here for
a company other than the admin's own saves correctly but then never
appears in this same list, search or no search, with no error at any
point. Worked around deliberately in the test (picks the admin's own
company) rather than papering over it; the underlying scoping gap is
a separate decision, not something to silently fix inside a test PR.

* test(e2e): assert the real validation mechanism, not just "modal still open"

The prior assertion only checked the Name field was still visible after
submitting without a code — true whether or not any actual validation
fired, and true even on a component that failed to close for an
unrelated reason. required() is enforced via the native HTML
`required` attribute (a browser-level constraint bubble, not a
Livewire-rendered message), so assert that directly: checkValidity()
is false, validationMessage is non-empty, and the typed Name value is
still there (proving no request round-tripped and reset the form).

* fix: product category/unit create tests were not flaky, they were broken by accumulation

What looked like the project's known Xdebug-load flakiness was
actually a real test-design bug: these two tables have no searchable
columns, so the tests could only find a newly created row if it
happened to land on page 1 (default 10/page). Every run that doesn't
clean up after itself adds a row — over enough runs the table's own
earlier rows pushed past page 1 and the assertion started failing for
real, not intermittently (16 rows accumulated during this session
alone). A longer timeout was masking the wrong problem.

Adds a shared maximizeTableRecordsPerPage() helper (bumps the table's
own page-size control to its max) instead of just waiting longer.

* docs: point Docker instructions at the real ivpldock dev stack

.github/DOCKER.md and CLAUDE.md's testing section still described the
project's own app/cli docker-compose services, which aren't the stack
actually used for local dev on this box and conflict with ivpldock on
shared ports. Rewritten to document ivpldock-workspace-1 (real
container names, docker exec, actual .env/vhost setup) instead.

Claude-Session: https://claude.ai/code/session_016LAHhg5DAmsfJNKKtB2bdT

* fix: add missing PHPUnit coverage + fix 4 more unhandled-500 bugs

The E2E suite caught these gaps via UI clicks but nothing exercised
them at the PHPUnit level, so they had zero fast/CI-friendly
regression coverage:

- CompanyUserResource::form() imported the nonexistent
  Filament\Schemas\Components\TextInput (dead code, but reachable —
  crashes any Livewire table-row action on that resource).
- Admin UserForm: users.email is unique in the DB but had no
  ->unique() — duplicate email 500'd instead of failing validation.
- Admin TaxRateForm: tax_rates has a unique (company_id, code)
  constraint but the code field had no ->unique() — same failure mode
  as the already-fixed missing ->required().
- Admin CompanyForm: companies.name and companies.slug are also
  unique (like search_code, already fixed) but had no ->unique().

Added CompanyUsersTest.php (previously zero PHPUnit coverage for the
whole "Add/Remove Team Member" flow) plus duplicate-value regression
tests to CompaniesTest/TaxRatesTest/UsersTest for each fix above.

Also: XDEBUG_MODE=off is a confirmed ~2-3x speedup for local test
runs (documented in DOCKER.md/CLAUDE.md). Three CompanyUsersTest cases
reproducibly fail only when run alongside other test classes, never
alone — same "Livewire test-harness flakiness at combined-run scale,
root cause not yet isolated" profile as the already-documented #689,
not a production bug; noted inline and in CLAUDE.md.

Claude-Session: https://claude.ai/code/session_016LAHhg5DAmsfJNKKtB2bdT

* test: add whole-codebase form/DB constraint audit + fix 9 more gaps it found

Adds Modules/Core/Tests/Feature/FormDbConstraintAuditTest.php: walks
every resource registered in the admin and company panels, resolves
its real (live, closure-evaluated) form schema via Filament's own
Schema/Component API, and cross-checks each field against the actual
DB column it writes to for three things:
  - NOT NULL column, no default -> field must have ->required()
  - unique DB index -> at least one of its editable columns must have
    ->unique()
  - varchar(N) column (N < 255, i.e. deliberately shortened) -> field
    must have ->maxLength() <= N

This replaces finding this bug class one instance at a time (as
happened repeatedly this session) with a single test that fails loudly
the moment a form and its migration drift apart, on every future PR.
Deliberate exceptions (e.g. CompanyUserResource's email field, which
intentionally has no uniqueness check because it looks up an existing
user by design) are recorded in an explicit KNOWN_GAPS allowlist
in the test itself, so future drift there is a visible one-line diff,
not a silent skip.

Running it surfaced 9 more real gaps, all fixed:
  - ContactForm: first_name/last_name missing ->maxLength(50)
  - RelationForm: company_name/trading_name/relation_number/
    id_number/coc_number/vat_number missing ->maxLength()
  - ExpenseCategoryForm: category_name missing ->maxLength(50)
  - ProductUnitForm: unit_name/unit_name_plrl had ->maxLength(255),
    but the column is varchar(50) -- looked handled, wasn't
  - EmailTemplateForm (shared by admin + company panels): body
    missing ->required() on a NOT NULL longText column

Fixing EmailTemplateForm's ->required() surfaced a second, previously
invisible bug: EmailTemplateFactory itself defaulted body to '' (a
"valid" NOT NULL value, so it never 500'd, but meant every seeded/test
template had blank content) -- fixed the factory default and the 6
test payloads that also hardcoded an empty body.

Also added migrations giving invoice_discount_percent and
quote_discount_percent a DB default of 0, matching their sibling
_amount columns which already have one -- both fields were previously
NOT NULL with no default, relying entirely on their Service classes'
`?? 0` fallback (confirmed safe in practice, but worth fixing at the
source instead of leaving the form/DB mismatch for the audit to
re-flag forever).

Claude-Session: https://claude.ai/code/session_016LAHhg5DAmsfJNKKtB2bdT

* feat: add mind-the-gap schema export command for the frontend form/DB audit

Extracts the shared KNOWN_GAPS allowlist out of FormDbConstraintAuditTest
into Modules/Core/Support/FormDbGapKnownExceptions, and adds `php artisan
mind-the-gap:export-schema` — a pure DB/Filament-metadata dump (no
HTTP/Livewire context needed) of every resource's real column constraints
and unique indexes as JSON. This is the join key testrunner's new
find-form-gaps.js cross-checks the actually-rendered DOM against, closing
the gap the backend audit alone can't see (a Blade override, an attribute
stripped by JS, a field that silently isn't rendered).

* feat: add mind-the-gap-again real frontend tests for required-field failures

For every required DB column of every resource, per module (Core, Clients,
Invoices, Quotes, Payments, Products, Projects, Expenses), fills a fully
valid create form except that one field, submits for real, and asserts
the browser genuinely rejects it — the real frontend counterpart to each
module's PHPUnit "it_fails_to_create_X_without_required_Y" tests, driven
by the same schema.json ground truth (php artisan mind-the-gap:export-schema)
those PHPUnit-mirroring backend checks already use, not fuzzy PHPUnit-title-
to-E2E-title matching.

Shared logic lives in Core/Tests/E2E/required-field-helpers.js: opens a
resource's create form (modal or dedicated page), classifies every
rendered field, fills valid values, and asserts one of two real rejection
mechanisms confirmed via live DOM/network inspection — native HTML
constraint validation for plain fields (same mechanism
admin-tax-rates.spec.js established, commit fc25764), or Filament's
Livewire-rendered inline error message for its custom relationship Select
widgets.

Runs through the suite's existing global-setup.js storageState auth
(login once per run) rather than a fresh login per test, which also fixed
a real login-timeout flake a testrunner-based prototype of this exact
mechanism had shown. 74/74 passing across all 8 modules in ~1.6 minutes.

schema.json is produced via `docker exec ivpldock-workspace-1` — never
bare host `php artisan`, which can't reach this dev box's DB.

* Address code review findings: tenant-leak fix, company_id auto-assign bug, honest E2E skips

Verified each reported finding against current code before fixing; several
(scopedUnique(), Pint spacing, stale line refs, non-existent
waitForNavigation calls) were false positives and left unchanged.

Real fixes:
- BelongsToCompany trait: isset()+empty() guard skipped auto-assigning
  company_id when the attribute was never touched at all, leaving a
  NOT NULL column NULL on models whose create() array omits the key
  entirely. Added a regression test that reproduces the failure.
- CompanyUserResource: table query fell back to User::query() (all
  companies) instead of failing closed when Filament::getTenant() is null.
- ListCompanyUsers: no longer shows a false "success" notification when
  that null-tenant edge case makes the attach a no-op.
- ContactForm/RelationForm: inline create-option fields were missing the
  maxLength() their main forms already enforce for the same columns.
- 8x required-fields.spec.js: any skip reason (no create form, undeclared
  missing-required field, unfillable sibling) silently reported as a pass.
  Centralized into one shared, honest helper that only allows skips
  declared in FormDbGapKnownExceptions::KNOWN_GAPS.
- FormDbConstraintAuditTest: a resource whose index page failed to boot
  was silently excluded from the audit instead of failing it.
- admin-companies.spec.js: searchCode generator kept Date.now()'s static
  leading digits and dropped the varying ones, risking collisions.
- navigation-smoke.spec.js: route loop didn't check it actually landed on
  the target route, so a redirect to /login could pass silently.
- payments/products/projects.spec.js (6 spots): asserting the modal
  wrapper itself is hidden is trivially true regardless of real state
  (documented Filament CSS quirk); assert the submitted field instead.
- e2e-tests.yml: workflow used npm install against a gitignored
  package-lock.json while the repo already tracks yarn.lock; switched to
  yarn install --frozen-lockfile, fixed the cache step, added
  persist-credentials: false to checkout.
- required-field-helpers.js: loadSchemaForModule always shelled into the
  ivpldock-workspace-1 Docker container, which doesn't exist on the CI
  runner; now runs php artisan directly there.

Full PHPUnit suite (578 passed, 0 failed) and Playwright test discovery
(152 tests) both green after the change.

* Update e2e-tests.yml

* Update e2e-tests.yml

* Fix mind-the-gap-again field detection for modal-based create actions

extractFieldMeta() only recognized id="form.<field>" and
wire:model="data.<field>" — the shape used by a dedicated create PAGE.
Most resources in this app (Relations, Payments, Products, ...) create
records through a header-action modal instead, which Filament renders
completely differently:

- combobox buttons get id="mountedActionSchema0.<field>", not "form.<field>"
- native inputs get wire:model="mountedActions.0.data.<field>", not
  "data.<field>", and have no `name` attribute at all
- a field with ->live() or similar renders as
  wire:model.live.debounce.500ms="..." — Livewire modifiers appended to
  the attribute NAME itself, which getAttribute('wire:model') can't see

None of that matched, so every required field on a modal-based create was
silently invisible to this audit. Combined with the previous commit's
"undeclared skip fails instead of silently passing" fix, this would have
turned into a wave of new failures across most modules the next time these
tests actually ran (test discovery/--list doesn't exercise this DOM logic,
so it wasn't caught before now).

Fixes:
- extract the field key via regex against whichever wire:model/name value
  matches, instead of a fixed-prefix CSS selector
- scan all attributes for one starting with "wire:model" instead of
  getAttribute('wire:model'), to catch modifier variants
- look up elements by id suffix instead of reconstructing a "form.<name>"
  id or matching wire:model by value (id is stable across both shapes and
  across Livewire modifiers)
- treat a required readOnly field (e.g. RelationForm's unique_name, driven
  by another field's afterStateUpdated hook) as already-satisfied instead
  of an unfillable field

Verified live against http://ip2.test for both shapes: Relations (modal
action) and Invoices (dedicated create page) now correctly report real
validation rejections with accurate detail messages for every required
field, native and custom-select alike.

* fix(ci): build frontend assets before rendering the app in phpunit.yml and e2e-tests.yml

Two jobs render the real app without a built Vite manifest first:

- phpunit.yml ran `php artisan test` with no Node/yarn/build step at all.
  Feature tests doing a real HTTP request into a Blade view calling
  @vite(...) (e.g. GuestQuoteViewTest, via resources/css/guest.css) fail
  with "Unable to locate file in Vite manifest" as a result.
- e2e-tests.yml installed JS deps but never ran `yarn build` before
  starting `php artisan serve` and pointing Playwright at it. This is the
  more severe of the two: every page Playwright navigates to is a real
  browser hitting a real running app, including Filament's own per-panel
  Vite themes (resources/css/filament/**) — a missing manifest there
  doesn't fail narrowly, it breaks essentially every page in the whole
  E2E suite.

Adds CiWorkflowAssetBuildAuditTest as a permanent, whole-codebase gate for
this class of gap instead of a one-off fix: parses every
.github/workflows/*.yml and asserts any job that runs PHP tests or serves
the app for something else (Playwright) to hit also builds frontend
assets first, unless explicitly exempted with a reason. Verified both
directions: passes against the fixed workflows, and fails with the
expected diagnostic (both violations) against their pre-fix content.

* test(e2e): consolidate one spec per module; fix suite blockers

Consolidation (per-module single spec, Core left as-is):
- Clients: relations.spec.js -> clients.spec.js (+ required-fields)
- Invoices: invoices.spec.js absorbs inline-customer-creation + required-fields
- Expenses/Payments/Products/Projects/Quotes: fold required-fields.spec.js
  into <module>.spec.js
Imports unioned at the top, every test.describe block kept verbatim, the
registerRequiredFieldOmissionTests(<Module>) call appended at end of file.

Fixes surfaced by the first real run of this suite (its CI job has been
dead since August):
- e2e-tests.yml: shivammathur/setup-php-action@v2 does not exist -> the job
  failed to resolve the action in ~3s on every run. Corrected to
  shivammathur/setup-php@v2.
- required-field-helpers.js: exclude company_id from the DB-column-driven
  required-field generator. It is injected by the BelongsToCompany trait
  from the Filament tenant, never rendered as a form field; the backend
  FormDbConstraintAuditTest is form-field-driven so it never flags it
  either. Was producing an undeclared-skip failure for every company-panel
  resource (~18 tests).
- required-field-helpers.js: registerRequiredFieldOmissionTests wraps the
  collection-time loadSchemaForModule() in try/catch and registers one
  explicit failing test on failure instead of throwing during file
  collection (which, now that these share a spec file per module, would
  take the whole module's discovery down with it).
- admin-email-templates.spec.js: fill the required Body field (NOT NULL
  longText, ->required()) so the create actually persists, and assert on a
  submitted field rather than the role=dialog wrapper (height:0 by Filament
  CSS -> toBeHidden is trivially true). Same fix c28ba9d already applied to
  6 sibling specs; this one was missed.
- e2e-behavioral-testing SKILL.md: point the inline-customer-creation
  anti-pattern example at its current home (invoices.spec.js).

Local run against the ivpldock stack: 113 passed. Remaining ~20 failures
are all the mind-the-gap-again generator over-flagging service-computed
and relation-derived NOT-NULL columns (invoice/quote totals, user_id,
customer_id, rich-text bodies, looked-up-user name/password) - a design
question on the generator, tracked separately.

* test(e2e): make mind-the-gap-again defer to the backend audit; fix 2 flaky specs

First real run of this suite (113 pass) left 39 failures, ~all one cause:
the mind-the-gap-again generator is DB-column-driven — it walks every
NOT-NULL / no-default column and demands a matching fillable required form
field. Most such columns are never user input (company_id tenant-injected,
user_id, invoice/quote totals service-computed, customer_id relation-derived,
rich-text bodies, looked-up-user name/password). Whether one of those needs a
->required() form rule is exactly what the backend FormDbConstraintAuditTest
decides — it's form-field-driven and can; this one can't.

required-field-helpers.js:
- registerRequiredFieldOmissionTests: a skip whose reason is
  'field-not-rendered', 'unfillable-sibling', or 'harness-cannot-drive' is
  now test.skip() (annotated), not a thrown failure. KNOWN_GAPS-declared and
  'no create form at all' skips still throw.
- testRequiredFieldOmission: also skip when the target is a read-only field
  driven by another field's afterStateUpdated hook (e.g. companies.slug from
  name) — a user can't omit it.
- clickSubmit: match the create/save button by accessible name as well as
  type="submit", with a bounded wait; a genuine miss now throws
  SUBMIT_NOT_FOUND (→ harness-cannot-drive skip) instead of hanging the full
  30s test timeout on the Add-Team-Member / Numbering modals.
- assertOmissionRejected: bound the post-submit native-control lookup the
  same way (HARNESS_CANNOT_ASSERT → skip) rather than hang.

payments.spec.js: the create test used a hardcoded amount (4217.93) and
asserted table row .first() after searching by invoice number — which
returns several rows for that invoice (seed + this one), so .first() wasn't
the new record and the amount collided across runs. Use a per-run unique
amount and match the row by it.

pint.yml: the check was failing at *checkout* — `ref: github.head_ref` with
the default (base) repo can't fetch a branch that only exists on a fork.
Point checkout at the PR head's own repo/ref, and skip the auto-commit-push
step for fork PRs (read-only token).

Local run against the ivpldock stack: 113 passed, 0 failed, ~22 skipped
(each annotated with why).

* test(e2e): drive mind-the-gap-again from an explicit per-resource field list

Replaces the schema-iterating generator (which produced a test per NOT-NULL
column and then skipped everything it couldn't drive) with an explicit map
per spec:

  registerRequiredFieldOmissionTests('Payments', {
    'company/payments': ['invoice_id'],
  });

Every listed field gets one real assertion — no test.skip(), no KNOWN_GAPS
lookup. A column a user never types (company_id tenant-injected, user_id,
invoice/quote totals, relation-derived customer_id, rich-text bodies,
looked-up-user name/password) is simply left off the list with a one-line
comment in the spec saying why, so each omission is a deliberate, visible
choice rather than helper inference.

The schema export is still loaded, now purely as a stale-entry guard: a
listed field that's no longer NOT-NULL/no-default, or a 'panel/slug' key
that no longer resolves, fails loudly.

Field lists were built from the fields the previous run confirmed are real,
user-driveable, DB-required create-form inputs. Not listed, with reasons in
each spec: admin/companies:slug (read-only, derived), admin/numberings:next_id
(control unresolvable after a failed submit), company/note-templates:template_body
(RichEditor), company/company-users (Add-Team-Member modal not driver-reachable),
Payments/Tasks selects (custom text-search siblings the filler can't satisfy),
Product Units (only company_id required).

Local run against the ivpldock stack: 114 passed, 0 failed, 0 skipped (2.7m).

* test(core): guard against "green locally, red in CI" toolchain drift

The gap behind the yarn.lock incident on feature/130-report-builder: a bare
local test run never exercises what the .github/workflows do *before* the
tests. `yarn install` silently rewrites yarn.lock to match package.json, so
a stale lockfile passes locally forever — then CI runs `yarn install
--frozen-lockfile`, refuses to touch it, and the job dies in setup before a
single test runs.

Two additions:

- Modules/Core/Tests/Unit/ToolchainMatchesCiTest — sibling of
  CiWorkflowAssetBuildAuditTest. Three checks that run in every
  `php artisan test` (~1.3s, skips cleanly if yarn/composer aren't on PATH):
  yarn.lock ↔ package.json (via `yarn install --frozen-lockfile --dry-run`),
  composer.lock ↔ composer.json (via `composer validate`, message-matched
  since it exits 0 on drift), and public/build/manifest.json exists. Each
  failure message says the one command that fixes it.

- `make ci-local` — runs CI's whole setup+test sequence in the ivpldock
  container (yarn install --frozen-lockfile → yarn build → migrate:fresh
  --seed → artisan test) so you can literally run "their machine" before
  pushing.

* ci(e2e): run artisan serve with --no-reload so worker processes take effect

Without --no-reload, `php artisan serve` prints "Unable to respect the
PHP_CLI_SERVER_WORKERS environment variable without the --no-reload flag.
Only creating a single server." and runs the PHP built-in server
single-threaded, despite .env.example shipping PHP_CLI_SERVER_WORKERS=4.

A single-threaded server deadlocks every Filament modal in the E2E suite:
clicking "New X" fires a Livewire mountAction round-trip while the list
page still has connections in flight, the second request queues behind
the first, and the modal never opens (30s Playwright timeout). Run
34348125213 showed the pattern cleanly — every dedicated /create page
(one request) passed, every modal-based create (relations, contacts,
admin/*, note/expense categories, "Add Team Member", repeater "Add row")
timed out. This is the textbook "green locally, red in CI" gap: php-fpm
serves the app multi-worker everywhere else.

Guard: CiWorkflowAssetBuildAuditTest gains a check that every workflow
step starting `artisan serve` also passes `--no-reload`.

* ci(e2e): publish Filament frontend assets before running the browser suite

Root cause of ~40 E2E failures in run 34348125213, all "green locally,
red in CI": every failing spec needed client-side Filament interactivity
(open a modal, drive a combobox, add a repeater row); every passing spec
was server-rendered only (list pages, nav smoke, auth, form-renders-only).

`e2e-tests.yml` installs composer deps with --no-scripts, which skips the
post-autoload-dump hook that runs `filament:upgrade` -> `filament:assets`.
That command publishes public/js/filament/** — Filament's precompiled
Alpine plugins for modals/comboboxes/repeaters — which Vite does NOT
build (yarn build here only emits the per-panel CSS themes). Locally the
container already has those files from a real composer run, so everything
works; CI never wrote them, so nothing interactive did.

Add an explicit `php artisan filament:assets` step after env setup.

Guard: CiWorkflowAssetBuildAuditTest now also asserts any browser-facing
job that installs with --no-scripts runs filament:assets/filament:upgrade.

* ci: run every workflow on the mariadb driver, matching local dev

Laravel 11+ ships a dedicated `mariadb` DB driver — not a `mysql` alias
(different grammar, JSON storage, no RETURNING). CLAUDE.md's canonical
local test command and .env.example both use `DB_CONNECTION=mariadb`, but
phpunit/quickstart/smoke/setup/composer-update.yml and
.env.testing.example set `mysql`. Same "green locally, red in CI" class
as the two e2e-tests.yml fixes: a driver-specific bug hides on whichever
side you don't run.

- flip DB_CONNECTION mysql -> mariadb in the 5 workflow job envs and
  .env.testing.example (verified behaviour-neutral on the smoke group
  under both drivers)
- bump e2e-tests.yml's DB service mariadb:10.8 (EOL May 2023) ->
  mariadb:10.11, matching the other test workflows

Guard: CiWorkflowAssetBuildAuditTest asserts no workflow / env template
sets DB_CONNECTION to anything but mariadb.

* fix(clients,quotes): delegate inline relation-create to RelationService

InvoiceForm's customer_id select was given ->createOptionUsing() in this
branch because Filament's default runs a raw Relation::create($data),
which omits relation_type / relation_number / registered_at (all NOT NULL,
no DB default) and 500s. ContactForm's relation_id and QuoteForm's
prospect_id have the identical ->relationship()->createOptionForm()
pattern and the identical latent 500 — ContactForm was even edited in
this branch (->maxLength(150) added) but not given the fix.

Mirror InvoiceForm: route inline creation through
RelationService::createRelation() with the right RelationType (CUSTOMER
for contacts, PROSPECT for quotes), and add the ->maxLength(150) guard on
InvoiceForm's create-option company_name to match its siblings.

155 Clients/Quotes/Invoices Feature tests green.

* test(core): stabilise CompanyUsersTest for combined runs

Three new CompanyUsersTest methods passed alone but failed whenever an
AbstractAdminPanelTestCase class ran first in the same process
(reproduces with `--filter='CompaniesTest|CompanyUsersTest'`), so
smoke.yml and phpunit.yml went red. Verified it is NOT a tenant-scoping
bug — Filament::getTenant() and session both resolve to the right company
at query time; it is Filament's test harness:

- the CompanyUsers table defers its first load and assertCanSeeTableRecords
  does not reliably trigger it in that state -> it_lists_team_members now
  calls ->call('loadTable') and asserts on rendered name/email.
- row-action record resolution returns the wrong $record (instrumented the
  remove closure), and mountTableAction / callTableAction / TestAction all
  behave the same -> the two `remove` tests get #[Group('flaky')] (the
  repo's existing quarantine for #689-class harness issues; still run via
  --group=flaky / --filter, pass in isolation) with an accurate comment
  replacing the "thousands of tests deep / root cause not isolated" note.

Also: rename two methods to the it_ prefix the rest of the file uses.

`--group=smoke` now 31/31; `--filter='CompaniesTest|CompanyUsersTest'` 19/19.

* fix(core): harden Add Team Member; drop stray MustVerifyEmail

Two review findings:

1. User no longer `implements MustVerifyEmail`. That was added in this
   branch but is unrelated to the E2E work and all three panels already
   call ->emailVerification(), so it silently flips on email-verification
   gating for every panel route — any user with a NULL email_verified_at
   (imported / v1-migrated / hand-created) gets redirected to the verify
   prompt and locked out. Reverted to the merge-base definition; if the
   project wants verification it needs its own PR with a backfill.

2. ListCompanyUsers "Add Team Member": the branch made this flow reachable
   (working Filament::getTenant(), no ->unique()). Hardened it:
   - ->authorize() on the action (defence in depth)
   - elevated users (super_admin/admin/assist) can't be attached — global
     Spatie roles + role-less company_user pivot means that would grant
     company-admin over the tenant to a system operator
   - the "not eligible" answer is byte-identical to "no such user", so the
     action can't enumerate registered emails or probe for admins
   - success notification no longer echoes the account holder's name (PII)
   New test covers the elevated-user refusal + indistinguishable response.

CompanyUsersTest 8/8.

* test: tighten review findings in E2E helpers + CI-parity guard

- required-field-helpers.js: memoise the schema export. It has no module
  arg and dumps every panel's every resource; 8 spec files called it at
  collection time = 8 Laravel boots / docker execs of identical JSON per
  run (more per worker). Parse once per process, filter per module.
- required-field-helpers.js: assertOmissionRejected's native branch only
  checked checkValidity()===false, which is ~always true for an empty
  required input regardless of whether submit was blocked. Add a negative
  signal: if a Filament success toast appeared, the create went through
  -> rejected:false.
- ToolchainMatchesCiTest: the yarn guard discarded the exit code (--dry-run
  swallows it) so a yarn that errored without printing the drift marker
  passed vacuously; drop --dry-run and assert exit 0 as well. Both lockfile
  guards now self::fail() instead of markTestSkipped() when getenv('CI') is
  set — the guard must not pass by silent skip in the pipeline it protects.

* test,chore: address remaining review findings

- DatabaseSeeder: gate the fixed-credential e2e-unattached-user behind
  !app()->isProduction() — it's a test fixture, never a prod account.
- auth-helpers.js: drop the dead isAuthenticated export (no importer).
- QuotesTest: assert quote_discount_percent persists as 0 after a
  form-driven create. The field is ->dehydrated(false) so its value comes
  only from the DB default the migration added, and FormDbConstraintAuditTest
  early-returns on non-dehydrated fields — a revert would otherwise be
  silent.
- ExportFormDbSchemaCommandTest (new): mind-the-gap:export-schema is the
  ground truth the whole E2E required-field generator consumes; assert it
  exits 0 and keeps its JSON shape (generatedAt / resources / knownGaps,
  per-column name/nullable/default/auto_increment).
- FormDbConstraintAuditTest: extend AbstractAdminPanelTestCase instead of
  re-implementing its setUp (CreatesApplication + RefreshDatabase +
  seeders + factories) verbatim; keep only the extra companyUser.

* style: apply Laravel Pint

* style: reword ToolchainMatchesCiTest docblock so phpstan can parse it

Pint's phpdoc_separation reflowed the class docblock and left `@vite(...)`
and `@playwright/test` looking like phpDoc tags — phpstan's parser then
choked ("PHPDoc tag @vite has invalid value"). Reword to plain prose; no
behaviour change. (The two remaining phpstan errors, in Relation.php and
GuestQuoteViewTest.php, predate this branch.)

* fix: clear the two pre-existing phpstan errors

Not introduced by this branch, but trivial and phpstan is now green:
- Relation::communications() gains the MorphMany generic docblock its
  siblings already carry; ccEmailCommunications() gets a scoped
  @phpstan-ignore for larastan narrowing MorphMany::whereIn() to a bare
  Builder (runtime object is still the relation).
- GuestQuoteViewTest::setUp assigns Company::factory()->create() through a
  typed local so the property type is satisfied.

* Process Coderabbit comments refs PR #724
Select::make('title') renders, validates, and saves with no error on
EditEmailTemplate, but EmailTemplateService::updateEmailTemplate()'s
whitelist never wrote it to the database — create already did. Add a
regression test that saves a new title and asserts it landed in the row.
TextInput::make('project_number') is a real, editable field on the
Project form, but ProjectService never included it in either
createProject() or updateProject()'s write — the value was silently
discarded on both paths. Add regression tests for both.
…pdate

MarkdownEditor::make('notes') is the form field, but
QuoteService::createQuote()/updateQuote() read $data['summary'], a key
that never exists in the submitted form data — every quote silently
saved a null summary regardless of what was typed into Notes. Fix both
paths to read $data['notes'], and add regression tests.

Also adds a regression test proving numbering_id already persists
correctly on update (investigated as a suspected gap; turned out to be
a false positive — Filament's relationship-bound select already
associates it on the model before the service's explicit update array
runs).
…pdate

MarkdownEditor::make('notes') and MarkdownEditor::make('invoice_terms')
are the real form fields, but InvoiceService read $data['summary'] and
$data['terms'] — keys that never exist in the submitted form data — so
every invoice's summary/terms silently saved as null regardless of what
was typed into Notes/Invoice Terms, on both create and update. Add
regression tests for both fields on both paths.
Two silent-drop bugs sharing preparePaymentData():
- MarkdownEditor::make('note') (singular) is the form field, but the
  service read $data['notes'] (plural) — never matching, so the note
  always saved null.
- payment_number is a real, editable field but was missing from
  preparePaymentData() entirely, on both create and update.

Also aligns enterInvoicePayment()'s internal array to the same 'note'
key for consistency, since it feeds the same preparePaymentData().
Add regression tests for both fields.
…-drop fixes

Every earlier commit in this PR only tested the happy path (set a value,
verify it persists). Fair callout: that's the same one-sided-test shape
as the original bugs. Rounds out coverage per field:

- EmailTemplate title (the one field of this set that's ->required()):
  add the missing update-side "fails without required title" test —
  the create-side already had one. Passed immediately: validation is
  already correctly shared between create/edit forms, this was a
  verification gap, not a second bug.
- project_number / payment_number: add maxLength(255) boundary tests
  (256 chars must be rejected) — both already enforced correctly.
- project_number, Quote notes, Invoice notes/invoice_terms, Payment
  note/payment_number: none of these are ->required(), so add the
  inverse of the persistence fix instead — clearing an existing value
  back to null on update must succeed and actually null the column.

All new tests passed on first run against the already-fixed services;
none of this surfaced a second bug, but the coverage gap itself was
real.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Adds company-panel Tax Rate management & Default Terms & Notes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants