fix: validate grade input and save modal without reload
- require a score and return a clear message for empty/non-numeric values - handle unreadable API request bodies instead of falling back to a bare 400 - submit the grade modal via fetch, show errors in place and update the cell - keep the page scroll position; restore the form feedback tests
This commit is contained in:
@@ -2,6 +2,7 @@ package ru.ysu.lms.common
|
|||||||
|
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
@@ -48,6 +49,20 @@ class LmsExceptionHandler {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тело запроса не удалось разобрать: пустая строка вместо числа, битый JSON и т.п.
|
||||||
|
* Без этого обработчика клиент получал стандартный ответ Spring без detail.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException::class)
|
||||||
|
fun handleNotReadable(): ResponseEntity<ApiError> =
|
||||||
|
ResponseEntity.badRequest().body(
|
||||||
|
ApiError(
|
||||||
|
title = "Bad Request",
|
||||||
|
status = 400,
|
||||||
|
detail = "Проверьте формат введённых данных",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
/** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */
|
/** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */
|
||||||
@ExceptionHandler(DataIntegrityViolationException::class)
|
@ExceptionHandler(DataIntegrityViolationException::class)
|
||||||
fun handleDataIntegrity(): ResponseEntity<ApiError> =
|
fun handleDataIntegrity(): ResponseEntity<ApiError> =
|
||||||
|
|||||||
@@ -353,13 +353,16 @@ class AdminGroupController(
|
|||||||
redirect: RedirectAttributes,
|
redirect: RedirectAttributes,
|
||||||
): String =
|
): String =
|
||||||
try {
|
try {
|
||||||
|
val score = form.score.trim()
|
||||||
|
if (score.isEmpty()) throw ApiException(400, "Укажите оценку")
|
||||||
|
if (score.toBigDecimalOrNull() == null) throw ApiException(400, "Оценка должна быть числом")
|
||||||
val comments = parseComments(form.commentsJson ?: "[]")
|
val comments = parseComments(form.commentsJson ?: "[]")
|
||||||
api.setGrade(
|
api.setGrade(
|
||||||
token(session),
|
token(session),
|
||||||
GradeSetPayload(
|
GradeSetPayload(
|
||||||
studentId = form.studentId,
|
studentId = form.studentId,
|
||||||
labId = form.labId,
|
labId = form.labId,
|
||||||
score = form.score,
|
score = score,
|
||||||
comments = comments,
|
comments = comments,
|
||||||
generalComment = form.generalComment,
|
generalComment = form.generalComment,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -79,14 +79,16 @@ class ApiClient(
|
|||||||
rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
|
rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
|
||||||
violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
|
violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
|
||||||
fallback != null -> fallback
|
fallback != null -> fallback
|
||||||
else -> "Ошибка запроса (${ex.statusCode.value()})"
|
else -> fallbackDetail(ex.statusCode.value())
|
||||||
}
|
}
|
||||||
ParsedError(detail, violations)
|
ParsedError(detail, violations)
|
||||||
} catch (ignore: Exception) {
|
} catch (ignore: Exception) {
|
||||||
ParsedError(fallback ?: "Ошибка запроса (${ex.statusCode.value()})", emptyList())
|
ParsedError(fallback ?: fallbackDetail(ex.statusCode.value()), emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun fallbackDetail(status: Int): String = if (status == 400) "Проверьте введённые данные" else "Ошибка запроса ($status)"
|
||||||
|
|
||||||
private fun detailFromBody(body: String): String? {
|
private fun detailFromBody(body: String): String? {
|
||||||
val match = DETAIL_PATTERN.find(body)
|
val match = DETAIL_PATTERN.find(body)
|
||||||
return match
|
return match
|
||||||
|
|||||||
@@ -615,8 +615,95 @@ function focusLab() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Оценка: отправка без перезагрузки страницы ----------
|
||||||
|
* Модалка не закрывается при ошибке, значения остаются на месте,
|
||||||
|
* позиция прокрутки страницы не меняется. */
|
||||||
|
function submitGradeForm(form) {
|
||||||
|
if (form.dataset.saving === '1') return;
|
||||||
|
form.dataset.saving = '1';
|
||||||
|
var btn = form.querySelector('button[type="submit"]');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(form),
|
||||||
|
headers: { 'X-Requested-With': 'fetch' }
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then(function (html) {
|
||||||
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
// Сервер вернул форму с ошибкой — показываем её без перезагрузки.
|
||||||
|
var feedbackNode = doc.getElementById('form-feedback');
|
||||||
|
if (feedbackNode) {
|
||||||
|
var feedback = null;
|
||||||
|
try {
|
||||||
|
feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
|
||||||
|
} catch (e) {
|
||||||
|
feedback = null;
|
||||||
|
}
|
||||||
|
if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
|
||||||
|
window.lmsApplyFormFeedback(feedback);
|
||||||
|
}
|
||||||
|
var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
|
||||||
|
showToast(errorToast
|
||||||
|
? errorToast.textContent.trim()
|
||||||
|
: ((feedback && feedback.general) || 'Не удалось сохранить оценку'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var student = form.querySelector('[name="studentId"]').value;
|
||||||
|
var lab = form.querySelector('[name="labId"]').value;
|
||||||
|
var fresh = doc.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
|
||||||
|
var current = document.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
|
||||||
|
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
|
||||||
|
var errorWithoutFeedback = doc.querySelector('.toast-item.toast-error .toast-text');
|
||||||
|
|
||||||
|
if (errorWithoutFeedback) {
|
||||||
|
showToast(errorWithoutFeedback.textContent.trim(), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fresh && !notice) {
|
||||||
|
showToast('Не удалось сохранить оценку. Обновите страницу и попробуйте ещё раз.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fresh && current) {
|
||||||
|
['data-score', 'data-general-comment', 'data-comments', 'title'].forEach(function (attr) {
|
||||||
|
if (fresh.hasAttribute(attr)) current.setAttribute(attr, fresh.getAttribute(attr));
|
||||||
|
else current.removeAttribute(attr);
|
||||||
|
});
|
||||||
|
var freshLabel = fresh.querySelector('span');
|
||||||
|
var currentLabel = current.querySelector('span');
|
||||||
|
if (freshLabel && currentLabel) currentLabel.textContent = freshLabel.textContent;
|
||||||
|
current.classList.toggle('none', fresh.classList.contains('none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var openDialog = form.closest('dialog');
|
||||||
|
if (openDialog) {
|
||||||
|
var staleToasts = openDialog.querySelector('.toast-host');
|
||||||
|
if (staleToasts) staleToasts.remove();
|
||||||
|
}
|
||||||
|
if (window.closeModal) window.closeModal('modal-grade');
|
||||||
|
showToast(notice ? notice.textContent.trim() : 'Оценка сохранена', 'success');
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
showToast('Не удалось сохранить оценку: ' + e.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
form.dataset.saving = '';
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('submit', function (ev) {
|
document.addEventListener('submit', function (ev) {
|
||||||
if (ev.target && ev.target.id === 'grade-form') csSerialize();
|
if (!ev.target || ev.target.id !== 'grade-form') return;
|
||||||
|
csSerialize();
|
||||||
|
ev.preventDefault();
|
||||||
|
submitGradeForm(ev.target);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', function () {
|
window.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|||||||
@@ -193,6 +193,16 @@
|
|||||||
form.insertBefore(box, form.firstChild);
|
form.insertBefore(box, form.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAllErrors(form) {
|
||||||
|
form.querySelectorAll('.' + FIELD_ERROR).forEach(function (node) { node.remove(); });
|
||||||
|
form.querySelectorAll('.' + INVALID).forEach(function (el) {
|
||||||
|
el.classList.remove(INVALID);
|
||||||
|
el.removeAttribute('aria-invalid');
|
||||||
|
});
|
||||||
|
var summary = form.querySelector('.' + SUMMARY);
|
||||||
|
if (summary) summary.remove();
|
||||||
|
}
|
||||||
|
|
||||||
function focusFirstInvalid(form) {
|
function focusFirstInvalid(form) {
|
||||||
var el = form.querySelector('.' + INVALID);
|
var el = form.querySelector('.' + INVALID);
|
||||||
if (!el || typeof el.focus !== 'function') return;
|
if (!el || typeof el.focus !== 'function') return;
|
||||||
@@ -223,6 +233,7 @@
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
restoreValues(form, feedback.values);
|
restoreValues(form, feedback.values);
|
||||||
|
clearAllErrors(form);
|
||||||
|
|
||||||
if (form.hidden) form.hidden = false;
|
if (form.hidden) form.hidden = false;
|
||||||
var dialog = form.closest('dialog');
|
var dialog = form.closest('dialog');
|
||||||
@@ -248,6 +259,8 @@
|
|||||||
function validationMessage(el) {
|
function validationMessage(el) {
|
||||||
var v = el.validity || {};
|
var v = el.validity || {};
|
||||||
if (v.valueMissing) {
|
if (v.valueMissing) {
|
||||||
|
var requiredMessage = el.getAttribute('data-error-required');
|
||||||
|
if (requiredMessage) return requiredMessage;
|
||||||
if (el.type === 'file') return 'Выберите файл';
|
if (el.type === 'file') return 'Выберите файл';
|
||||||
if (el.tagName === 'SELECT') return 'Выберите значение';
|
if (el.tagName === 'SELECT') return 'Выберите значение';
|
||||||
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
|
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
|
||||||
@@ -285,6 +298,9 @@
|
|||||||
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
|
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
|
||||||
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
|
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
|
||||||
|
|
||||||
|
// Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы.
|
||||||
|
window.lmsApplyFormFeedback = applyFeedback;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
|
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
|
||||||
setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
|
setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
|
||||||
|
|||||||
@@ -159,7 +159,8 @@
|
|||||||
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
|
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
|
||||||
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
|
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
|
||||||
<div><label class="label" for="grade-score">Балл (0–5)</label>
|
<div><label class="label" for="grade-score">Балл (0–5)</label>
|
||||||
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
|
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1" required
|
||||||
|
data-error-required="Укажите оценку"></div>
|
||||||
<div><label class="label" for="grade-general">Общий комментарий по лабораторной</label>
|
<div><label class="label" for="grade-general">Общий комментарий по лабораторной</label>
|
||||||
<textarea class="input" id="grade-general" name="generalComment" rows="4"
|
<textarea class="input" id="grade-general" name="generalComment" rows="4"
|
||||||
placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
|
placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
|
||||||
@@ -345,7 +346,7 @@
|
|||||||
<div class="dlg-body" id="assign-modal-body"></div>
|
<div class="dlg-body" id="assign-modal-body"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script th:src="@{/js/admin-group.js(v='37')}" defer></script>
|
<script th:src="@{/js/admin-group.js(v='38')}" defer></script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<script th:src="@{/js/theme-init.js(v='36')}"></script>
|
<script th:src="@{/js/theme-init.js(v='36')}"></script>
|
||||||
<script th:src="@{/js/app.js(v='36')}" defer></script>
|
<script th:src="@{/js/app.js(v='36')}" defer></script>
|
||||||
<script th:src="@{/js/ui-state.js(v='36')}" defer></script>
|
<script th:src="@{/js/ui-state.js(v='36')}" defer></script>
|
||||||
<script th:src="@{/js/form-feedback.js(v='37')}" defer></script>
|
<script th:src="@{/js/form-feedback.js(v='38')}" defer></script>
|
||||||
<script th:src="@{/js/md-content.js(v='36')}" defer></script>
|
<script th:src="@{/js/md-content.js(v='36')}" defer></script>
|
||||||
<script th:src="@{/js/submit-guard.js(v='36')}" defer></script>
|
<script th:src="@{/js/submit-guard.js(v='36')}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package ru.ysu.lms.web.config
|
||||||
|
|
||||||
|
import org.hamcrest.Matchers.notNullValue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.stereotype.Controller
|
||||||
|
import org.springframework.test.web.servlet.post
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
|
import org.springframework.web.servlet.support.SessionFlashMapManager
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.form.FormFeedback
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
|
||||||
|
class FormFeedbackFlashTest {
|
||||||
|
@Controller
|
||||||
|
private class SubmitController {
|
||||||
|
@PostMapping("/submit")
|
||||||
|
fun submit(
|
||||||
|
@RequestParam title: String,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String {
|
||||||
|
// Контроллер поймал ошибку сервиса и возвращает пользователя на страницу.
|
||||||
|
val message = "Не удалось сохранить «$title»"
|
||||||
|
ApiException(400, message)
|
||||||
|
redirect.addFlashAttribute("error", message)
|
||||||
|
return "redirect:/done"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `feedback survives redirect through flash map`() {
|
||||||
|
val mockMvc =
|
||||||
|
MockMvcBuilders
|
||||||
|
.standaloneSetup(SubmitController())
|
||||||
|
.addInterceptors(FormFeedbackInterceptor(ObjectMapper()))
|
||||||
|
.setFlashMapManager(SessionFlashMapManager())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.post("/submit") { param("title", "Лабораторная") }
|
||||||
|
.andExpect {
|
||||||
|
status { is3xxRedirection() }
|
||||||
|
redirectedUrl("/done")
|
||||||
|
flash {
|
||||||
|
attribute(FormFeedback.MODEL_ATTR, notNullValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package ru.ysu.lms.web.config
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse
|
||||||
|
import org.springframework.web.servlet.DispatcherServlet
|
||||||
|
import org.springframework.web.servlet.FlashMap
|
||||||
|
import org.springframework.web.servlet.ModelAndView
|
||||||
|
import ru.ysu.lms.web.form.FormFeedback
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
|
||||||
|
class FormFeedbackInterceptorTest {
|
||||||
|
private val mapper = ObjectMapper()
|
||||||
|
private val interceptor = FormFeedbackInterceptor(mapper)
|
||||||
|
private val feedback =
|
||||||
|
FormFeedback(
|
||||||
|
action = "/admin/labs/create",
|
||||||
|
values = mapOf("title" to listOf("Лабораторная")),
|
||||||
|
errors = mapOf("title" to "Укажите название"),
|
||||||
|
general = "Проверьте поля",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `puts feedback into flash map on redirect`() {
|
||||||
|
val request = MockHttpServletRequest("POST", "/admin/labs/create")
|
||||||
|
request.setAttribute(FormFeedback.REQUEST_ATTR, feedback)
|
||||||
|
val flashMap = FlashMap()
|
||||||
|
request.setAttribute(DispatcherServlet.OUTPUT_FLASH_MAP_ATTRIBUTE, flashMap)
|
||||||
|
|
||||||
|
interceptor.postHandle(request, MockHttpServletResponse(), Any(), ModelAndView("redirect:/admin/labs"))
|
||||||
|
|
||||||
|
val json = flashMap[FormFeedback.MODEL_ATTR] as? String
|
||||||
|
assertNotNull(json)
|
||||||
|
assertEquals("/admin/labs/create", mapper.readTree(json).path("action").asString(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `puts feedback into model on direct render`() {
|
||||||
|
val request = MockHttpServletRequest("POST", "/login")
|
||||||
|
request.setAttribute(FormFeedback.REQUEST_ATTR, feedback)
|
||||||
|
val modelAndView = ModelAndView("login")
|
||||||
|
|
||||||
|
interceptor.postHandle(request, MockHttpServletResponse(), Any(), modelAndView)
|
||||||
|
|
||||||
|
assertNotNull(modelAndView.model[FormFeedback.MODEL_ATTR])
|
||||||
|
}
|
||||||
|
}
|
||||||
74
web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt
Normal file
74
web/src/test/kotlin/ru/ysu/lms/web/form/FormFeedbackTest.kt
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package ru.ysu.lms.web.form
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes
|
||||||
|
import ru.ysu.lms.common.FieldViolation
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
|
||||||
|
class FormFeedbackTest {
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
RequestContextHolder.resetRequestAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(vararg params: Pair<String, String>): MockHttpServletRequest =
|
||||||
|
MockHttpServletRequest("POST", "/admin/labs/create").apply {
|
||||||
|
params.forEach { (name, value) -> addParameter(name, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `captures values and field violations`() {
|
||||||
|
val request = request("title" to "Лабораторная", "number" to "0", "_csrf" to "token")
|
||||||
|
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
|
||||||
|
|
||||||
|
ApiException(400, "Request validation failed", listOf(FieldViolation("title", "Укажите название")))
|
||||||
|
|
||||||
|
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
|
||||||
|
assertNotNull(feedback)
|
||||||
|
assertEquals("/admin/labs/create", feedback!!.action)
|
||||||
|
assertEquals(listOf("Лабораторная"), feedback.values["title"])
|
||||||
|
assertEquals("Укажите название", feedback.errors["title"])
|
||||||
|
assertFalse(feedback.values.containsKey("_csrf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not keep passwords`() {
|
||||||
|
val request = request("login" to "admin", "password" to "secret")
|
||||||
|
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
|
||||||
|
|
||||||
|
ApiException(401, "Неверный логин или пароль")
|
||||||
|
|
||||||
|
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
|
||||||
|
assertEquals(listOf("admin"), feedback!!.values["login"])
|
||||||
|
assertEquals(emptyList<String>(), feedback.values["password"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `infers field from domain message`() {
|
||||||
|
val request = request("score" to "7")
|
||||||
|
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
|
||||||
|
|
||||||
|
ApiException(400, "Оценка должна быть в диапазоне 0–5")
|
||||||
|
|
||||||
|
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
|
||||||
|
assertEquals("Оценка должна быть в диапазоне 0–5", feedback!!.errors["score"], feedback.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `marks both file field names`() {
|
||||||
|
val request = request()
|
||||||
|
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
|
||||||
|
|
||||||
|
ApiException(400, "Выберите файл")
|
||||||
|
|
||||||
|
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback
|
||||||
|
assertEquals("Выберите файл", feedback!!.errors["file"])
|
||||||
|
assertEquals("Выберите файл", feedback.errors["files"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user