fix(inventory): malformed rgba no longer bricks the Filaments page (#1055)

A single legacy spool with a 7-char rgba ('FFFFFFF', missing one F)
  caused GET /api/v1/inventory/spools to 500 with a pydantic
  ResponseValidationError, leaving the reporter with a blank Filaments
  page and "Add Spool" silently failing. Root cause spans three layers:

  1. Write path: SpoolUpdate.rgba had no pattern constraint (only
     SpoolCreate did), so PATCH could plant malformed values in the DB.
  2. Frontend: ColorSection hex input's `val.length <= 6 ? 'FF' : ''`
     emitted 7-char rgba for 5-char input (XXXXX + FF = 7) and for
     7-char typed input (no alpha appended).
  3. Read path: SpoolResponse inherited the write-side pattern, so a
     single bad row 500'd the entire list endpoint instead of being
     tolerated through serialize.

  SpoolUpdate.rgba now carries the same ^[0-9A-Fa-f]{8}$ pattern as
  SpoolCreate. The hex input emits a fully-formed 8-char RRGGBBAA on
  every keystroke — 8-char paste passes through, 7-char drops the
  stray, shorter input pads RGB with '0' and appends FF alpha.
  SpoolResponse.rgba is now Optional[str] with no pattern — write-side
  validation is the right place for format rules; responses must
  tolerate historical rows.

  Tests: 16 schema tests (SpoolCreate/Update reject, SpoolResponse
  tolerate), 7 frontend tests covering every input length 0–8 plus
  non-hex strip. A user who already has a bad row in their DB now sees
  it render with a default color instead of having to hand-edit SQLite.
This commit is contained in:
maziggy
2026-04-21 14:40:03 +02:00
parent 1682b6956f
commit bf5135cb12
9 changed files with 349 additions and 6 deletions
@@ -348,6 +348,73 @@ describe('SpoolFormModal weightTouched', () => {
expect(payload).toHaveProperty('cost_per_kg', null);
});
it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {
// #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')
// was editable in the UI but any save 422'd because SpoolUpdate now enforces
// the 8-char pattern. The form must sanitize the loaded value to a valid
// default so users can edit unrelated fields without being forced to fix
// a color they may not even have noticed was broken.
const spoolWithBadRgba: InventorySpool = {
...existingSpool,
rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern
};
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolWithBadRgba}
currencySymbol="$"
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(api.updateSpool).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
// The PATCH payload must carry a valid 8-char rgba — never the raw 7-char
// value loaded from the stale DB row.
expect(payload).toHaveProperty('rgba');
expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');
expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);
});
it('preserves a valid existing rgba on edit (no forced default)', async () => {
// Sanity: the normalization only kicks in for malformed values. A valid
// 8-char rgba must round-trip untouched so untouched edits don't quietly
// reset a user's chosen color.
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={existingSpool} // rgba = 'FF0000FF' (valid)
currencySymbol="$"
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(api.updateSpool).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
});
it('displays correct catalog name when duplicates exist', async () => {
const spoolWithCatalogId: InventorySpool = {
...existingSpool,