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,
@@ -0,0 +1,119 @@
/**
* Regression tests for the ColorSection hex input normalization (#1055).
*
* The original bug: typing 5 hex chars on the RRGGBB field produced a 7-char
* rgba ("FFFFF" + "FF" alpha = 7 chars); typing 7 chars left the 7-char string
* unpadded. Either way the value passed frontend validation, survived a backend
* PATCH (SpoolUpdate had no pattern constraint), and then bricked the entire
* Filaments page because SpoolResponse enforced the 8-char pattern on serialize
* and one bad row 500'd the whole list endpoint.
*
* The input now emits a valid 8-char RRGGBBAA on every keystroke: shorter input
* is right-padded with '0' and given FF alpha; 7-char input drops the stray 7th
* char; 8-char paste passes through unchanged.
*
* These tests drive the onChange handler directly (via fireEvent.change) rather
* than userEvent.type so each assertion exercises a specific input length. The
* component itself is a controlled input whose displayed value derives from
* formData.rgba.substring(0, 6), so the real-world UX of typing one char at a
* time is quirkier than the handler contract — but the handler contract is
* what this regression guards.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../../i18n';
import { ColorSection } from '../../../components/spool-form/ColorSection';
import { defaultFormData } from '../../../components/spool-form/types';
type UpdateField = <K extends keyof typeof defaultFormData>(
key: K,
value: (typeof defaultFormData)[K],
) => void;
function renderColorSection(overrides: Partial<typeof defaultFormData> = {}) {
const updateField = vi.fn() as ReturnType<typeof vi.fn> & UpdateField;
const formData = { ...defaultFormData, ...overrides };
render(
<I18nextProvider i18n={i18n}>
<ColorSection
formData={formData}
updateField={updateField}
recentColors={[]}
onColorUsed={vi.fn()}
catalogColors={[]}
/>
</I18nextProvider>,
);
const hexInput = screen.getByPlaceholderText('RRGGBB') as HTMLInputElement;
return { hexInput, updateField };
}
function lastRgba(updateField: ReturnType<typeof vi.fn>): string | undefined {
const rgbaCalls = updateField.mock.calls.filter(([key]) => key === 'rgba');
return rgbaCalls.at(-1)?.[1] as string | undefined;
}
describe('ColorSection hex input normalization (#1055)', () => {
it('pads a 6-char RRGGBB to 8-char RRGGBBAA with FF alpha', () => {
const { hexInput, updateField } = renderColorSection();
fireEvent.change(hexInput, { target: { value: 'FF0000' } });
expect(lastRgba(updateField)).toBe('FF0000FF');
});
it('passes an 8-char RRGGBBAA paste through unchanged', () => {
const { hexInput, updateField } = renderColorSection();
fireEvent.change(hexInput, { target: { value: '00112233' } });
expect(lastRgba(updateField)).toBe('00112233');
});
it('drops the stray 7th char — the exact #1055 trigger pattern', () => {
const { hexInput, updateField } = renderColorSection();
fireEvent.change(hexInput, { target: { value: 'FFFFFFF' } });
// Previously emitted "FFFFFFF" (7 chars) verbatim. Must now be 8 chars.
const rgba = lastRgba(updateField);
expect(rgba).toBe('FFFFFFFF');
expect(rgba).toMatch(/^[0-9A-F]{8}$/);
});
it('pads a 5-char input to 8 chars instead of emitting a 7-char rgba', () => {
// 5-char + 'FF' alpha = 7 chars was the other #1055 trigger pattern.
// Right-pad RGB to 6 with '0' so the output is always 8 chars.
const { hexInput, updateField } = renderColorSection();
fireEvent.change(hexInput, { target: { value: 'FFFFF' } });
const rgba = lastRgba(updateField);
expect(rgba).toBe('FFFFF0FF');
expect(rgba).toMatch(/^[0-9A-F]{8}$/);
});
it('pads any partial input to exactly 8 chars — never 7', () => {
// The essential invariant: for every legal input length (0..8), the
// emitted rgba must be 8 chars. Anything else risks reintroducing #1055.
const { hexInput, updateField } = renderColorSection();
for (const input of ['', 'F', 'FF', 'FFF', 'FFFF', 'FFFFF', 'FFFFFF', 'FFFFFFF', 'FFFFFFFF']) {
updateField.mockClear();
fireEvent.change(hexInput, { target: { value: input } });
const rgba = lastRgba(updateField);
expect(rgba).toBeDefined();
expect(rgba!.length).toBe(8);
expect(rgba).toMatch(/^[0-9A-F]{8}$/);
}
});
it('ignores input past 8 chars (no updateField call)', () => {
const { hexInput, updateField } = renderColorSection({ rgba: 'FFFFFFFF' });
updateField.mockClear();
fireEvent.change(hexInput, { target: { value: '0011223344' } });
expect(updateField.mock.calls.filter(([k]) => k === 'rgba')).toHaveLength(0);
});
it('strips non-hex characters before normalizing', () => {
// '#FF00ZZ' → strip '#' and non-hex → 'FF00' (4 chars) → pad to 6 + FF alpha
const { hexInput, updateField } = renderColorSection();
fireEvent.change(hexInput, { target: { value: '#FF00ZZ' } });
expect(lastRgba(updateField)).toBe('FF0000FF');
});
});
+11 -1
View File
@@ -253,12 +253,22 @@ export function SpoolFormModal({
useEffect(() => {
if (isOpen) {
if (spool) {
// Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
// from #1055 before the create/update pattern was enforced). The
// backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
// re-submitting a malformed value would 422 every edit on that spool
// — even edits that don't touch color. Normalize on load: any value
// that isn't exactly 8 hex chars falls back to the default, so the
// user can save unrelated fields (weight, material, note) without
// first being forced to fix a color they may not even be aware is
// broken. Saving also purges the bad value from the DB.
const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
setFormData({
material: spool.material || '',
subtype: spool.subtype || '',
brand: spool.brand || '',
color_name: spool.color_name || '',
rgba: spool.rgba || '808080FF',
rgba: validRgba,
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
@@ -298,8 +298,18 @@ export function ColorSection({
placeholder="RRGGBB"
value={currentHex.toUpperCase()}
onChange={(e) => {
const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '');
if (val.length <= 8) updateField('rgba', val.toUpperCase() + (val.length <= 6 ? 'FF' : ''));
const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
if (val.length > 8) return;
// Normalize to a valid 8-char RRGGBBAA on every keystroke so
// the backend never receives a malformed rgba (#1055). 8-char
// paste passes through; 7-char drops the stray typo; anything
// shorter is right-padded with '0' to a full RGB triplet and
// given FF alpha. Prior logic emitted 3/5/7-char strings mid-
// typing that PATCH would accept (SpoolUpdate was unchecked)
// and later 500 the list endpoint on response serialization.
const rgba =
val.length === 8 ? val : val.length === 7 ? val.substring(0, 6) + 'FF' : val.padEnd(6, '0') + 'FF';
updateField('rgba', rgba);
}}
/>
</div>