diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx index 7d42d5a..6c74298 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx @@ -219,4 +219,39 @@ describe('SchemaDrivenForm', () => { ) expect(screen.getByText('Дубликат business_key')).toBeInTheDocument() }) + + // Regression: после первой неудачной AJV-валидации manual-ошибки + // (setError на data.*) застревали в formState.errors. На повторном Save + // RHF handleSubmit видел непустой errors и молча НЕ вызывал submit — + // форма «не сохранялась» после исправления полей, ошибок не видно. + it('повторный submit после исправления валидации вызывает onSubmit', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + const { container } = renderWithI18n( + {}} + />, + ) + const save = () => screen.getByRole('button', { name: /^Сохранить$/i }) + const field = (name: string): HTMLElement => { + const el = container.querySelector(`input[name="${name}"]`) + if (!el) throw new Error(`field input not found: ${name}`) + return el as HTMLElement + } + // Попытка 1 — required code/name пустые → AJV fail, submit не проходит. + await user.click(save()) + expect(onSubmit).not.toHaveBeenCalled() + // Исправляем — заполняем оба required-поля. + await user.type(field('data.code'), 'X1') + await user.type(field('data.name'), 'Тест') + // Попытка 2 — данные валидны, должно вызвать onSubmit. + await user.click(save()) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) }) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index b15cc9b..9a8fb59 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -268,10 +268,18 @@ export const SchemaDrivenForm = ({ }, [visibleFieldCount, totalFieldCount]) const submit: SubmitHandler = (values) => { + // [ordinis-diag] DIAGNOSTIC BUILD — временная инструментация для поиска + // причины «Save ничего не делает». Удалить после диагностики. + console.warn('[ordinis-diag] submit(): RHF-валидация пройдена, handler запущен', { + mode, + businessKey: values.businessKey, + dataKeys: Object.keys(values.data ?? {}), + }) if (values.validFrom && values.validTo) { const from = new Date(values.validFrom) const to = new Date(values.validTo) if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from >= to) { + console.warn('[ordinis-diag] submit(): ЗАБЛОКИРОВАНО — validTo <= validFrom') setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') }) setActiveTab('identity') return @@ -279,7 +287,13 @@ export const SchemaDrivenForm = ({ } if (validator) { const ok = validator(values.data) + console.warn('[ordinis-diag] submit(): AJV-валидация →', { + ok, + errorCount: validator.errors?.length ?? 0, + errors: validator.errors, + }) if (!ok && validator.errors && validator.errors.length > 0) { + console.warn('[ordinis-diag] submit(): ЗАБЛОКИРОВАНО — AJV errors, onSubmit НЕ вызван') applyAjvErrors(validator.errors, setError) const firstField = firstFieldFromErrors(validator.errors) if (firstField) { @@ -288,10 +302,15 @@ export const SchemaDrivenForm = ({ } return } + } else { + console.warn('[ordinis-diag] submit(): validator == null (схема не скомпилировалась)') } const derivedKey = idSource && mode === 'create' ? String(values.data?.[idSource] ?? '').trim() : values.businessKey.trim() + console.warn('[ordinis-diag] submit(): вызываю onSubmit() → мутация', { + businessKey: derivedKey, + }) onSubmit({ businessKey: derivedKey, data: values.data, @@ -300,6 +319,13 @@ export const SchemaDrivenForm = ({ }) } + // [ordinis-diag] RHF onInvalid — вызывается когда handleSubmit РЕШИЛ что + // форма невалидна и НЕ вызвал submit(). Показывает что именно RHF считает + // ошибкой (включая застрявшие manual-ошибки). Удалить после диагностики. + const diagOnInvalid = (errs: unknown) => { + console.warn('[ordinis-diag] handleSubmit ЗАБЛОКИРОВАН (onInvalid) — formState.errors:', errs) + } + const tabErrorCount = countErrorsPerSection(formState.errors, buckets, sectionOrder) const visibleTabs: TabItem[] = sectionOrder .filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0) @@ -474,7 +500,12 @@ export const SchemaDrivenForm = ({ return (
{ + // [ordinis-diag] DIAGNOSTIC — событие submit формы вообще дошло? + // Удалить после диагностики. + console.warn('[ordinis-diag] submit event сработал (formId=' + String(formId) + ')') + void handleSubmit(submit, diagOnInvalid)(e) + }} ref={formContainerRef} >
diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 6fc57e1..baa3e5f 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -578,6 +578,12 @@ function DictionaryDetail() { const rawRecordQuery = useRecordRaw(name, editingKey) const handleSubmit = (req: CreateRecordRequest) => { + // [ordinis-diag] DIAGNOSTIC BUILD — удалить после диагностики. + console.warn('[ordinis-diag] route.handleSubmit(): получен req от формы', { + editKind: edit.kind, + approvalRequired, + businessKey: req.businessKey, + }) // Fill validFrom at submit (review #5): defaultValues в RHF берётся // ТОЛЬКО при mount. Если user открыл modal в 12:00 и submit в 12:05 — // `validFrom` в req это значение из 12:00. Backend check `validFrom > @@ -631,14 +637,20 @@ function DictionaryDetail() { ) return } + console.warn('[ordinis-diag] route: вызываю createMut.mutate() — POST /records') createMut.mutate( { payload: submitReq, idempotencyKey: recordIdempotencyKey }, { onSuccess: () => { + console.warn('[ordinis-diag] createMut onSuccess — запись создана') setEdit({ kind: 'closed' }) resetRecordIdempotencyKey() }, onError: (err) => { + console.warn('[ordinis-diag] createMut onError', { + status: (err as { response?: { status?: number } })?.response?.status, + data: (err as { response?: { data?: unknown } })?.response?.data, + }) // Create rarely conflicts (no prior record to lock), но если backend // вернёт 409 по уникальному ключу — businessKey возьмём из payload. handleSaveError(err, submitReq.businessKey ?? '') @@ -669,6 +681,7 @@ function DictionaryDetail() { ) return } + console.warn('[ordinis-diag] route: вызываю updateMut.mutate() — PUT /records/' + edit.record.businessKey) updateMut.mutate( { businessKey: edit.record.businessKey, @@ -677,10 +690,17 @@ function DictionaryDetail() { }, { onSuccess: () => { + console.warn('[ordinis-diag] updateMut onSuccess — запись обновлена') setEdit({ kind: 'closed' }) resetRecordIdempotencyKey() }, - onError: (err) => handleSaveError(err, edit.record.businessKey), + onError: (err) => { + console.warn('[ordinis-diag] updateMut onError', { + status: (err as { response?: { status?: number } })?.response?.status, + data: (err as { response?: { data?: unknown } })?.response?.data, + }) + handleSaveError(err, edit.record.businessKey) + }, }, ) } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java index 0634b82..899a9b4 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.server.ResponseStatusException; @@ -140,6 +141,32 @@ public class GlobalExceptionHandler { return ResponseEntity.status(e.getStatusCode()).body(body); } + /** + * Bean-валидация {@code @Valid @RequestBody} провалилась — клиент прислал + * тело, не прошедшее {@code @NotBlank}/{@code @NotNull}/{@code @Size} и т.п. + * + *

До этого handler'а {@link MethodArgumentNotValidException} падал в + * generic {@link Exception} catch ниже → 500 {@code internal_error} с пустым + * traceId вместо 400. Эффект: admin-ui на создании/редактировании записи + * получал бесполезный «Internal server error» без указания полей, форма + * молча не сохранялась. Теперь — 400 {@code validation_failed} с + * пофайловой разбивкой ошибок (как у доменного {@link ValidationException}, + * только статус 400 — тело запроса malformed, а не бизнес-правило). + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity onMethodArgumentNotValid(MethodArgumentNotValidException e) { + List fields = e.getBindingResult().getFieldErrors().stream() + .map(fe -> new ApiErrorResponse.FieldError( + fe.getField(), + fe.getCode() != null ? fe.getCode() : "invalid", + fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid")) + .toList(); + log.debug("Bean validation failed: {} field error(s)", fields.size()); + var body = ApiErrorResponse.withFieldErrors( + 400, "validation_failed", "Запрос не прошёл валидацию", fields, currentTraceId()); + return ResponseEntity.badRequest().body(body); + } + @ExceptionHandler(Exception.class) public ResponseEntity onGeneric(Exception e) { log.error("Unhandled exception in REST API", e); diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandlerTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..d8dcf5b --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandlerTest.java @@ -0,0 +1,63 @@ +package cloud.nstart.terravault.ordinis.restapi.error; + +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.core.MethodParameter; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.Collections; +import java.util.Iterator; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests для {@link GlobalExceptionHandler}. + * + *

Regression: {@link MethodArgumentNotValidException} (провал bean-валидации + * {@code @Valid @RequestBody}) раньше не имел собственного handler'а → падал в + * generic {@code Exception} catch → HTTP 500 {@code internal_error}. admin-ui + * на создании/редактировании записи получал бесполезный «Internal server error», + * форма молча не сохранялась. Должен возвращать 400 {@code validation_failed}. + */ +class GlobalExceptionHandlerTest { + + /** Пустой ObjectProvider — currentTraceId() получит null, без трассировки. */ + private static final ObjectProvider NO_TRACER = new ObjectProvider<>() { + @Override public Tracer getObject() { throw new UnsupportedOperationException(); } + @Override public Tracer getObject(Object... args) { throw new UnsupportedOperationException(); } + @Override public Tracer getIfAvailable() { return null; } + @Override public Tracer getIfUnique() { return null; } + @Override public Iterator iterator() { return Collections.emptyIterator(); } + }; + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(NO_TRACER); + + /** Цель только для конструктора {@link MethodParameter} — нужен реальный параметр метода. */ + @SuppressWarnings("unused") + private void sampleEndpoint(String body) {} + + @Test + void beanValidationFailureReturns400WithFieldErrors() throws Exception { + BindingResult br = new BeanPropertyBindingResult(new Object(), "createRecordRequest"); + br.addError(new FieldError("createRecordRequest", "businessKey", "must not be blank")); + br.addError(new FieldError("createRecordRequest", "data", "must not be null")); + MethodParameter mp = new MethodParameter( + GlobalExceptionHandlerTest.class.getDeclaredMethod("sampleEndpoint", String.class), 0); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(mp, br); + + ResponseEntity resp = handler.onMethodArgumentNotValid(ex); + + // Главное: 400, НЕ 500. + assertThat(resp.getStatusCode().value()).isEqualTo(400); + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().code()).isEqualTo("validation_failed"); + assertThat(resp.getBody().fieldErrors()) + .extracting(ApiErrorResponse.FieldError::path) + .containsExactlyInAnyOrder("businessKey", "data"); + } +}