diff --git a/src/Orbit.Api/Middleware/ValidationExceptionHandler.cs b/src/Orbit.Api/Middleware/ValidationExceptionHandler.cs index 53845029..f77d5eed 100644 --- a/src/Orbit.Api/Middleware/ValidationExceptionHandler.cs +++ b/src/Orbit.Api/Middleware/ValidationExceptionHandler.cs @@ -1,7 +1,7 @@ -using System.Text.Json; using FluentValidation; using Microsoft.AspNetCore.Diagnostics; using Orbit.Api.Extensions; +using Orbit.Application.Common; namespace Orbit.Api.Middleware; @@ -19,11 +19,20 @@ public async ValueTask TryHandleAsync( httpContext.Response.ContentType = "application/json"; httpContext.Response.Headers[HttpContextExtensions.RequestIdHeaderName] = httpContext.GetRequestId(); - var errors = validationException.Errors + var languageResolver = httpContext.RequestServices.GetRequiredService(); + var isPtBr = await languageResolver.IsPortugueseAsync(httpContext, cancellationToken); + var errorDetails = validationException.Errors .GroupBy(e => e.PropertyName) .ToDictionary( g => g.Key, - g => g.Select(e => e.ErrorMessage).ToArray()); + g => g.Select(e => new + { + code = e.ErrorCode, + message = e.LocalizedMessage(isPtBr) + }).ToArray()); + var errors = errorDetails.ToDictionary( + g => g.Key, + g => g.Value.Select(e => e.message).ToArray()); LogValidationFailed( logger, @@ -36,7 +45,8 @@ public async ValueTask TryHandleAsync( type = "ValidationFailure", status = 400, requestId = httpContext.GetRequestId(), - errors + errors, + errorDetails }; await httpContext.Response.WriteAsJsonAsync(response, cancellationToken); diff --git a/src/Orbit.Application/Accountability/Validators/AccountabilityHabitRules.cs b/src/Orbit.Application/Accountability/Validators/AccountabilityHabitRules.cs index 1c01f436..ea966263 100644 --- a/src/Orbit.Application/Accountability/Validators/AccountabilityHabitRules.cs +++ b/src/Orbit.Application/Accountability/Validators/AccountabilityHabitRules.cs @@ -10,10 +10,10 @@ public static void AddHabitIdsRules(IRuleBuilder> rule rule .NotEmpty() .Must(ids => ids is null || ids.Count <= AppConstants.MaxAccountabilityHabitsPerUser) - .WithMessage($"You can link at most {AppConstants.MaxAccountabilityHabitsPerUser} habits per pair.") + .WithCopy(ValidationErrorCodes.AccountabilityHabitLimit) .Must(ids => ids is null || ids.All(id => id != Guid.Empty)) - .WithMessage("Habit ids must not be empty.") + .WithCopy(ValidationErrorCodes.HabitIdsRequired) .Must(ids => ids is null || ids.Distinct().Count() == ids.Count) - .WithMessage("Habit ids must not contain duplicates."); + .WithCopy(ValidationErrorCodes.HabitIdsUnique); } } diff --git a/src/Orbit.Application/ApiKeys/Validators/ConfirmApiKeyCreationChallengeCommandValidator.cs b/src/Orbit.Application/ApiKeys/Validators/ConfirmApiKeyCreationChallengeCommandValidator.cs index 36da8cdf..21cf9c1e 100644 --- a/src/Orbit.Application/ApiKeys/Validators/ConfirmApiKeyCreationChallengeCommandValidator.cs +++ b/src/Orbit.Application/ApiKeys/Validators/ConfirmApiKeyCreationChallengeCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.ApiKeys.Commands; @@ -14,6 +15,6 @@ public ConfirmApiKeyCreationChallengeCommandValidator() .NotEmpty() .Length(6) .Matches(@"^\d{6}$") - .WithMessage("Code must be a 6-digit number"); + .WithCopy(ValidationErrorCodes.VerificationCodeFormat); } } diff --git a/src/Orbit.Application/ApiKeys/Validators/CreateApiKeyValidator.cs b/src/Orbit.Application/ApiKeys/Validators/CreateApiKeyValidator.cs index d8771005..f9255656 100644 --- a/src/Orbit.Application/ApiKeys/Validators/CreateApiKeyValidator.cs +++ b/src/Orbit.Application/ApiKeys/Validators/CreateApiKeyValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.ApiKeys.Commands; using Orbit.Domain.Models; @@ -13,18 +14,18 @@ public CreateApiKeyValidator() RuleFor(x => x.Name) .NotEmpty() - .WithMessage("API key name is required.") + .WithCopy(ValidationErrorCodes.ApiKeyNameRequired) .MaximumLength(50) - .WithMessage("API key name must be 50 characters or less."); + .WithCopy(ValidationErrorCodes.ApiKeyNameLength); RuleForEach(x => x.Scopes) .NotEmpty() - .WithMessage("API key scopes must be non-empty strings.") + .WithCopy(ValidationErrorCodes.ApiKeyScopesRequired) .Must(scope => string.IsNullOrWhiteSpace(scope) || AgentScopes.All.Contains(scope.Trim())) - .WithMessage("API key scope '{PropertyValue}' is not a recognized scope."); + .WithCopy(ValidationErrorCodes.ApiKeyScopeInvalid); RuleFor(x => x.ExpiresAtUtc) .Must(expiresAtUtc => !expiresAtUtc.HasValue || expiresAtUtc.Value > DateTime.UtcNow) - .WithMessage("API key expiry must be in the future."); + .WithCopy(ValidationErrorCodes.ApiKeyExpiryFuture); } } diff --git a/src/Orbit.Application/Auth/Validators/ConfirmAccountDeletionCommandValidator.cs b/src/Orbit.Application/Auth/Validators/ConfirmAccountDeletionCommandValidator.cs index da4ab80c..d50bb91c 100644 --- a/src/Orbit.Application/Auth/Validators/ConfirmAccountDeletionCommandValidator.cs +++ b/src/Orbit.Application/Auth/Validators/ConfirmAccountDeletionCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Auth.Commands; @@ -14,6 +15,6 @@ public ConfirmAccountDeletionCommandValidator() .NotEmpty() .Length(6) .Matches(@"^\d{6}$") - .WithMessage("Code must be a 6-digit number"); + .WithCopy(ValidationErrorCodes.VerificationCodeFormat); } } diff --git a/src/Orbit.Application/Auth/Validators/GoogleAuthCommandValidator.cs b/src/Orbit.Application/Auth/Validators/GoogleAuthCommandValidator.cs index 03e38e26..70fc65d3 100644 --- a/src/Orbit.Application/Auth/Validators/GoogleAuthCommandValidator.cs +++ b/src/Orbit.Application/Auth/Validators/GoogleAuthCommandValidator.cs @@ -10,12 +10,12 @@ public GoogleAuthCommandValidator() { RuleFor(x => x.AccessToken) .NotEmpty() - .WithMessage("Access token is required."); + .WithCopy(ValidationErrorCodes.AccessTokenRequired); RuleFor(x => x.Language) .NotEmpty() .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Auth/Validators/RefreshTokenRules.cs b/src/Orbit.Application/Auth/Validators/RefreshTokenRules.cs index 20c627c1..e17bdba8 100644 --- a/src/Orbit.Application/Auth/Validators/RefreshTokenRules.cs +++ b/src/Orbit.Application/Auth/Validators/RefreshTokenRules.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; namespace Orbit.Application.Auth.Validators; @@ -15,6 +16,6 @@ public static void AddRefreshTokenRules(IRuleBuilder rule) rule .NotEmpty() .Must(token => IsWellFormed(token)) - .WithMessage("Refresh token format is invalid."); + .WithCopy(ValidationErrorCodes.RefreshTokenFormat); } } diff --git a/src/Orbit.Application/Auth/Validators/VerifyCodeCommandValidator.cs b/src/Orbit.Application/Auth/Validators/VerifyCodeCommandValidator.cs index b91133c3..96694672 100644 --- a/src/Orbit.Application/Auth/Validators/VerifyCodeCommandValidator.cs +++ b/src/Orbit.Application/Auth/Validators/VerifyCodeCommandValidator.cs @@ -16,12 +16,12 @@ public VerifyCodeCommandValidator() .NotEmpty() .Length(6) .Matches(@"^\d{6}$") - .WithMessage("Code must be a 6-digit number"); + .WithCopy(ValidationErrorCodes.VerificationCodeFormat); RuleFor(x => x.Language) .NotEmpty() .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs b/src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs index 51982c0f..f7f70bf6 100644 --- a/src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs +++ b/src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs @@ -13,10 +13,10 @@ public SetSelectedCalendarsCommandValidator() RuleFor(x => x.CalendarIds) .NotNull() .Must(ids => ids.Count <= AppConstants.MaxSelectedCalendars) - .WithMessage($"You can select at most {AppConstants.MaxSelectedCalendars} calendars."); + .WithCopy(ValidationErrorCodes.CalendarSelectionLimit); RuleForEach(x => x.CalendarIds) .NotEmpty() - .WithMessage("Calendar ids must be non-empty."); + .WithCopy(ValidationErrorCodes.CalendarIdsRequired); } } diff --git a/src/Orbit.Application/Challenges/Validators/CreateChallengeCommandValidator.cs b/src/Orbit.Application/Challenges/Validators/CreateChallengeCommandValidator.cs index 33a43776..eacb30c7 100644 --- a/src/Orbit.Application/Challenges/Validators/CreateChallengeCommandValidator.cs +++ b/src/Orbit.Application/Challenges/Validators/CreateChallengeCommandValidator.cs @@ -18,17 +18,17 @@ public CreateChallengeCommandValidator() RuleFor(x => x.TargetCount) .NotNull().GreaterThan(0) .When(x => x.Type == ChallengeType.CoopGoal) - .WithMessage("A goal challenge requires a target count greater than 0."); + .WithCopy(ValidationErrorCodes.ChallengeTargetPositive); RuleFor(x => x.TargetCount) .Null() .When(x => x.Type == ChallengeType.StreakTogether) - .WithMessage("A streak challenge cannot have a target count."); + .WithCopy(ValidationErrorCodes.StreakChallengeTargetAbsent); RuleFor(x => x.PeriodEndUtc) .NotNull() .When(x => x.Type == ChallengeType.CoopGoal) - .WithMessage("A goal challenge requires an end date."); + .WithCopy(ValidationErrorCodes.ChallengeEndDateRequired); RuleFor(x => x.PeriodEndUtc) .GreaterThanOrEqualTo(x => x.PeriodStartUtc) @@ -36,16 +36,16 @@ public CreateChallengeCommandValidator() RuleFor(x => x.LinkedHabitIds) .NotEmpty() - .WithMessage("Link at least one of your habits to the challenge."); + .WithCopy(ValidationErrorCodes.ChallengeHabitsRequired); RuleFor(x => x.LinkedHabitIds) .Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant) - .WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits."); + .WithCopy(ValidationErrorCodes.ChallengeHabitLimit); RuleForEach(x => x.LinkedHabitIds).NotEmpty(); RuleFor(x => x.InvitedFriendUserIds) .Must(ids => ids.Count <= AppConstants.MaxChallengeParticipants - 1) - .WithMessage($"You can invite at most {AppConstants.MaxChallengeParticipants - 1} friends."); + .WithCopy(ValidationErrorCodes.ChallengeInviteLimit); } } diff --git a/src/Orbit.Application/Challenges/Validators/JoinChallengeCommandValidator.cs b/src/Orbit.Application/Challenges/Validators/JoinChallengeCommandValidator.cs index a3e2d7be..f0440fab 100644 --- a/src/Orbit.Application/Challenges/Validators/JoinChallengeCommandValidator.cs +++ b/src/Orbit.Application/Challenges/Validators/JoinChallengeCommandValidator.cs @@ -16,11 +16,11 @@ public JoinChallengeCommandValidator() RuleFor(x => x.LinkedHabitIds) .NotEmpty() - .WithMessage("Link at least one of your habits to the challenge."); + .WithCopy(ValidationErrorCodes.ChallengeHabitsRequired); RuleFor(x => x.LinkedHabitIds) .Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant) - .WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits."); + .WithCopy(ValidationErrorCodes.ChallengeHabitLimit); RuleForEach(x => x.LinkedHabitIds).NotEmpty(); } diff --git a/src/Orbit.Application/Challenges/Validators/SetChallengeHabitsCommandValidator.cs b/src/Orbit.Application/Challenges/Validators/SetChallengeHabitsCommandValidator.cs index 1a2f818f..6028fa59 100644 --- a/src/Orbit.Application/Challenges/Validators/SetChallengeHabitsCommandValidator.cs +++ b/src/Orbit.Application/Challenges/Validators/SetChallengeHabitsCommandValidator.cs @@ -13,11 +13,11 @@ public SetChallengeHabitsCommandValidator() RuleFor(x => x.HabitIds) .NotEmpty() - .WithMessage("Link at least one of your habits to the challenge."); + .WithCopy(ValidationErrorCodes.ChallengeHabitsRequired); RuleFor(x => x.HabitIds) .Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant) - .WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits."); + .WithCopy(ValidationErrorCodes.ChallengeHabitLimit); RuleForEach(x => x.HabitIds).NotEmpty(); } diff --git a/src/Orbit.Application/Chat/Validators/ProcessUserChatCommandValidator.cs b/src/Orbit.Application/Chat/Validators/ProcessUserChatCommandValidator.cs index 3b72511a..825d500c 100644 --- a/src/Orbit.Application/Chat/Validators/ProcessUserChatCommandValidator.cs +++ b/src/Orbit.Application/Chat/Validators/ProcessUserChatCommandValidator.cs @@ -14,21 +14,21 @@ public ProcessUserChatCommandValidator() RuleFor(x => x.Message) .NotEmpty() - .WithMessage("Message cannot be empty.") + .WithCopy(ValidationErrorCodes.ChatMessageRequired) .MaximumLength(AppConstants.MaxChatMessageLength) - .WithMessage($"Message cannot exceed {AppConstants.MaxChatMessageLength} characters."); + .WithCopy(ValidationErrorCodes.ChatMessageLength); RuleFor(x => x.History) .Must(history => history is null || history.Count <= AppConstants.MaxChatHistoryMessages) - .WithMessage($"Chat history cannot exceed {AppConstants.MaxChatHistoryMessages} messages."); + .WithCopy(ValidationErrorCodes.ChatHistoryLimit); RuleForEach(x => x.History) .Must(message => ChatHistoryMessage.IsSupportedRole(message.Role)) - .WithMessage("Chat history contains an invalid role."); + .WithCopy(ValidationErrorCodes.ChatHistoryRole); RuleForEach(x => x.History) .Must(message => !string.IsNullOrWhiteSpace(message.Content) && message.Content.Length <= AppConstants.MaxChatHistoryMessageLength) - .WithMessage($"Chat history messages must be between 1 and {AppConstants.MaxChatHistoryMessageLength} characters."); + .WithCopy(ValidationErrorCodes.ChatHistoryMessageLength); } } diff --git a/src/Orbit.Application/Chat/Validators/ResolveClarificationRequestValidator.cs b/src/Orbit.Application/Chat/Validators/ResolveClarificationRequestValidator.cs index 8ad7a0ce..7b91a6ea 100644 --- a/src/Orbit.Application/Chat/Validators/ResolveClarificationRequestValidator.cs +++ b/src/Orbit.Application/Chat/Validators/ResolveClarificationRequestValidator.cs @@ -13,14 +13,11 @@ public ResolveClarificationRequestValidator() RuleFor(x => x.Value) .Cascade(CascadeMode.Stop) .NotEmpty() - .WithMessage(ErrorMessages.ClarificationValueEmpty.Message) - .WithErrorCode(ErrorMessages.ClarificationValueEmpty.Code) + .WithCopy(ErrorMessages.ClarificationValueEmpty.Code) .MaximumLength(AppConstants.MaxClarificationValueLength) - .WithMessage(ErrorMessages.ClarificationValueTooLong.Format(AppConstants.MaxClarificationValueLength).Message) - .WithErrorCode(ErrorMessages.ClarificationValueTooLong.Code) + .WithCopy(ErrorMessages.ClarificationValueTooLong.Code, AppConstants.MaxClarificationValueLength) .Must(BeJsonObject) - .WithMessage(ErrorMessages.ClarificationValueNotJsonObject.Message) - .WithErrorCode(ErrorMessages.ClarificationValueNotJsonObject.Code); + .WithCopy(ErrorMessages.ClarificationValueNotJsonObject.Code); } private static bool BeJsonObject(string value) diff --git a/src/Orbit.Application/Chat/Validators/RevisePendingOperationRequestValidator.cs b/src/Orbit.Application/Chat/Validators/RevisePendingOperationRequestValidator.cs index 4aa7bca0..968399a4 100644 --- a/src/Orbit.Application/Chat/Validators/RevisePendingOperationRequestValidator.cs +++ b/src/Orbit.Application/Chat/Validators/RevisePendingOperationRequestValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; namespace Orbit.Application.Chat.Validators; @@ -9,11 +10,11 @@ public RevisePendingOperationRequestValidator() RuleFor(request => request.PreviewFingerprint).NotEmpty().MaximumLength(256); RuleFor(request => request.Items).NotNull() .Must(items => items is not null && items.Count <= 500) - .WithMessage("At most 500 items may be revised."); + .WithCopy(ValidationErrorCodes.RevisionItemLimit); RuleFor(request => request.Items) .Must(items => items is not null && items.Select(item => item.ItemId) .Distinct(StringComparer.Ordinal).Count() == items.Count) - .WithMessage("Item IDs must be unique."); + .WithCopy(ValidationErrorCodes.RevisionItemIdsUnique); RuleForEach(request => request.Items).ChildRules(item => item.RuleFor(value => value.ItemId).NotEmpty().MaximumLength(100)); } diff --git a/src/Orbit.Application/Chat/Validators/TranscribeAudioCommandValidator.cs b/src/Orbit.Application/Chat/Validators/TranscribeAudioCommandValidator.cs index 66e53428..234a2bf3 100644 --- a/src/Orbit.Application/Chat/Validators/TranscribeAudioCommandValidator.cs +++ b/src/Orbit.Application/Chat/Validators/TranscribeAudioCommandValidator.cs @@ -15,13 +15,13 @@ public TranscribeAudioCommandValidator() { RuleFor(x => x.Audio) .Must(audio => audio.Length > 0) - .WithMessage("Audio file is required.") + .WithCopy(ValidationErrorCodes.AudioRequired) .Must(audio => audio.Length <= AppConstants.MaxAudioBytes) - .WithMessage($"Audio exceeds the maximum size of {AppConstants.MaxAudioBytes / (1024 * 1024)}MB."); + .WithCopy(ValidationErrorCodes.AudioSizeLimit); RuleFor(x => x.FileName) .Must(HasAllowedExtension) - .WithMessage(x => $"Audio format '{Path.GetExtension(x.FileName)}' is not supported."); + .WithCopy(ValidationErrorCodes.AudioFormat, x => [Path.GetExtension(x.FileName)]); } private static bool HasAllowedExtension(string fileName) diff --git a/src/Orbit.Application/ChecklistTemplates/Validators/CreateChecklistTemplateCommandValidator.cs b/src/Orbit.Application/ChecklistTemplates/Validators/CreateChecklistTemplateCommandValidator.cs index 1d1fe0ac..3ce086bd 100644 --- a/src/Orbit.Application/ChecklistTemplates/Validators/CreateChecklistTemplateCommandValidator.cs +++ b/src/Orbit.Application/ChecklistTemplates/Validators/CreateChecklistTemplateCommandValidator.cs @@ -13,12 +13,12 @@ public CreateChecklistTemplateCommandValidator() RuleFor(x => x.Items) .Must(items => items.Count <= AppConstants.MaxChecklistItems) .When(x => x.Items is not null) - .WithMessage($"A checklist template can have at most {AppConstants.MaxChecklistItems} items."); + .WithCopy(ValidationErrorCodes.TemplateItemLimit); RuleForEach(x => x.Items) .NotEmpty() - .WithMessage("Checklist template items must not be empty.") + .WithCopy(ValidationErrorCodes.TemplateItemRequired) .MaximumLength(AppConstants.MaxChecklistItemTextLength) - .WithMessage($"Checklist template item text must not exceed {AppConstants.MaxChecklistItemTextLength} characters."); + .WithCopy(ValidationErrorCodes.TemplateItemLength); } } diff --git a/src/Orbit.Application/Common/ErrorCopy.Validation.cs b/src/Orbit.Application/Common/ErrorCopy.Validation.cs new file mode 100644 index 00000000..bdac0108 --- /dev/null +++ b/src/Orbit.Application/Common/ErrorCopy.Validation.cs @@ -0,0 +1,134 @@ +using Orbit.Application.Uploads.Common; + +namespace Orbit.Application.Common; + +public static partial class ErrorCopy +{ + private static (string Code, string En, string PtBr)[] Validation => + [ + (ValidationErrorCodes.AccountabilityHabitLimit, $"You can link at most {AppConstants.MaxAccountabilityHabitsPerUser} habits per pair.", $"Você pode vincular no máximo {AppConstants.MaxAccountabilityHabitsPerUser} hábitos por dupla."), + (ValidationErrorCodes.HabitIdsRequired, "Habit ids must not be empty.", "Os IDs dos hábitos não podem estar vazios."), + (ValidationErrorCodes.HabitIdsUnique, "Habit ids must not contain duplicates.", "Os IDs dos hábitos não podem se repetir."), + (ValidationErrorCodes.VerificationCodeFormat, "Code must be a 6-digit number", "O código deve ter 6 dígitos"), + (ValidationErrorCodes.ApiKeyNameRequired, "API key name is required.", "O nome da chave de API é obrigatório."), + (ValidationErrorCodes.ApiKeyNameLength, "API key name must be 50 characters or less.", "O nome da chave de API deve ter no máximo 50 caracteres."), + (ValidationErrorCodes.ApiKeyScopesRequired, "API key scopes must be non-empty strings.", "As permissões da chave de API não podem estar vazias."), + (ValidationErrorCodes.ApiKeyScopeInvalid, "API key scope '{PropertyValue}' is not a recognized scope.", "A permissão '{PropertyValue}' da chave de API não é reconhecida."), + (ValidationErrorCodes.ApiKeyExpiryFuture, "API key expiry must be in the future.", "A validade da chave de API deve estar no futuro."), + (ValidationErrorCodes.AccessTokenRequired, "Access token is required.", "O token de acesso é obrigatório."), + (ValidationErrorCodes.LanguageSupported, $"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}", $"O idioma deve ser um destes: {string.Join(", ", AppConstants.SupportedLanguages)}"), + (ValidationErrorCodes.RefreshTokenFormat, "Refresh token format is invalid.", "O formato do token de atualização é inválido."), + (ValidationErrorCodes.CalendarSelectionLimit, $"You can select at most {AppConstants.MaxSelectedCalendars} calendars.", $"Você pode selecionar no máximo {AppConstants.MaxSelectedCalendars} agendas."), + (ValidationErrorCodes.CalendarIdsRequired, "Calendar ids must be non-empty.", "Os IDs das agendas não podem estar vazios."), + (ValidationErrorCodes.ChallengeTargetPositive, "A goal challenge requires a target count greater than 0.", "Um desafio de meta precisa de uma quantidade maior que 0."), + (ValidationErrorCodes.StreakChallengeTargetAbsent, "A streak challenge cannot have a target count.", "Um desafio de sequência não pode ter uma quantidade alvo."), + (ValidationErrorCodes.ChallengeEndDateRequired, "A goal challenge requires an end date.", "Um desafio de meta precisa de uma data de término."), + (ValidationErrorCodes.ChallengeHabitsRequired, "Link at least one of your habits to the challenge.", "Vincule pelo menos um dos seus hábitos ao desafio."), + (ValidationErrorCodes.ChallengeHabitLimit, $"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits.", $"Você pode vincular no máximo {AppConstants.MaxHabitsPerChallengeParticipant} hábitos."), + (ValidationErrorCodes.ChallengeInviteLimit, $"You can invite at most {AppConstants.MaxChallengeParticipants - 1} friends.", $"Você pode convidar no máximo {AppConstants.MaxChallengeParticipants - 1} amigos."), + (ValidationErrorCodes.ChatMessageRequired, "Message cannot be empty.", "A mensagem não pode estar vazia."), + (ValidationErrorCodes.ChatMessageLength, $"Message cannot exceed {AppConstants.MaxChatMessageLength} characters.", $"A mensagem deve ter no máximo {AppConstants.MaxChatMessageLength} caracteres."), + (ValidationErrorCodes.ChatHistoryLimit, $"Chat history cannot exceed {AppConstants.MaxChatHistoryMessages} messages.", $"O histórico da conversa deve ter no máximo {AppConstants.MaxChatHistoryMessages} mensagens."), + (ValidationErrorCodes.ChatHistoryRole, "Chat history contains an invalid role.", "O histórico da conversa contém um papel inválido."), + (ValidationErrorCodes.ChatHistoryMessageLength, $"Chat history messages must be between 1 and {AppConstants.MaxChatHistoryMessageLength} characters.", $"As mensagens do histórico devem ter entre 1 e {AppConstants.MaxChatHistoryMessageLength} caracteres."), + (ValidationErrorCodes.RevisionItemLimit, "At most 500 items may be revised.", "No máximo 500 itens podem ser revisados."), + (ValidationErrorCodes.RevisionItemIdsUnique, "Item IDs must be unique.", "Os IDs dos itens não podem se repetir."), + (ValidationErrorCodes.AudioRequired, "Audio file is required.", "O arquivo de áudio é obrigatório."), + (ValidationErrorCodes.AudioSizeLimit, $"Audio exceeds the maximum size of {AppConstants.MaxAudioBytes / (1024 * 1024)}MB.", $"O áudio excede o tamanho máximo de {AppConstants.MaxAudioBytes / (1024 * 1024)}MB."), + (ValidationErrorCodes.AudioFormat, "Audio format '{0}' is not supported.", "O formato de áudio '{0}' não é suportado."), + (ValidationErrorCodes.TemplateItemLimit, $"A checklist template can have at most {AppConstants.MaxChecklistItems} items.", $"Um modelo de checklist pode ter no máximo {AppConstants.MaxChecklistItems} itens."), + (ValidationErrorCodes.TemplateItemRequired, "Checklist template items must not be empty.", "Os itens do modelo de checklist não podem estar vazios."), + (ValidationErrorCodes.TemplateItemLength, $"Checklist template item text must not exceed {AppConstants.MaxChecklistItemTextLength} characters.", $"O texto de um item do modelo de checklist deve ter no máximo {AppConstants.MaxChecklistItemTextLength} caracteres."), + (ValidationErrorCodes.PeriodSupported, "Period must be one of: week, month, quarter, semester, year.", "O período deve ser um destes: week, month, quarter, semester, year."), + (ValidationErrorCodes.DateRangeOrder, "DateFrom must be on or before DateTo.", "DateFrom deve ser anterior ou igual a DateTo."), + (ValidationErrorCodes.ClosedPeriodMatch, "Closed period parameters must match the requested period.", "Os parâmetros do período fechado devem corresponder ao período solicitado."), + (ValidationErrorCodes.ClosedCalendarMonthOnly, "Closed calendar parameters can only be used with the month period.", "Os parâmetros de calendário fechado só podem ser usados com o período month."), + (ValidationErrorCodes.ClosedYearRange, "ClosedYear must be between 1 and 9999.", "ClosedYear deve estar entre 1 e 9999."), + (ValidationErrorCodes.ClosedMonthRange, "ClosedMonth must be between 1 and 12.", "ClosedMonth deve estar entre 1 e 12."), + (ValidationErrorCodes.ClosedMonthDates, "DateFrom and DateTo must match the complete closed calendar month.", "DateFrom e DateTo devem corresponder ao mês completo do calendário fechado."), + (ValidationErrorCodes.ClosedYearDates, "DateFrom and DateTo must match the complete closed calendar year.", "DateFrom e DateTo devem corresponder ao ano completo do calendário fechado."), + (ValidationErrorCodes.ClosedWeekDates, "DateFrom and DateTo must match the complete closed calendar week.", "DateFrom e DateTo devem corresponder à semana completa do calendário fechado."), + (ValidationErrorCodes.DateRangeLimit, $"Date range must not exceed {AppConstants.MaxRangeDays} days.", $"O intervalo de datas deve ter no máximo {AppConstants.MaxRangeDays} dias."), + (ValidationErrorCodes.StreakGapLimit, "The gap exceeds the streak history window.", "O intervalo excede a janela do histórico de sequência."), + (ValidationErrorCodes.StreakDateRequired, "Each date is required.", "Cada data é obrigatória."), + (ValidationErrorCodes.StreakDatesUnique, "Select each date once.", "Selecione cada data uma vez."), + (ValidationErrorCodes.EventKeyKnown, "Unknown event key.", "A chave do evento é desconhecida."), + (ValidationErrorCodes.GoalHabitLimit, $"A goal can have at most {AppConstants.MaxHabitsPerGoal} linked habits.", $"Uma meta pode ter no máximo {AppConstants.MaxHabitsPerGoal} hábitos vinculados."), + (ValidationErrorCodes.BulkHabitsRequired, "Habits list must not be empty", "A lista de hábitos não pode estar vazia"), + (ValidationErrorCodes.BulkCreateLimit, $"Cannot create more than {AppConstants.MaxBulkOperationSize} habits at once", $"Não é possível criar mais de {AppConstants.MaxBulkOperationSize} hábitos de uma vez"), + (ValidationErrorCodes.FrequencyQuantityRequired, "Frequency quantity is required when frequency unit is set", "A quantidade de frequência é obrigatória quando a unidade de frequência está definida"), + (ValidationErrorCodes.BulkTagLimit, $"Cannot assign more than {AppConstants.MaxTagsPerHabit} tags per habit", $"Não é possível atribuir mais de {AppConstants.MaxTagsPerHabit} tags por hábito"), + (ValidationErrorCodes.BulkHabitIdsRequired, "HabitIds list must not be empty", "A lista de IDs dos hábitos não pode estar vazia"), + (ValidationErrorCodes.BulkDeleteHabitLimit, $"Cannot delete more than {AppConstants.MaxBulkOperationSize} habits at once", $"Não é possível excluir mais de {AppConstants.MaxBulkOperationSize} hábitos de uma vez"), + (ValidationErrorCodes.HabitIdRequired, "Habit ID must not be empty", "O ID do hábito não pode estar vazio"), + (ValidationErrorCodes.BulkItemsRequired, "Items list must not be empty", "A lista de itens não pode estar vazia"), + (ValidationErrorCodes.BulkFilterRequired, "A bulk habit filter is required.", "Um filtro de hábitos é obrigatório."), + (ValidationErrorCodes.BulkChangesRequired, "At least one habit change is required.", "Pelo menos uma alteração de hábito é obrigatória."), + (ValidationErrorCodes.RecurringQuantityPositive, "A recurring frequency requires a positive frequency quantity.", "Uma frequência recorrente precisa de uma quantidade positiva."), + (ValidationErrorCodes.SubHabitLimit, $"A habit can have at most {AppConstants.MaxSubHabits} sub-habits", $"Um hábito pode ter no máximo {AppConstants.MaxSubHabits} sub-hábitos"), + (ValidationErrorCodes.SubHabitTitleRequired, "Sub-habit title must not be empty", "O título do sub-hábito não pode estar vazio"), + (ValidationErrorCodes.SubHabitTitleLength, $"Sub-habit title must not exceed {AppConstants.MaxHabitTitleLength} characters", $"O título do sub-hábito deve ter no máximo {AppConstants.MaxHabitTitleLength} caracteres"), + (ValidationErrorCodes.GeneralHabitNotBad, "General habits cannot be bad habits", "Hábitos gerais não podem ser hábitos ruins"), + (ValidationErrorCodes.HabitTagLimit, $"A habit can have at most {AppConstants.MaxTagsPerHabit} tags", $"Um hábito pode ter no máximo {AppConstants.MaxTagsPerHabit} tags"), + (ValidationErrorCodes.DateRangeOrderLower, "dateTo must be >= dateFrom", "dateTo deve ser posterior ou igual a dateFrom"), + (ValidationErrorCodes.DateRangeLimitShort, $"Date range must not exceed {AppConstants.MaxRangeDays} days", $"O intervalo de datas deve ter no máximo {AppConstants.MaxRangeDays} dias"), + (ValidationErrorCodes.CalendarDateRangeLimit, $"Date range must not exceed {AppConstants.MaxCalendarRangeDays} days", $"O intervalo de datas deve ter no máximo {AppConstants.MaxCalendarRangeDays} dias"), + (ValidationErrorCodes.DateFromRequired, "dateFrom is required", "dateFrom é obrigatório"), + (ValidationErrorCodes.DateToRequired, "dateTo is required", "dateTo é obrigatório"), + (ValidationErrorCodes.HabitGoalLimit, $"A habit can have at most {AppConstants.MaxGoalsPerHabit} linked goals.", $"Um hábito pode ter no máximo {AppConstants.MaxGoalsPerHabit} metas vinculadas."), + (ValidationErrorCodes.PositionsRequired, "Positions list must not be empty", "A lista de posições não pode estar vazia"), + (ValidationErrorCodes.PositionsUnique, "Positions list must not contain duplicate habit IDs", "A lista de posições não pode conter IDs de hábitos repetidos"), + (ValidationErrorCodes.ChecklistItemLength, $"Checklist item text must not exceed {AppConstants.MaxChecklistItemTextLength} characters", $"O texto de um item do checklist deve ter no máximo {AppConstants.MaxChecklistItemTextLength} caracteres"), + (ValidationErrorCodes.DaysDailyOnly, "Days can only be specified for a daily habit (frequency unit Day, quantity 1)", "Dias só podem ser definidos para um hábito diário (unidade Day, quantidade 1)"), + (ValidationErrorCodes.EndDateRecurringOnly, "An end date requires a recurring habit", "Uma data de término precisa de um hábito recorrente"), + (ValidationErrorCodes.ScheduledReminderLimit, $"A habit can have at most {AppConstants.MaxScheduledReminders} scheduled reminders", $"Um hábito pode ter no máximo {AppConstants.MaxScheduledReminders} lembretes agendados"), + (ValidationErrorCodes.ScheduledReminderWhen, "Scheduled reminder 'when' must be 'day_before' or 'same_day'", "O campo 'when' do lembrete deve ser 'day_before' ou 'same_day'"), + (ValidationErrorCodes.ScheduledRemindersUnique, "Scheduled reminders must not contain duplicate entries", "Os lembretes agendados não podem se repetir"), + (ValidationErrorCodes.ReminderTimeLimit, $"A habit can have at most {AppConstants.MaxReminderTimes} reminder times", $"Um hábito pode ter no máximo {AppConstants.MaxReminderTimes} horários de lembrete"), + (ValidationErrorCodes.ReminderTimeRange, $"Reminder times must be between 0 and {AppConstants.MaxReminderMinutesBefore} minutes before the due time", $"Os lembretes devem ser de 0 a {AppConstants.MaxReminderMinutesBefore} minutos antes do horário previsto"), + (ValidationErrorCodes.ReminderTimesUnique, "Reminder times must not contain duplicate values", "Os horários de lembrete não podem se repetir"), + (ValidationErrorCodes.RelativeRemindersValid, "Relative reminders must be unique and have a valid offset or local time", "Os lembretes relativos não podem se repetir e devem ter um intervalo ou horário local válido"), + (ValidationErrorCodes.GeneralFrequencyUnitAbsent, "General habits cannot have a frequency unit", "Hábitos gerais não podem ter unidade de frequência"), + (ValidationErrorCodes.GeneralFrequencyQuantityAbsent, "General habits cannot have a frequency quantity", "Hábitos gerais não podem ter quantidade de frequência"), + (ValidationErrorCodes.GeneralDaysAbsent, "General habits cannot have specific days", "Hábitos gerais não podem ter dias específicos"), + (ValidationErrorCodes.CalendarDateValid, "Date must be a valid calendar date", "A data deve ser uma data válida do calendário"), + (ValidationErrorCodes.ChecklistItemLimit, $"A checklist can have at most {AppConstants.MaxChecklistItems} items", $"Um checklist pode ter no máximo {AppConstants.MaxChecklistItems} itens"), + (ValidationErrorCodes.WeekStartOnboarding, "Week start day must be 0 (Sunday) or 1 (Monday).", "O início da semana deve ser 0 (domingo) ou 1 (segunda-feira)."), + (ValidationErrorCodes.HandleFormat, "Handle must be 3-20 characters using only letters, numbers, or underscores.", "O nome de usuário deve ter de 3 a 20 caracteres, usando apenas letras, números ou sublinhados."), + (ValidationErrorCodes.ProfileNameLength, $"Name must be at most {AppConstants.MaxUserNameLength} characters", $"O nome deve ter no máximo {AppConstants.MaxUserNameLength} caracteres"), + (ValidationErrorCodes.WeekStartDay, "WeekStartDay must be 0 (Sunday) or 1 (Monday).", "WeekStartDay deve ser 0 (domingo) ou 1 (segunda-feira)."), + (ValidationErrorCodes.CheersDirection, "Direction must be 'received' or 'sent'.", "A direção deve ser 'received' ou 'sent'."), + (ValidationErrorCodes.FeedCursorFormat, "Cursor is malformed.", "O cursor está malformado."), + (ValidationErrorCodes.FriendIdentifierExclusive, "Provide exactly one of handle or referralCode.", "Informe exatamente um destes: handle ou referralCode."), + (ValidationErrorCodes.BillingIntervalRequired, "Billing interval is required.", "O intervalo de cobrança é obrigatório."), + (ValidationErrorCodes.BillingIntervalSupported, "Billing interval must be 'monthly' or 'yearly'.", "O intervalo de cobrança deve ser 'monthly' ou 'yearly'."), + (ValidationErrorCodes.TagColorFormat, "Color must be a valid hex color (e.g. #FF5733)", "A cor deve ser um código hexadecimal válido (por exemplo, #FF5733)"), + (ValidationErrorCodes.UploadContentTypeRequired, "Content type is required.", "O tipo de conteúdo é obrigatório."), + (ValidationErrorCodes.UploadContentTypeSupported, $"Content type must be one of: {string.Join(", ", UploadContentTypes.Allowed)}.", $"O tipo de conteúdo deve ser um destes: {string.Join(", ", UploadContentTypes.Allowed)}."), + (ValidationErrorCodes.UploadSizePositive, "File size must be greater than zero.", "O tamanho do arquivo deve ser maior que zero."), + (ValidationErrorCodes.UploadSizeLimit, $"File size must be {UploadContentTypes.MaxSizeBytes} bytes or less.", $"O arquivo deve ter no máximo {UploadContentTypes.MaxSizeBytes} bytes."), + (ValidationErrorCodes.FactIdsRequired, "FactIds list must not be empty", "A lista de IDs dos fatos não pode estar vazia"), + (ValidationErrorCodes.BulkDeleteFactLimit, $"Cannot delete more than {AppConstants.MaxBulkOperationSize} facts at once", $"Não é possível excluir mais de {AppConstants.MaxBulkOperationSize} fatos de uma vez"), + (ValidationErrorCodes.FactIdRequired, "Fact ID must not be empty", "O ID do fato não pode estar vazio"), + (ValidationErrorCodes.WaitlistLanguageSupported, "Language must be one of: en, pt-BR.", "O idioma deve ser um destes: en, pt-BR."), + (ValidationErrorCodes.BulkLogLimit, $"Bulk log cannot exceed {AppConstants.MaxBulkOperationSize} items", $"Não é possível registrar mais de {AppConstants.MaxBulkOperationSize} itens de uma vez"), + (ValidationErrorCodes.BulkSkipLimit, $"Bulk skip cannot exceed {AppConstants.MaxBulkOperationSize} items", $"Não é possível pular mais de {AppConstants.MaxBulkOperationSize} itens de uma vez"), + ("NotEmptyValidator", "'{PropertyName}' must not be empty.", "'{PropertyName}' deve ser informado."), + ("NotNullValidator", "'{PropertyName}' must not be empty.", "'{PropertyName}' não pode ser nulo."), + ("NullValidator", "'{PropertyName}' must be empty.", "'{PropertyName}' deve estar vazio."), + ("EmailValidator", "'{PropertyName}' is not a valid email address.", "'{PropertyName}' é um endereço de email inválido."), + ("LengthValidator", "'{PropertyName}' must be between {MinLength} and {MaxLength} characters. You entered {TotalLength} characters.", "'{PropertyName}' deve ter entre {MinLength} e {MaxLength} caracteres. Você digitou {TotalLength} caracteres."), + ("MaximumLengthValidator", "The length of '{PropertyName}' must be {MaxLength} characters or fewer. You entered {TotalLength} characters.", "'{PropertyName}' deve ser menor ou igual a {MaxLength} caracteres. Você digitou {TotalLength} caracteres."), + ("RegularExpressionValidator", "'{PropertyName}' is not in the correct format.", "'{PropertyName}' não está no formato correto."), + ("EqualValidator", "'{PropertyName}' must be equal to '{ComparisonValue}'.", "'{PropertyName}' deve ser igual a '{ComparisonValue}'."), + ("NotEqualValidator", "'{PropertyName}' must not be equal to '{ComparisonValue}'.", "'{PropertyName}' deve ser diferente de '{ComparisonValue}'."), + ("PredicateValidator", "The specified condition was not met for '{PropertyName}'.", "'{PropertyName}' não atende a condição definida."), + ("GreaterThanValidator", "'{PropertyName}' must be greater than '{ComparisonValue}'.", "'{PropertyName}' deve ser superior a '{ComparisonValue}'."), + ("GreaterThanOrEqualValidator", "'{PropertyName}' must be greater than or equal to '{ComparisonValue}'.", "'{PropertyName}' deve ser superior ou igual a '{ComparisonValue}'."), + ("LessThanOrEqualValidator", "'{PropertyName}' must be less than or equal to '{ComparisonValue}'.", "'{PropertyName}' deve ser inferior ou igual a '{ComparisonValue}'."), + ("InclusiveBetweenValidator", "'{PropertyName}' must be between {From} and {To}. You entered {PropertyValue}.", "'{PropertyName}' deve estar entre {From} e {To}. Você digitou {PropertyValue}."), + ("EnumValidator", "'{PropertyName}' has a range of values which does not include '{PropertyValue}'.", "'{PropertyName}' possui um intervalo de valores que não inclui '{PropertyValue}'."), + ("ExactLengthValidator", "'{PropertyName}' must be {MaxLength} characters in length. You entered {TotalLength} characters.", "'{PropertyName}' deve ter exatamente {MaxLength} caracteres. Você digitou {TotalLength} caracteres."), + ("AsyncPredicateValidator", "The specified condition was not met for '{PropertyName}'.", "'{PropertyName}' não atende a condição definida."), + ]; +} diff --git a/src/Orbit.Application/Common/ErrorCopy.cs b/src/Orbit.Application/Common/ErrorCopy.cs index 2758d404..8a1a93e1 100644 --- a/src/Orbit.Application/Common/ErrorCopy.cs +++ b/src/Orbit.Application/Common/ErrorCopy.cs @@ -2,7 +2,7 @@ namespace Orbit.Application.Common; -public static class ErrorCopy +public static partial class ErrorCopy { /// Accounts, sessions, sign-in and the emailed confirmation codes. private static readonly (string Code, string En, string PtBr)[] AccountAndAccess = @@ -393,7 +393,7 @@ public static bool TryResolve( { AccountAndAccess, StepUpAndApiKeys, Habits, Goals, TagsFactsAndTemplates, Astra, Billing, Streaks, Social, Calendar, Notifications, Media, - SyncAndSupport, Profile, + SyncAndSupport, Profile, Validation, }) { foreach (var (code, en, ptBr) in group) diff --git a/src/Orbit.Application/Common/ValidationCopyExtensions.cs b/src/Orbit.Application/Common/ValidationCopyExtensions.cs new file mode 100644 index 00000000..e74bcbf3 --- /dev/null +++ b/src/Orbit.Application/Common/ValidationCopyExtensions.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using FluentValidation.Internal; +using FluentValidation.Results; + +namespace Orbit.Application.Common; + +public static class ValidationCopyExtensions +{ + public static IRuleBuilderOptions WithCopy( + this IRuleBuilderOptions rule, string code, params object?[] args) => + rule.WithCopy(code, _ => args); + + public static IRuleBuilderOptions WithCopy( + this IRuleBuilderOptions rule, string code, Func args) => + rule.WithErrorCode(code) + .WithMessage(instance => Resolve(code, false, args(instance))) + .WithState(instance => new ValidationCopyArguments(args(instance))); + + public static string LocalizedMessage(this ValidationFailure failure, bool isPtBr) + { + var args = failure.CustomState is ValidationCopyArguments copyArgs ? copyArgs.Values : []; + if (!ErrorCopy.TryResolve(failure.ErrorCode, isPtBr, args, out var template)) + return failure.ErrorMessage; + + var formatter = new MessageFormatter(); + if (failure.FormattedMessagePlaceholderValues is not null) + { + foreach (var (key, value) in failure.FormattedMessagePlaceholderValues) + formatter.AppendArgument(key, value); + } + + return formatter.BuildMessage(template); + } + + private static string Resolve(string code, bool isPtBr, IReadOnlyList args) => + ErrorCopy.TryResolve(code, isPtBr, args, out var message) + ? message + : throw new InvalidOperationException($"Validation code has no user-facing copy: {code}"); + + private sealed record ValidationCopyArguments(IReadOnlyList Values); +} diff --git a/src/Orbit.Application/Common/ValidationErrorCodes.cs b/src/Orbit.Application/Common/ValidationErrorCodes.cs new file mode 100644 index 00000000..8b77c6b5 --- /dev/null +++ b/src/Orbit.Application/Common/ValidationErrorCodes.cs @@ -0,0 +1,112 @@ +namespace Orbit.Application.Common; + +public static class ValidationErrorCodes +{ + public const string AccountabilityHabitLimit = "VALIDATION_ACCOUNTABILITY_HABIT_LIMIT"; + public const string HabitIdsRequired = "VALIDATION_HABIT_IDS_REQUIRED"; + public const string HabitIdsUnique = "VALIDATION_HABIT_IDS_UNIQUE"; + public const string VerificationCodeFormat = "VALIDATION_VERIFICATION_CODE_FORMAT"; + public const string ApiKeyNameRequired = "VALIDATION_API_KEY_NAME_REQUIRED"; + public const string ApiKeyNameLength = "VALIDATION_API_KEY_NAME_LENGTH"; + public const string ApiKeyScopesRequired = "VALIDATION_API_KEY_SCOPES_REQUIRED"; + public const string ApiKeyScopeInvalid = "VALIDATION_API_KEY_SCOPE_INVALID"; + public const string ApiKeyExpiryFuture = "VALIDATION_API_KEY_EXPIRY_FUTURE"; + public const string AccessTokenRequired = "VALIDATION_ACCESS_TOKEN_REQUIRED"; + public const string LanguageSupported = "VALIDATION_LANGUAGE_SUPPORTED"; + public const string RefreshTokenFormat = "VALIDATION_REFRESH_TOKEN_FORMAT"; + public const string CalendarSelectionLimit = "VALIDATION_CALENDAR_SELECTION_LIMIT"; + public const string CalendarIdsRequired = "VALIDATION_CALENDAR_IDS_REQUIRED"; + public const string ChallengeTargetPositive = "VALIDATION_CHALLENGE_TARGET_POSITIVE"; + public const string StreakChallengeTargetAbsent = "VALIDATION_STREAK_CHALLENGE_TARGET_ABSENT"; + public const string ChallengeEndDateRequired = "VALIDATION_CHALLENGE_END_DATE_REQUIRED"; + public const string ChallengeHabitsRequired = "VALIDATION_CHALLENGE_HABITS_REQUIRED"; + public const string ChallengeHabitLimit = "VALIDATION_CHALLENGE_HABIT_LIMIT"; + public const string ChallengeInviteLimit = "VALIDATION_CHALLENGE_INVITE_LIMIT"; + public const string ChatMessageRequired = "VALIDATION_CHAT_MESSAGE_REQUIRED"; + public const string ChatMessageLength = "VALIDATION_CHAT_MESSAGE_LENGTH"; + public const string ChatHistoryLimit = "VALIDATION_CHAT_HISTORY_LIMIT"; + public const string ChatHistoryRole = "VALIDATION_CHAT_HISTORY_ROLE"; + public const string ChatHistoryMessageLength = "VALIDATION_CHAT_HISTORY_MESSAGE_LENGTH"; + public const string RevisionItemLimit = "VALIDATION_REVISION_ITEM_LIMIT"; + public const string RevisionItemIdsUnique = "VALIDATION_REVISION_ITEM_IDS_UNIQUE"; + public const string AudioRequired = "VALIDATION_AUDIO_REQUIRED"; + public const string AudioSizeLimit = "VALIDATION_AUDIO_SIZE_LIMIT"; + public const string AudioFormat = "VALIDATION_AUDIO_FORMAT"; + public const string TemplateItemLimit = "VALIDATION_TEMPLATE_ITEM_LIMIT"; + public const string TemplateItemRequired = "VALIDATION_TEMPLATE_ITEM_REQUIRED"; + public const string TemplateItemLength = "VALIDATION_TEMPLATE_ITEM_LENGTH"; + public const string PeriodSupported = "VALIDATION_PERIOD_SUPPORTED"; + public const string DateRangeOrder = "VALIDATION_DATE_RANGE_ORDER"; + public const string ClosedPeriodMatch = "VALIDATION_CLOSED_PERIOD_MATCH"; + public const string ClosedCalendarMonthOnly = "VALIDATION_CLOSED_CALENDAR_MONTH_ONLY"; + public const string ClosedYearRange = "VALIDATION_CLOSED_YEAR_RANGE"; + public const string ClosedMonthRange = "VALIDATION_CLOSED_MONTH_RANGE"; + public const string ClosedMonthDates = "VALIDATION_CLOSED_MONTH_DATES"; + public const string ClosedYearDates = "VALIDATION_CLOSED_YEAR_DATES"; + public const string ClosedWeekDates = "VALIDATION_CLOSED_WEEK_DATES"; + public const string DateRangeLimit = "VALIDATION_DATE_RANGE_LIMIT"; + public const string StreakGapLimit = "VALIDATION_STREAK_GAP_LIMIT"; + public const string StreakDateRequired = "VALIDATION_STREAK_DATE_REQUIRED"; + public const string StreakDatesUnique = "VALIDATION_STREAK_DATES_UNIQUE"; + public const string EventKeyKnown = "VALIDATION_EVENT_KEY_KNOWN"; + public const string GoalHabitLimit = "VALIDATION_GOAL_HABIT_LIMIT"; + public const string BulkHabitsRequired = "VALIDATION_BULK_HABITS_REQUIRED"; + public const string BulkCreateLimit = "VALIDATION_BULK_CREATE_LIMIT"; + public const string FrequencyQuantityRequired = "VALIDATION_FREQUENCY_QUANTITY_REQUIRED"; + public const string BulkTagLimit = "VALIDATION_BULK_TAG_LIMIT"; + public const string BulkHabitIdsRequired = "VALIDATION_BULK_HABIT_IDS_REQUIRED"; + public const string BulkDeleteHabitLimit = "VALIDATION_BULK_DELETE_HABIT_LIMIT"; + public const string HabitIdRequired = "VALIDATION_HABIT_ID_REQUIRED"; + public const string BulkItemsRequired = "VALIDATION_BULK_ITEMS_REQUIRED"; + public const string BulkFilterRequired = "VALIDATION_BULK_FILTER_REQUIRED"; + public const string BulkChangesRequired = "VALIDATION_BULK_CHANGES_REQUIRED"; + public const string RecurringQuantityPositive = "VALIDATION_RECURRING_QUANTITY_POSITIVE"; + public const string SubHabitLimit = "VALIDATION_SUB_HABIT_LIMIT"; + public const string SubHabitTitleRequired = "VALIDATION_SUB_HABIT_TITLE_REQUIRED"; + public const string SubHabitTitleLength = "VALIDATION_SUB_HABIT_TITLE_LENGTH"; + public const string GeneralHabitNotBad = "VALIDATION_GENERAL_HABIT_NOT_BAD"; + public const string HabitTagLimit = "VALIDATION_HABIT_TAG_LIMIT"; + public const string DateRangeOrderLower = "VALIDATION_DATE_RANGE_ORDER_LOWER"; + public const string DateRangeLimitShort = "VALIDATION_DATE_RANGE_LIMIT_SHORT"; + public const string CalendarDateRangeLimit = "VALIDATION_CALENDAR_DATE_RANGE_LIMIT"; + public const string DateFromRequired = "VALIDATION_DATE_FROM_REQUIRED"; + public const string DateToRequired = "VALIDATION_DATE_TO_REQUIRED"; + public const string HabitGoalLimit = "VALIDATION_HABIT_GOAL_LIMIT"; + public const string PositionsRequired = "VALIDATION_POSITIONS_REQUIRED"; + public const string PositionsUnique = "VALIDATION_POSITIONS_UNIQUE"; + public const string ChecklistItemLength = "VALIDATION_CHECKLIST_ITEM_LENGTH"; + public const string DaysDailyOnly = "VALIDATION_DAYS_DAILY_ONLY"; + public const string EndDateRecurringOnly = "VALIDATION_END_DATE_RECURRING_ONLY"; + public const string ScheduledReminderLimit = "VALIDATION_SCHEDULED_REMINDER_LIMIT"; + public const string ScheduledReminderWhen = "VALIDATION_SCHEDULED_REMINDER_WHEN"; + public const string ScheduledRemindersUnique = "VALIDATION_SCHEDULED_REMINDERS_UNIQUE"; + public const string ReminderTimeLimit = "VALIDATION_REMINDER_TIME_LIMIT"; + public const string ReminderTimeRange = "VALIDATION_REMINDER_TIME_RANGE"; + public const string ReminderTimesUnique = "VALIDATION_REMINDER_TIMES_UNIQUE"; + public const string RelativeRemindersValid = "VALIDATION_RELATIVE_REMINDERS_VALID"; + public const string GeneralFrequencyUnitAbsent = "VALIDATION_GENERAL_FREQUENCY_UNIT_ABSENT"; + public const string GeneralFrequencyQuantityAbsent = "VALIDATION_GENERAL_FREQUENCY_QUANTITY_ABSENT"; + public const string GeneralDaysAbsent = "VALIDATION_GENERAL_DAYS_ABSENT"; + public const string CalendarDateValid = "VALIDATION_CALENDAR_DATE_VALID"; + public const string ChecklistItemLimit = "VALIDATION_CHECKLIST_ITEM_LIMIT"; + public const string WeekStartOnboarding = "VALIDATION_WEEK_START_ONBOARDING"; + public const string HandleFormat = "VALIDATION_HANDLE_FORMAT"; + public const string ProfileNameLength = "VALIDATION_PROFILE_NAME_LENGTH"; + public const string WeekStartDay = "VALIDATION_WEEK_START_DAY"; + public const string CheersDirection = "VALIDATION_CHEERS_DIRECTION"; + public const string FeedCursorFormat = "VALIDATION_FEED_CURSOR_FORMAT"; + public const string FriendIdentifierExclusive = "VALIDATION_FRIEND_IDENTIFIER_EXCLUSIVE"; + public const string BillingIntervalRequired = "VALIDATION_BILLING_INTERVAL_REQUIRED"; + public const string BillingIntervalSupported = "VALIDATION_BILLING_INTERVAL_SUPPORTED"; + public const string TagColorFormat = "VALIDATION_TAG_COLOR_FORMAT"; + public const string UploadContentTypeRequired = "VALIDATION_UPLOAD_CONTENT_TYPE_REQUIRED"; + public const string UploadContentTypeSupported = "VALIDATION_UPLOAD_CONTENT_TYPE_SUPPORTED"; + public const string UploadSizePositive = "VALIDATION_UPLOAD_SIZE_POSITIVE"; + public const string UploadSizeLimit = "VALIDATION_UPLOAD_SIZE_LIMIT"; + public const string FactIdsRequired = "VALIDATION_FACT_IDS_REQUIRED"; + public const string BulkDeleteFactLimit = "VALIDATION_BULK_DELETE_FACT_LIMIT"; + public const string FactIdRequired = "VALIDATION_FACT_ID_REQUIRED"; + public const string WaitlistLanguageSupported = "VALIDATION_WAITLIST_LANGUAGE_SUPPORTED"; + public const string BulkLogLimit = "VALIDATION_BULK_LOG_LIMIT"; + public const string BulkSkipLimit = "VALIDATION_BULK_SKIP_LIMIT"; +} diff --git a/src/Orbit.Application/Gamification/Queries/GetRecapQueryValidator.cs b/src/Orbit.Application/Gamification/Queries/GetRecapQueryValidator.cs index d5840c32..04a81f21 100644 --- a/src/Orbit.Application/Gamification/Queries/GetRecapQueryValidator.cs +++ b/src/Orbit.Application/Gamification/Queries/GetRecapQueryValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Habits.Queries; @@ -10,47 +11,47 @@ public GetRecapQueryValidator() RuleFor(x => x.Period) .NotEmpty() .Must(period => RetrospectivePeriodRange.IsKnownPeriod(period)) - .WithMessage("Period must be one of: week, month, quarter, semester, year."); + .WithCopy(ValidationErrorCodes.PeriodSupported); RuleFor(x => x.DateFrom) .LessThanOrEqualTo(x => x.DateTo) - .WithMessage("DateFrom must be on or before DateTo."); + .WithCopy(ValidationErrorCodes.DateRangeOrder); RuleFor(x => x) .Must(HasValidClosedParameters) - .WithMessage("Closed period parameters must match the requested period."); + .WithCopy(ValidationErrorCodes.ClosedPeriodMatch); When(x => x.ClosedYear.HasValue && x.ClosedMonth.HasValue, () => { RuleFor(x => x.Period) .Equal("month", StringComparer.OrdinalIgnoreCase) - .WithMessage("Closed calendar parameters can only be used with the month period."); + .WithCopy(ValidationErrorCodes.ClosedCalendarMonthOnly); RuleFor(x => x.ClosedYear) .InclusiveBetween(1, 9999) - .WithMessage("ClosedYear must be between 1 and 9999."); + .WithCopy(ValidationErrorCodes.ClosedYearRange); RuleFor(x => x.ClosedMonth) .InclusiveBetween(1, 12) - .WithMessage("ClosedMonth must be between 1 and 12."); + .WithCopy(ValidationErrorCodes.ClosedMonthRange); RuleFor(x => x) .Must(x => MatchesClosedMonth(x.DateFrom, x.DateTo, x.ClosedYear!.Value, x.ClosedMonth!.Value)) .When(x => x.ClosedYear is >= 1 and <= 9999 && x.ClosedMonth is >= 1 and <= 12) - .WithMessage("DateFrom and DateTo must match the complete closed calendar month."); + .WithCopy(ValidationErrorCodes.ClosedMonthDates); }); When(x => x.ClosedYear.HasValue && !x.ClosedMonth.HasValue, () => { RuleFor(x => x.ClosedYear) .InclusiveBetween(1, 9999) - .WithMessage("ClosedYear must be between 1 and 9999."); + .WithCopy(ValidationErrorCodes.ClosedYearRange); RuleFor(x => x) .Must(x => x.ClosedYear is >= 1 and <= 9999 && x.DateFrom == new DateOnly(x.ClosedYear.Value, 1, 1) && x.DateTo == new DateOnly(x.ClosedYear.Value, 12, 31)) - .WithMessage("DateFrom and DateTo must match the complete closed calendar year."); + .WithCopy(ValidationErrorCodes.ClosedYearDates); }); When(x => x.ClosedWeekStart.HasValue, () => @@ -60,7 +61,7 @@ public GetRecapQueryValidator() && weekStart.DayNumber <= DateOnly.MaxValue.DayNumber - 6 && x.DateFrom == weekStart && x.DateTo == weekStart.AddDays(6)) - .WithMessage("DateFrom and DateTo must match the complete closed calendar week."); + .WithCopy(ValidationErrorCodes.ClosedWeekDates); }); } diff --git a/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQueryValidator.cs b/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQueryValidator.cs index 3a8f9d50..62167640 100644 --- a/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQueryValidator.cs +++ b/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQueryValidator.cs @@ -9,10 +9,10 @@ public GetStreakHistoryQueryValidator() { RuleFor(x => x.DateFrom) .LessThanOrEqualTo(x => x.DateTo) - .WithMessage("DateFrom must be on or before DateTo."); + .WithCopy(ValidationErrorCodes.DateRangeOrder); RuleFor(x => x) .Must(x => x.DateTo.DayNumber - x.DateFrom.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days."); + .WithCopy(ValidationErrorCodes.DateRangeLimit); } } diff --git a/src/Orbit.Application/Gamification/Queries/GetXpHistoryQueryValidator.cs b/src/Orbit.Application/Gamification/Queries/GetXpHistoryQueryValidator.cs index e7dd91b7..ddaacbe4 100644 --- a/src/Orbit.Application/Gamification/Queries/GetXpHistoryQueryValidator.cs +++ b/src/Orbit.Application/Gamification/Queries/GetXpHistoryQueryValidator.cs @@ -9,10 +9,10 @@ public GetXpHistoryQueryValidator() { RuleFor(x => x.DateFrom) .LessThanOrEqualTo(x => x.DateTo) - .WithMessage("DateFrom must be on or before DateTo."); + .WithCopy(ValidationErrorCodes.DateRangeOrder); RuleFor(x => x) .Must(x => x.DateTo.DayNumber - x.DateFrom.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days."); + .WithCopy(ValidationErrorCodes.DateRangeLimit); } } diff --git a/src/Orbit.Application/Gamification/Validators/RepairStreakGapCommandValidator.cs b/src/Orbit.Application/Gamification/Validators/RepairStreakGapCommandValidator.cs index 234402f3..a4a3d994 100644 --- a/src/Orbit.Application/Gamification/Validators/RepairStreakGapCommandValidator.cs +++ b/src/Orbit.Application/Gamification/Validators/RepairStreakGapCommandValidator.cs @@ -12,15 +12,15 @@ public RepairStreakGapCommandValidator() RuleFor(command => command.Dates).Cascade(CascadeMode.Stop) .NotEmpty() .Must(dates => dates.Count < AppConstants.MaxStreakLookbackDays) - .WithMessage("The gap exceeds the streak history window.") + .WithCopy(ValidationErrorCodes.StreakGapLimit) .Must(dates => dates.All(date => date != DateOnly.MinValue)) - .WithMessage("Each date is required.") + .WithCopy(ValidationErrorCodes.StreakDateRequired) /** * Duplicates only. Contiguity is a property of the user's SCHEDULE, not of the calendar, so * it is enforced in UserStreakService where the scheduled occurrences are known. Asserting * calendar-consecutiveness at this boundary rejected every valid weekly and every-N-day gap. */ .Must(dates => dates.Distinct().Count() == dates.Count) - .WithMessage("Select each date once."); + .WithCopy(ValidationErrorCodes.StreakDatesUnique); } } diff --git a/src/Orbit.Application/Gamification/Validators/ReportEventCommandValidator.cs b/src/Orbit.Application/Gamification/Validators/ReportEventCommandValidator.cs index 57a0ca30..0e7fe348 100644 --- a/src/Orbit.Application/Gamification/Validators/ReportEventCommandValidator.cs +++ b/src/Orbit.Application/Gamification/Validators/ReportEventCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Gamification.Commands; @@ -11,6 +12,6 @@ public ReportEventCommandValidator() RuleFor(x => x.EventKey) .NotEmpty() .Must(eventKey => AchievementEventMap.IsKnown(eventKey)) - .WithMessage("Unknown event key."); + .WithCopy(ValidationErrorCodes.EventKeyKnown); } } diff --git a/src/Orbit.Application/Goals/Queries/GetGoalProgressHistoryQueryValidator.cs b/src/Orbit.Application/Goals/Queries/GetGoalProgressHistoryQueryValidator.cs index 02f85dd8..17a13410 100644 --- a/src/Orbit.Application/Goals/Queries/GetGoalProgressHistoryQueryValidator.cs +++ b/src/Orbit.Application/Goals/Queries/GetGoalProgressHistoryQueryValidator.cs @@ -9,10 +9,10 @@ public GetGoalProgressHistoryQueryValidator() { RuleFor(x => x.DateFrom) .LessThanOrEqualTo(x => x.DateTo) - .WithMessage("DateFrom must be on or before DateTo."); + .WithCopy(ValidationErrorCodes.DateRangeOrder); RuleFor(x => x) .Must(x => x.DateTo.DayNumber - x.DateFrom.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days."); + .WithCopy(ValidationErrorCodes.DateRangeLimit); } } diff --git a/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs b/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs index ecae5596..1bb55b0c 100644 --- a/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs +++ b/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs @@ -16,6 +16,6 @@ public CreateGoalCommandValidator() RuleFor(x => x.Type).IsInEnum(); RuleFor(x => x.HabitIds) .Must(ids => ids is null || ids.Count <= AppConstants.MaxHabitsPerGoal) - .WithMessage(ErrorMessages.MaxHabitsPerGoal.Format(AppConstants.MaxHabitsPerGoal).Message); + .WithCopy(ErrorMessages.MaxHabitsPerGoal.Code, AppConstants.MaxHabitsPerGoal); } } diff --git a/src/Orbit.Application/Goals/Validators/GetGoalReviewQueryValidator.cs b/src/Orbit.Application/Goals/Validators/GetGoalReviewQueryValidator.cs index 8fb60751..bc96687d 100644 --- a/src/Orbit.Application/Goals/Validators/GetGoalReviewQueryValidator.cs +++ b/src/Orbit.Application/Goals/Validators/GetGoalReviewQueryValidator.cs @@ -13,6 +13,6 @@ public GetGoalReviewQueryValidator() RuleFor(x => x.Language) .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => string.IsNullOrEmpty(lang) || AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Goals/Validators/LinkHabitsToGoalCommandValidator.cs b/src/Orbit.Application/Goals/Validators/LinkHabitsToGoalCommandValidator.cs index cd0fa5db..5488bd31 100644 --- a/src/Orbit.Application/Goals/Validators/LinkHabitsToGoalCommandValidator.cs +++ b/src/Orbit.Application/Goals/Validators/LinkHabitsToGoalCommandValidator.cs @@ -12,6 +12,6 @@ public LinkHabitsToGoalCommandValidator() RuleFor(x => x.GoalId).NotEmpty(); RuleFor(x => x.HabitIds) .Must(ids => ids.Count <= AppConstants.MaxHabitsPerGoal) - .WithMessage($"A goal can have at most {AppConstants.MaxHabitsPerGoal} linked habits."); + .WithCopy(ValidationErrorCodes.GoalHabitLimit); } } diff --git a/src/Orbit.Application/Habits/Queries/GetHabitsCompletionTrendsQueryValidator.cs b/src/Orbit.Application/Habits/Queries/GetHabitsCompletionTrendsQueryValidator.cs index 1e4863fb..36fdd896 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitsCompletionTrendsQueryValidator.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitsCompletionTrendsQueryValidator.cs @@ -9,10 +9,10 @@ public GetHabitsCompletionTrendsQueryValidator() { RuleFor(x => x.DateFrom) .LessThanOrEqualTo(x => x.DateTo) - .WithMessage("DateFrom must be on or before DateTo."); + .WithCopy(ValidationErrorCodes.DateRangeOrder); RuleFor(x => x) .Must(x => x.DateTo.DayNumber - x.DateFrom.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days."); + .WithCopy(ValidationErrorCodes.DateRangeLimit); } } diff --git a/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs index a0674641..e4303f15 100644 --- a/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs @@ -13,9 +13,9 @@ public BulkCreateHabitsCommandValidator() RuleFor(x => x.Habits) .NotEmpty() - .WithMessage("Habits list must not be empty") + .WithCopy(ValidationErrorCodes.BulkHabitsRequired) .Must(habits => habits.Count <= AppConstants.MaxBulkOperationSize) - .WithMessage($"Cannot create more than {AppConstants.MaxBulkOperationSize} habits at once"); + .WithCopy(ValidationErrorCodes.BulkCreateLimit); RuleForEach(x => x.Habits).ChildRules(habit => { @@ -29,7 +29,7 @@ public BulkCreateHabitsCommandValidator() habit.RuleFor(h => h.FrequencyQuantity) .NotNull() - .WithMessage("Frequency quantity is required when frequency unit is set") + .WithCopy(ValidationErrorCodes.FrequencyQuantityRequired) .When(h => h.FrequencyUnit is not null); SharedHabitRules.AddIntervalWeeksRules(habit.RuleFor(h => h.IntervalWeeks)); @@ -37,7 +37,7 @@ public BulkCreateHabitsCommandValidator() habit.RuleFor(h => h.Tags) .Must(tags => tags!.Count <= AppConstants.MaxTagsPerHabit) - .WithMessage($"Cannot assign more than {AppConstants.MaxTagsPerHabit} tags per habit") + .WithCopy(ValidationErrorCodes.BulkTagLimit) .When(h => h.Tags is not null); habit.RuleForEach(h => h.Tags) diff --git a/src/Orbit.Application/Habits/Validators/BulkDeleteHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/BulkDeleteHabitsCommandValidator.cs index ff36dd59..c9fbc37a 100644 --- a/src/Orbit.Application/Habits/Validators/BulkDeleteHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/BulkDeleteHabitsCommandValidator.cs @@ -13,12 +13,12 @@ public BulkDeleteHabitsCommandValidator() RuleFor(x => x.HabitIds) .NotEmpty() - .WithMessage("HabitIds list must not be empty") + .WithCopy(ValidationErrorCodes.BulkHabitIdsRequired) .Must(ids => ids.Count <= AppConstants.MaxBulkOperationSize) - .WithMessage($"Cannot delete more than {AppConstants.MaxBulkOperationSize} habits at once"); + .WithCopy(ValidationErrorCodes.BulkDeleteHabitLimit); RuleForEach(x => x.HabitIds) .NotEmpty() - .WithMessage("Habit ID must not be empty"); + .WithCopy(ValidationErrorCodes.HabitIdRequired); } } diff --git a/src/Orbit.Application/Habits/Validators/BulkHabitCommandValidatorBase.cs b/src/Orbit.Application/Habits/Validators/BulkHabitCommandValidatorBase.cs index 0b4aee6b..b73cde8d 100644 --- a/src/Orbit.Application/Habits/Validators/BulkHabitCommandValidatorBase.cs +++ b/src/Orbit.Application/Habits/Validators/BulkHabitCommandValidatorBase.cs @@ -7,24 +7,24 @@ namespace Orbit.Application.Habits.Validators; /// /// Shared FluentValidation rules for bulk habit commands: non-empty user, a non-empty item /// list capped at , and a non-empty habit id per item. -/// The exceed-cap message differs per operation and is supplied by the derived validator. +/// The exceed-cap code differs per operation and is supplied by the derived validator. /// public abstract class BulkHabitCommandValidatorBase : AbstractValidator where TCommand : IBulkHabitCommand where TItem : IBulkHabitItem { - protected BulkHabitCommandValidatorBase(string exceedLimitMessage) + protected BulkHabitCommandValidatorBase(string exceedLimitCode) { RuleFor(x => x.UserId) .NotEmpty(); RuleFor(x => x.Items) .NotEmpty() - .WithMessage("Items list must not be empty"); + .WithCopy(ValidationErrorCodes.BulkItemsRequired); RuleFor(x => x.Items) .Must(items => items is null || items.Count <= AppConstants.MaxBulkOperationSize) - .WithMessage(exceedLimitMessage); + .WithCopy(exceedLimitCode); RuleForEach(x => x.Items) .ChildRules(item => diff --git a/src/Orbit.Application/Habits/Validators/BulkLogHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/BulkLogHabitsCommandValidator.cs index 8c9add5f..40bf7df1 100644 --- a/src/Orbit.Application/Habits/Validators/BulkLogHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/BulkLogHabitsCommandValidator.cs @@ -7,7 +7,7 @@ public class BulkLogHabitsCommandValidator : BulkHabitCommandValidatorBase { public BulkLogHabitsCommandValidator() - : base($"Bulk log cannot exceed {AppConstants.MaxBulkOperationSize} items") + : base(ValidationErrorCodes.BulkLogLimit) { } } diff --git a/src/Orbit.Application/Habits/Validators/BulkSkipHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/BulkSkipHabitsCommandValidator.cs index 17e89f99..0471077f 100644 --- a/src/Orbit.Application/Habits/Validators/BulkSkipHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/BulkSkipHabitsCommandValidator.cs @@ -7,7 +7,7 @@ public class BulkSkipHabitsCommandValidator : BulkHabitCommandValidatorBase { public BulkSkipHabitsCommandValidator() - : base($"Bulk skip cannot exceed {AppConstants.MaxBulkOperationSize} items") + : base(ValidationErrorCodes.BulkSkipLimit) { } } diff --git a/src/Orbit.Application/Habits/Validators/BulkUpdateHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/BulkUpdateHabitsCommandValidator.cs index 37378560..c73e279d 100644 --- a/src/Orbit.Application/Habits/Validators/BulkUpdateHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/BulkUpdateHabitsCommandValidator.cs @@ -11,10 +11,10 @@ public BulkUpdateHabitsCommandValidator() RuleFor(command => command.UserId).NotEmpty(); RuleFor(command => command.Filter) .Must(filter => filter.HasSelector) - .WithMessage("A bulk habit filter is required."); + .WithCopy(ValidationErrorCodes.BulkFilterRequired); RuleFor(command => command.Changes) .Must(changes => changes.HasAnyChange) - .WithMessage("At least one habit change is required."); + .WithCopy(ValidationErrorCodes.BulkChangesRequired); RuleFor(command => command.Changes.Title) .NotEmpty() .MaximumLength(AppConstants.MaxHabitTitleLength) @@ -30,7 +30,7 @@ public BulkUpdateHabitsCommandValidator() .When(command => command.Changes.HasFrequencyQuantity && command.Changes.FrequencyQuantity.HasValue); RuleFor(command => command.Changes) .Must(HaveValidCadenceChange) - .WithMessage("A recurring frequency requires a positive frequency quantity."); + .WithCopy(ValidationErrorCodes.RecurringQuantityPositive); RuleFor(command => command.Changes.IntervalWeeks) .InclusiveBetween(1, AppConstants.MaxIntervalWeeks) .When(command => command.Changes.HasIntervalWeeks && command.Changes.IntervalWeeks.HasValue); diff --git a/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs index d4446dd8..474d8b9c 100644 --- a/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs @@ -25,7 +25,7 @@ public CreateHabitCommandValidator() RuleFor(x => x.FrequencyQuantity) .NotNull() - .WithMessage("Frequency quantity is required when frequency unit is set") + .WithCopy(ValidationErrorCodes.FrequencyQuantityRequired) .When(x => x.FrequencyUnit is not null); SharedHabitRules.AddIntervalWeeksRules(RuleFor(x => x.IntervalWeeks)); @@ -42,13 +42,13 @@ public CreateHabitCommandValidator() RuleFor(x => x.SubHabits) .Must(subs => subs is null || subs.Count <= AppConstants.MaxSubHabits) - .WithMessage($"A habit can have at most {AppConstants.MaxSubHabits} sub-habits"); + .WithCopy(ValidationErrorCodes.SubHabitLimit); RuleForEach(x => x.SubHabits) .NotEmpty() - .WithMessage("Sub-habit title must not be empty") + .WithCopy(ValidationErrorCodes.SubHabitTitleRequired) .MaximumLength(AppConstants.MaxHabitTitleLength) - .WithMessage($"Sub-habit title must not exceed {AppConstants.MaxHabitTitleLength} characters"); + .WithCopy(ValidationErrorCodes.SubHabitTitleLength); SharedHabitRules.AddGeneralHabitRules(this, x => x.IsGeneral, @@ -59,7 +59,7 @@ public CreateHabitCommandValidator() RuleFor(x => x.IsBadHabit) .Equal(false) .When(x => x.IsGeneral) - .WithMessage("General habits cannot be bad habits"); + .WithCopy(ValidationErrorCodes.GeneralHabitNotBad); SharedHabitRules.AddScheduledReminderRules(RuleFor(x => x.Options != null ? x.Options.ScheduledReminders : null)); diff --git a/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs index 06e1dd6b..5bf0085a 100644 --- a/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs @@ -16,8 +16,8 @@ public CreateSubHabitCommandValidator() SharedHabitRules.AddTitleRules( RuleFor(x => x.Title), - requiredMessage: "Sub-habit title must not be empty", - maximumLengthMessage: $"Sub-habit title must not exceed {AppConstants.MaxHabitTitleLength} characters"); + requiredCode: ValidationErrorCodes.SubHabitTitleRequired, + maximumLengthCode: ValidationErrorCodes.SubHabitTitleLength); SharedHabitRules.AddDescriptionRules(RuleFor(x => x.Description)); @@ -44,6 +44,6 @@ public CreateSubHabitCommandValidator() RuleFor(x => x.TagIds) .Must(tags => tags is null || tags.Count <= AppConstants.MaxTagsPerHabit) - .WithMessage($"A habit can have at most {AppConstants.MaxTagsPerHabit} tags"); + .WithCopy(ValidationErrorCodes.HabitTagLimit); } } diff --git a/src/Orbit.Application/Habits/Validators/GetAllHabitLogsQueryValidator.cs b/src/Orbit.Application/Habits/Validators/GetAllHabitLogsQueryValidator.cs index 7de7d253..86dd2f25 100644 --- a/src/Orbit.Application/Habits/Validators/GetAllHabitLogsQueryValidator.cs +++ b/src/Orbit.Application/Habits/Validators/GetAllHabitLogsQueryValidator.cs @@ -10,11 +10,11 @@ public GetAllHabitLogsQueryValidator() { RuleFor(q => q.DateTo) .GreaterThanOrEqualTo(q => q.DateFrom) - .WithMessage("dateTo must be >= dateFrom"); + .WithCopy(ValidationErrorCodes.DateRangeOrderLower); RuleFor(q => q) .Must(q => q.DateTo.DayNumber - q.DateFrom.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days") + .WithCopy(ValidationErrorCodes.DateRangeLimitShort) .When(q => q.DateTo >= q.DateFrom); } } diff --git a/src/Orbit.Application/Habits/Validators/GetCalendarMonthQueryValidator.cs b/src/Orbit.Application/Habits/Validators/GetCalendarMonthQueryValidator.cs index 792fdc6f..94e4a49a 100644 --- a/src/Orbit.Application/Habits/Validators/GetCalendarMonthQueryValidator.cs +++ b/src/Orbit.Application/Habits/Validators/GetCalendarMonthQueryValidator.cs @@ -10,11 +10,11 @@ public GetCalendarMonthQueryValidator() { RuleFor(q => q.DateTo) .GreaterThanOrEqualTo(q => q.DateFrom) - .WithMessage("dateTo must be >= dateFrom"); + .WithCopy(ValidationErrorCodes.DateRangeOrderLower); RuleFor(q => q) .Must(q => q.DateTo.DayNumber - q.DateFrom.DayNumber <= AppConstants.MaxCalendarRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxCalendarRangeDays} days") + .WithCopy(ValidationErrorCodes.CalendarDateRangeLimit) .When(q => q.DateTo >= q.DateFrom); } } diff --git a/src/Orbit.Application/Habits/Validators/GetDailySummaryQueryValidator.cs b/src/Orbit.Application/Habits/Validators/GetDailySummaryQueryValidator.cs index 0f01b324..202c8c7f 100644 --- a/src/Orbit.Application/Habits/Validators/GetDailySummaryQueryValidator.cs +++ b/src/Orbit.Application/Habits/Validators/GetDailySummaryQueryValidator.cs @@ -10,11 +10,11 @@ public GetDailySummaryQueryValidator() { RuleFor(q => q.DateTo) .GreaterThanOrEqualTo(q => q.DateFrom) - .WithMessage("dateTo must be >= dateFrom"); + .WithCopy(ValidationErrorCodes.DateRangeOrderLower); RuleFor(q => q) .Must(q => q.DateTo.DayNumber - q.DateFrom.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days") + .WithCopy(ValidationErrorCodes.DateRangeLimitShort) .When(q => q.DateTo >= q.DateFrom); } } diff --git a/src/Orbit.Application/Habits/Validators/GetHabitScheduleQueryValidator.cs b/src/Orbit.Application/Habits/Validators/GetHabitScheduleQueryValidator.cs index 3bca1844..4f4834a1 100644 --- a/src/Orbit.Application/Habits/Validators/GetHabitScheduleQueryValidator.cs +++ b/src/Orbit.Application/Habits/Validators/GetHabitScheduleQueryValidator.cs @@ -10,22 +10,22 @@ public GetHabitScheduleQueryValidator() { RuleFor(q => q.DateFrom) .NotNull() - .WithMessage("dateFrom is required") + .WithCopy(ValidationErrorCodes.DateFromRequired) .When(q => q.IsGeneral != true && q.DateTo.HasValue); RuleFor(q => q.DateTo) .NotNull() - .WithMessage("dateTo is required") + .WithCopy(ValidationErrorCodes.DateToRequired) .When(q => q.IsGeneral != true && q.DateFrom.HasValue); RuleFor(q => q.DateTo) .GreaterThanOrEqualTo(q => q.DateFrom!.Value) - .WithMessage("dateTo must be >= dateFrom") + .WithCopy(ValidationErrorCodes.DateRangeOrderLower) .When(q => q.IsGeneral != true && q.DateFrom.HasValue && q.DateTo.HasValue); RuleFor(q => q) .Must(q => q.DateTo!.Value.DayNumber - q.DateFrom!.Value.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days") + .WithCopy(ValidationErrorCodes.DateRangeLimitShort) .When(q => q.IsGeneral != true && q.DateFrom.HasValue && q.DateTo.HasValue); RuleFor(q => q.Page) diff --git a/src/Orbit.Application/Habits/Validators/GetRescheduleSuggestionQueryValidator.cs b/src/Orbit.Application/Habits/Validators/GetRescheduleSuggestionQueryValidator.cs index bcf23e8f..72ded5b1 100644 --- a/src/Orbit.Application/Habits/Validators/GetRescheduleSuggestionQueryValidator.cs +++ b/src/Orbit.Application/Habits/Validators/GetRescheduleSuggestionQueryValidator.cs @@ -17,6 +17,6 @@ public GetRescheduleSuggestionQueryValidator() RuleFor(x => x.Language) .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => string.IsNullOrEmpty(lang) || AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Habits/Validators/GetRetrospectiveQueryValidator.cs b/src/Orbit.Application/Habits/Validators/GetRetrospectiveQueryValidator.cs index 1f80fb3e..2b925d0b 100644 --- a/src/Orbit.Application/Habits/Validators/GetRetrospectiveQueryValidator.cs +++ b/src/Orbit.Application/Habits/Validators/GetRetrospectiveQueryValidator.cs @@ -13,20 +13,20 @@ public GetRetrospectiveQueryValidator() RuleFor(x => x.Period) .NotEmpty() .Must(period => RetrospectivePeriodRange.IsKnownPeriod(period)) - .WithMessage("Period must be one of: week, month, quarter, semester, year."); + .WithCopy(ValidationErrorCodes.PeriodSupported); RuleFor(x => x.DateFrom) .LessThanOrEqualTo(x => x.DateTo) - .WithMessage("DateFrom must be on or before DateTo."); + .WithCopy(ValidationErrorCodes.DateRangeOrder); RuleFor(x => x) .Must(x => x.DateTo.DayNumber - x.DateFrom.DayNumber <= AppConstants.MaxRangeDays) - .WithMessage($"Date range must not exceed {AppConstants.MaxRangeDays} days.") + .WithCopy(ValidationErrorCodes.DateRangeLimit) .When(x => x.DateFrom <= x.DateTo); RuleFor(x => x.Language) .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => string.IsNullOrEmpty(lang) || AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Habits/Validators/LinkGoalsToHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/LinkGoalsToHabitCommandValidator.cs index dbda7fcb..1e7946ae 100644 --- a/src/Orbit.Application/Habits/Validators/LinkGoalsToHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/LinkGoalsToHabitCommandValidator.cs @@ -16,6 +16,6 @@ public LinkGoalsToHabitCommandValidator() RuleFor(x => x.GoalIds) .Must(ids => ids.Count <= AppConstants.MaxGoalsPerHabit) - .WithMessage($"A habit can have at most {AppConstants.MaxGoalsPerHabit} linked goals."); + .WithCopy(ValidationErrorCodes.HabitGoalLimit); } } diff --git a/src/Orbit.Application/Habits/Validators/ReorderHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/ReorderHabitsCommandValidator.cs index 77b9b398..36b222d7 100644 --- a/src/Orbit.Application/Habits/Validators/ReorderHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/ReorderHabitsCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Habits.Commands; @@ -12,11 +13,11 @@ public ReorderHabitsCommandValidator() RuleFor(x => x.Positions) .NotEmpty() - .WithMessage("Positions list must not be empty"); + .WithCopy(ValidationErrorCodes.PositionsRequired); RuleFor(x => x.Positions) .Must(positions => positions is null || positions.Select(p => p.HabitId).Distinct().Count() == positions.Count) - .WithMessage("Positions list must not contain duplicate habit IDs"); + .WithCopy(ValidationErrorCodes.PositionsUnique); RuleForEach(x => x.Positions) .ChildRules(position => diff --git a/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs b/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs index bf70dcc8..250c444f 100644 --- a/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs +++ b/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs @@ -8,16 +8,16 @@ public static class SharedHabitRules { public static void AddTitleRules( IRuleBuilder rule, - string? requiredMessage = null, - string? maximumLengthMessage = null) + string? requiredCode = null, + string? maximumLengthCode = null) { var requiredRule = rule.NotEmpty(); - if (requiredMessage is not null) - requiredRule.WithMessage(requiredMessage); + if (requiredCode is not null) + requiredRule.WithCopy(requiredCode); var lengthRule = requiredRule.MaximumLength(AppConstants.MaxHabitTitleLength); - if (maximumLengthMessage is not null) - lengthRule.WithMessage(maximumLengthMessage); + if (maximumLengthCode is not null) + lengthRule.WithCopy(maximumLengthCode); } public static void AddDescriptionRules(IRuleBuilder rule) @@ -33,7 +33,7 @@ public static void AddEmojiRules(IRuleBuilder rule) public static void AddChecklistItemRules(IRuleBuilder?> rule) { rule.Must(items => items is null || items.All(i => i.Text.Length <= AppConstants.MaxChecklistItemTextLength)) - .WithMessage($"Checklist item text must not exceed {AppConstants.MaxChecklistItemTextLength} characters"); + .WithCopy(ValidationErrorCodes.ChecklistItemLength); } public static void AddFrequencyQuantityRules(IRuleBuilderOptions rule) @@ -64,7 +64,7 @@ public static void AddDaysRules( if (isFlexibleFunc(command)) return true; return freqQtyFunc(command) == 1 && freqUnitFunc(command) == Domain.Enums.FrequencyUnit.Day; }) - .WithMessage("Days can only be specified for a daily habit (frequency unit Day, quantity 1)"); + .WithCopy(ValidationErrorCodes.DaysDailyOnly); } public static void AddOneTimeTaskEndDateRules( @@ -76,7 +76,7 @@ public static void AddOneTimeTaskEndDateRules( validator.RuleFor(endDateExpr) .Must((command, endDate) => !endDate.HasValue || freqUnitFunc(command) is not null) - .WithMessage("An end date requires a recurring habit"); + .WithCopy(ValidationErrorCodes.EndDateRecurringOnly); } public static void AddGeneralHabitRules( @@ -104,10 +104,10 @@ public static void AddGeneralHabitRules( public static void AddScheduledReminderRules(IRuleBuilder?> rule) { rule.Must(items => items is null || items.Count <= AppConstants.MaxScheduledReminders) - .WithMessage($"A habit can have at most {AppConstants.MaxScheduledReminders} scheduled reminders"); + .WithCopy(ValidationErrorCodes.ScheduledReminderLimit); rule.Must(items => items is null || items.All(sr => Enum.IsDefined(sr.When))) - .WithMessage("Scheduled reminder 'when' must be 'day_before' or 'same_day'"); + .WithCopy(ValidationErrorCodes.ScheduledReminderWhen); rule.Must(items => { @@ -115,19 +115,19 @@ public static void AddScheduledReminderRules(IRuleBuilder (sr.When, sr.Time)); return grouped.All(g => g.Count() == 1); }) - .WithMessage("Scheduled reminders must not contain duplicate entries"); + .WithCopy(ValidationErrorCodes.ScheduledRemindersUnique); } public static void AddReminderTimesRules(IRuleBuilder?> rule) { rule.Must(times => times is null || times.Count <= AppConstants.MaxReminderTimes) - .WithMessage($"A habit can have at most {AppConstants.MaxReminderTimes} reminder times"); + .WithCopy(ValidationErrorCodes.ReminderTimeLimit); rule.Must(times => times is null || times.All(m => m is >= 0 and <= AppConstants.MaxReminderMinutesBefore)) - .WithMessage($"Reminder times must be between 0 and {AppConstants.MaxReminderMinutesBefore} minutes before the due time"); + .WithCopy(ValidationErrorCodes.ReminderTimeRange); rule.Must(times => times is null || times.Distinct().Count() == times.Count) - .WithMessage("Reminder times must not contain duplicate values"); + .WithCopy(ValidationErrorCodes.ReminderTimesUnique); } public static void AddRelativeReminderRules(IRuleBuilder?> rule) @@ -139,7 +139,7 @@ public static void AddRelativeReminderRules(IRuleBuilder= -1439 and <= AppConstants.MaxReminderMinutesBefore) && (!r.When.HasValue || Enum.IsDefined(r.When.Value))))) - .WithMessage("Relative reminders must be unique and have a valid offset or local time"); + .WithCopy(ValidationErrorCodes.RelativeRemindersValid); } public static void AddGoalIdsRules( @@ -148,7 +148,7 @@ public static void AddGoalIdsRules( { validator.RuleFor(expression) .Must(ids => ids is null || ids.Count <= AppConstants.MaxGoalsPerHabit) - .WithMessage($"A habit can have at most {AppConstants.MaxGoalsPerHabit} linked goals."); + .WithCopy(ValidationErrorCodes.HabitGoalLimit); } private static void AddGeneralHabitRulesCore( @@ -161,16 +161,16 @@ private static void AddGeneralHabitRulesCore( validator.RuleFor(freqUnitExpr) .Null() .When(isGeneralPredicate) - .WithMessage("General habits cannot have a frequency unit"); + .WithCopy(ValidationErrorCodes.GeneralFrequencyUnitAbsent); validator.RuleFor(freqQtyExpr) .Null() .When(isGeneralPredicate) - .WithMessage("General habits cannot have a frequency quantity"); + .WithCopy(ValidationErrorCodes.GeneralFrequencyQuantityAbsent); validator.RuleFor(daysExpr) .Must(days => days is null || days.Count == 0) .When(isGeneralPredicate) - .WithMessage("General habits cannot have specific days"); + .WithCopy(ValidationErrorCodes.GeneralDaysAbsent); } } diff --git a/src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs index 588a2b1e..ae062e3c 100644 --- a/src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Habits.Commands; @@ -18,6 +19,6 @@ public SkipHabitCommandValidator() RuleFor(x => x.Date) .NotEqual(default(DateOnly)) .When(x => x.Date.HasValue) - .WithMessage("Date must be a valid calendar date"); + .WithCopy(ValidationErrorCodes.CalendarDateValid); } } diff --git a/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs b/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs index e520216d..e8b66abd 100644 --- a/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/SuggestHabitSetupCommandValidator.cs @@ -17,6 +17,6 @@ public SuggestHabitSetupCommandValidator() .NotEmpty() .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Habits/Validators/UpdateChecklistCommandValidator.cs b/src/Orbit.Application/Habits/Validators/UpdateChecklistCommandValidator.cs index e00c20cb..f636daa0 100644 --- a/src/Orbit.Application/Habits/Validators/UpdateChecklistCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/UpdateChecklistCommandValidator.cs @@ -19,10 +19,10 @@ public UpdateChecklistCommandValidator() RuleFor(x => x.ChecklistItems) .Must(items => items is null || items.Count <= AppConstants.MaxChecklistItems) - .WithMessage($"A checklist can have at most {AppConstants.MaxChecklistItems} items"); + .WithCopy(ValidationErrorCodes.ChecklistItemLimit); RuleFor(x => x.ChecklistItems) .Must(items => items is null || items.All(i => i.Text.Length <= AppConstants.MaxChecklistItemTextLength)) - .WithMessage($"Checklist item text must not exceed {AppConstants.MaxChecklistItemTextLength} characters"); + .WithCopy(ValidationErrorCodes.ChecklistItemLength); } } diff --git a/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs index 8eba6d7a..ab524c3c 100644 --- a/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs @@ -51,7 +51,7 @@ public UpdateHabitCommandValidator(IGenericRepository habitRepository) RuleFor(x => x.FrequencyQuantity) .NotNull() - .WithMessage("Frequency quantity is required when frequency unit is set") + .WithCopy(ValidationErrorCodes.FrequencyQuantityRequired) .When(x => x.FrequencyUnit is not null); SharedHabitRules.AddIntervalWeeksRules(RuleFor(x => x.IntervalWeeks)); @@ -75,7 +75,7 @@ public UpdateHabitCommandValidator(IGenericRepository habitRepository) RuleFor(x => x.IsBadHabit) .Equal(false) .When(x => x.IsGeneral == true) - .WithMessage("General habits cannot be bad habits"); + .WithCopy(ValidationErrorCodes.GeneralHabitNotBad); When(x => x.Options is not null, () => { @@ -94,9 +94,9 @@ public TitleValidator(bool isChild) { SharedHabitRules.AddTitleRules( RuleFor(x => x.Title), - requiredMessage: isChild ? "Sub-habit title must not be empty" : null, - maximumLengthMessage: isChild - ? $"Sub-habit title must not exceed {AppConstants.MaxHabitTitleLength} characters" + requiredCode: isChild ? ValidationErrorCodes.SubHabitTitleRequired : null, + maximumLengthCode: isChild + ? ValidationErrorCodes.SubHabitTitleLength : null); } } diff --git a/src/Orbit.Application/Profile/Validators/ApplyOnboardingCommandValidator.cs b/src/Orbit.Application/Profile/Validators/ApplyOnboardingCommandValidator.cs index 96bf1ed3..a6a8d99d 100644 --- a/src/Orbit.Application/Profile/Validators/ApplyOnboardingCommandValidator.cs +++ b/src/Orbit.Application/Profile/Validators/ApplyOnboardingCommandValidator.cs @@ -20,7 +20,7 @@ public ApplyHabitInputValidator() RuleFor(x => x.FrequencyQuantity) .NotNull() - .WithMessage("Frequency quantity is required when frequency unit is set") + .WithCopy(ValidationErrorCodes.FrequencyQuantityRequired) .When(x => x.FrequencyUnit is not null); SharedHabitRules.AddIntervalWeeksRules(RuleFor(x => x.IntervalWeeks)); @@ -57,6 +57,6 @@ public ApplyOnboardingCommandValidator() When(x => x.WeekStartDay is not null, () => RuleFor(x => x.WeekStartDay!.Value).Must(day => day is 0 or 1) - .WithMessage("Week start day must be 0 (Sunday) or 1 (Monday).")); + .WithCopy(ValidationErrorCodes.WeekStartOnboarding)); } } diff --git a/src/Orbit.Application/Profile/Validators/SetHandleCommandValidator.cs b/src/Orbit.Application/Profile/Validators/SetHandleCommandValidator.cs index bc489b36..7c3e7c40 100644 --- a/src/Orbit.Application/Profile/Validators/SetHandleCommandValidator.cs +++ b/src/Orbit.Application/Profile/Validators/SetHandleCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Profile.Commands; @@ -11,6 +12,6 @@ public SetHandleCommandValidator() RuleFor(x => x.Handle) .NotEmpty() .Matches("^[A-Za-z0-9_]{3,20}$") - .WithMessage("Handle must be 3-20 characters using only letters, numbers, or underscores."); + .WithCopy(ValidationErrorCodes.HandleFormat); } } diff --git a/src/Orbit.Application/Profile/Validators/SetLanguageCommandValidator.cs b/src/Orbit.Application/Profile/Validators/SetLanguageCommandValidator.cs index c080e759..e42f27ba 100644 --- a/src/Orbit.Application/Profile/Validators/SetLanguageCommandValidator.cs +++ b/src/Orbit.Application/Profile/Validators/SetLanguageCommandValidator.cs @@ -15,6 +15,6 @@ public SetLanguageCommandValidator() .NotEmpty() .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Profile/Validators/SetNameCommandValidator.cs b/src/Orbit.Application/Profile/Validators/SetNameCommandValidator.cs index 9e135f09..965237f8 100644 --- a/src/Orbit.Application/Profile/Validators/SetNameCommandValidator.cs +++ b/src/Orbit.Application/Profile/Validators/SetNameCommandValidator.cs @@ -14,6 +14,6 @@ public SetNameCommandValidator() RuleFor(x => x.Name) .NotEmpty() .Must(name => name is null || name.Trim().Length <= AppConstants.MaxUserNameLength) - .WithMessage($"Name must be at most {AppConstants.MaxUserNameLength} characters"); + .WithCopy(ValidationErrorCodes.ProfileNameLength); } } diff --git a/src/Orbit.Application/Profile/Validators/SetWeekStartDayCommandValidator.cs b/src/Orbit.Application/Profile/Validators/SetWeekStartDayCommandValidator.cs index 415eb0cc..d50251f6 100644 --- a/src/Orbit.Application/Profile/Validators/SetWeekStartDayCommandValidator.cs +++ b/src/Orbit.Application/Profile/Validators/SetWeekStartDayCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Profile.Commands; @@ -10,6 +11,6 @@ public SetWeekStartDayCommandValidator() RuleFor(x => x.UserId).NotEmpty(); RuleFor(x => x.WeekStartDay) .InclusiveBetween(0, 1) - .WithMessage("WeekStartDay must be 0 (Sunday) or 1 (Monday)."); + .WithCopy(ValidationErrorCodes.WeekStartDay); } } diff --git a/src/Orbit.Application/Social/Validators/GetCheersQueryValidator.cs b/src/Orbit.Application/Social/Validators/GetCheersQueryValidator.cs index 373729f9..b8b59e61 100644 --- a/src/Orbit.Application/Social/Validators/GetCheersQueryValidator.cs +++ b/src/Orbit.Application/Social/Validators/GetCheersQueryValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Social.Queries; @@ -12,6 +13,6 @@ public GetCheersQueryValidator() RuleFor(x => x.UserId).NotEmpty(); RuleFor(x => x.Direction) .Must(direction => AllowedDirections.Contains(direction)) - .WithMessage("Direction must be 'received' or 'sent'."); + .WithCopy(ValidationErrorCodes.CheersDirection); } } diff --git a/src/Orbit.Application/Social/Validators/GetFriendFeedQueryValidator.cs b/src/Orbit.Application/Social/Validators/GetFriendFeedQueryValidator.cs index 3a8d68d7..9c27ba03 100644 --- a/src/Orbit.Application/Social/Validators/GetFriendFeedQueryValidator.cs +++ b/src/Orbit.Application/Social/Validators/GetFriendFeedQueryValidator.cs @@ -16,6 +16,6 @@ public GetFriendFeedQueryValidator() When(x => !string.IsNullOrEmpty(x.Cursor), () => RuleFor(x => x.Cursor!) .Must(cursor => FeedCursor.TryDecode(cursor, out _, out _)) - .WithMessage("Cursor is malformed.")); + .WithCopy(ValidationErrorCodes.FeedCursorFormat)); } } diff --git a/src/Orbit.Application/Social/Validators/SendFriendRequestCommandValidator.cs b/src/Orbit.Application/Social/Validators/SendFriendRequestCommandValidator.cs index 48b2f3d7..295941fc 100644 --- a/src/Orbit.Application/Social/Validators/SendFriendRequestCommandValidator.cs +++ b/src/Orbit.Application/Social/Validators/SendFriendRequestCommandValidator.cs @@ -15,7 +15,7 @@ public SendFriendRequestCommandValidator() RuleFor(x => x) .Must(HaveExactlyOneIdentifier) - .WithMessage("Provide exactly one of handle or referralCode."); + .WithCopy(ValidationErrorCodes.FriendIdentifierExclusive); } private static bool HaveExactlyOneIdentifier(SendFriendRequestCommand command) => diff --git a/src/Orbit.Application/Subscriptions/Validators/CreateCheckoutCommandValidator.cs b/src/Orbit.Application/Subscriptions/Validators/CreateCheckoutCommandValidator.cs index 3f7422ad..5792e43e 100644 --- a/src/Orbit.Application/Subscriptions/Validators/CreateCheckoutCommandValidator.cs +++ b/src/Orbit.Application/Subscriptions/Validators/CreateCheckoutCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Subscriptions.Commands; @@ -14,8 +15,8 @@ public CreateCheckoutCommandValidator() RuleFor(x => x.Interval) .NotEmpty() - .WithMessage("Billing interval is required.") + .WithCopy(ValidationErrorCodes.BillingIntervalRequired) .Must(interval => AllowedIntervals.Contains(interval?.ToLower())) - .WithMessage("Billing interval must be 'monthly' or 'yearly'."); + .WithCopy(ValidationErrorCodes.BillingIntervalSupported); } } diff --git a/src/Orbit.Application/Tags/Validators/AssignTagsCommandValidator.cs b/src/Orbit.Application/Tags/Validators/AssignTagsCommandValidator.cs index 27b8e147..26e7843f 100644 --- a/src/Orbit.Application/Tags/Validators/AssignTagsCommandValidator.cs +++ b/src/Orbit.Application/Tags/Validators/AssignTagsCommandValidator.cs @@ -19,6 +19,6 @@ public AssignTagsCommandValidator() RuleFor(x => x.TagIds) .Must(tags => tags is null || tags.Count <= AppConstants.MaxTagsPerHabit) - .WithMessage($"A habit can have at most {AppConstants.MaxTagsPerHabit} tags"); + .WithCopy(ValidationErrorCodes.HabitTagLimit); } } diff --git a/src/Orbit.Application/Tags/Validators/CreateTagCommandValidator.cs b/src/Orbit.Application/Tags/Validators/CreateTagCommandValidator.cs index 98d8b0e0..05fcecca 100644 --- a/src/Orbit.Application/Tags/Validators/CreateTagCommandValidator.cs +++ b/src/Orbit.Application/Tags/Validators/CreateTagCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Tags.Commands; @@ -17,6 +18,6 @@ public CreateTagCommandValidator() RuleFor(x => x.Color) .NotEmpty() .Matches(@"^#[0-9A-Fa-f]{6}$") - .WithMessage("Color must be a valid hex color (e.g. #FF5733)"); + .WithCopy(ValidationErrorCodes.TagColorFormat); } } diff --git a/src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs b/src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs index e0cf416c..76680d5c 100644 --- a/src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs +++ b/src/Orbit.Application/Tags/Validators/SuggestTagsQueryValidator.cs @@ -22,6 +22,6 @@ public SuggestTagsQueryValidator() .NotEmpty() .MaximumLength(AppConstants.MaxLanguageLength) .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) - .WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}"); + .WithCopy(ValidationErrorCodes.LanguageSupported); } } diff --git a/src/Orbit.Application/Tags/Validators/UpdateTagCommandValidator.cs b/src/Orbit.Application/Tags/Validators/UpdateTagCommandValidator.cs index e7e3dd52..c1169ff0 100644 --- a/src/Orbit.Application/Tags/Validators/UpdateTagCommandValidator.cs +++ b/src/Orbit.Application/Tags/Validators/UpdateTagCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Tags.Commands; @@ -20,6 +21,6 @@ public UpdateTagCommandValidator() RuleFor(x => x.Color) .NotEmpty() .Matches(@"^#[0-9A-Fa-f]{6}$") - .WithMessage("Color must be a valid hex color (e.g. #FF5733)"); + .WithCopy(ValidationErrorCodes.TagColorFormat); } } diff --git a/src/Orbit.Application/Uploads/Validators/SignUploadValidator.cs b/src/Orbit.Application/Uploads/Validators/SignUploadValidator.cs index b6a54833..25800dc5 100644 --- a/src/Orbit.Application/Uploads/Validators/SignUploadValidator.cs +++ b/src/Orbit.Application/Uploads/Validators/SignUploadValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Uploads.Commands; using Orbit.Application.Uploads.Common; @@ -13,14 +14,14 @@ public SignUploadValidator() RuleFor(x => x.ContentType) .NotEmpty() - .WithMessage("Content type is required.") + .WithCopy(ValidationErrorCodes.UploadContentTypeRequired) .Must(UploadContentTypes.IsAllowed) - .WithMessage($"Content type must be one of: {string.Join(", ", UploadContentTypes.Allowed)}."); + .WithCopy(ValidationErrorCodes.UploadContentTypeSupported); RuleFor(x => x.SizeBytes) .GreaterThan(0) - .WithMessage("File size must be greater than zero.") + .WithCopy(ValidationErrorCodes.UploadSizePositive) .LessThanOrEqualTo(UploadContentTypes.MaxSizeBytes) - .WithMessage($"File size must be {UploadContentTypes.MaxSizeBytes} bytes or less."); + .WithCopy(ValidationErrorCodes.UploadSizeLimit); } } diff --git a/src/Orbit.Application/UserFacts/Validators/BulkDeleteUserFactsCommandValidator.cs b/src/Orbit.Application/UserFacts/Validators/BulkDeleteUserFactsCommandValidator.cs index d17f3564..7bce7e59 100644 --- a/src/Orbit.Application/UserFacts/Validators/BulkDeleteUserFactsCommandValidator.cs +++ b/src/Orbit.Application/UserFacts/Validators/BulkDeleteUserFactsCommandValidator.cs @@ -13,12 +13,12 @@ public BulkDeleteUserFactsCommandValidator() RuleFor(x => x.FactIds) .NotEmpty() - .WithMessage("FactIds list must not be empty") + .WithCopy(ValidationErrorCodes.FactIdsRequired) .Must(ids => ids.Count <= AppConstants.MaxBulkOperationSize) - .WithMessage($"Cannot delete more than {AppConstants.MaxBulkOperationSize} facts at once"); + .WithCopy(ValidationErrorCodes.BulkDeleteFactLimit); RuleForEach(x => x.FactIds) .NotEmpty() - .WithMessage("Fact ID must not be empty"); + .WithCopy(ValidationErrorCodes.FactIdRequired); } } diff --git a/src/Orbit.Application/Waitlist/Validators/JoinWaitlistCommandValidator.cs b/src/Orbit.Application/Waitlist/Validators/JoinWaitlistCommandValidator.cs index dcf6482d..15c18274 100644 --- a/src/Orbit.Application/Waitlist/Validators/JoinWaitlistCommandValidator.cs +++ b/src/Orbit.Application/Waitlist/Validators/JoinWaitlistCommandValidator.cs @@ -1,3 +1,4 @@ +using Orbit.Application.Common; using FluentValidation; using Orbit.Application.Waitlist.Commands; @@ -14,6 +15,6 @@ public JoinWaitlistCommandValidator() RuleFor(x => x.Language) .Must(language => WaitlistLanguage.TryCanonicalize(language, out _)) - .WithMessage("Language must be one of: en, pt-BR."); + .WithCopy(ValidationErrorCodes.WaitlistLanguageSupported); } } diff --git a/tests/Orbit.Application.Tests/Common/ErrorCopyTests.cs b/tests/Orbit.Application.Tests/Common/ErrorCopyTests.cs index 589e0c73..42d0c061 100644 --- a/tests/Orbit.Application.Tests/Common/ErrorCopyTests.cs +++ b/tests/Orbit.Application.Tests/Common/ErrorCopyTests.cs @@ -25,7 +25,7 @@ public class ErrorCopyTests private static readonly string EnDash = ((char)0x2013).ToString(); private static readonly Regex BareCodePattern = new("[A-Z]{2,}_[A-Z_]+", RegexOptions.None, TimeSpan.FromSeconds(1)); - private static readonly Regex PlaceholderPattern = new(@"\{\d+\}", RegexOptions.None, TimeSpan.FromSeconds(1)); + private static readonly Regex PlaceholderPattern = new(@"\{[A-Za-z0-9]+\}", RegexOptions.None, TimeSpan.FromSeconds(1)); private static IEnumerable DeclaredErrorCodes() => typeof(ErrorCodes) diff --git a/tests/Orbit.Application.Tests/Common/ValidationCopyTests.cs b/tests/Orbit.Application.Tests/Common/ValidationCopyTests.cs new file mode 100644 index 00000000..21f66eda --- /dev/null +++ b/tests/Orbit.Application.Tests/Common/ValidationCopyTests.cs @@ -0,0 +1,49 @@ +using System.Reflection; +using FluentAssertions; +using FluentValidation; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Habits.Validators; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Common; + +public class ValidationCopyTests +{ + [Fact] + public void EveryDeclaredValidationCodeHasCopyInBothLanguages() + { + foreach (var field in typeof(ValidationErrorCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var code = (string)field.GetRawConstantValue()!; + ErrorCopy.TryResolve(code, false, [], out var english).Should().BeTrue(code); + ErrorCopy.TryResolve(code, true, [], out var portuguese).Should().BeTrue(code); + english.Should().NotBeNullOrWhiteSpace(); + portuguese.Should().NotBeNullOrWhiteSpace().And.NotBe(english); + } + } + + [Fact] + public void EveryValidatorRuleHasACatalogCode() + { + var validators = typeof(ValidationErrorCodes).Assembly.GetExportedTypes() + .Where(type => !type.IsAbstract && typeof(IValidator).IsAssignableFrom(type)); + + foreach (var type in validators) + { + var validator = type == typeof(UpdateHabitCommandValidator) + ? new UpdateHabitCommandValidator(Substitute.For>()) + : (IValidator)(Activator.CreateInstance(type) + ?? throw new InvalidOperationException($"Cannot create validator: {type.Name}")); + foreach (var component in validator.CreateDescriptor().GetMembersWithValidators().SelectMany(g => g)) + { + if (component.Item1.Name == "ChildValidatorAdaptor") + continue; + + var code = component.Item2.ErrorCode ?? component.Item1.Name; + ErrorCopy.All.Should().ContainKey(code, $"{type.Name} must not fall back to raw validation copy"); + } + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs b/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs index 44bf4a50..527a27a0 100644 --- a/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Middleware/RequestObservabilityMiddlewareTests.cs @@ -2,6 +2,10 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; using Orbit.Api.Extensions; using Orbit.Api.Middleware; using Orbit.Application.Common; @@ -17,6 +21,10 @@ private static async Task RunCorrelationAsync() var context = new DefaultHttpContext(); context.Request.Headers[HttpContextExtensions.RequestIdHeaderName] = IncomingRequestId; context.Response.Body = new MemoryStream(); + context.RequestServices = new ServiceCollection() + .AddSingleton( + new RequestLanguageResolver(Substitute.For>())) + .BuildServiceProvider(); var correlation = new RequestCorrelationMiddleware(_ => Task.CompletedTask); await correlation.InvokeAsync(context); diff --git a/tests/Orbit.Infrastructure.Tests/Middleware/ValidationExceptionHandlerTests.cs b/tests/Orbit.Infrastructure.Tests/Middleware/ValidationExceptionHandlerTests.cs index ae87e6e8..1897ee5a 100644 --- a/tests/Orbit.Infrastructure.Tests/Middleware/ValidationExceptionHandlerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Middleware/ValidationExceptionHandlerTests.cs @@ -4,8 +4,14 @@ using FluentValidation.Results; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; using Orbit.Api.Extensions; using Orbit.Api.Middleware; +using Orbit.Application.Auth.Commands; +using Orbit.Application.Auth.Validators; namespace Orbit.Infrastructure.Tests.Middleware; @@ -23,6 +29,10 @@ private static DefaultHttpContext CreateContext(string requestId) { var context = new DefaultHttpContext { TraceIdentifier = requestId }; context.Response.Body = new MemoryStream(); + context.RequestServices = new ServiceCollection() + .AddSingleton( + new RequestLanguageResolver(Substitute.For>())) + .BuildServiceProvider(); return context; } @@ -57,6 +67,22 @@ public async Task TryHandleAsync_ValidationException_Writes400JsonEnvelopeWithGr .Should().ContainSingle().Which.Should().Be("Name is required"); } + [Fact] + public async Task PortugueseRequest_LocalizesTheRealFiveDigitCodeFailure() + { + var context = CreateContext("trace-pt-br"); + context.Request.Headers.AcceptLanguage = "pt-BR"; + var result = await new VerifyCodeCommandValidator() + .ValidateAsync(new VerifyCodeCommand("reader@example.com", "12345")); + + await CreateHandler().TryHandleAsync( + context, new ValidationException(result.Errors), CancellationToken.None); + + var root = await ReadJsonAsync(context); + root.GetProperty("errors").GetProperty("Code")[1].GetString() + .Should().Be("O código deve ter 6 dígitos"); + } + [Fact] public async Task TryHandleAsync_NonValidationException_ReturnsFalseAndLeavesResponseUntouched() { diff --git a/tests/Orbit.Infrastructure.Tests/Middleware/ValidationLocalizationTests.cs b/tests/Orbit.Infrastructure.Tests/Middleware/ValidationLocalizationTests.cs new file mode 100644 index 00000000..382dbcf8 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Middleware/ValidationLocalizationTests.cs @@ -0,0 +1,232 @@ +using System.Security.Claims; +using System.Text.Json; +using Xunit.Abstractions; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Orbit.Api.Middleware; +using Orbit.Application.Common; +using Orbit.Application.Uploads.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Application.Accountability.Commands; +using Orbit.Application.Accountability.Validators; +using Orbit.Application.ApiKeys.Commands; +using Orbit.Application.ApiKeys.Validators; +using Orbit.Application.Auth.Commands; +using Orbit.Application.Auth.Validators; +using Orbit.Application.Calendar.Commands; +using Orbit.Application.Calendar.Validators; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Challenges.Validators; +using Orbit.Application.Chat.Commands; +using Orbit.Application.Chat.Validators; +using Orbit.Application.Chat.Models; +using Orbit.Application.ChecklistTemplates.Commands; +using Orbit.Application.ChecklistTemplates.Validators; +using Orbit.Application.Email.Commands; +using Orbit.Application.Email.Validators; +using Orbit.Application.Gamification.Commands; +using Orbit.Application.Gamification.Validators; +using Orbit.Application.Goals.Commands; +using Orbit.Application.Goals.Validators; +using Orbit.Application.Habits.Commands; +using Orbit.Application.Habits.Validators; +using Orbit.Application.Marketing.Commands; +using Orbit.Application.Marketing.Validators; +using Orbit.Application.Notifications.Commands; +using Orbit.Application.Notifications.Validators; +using Orbit.Application.Profile.Commands; +using Orbit.Application.Profile.Validators; +using Orbit.Application.Referrals.Commands; +using Orbit.Application.Referrals.Validators; +using Orbit.Application.Social.Queries; +using Orbit.Application.Social.Validators; +using Orbit.Application.Subscriptions.Commands; +using Orbit.Application.Subscriptions.Validators; +using Orbit.Application.Support.Commands; +using Orbit.Application.Support.Validators; +using Orbit.Application.Tags.Commands; +using Orbit.Application.Tags.Validators; +using Orbit.Application.Uploads.Commands; +using Orbit.Application.Uploads.Validators; +using Orbit.Application.UserFacts.Commands; +using Orbit.Application.UserFacts.Validators; +using Orbit.Application.Waitlist.Commands; +using Orbit.Application.Waitlist.Validators; + +namespace Orbit.Infrastructure.Tests.Middleware; + +public class ValidationLocalizationTests(ITestOutputHelper output) +{ + [Theory] + [InlineData("Accountability")] + [InlineData("ApiKeys")] + [InlineData("Auth")] + [InlineData("Calendar")] + [InlineData("Challenges")] + [InlineData("Chat")] + [InlineData("ChecklistTemplates")] + [InlineData("Email")] + [InlineData("Gamification")] + [InlineData("Goals")] + [InlineData("Habits")] + [InlineData("Marketing")] + [InlineData("Notifications")] + [InlineData("Profile")] + [InlineData("Referrals")] + [InlineData("Social")] + [InlineData("Subscriptions")] + [InlineData("Support")] + [InlineData("Tags")] + [InlineData("Uploads")] + [InlineData("UserFacts")] + [InlineData("Waitlist")] + public async Task EveryValidatorFamily_ReturnsPortugueseCopyAndAlignedCodes(string family) + { + var (result, property, code, portuguese) = FailureFor(family); + output.WriteLine(JsonSerializer.Serialize(result.Errors)); + result.Errors.Should().Contain(f => f.PropertyName == property && f.ErrorCode == code); + + var englishBody = await HandleAsync(result, "en"); + var portugueseBody = await HandleAsync(result, "pt-BR"); + var translated = portugueseBody.GetProperty("errorDetails").GetProperty(property) + .EnumerateArray().Single(e => e.GetProperty("code").GetString() == code); + translated.GetProperty("message").GetString().Should().Be(portuguese); + + foreach (var group in result.Errors.GroupBy(f => f.PropertyName)) + { + var english = englishBody.GetProperty("errors").GetProperty(group.Key); + var localized = portugueseBody.GetProperty("errors").GetProperty(group.Key); + var details = portugueseBody.GetProperty("errorDetails").GetProperty(group.Key); + var englishDetails = englishBody.GetProperty("errorDetails").GetProperty(group.Key); + english.EnumerateArray().Select(e => e.GetString()).Should().Equal(group.Select(f => f.ErrorMessage)); + localized.GetArrayLength().Should().Be(group.Count()); + details.EnumerateArray().Select(e => e.GetProperty("code").GetString()) + .Should().Equal(group.Select(f => f.ErrorCode)); + englishDetails.EnumerateArray().Select(e => e.GetProperty("code").GetString()) + .Should().Equal(group.Select(f => f.ErrorCode)); + details.EnumerateArray().Select(e => e.GetProperty("message").GetString()) + .Should().Equal(localized.EnumerateArray().Select(e => e.GetString())); + for (var i = 0; i < group.Count(); i++) + { + localized[i].GetString().Should().NotBe(english[i].GetString()); + localized[i].GetString().Should().NotMatchRegex(@"\{[A-Za-z0-9]+\}"); + } + } + } + + [Theory] + [InlineData(null, "pt-BR", "O código deve ter 6 dígitos")] + [InlineData("en", "pt-BR", "O código deve ter 6 dígitos")] + [InlineData("pt-BR", "en", "Code must be a 6-digit number")] + [InlineData("pt-BR", null, "O código deve ter 6 dígitos")] + [InlineData(null, null, "Code must be a 6-digit number")] + public async Task StoredLanguageAndHeader_UseTheDomainResolverPrecedence( + string? header, string? storedLanguage, string expected) + { + var (result, _, _, _) = FailureFor("Auth"); + var body = await HandleAsync(result, header, storedLanguage, authenticated: true); + + body.GetProperty("errors").GetProperty("Code")[1].GetString().Should().Be(expected); + } + + [Fact] + public async Task FluentValidationPlaceholders_SurviveLocalization() + { + var id = Guid.NewGuid(); + var key = new CreateApiKeyValidator().Validate(new CreateApiKeyCommand(id, "Key", ["unknown:scope"])); + var keyBody = await HandleAsync(key, "pt-BR"); + keyBody.GetProperty("errors").GetProperty("Scopes[0]")[0].GetString() + .Should().Be("A permissão 'unknown:scope' da chave de API não é reconhecida."); + + var code = new VerifyCodeCommandValidator().Validate(new VerifyCodeCommand("reader@example.com", "12345")); + var codeBody = await HandleAsync(code, "pt-BR"); + codeBody.GetProperty("errors").GetProperty("Code")[0].GetString() + .Should().Be("'Code' deve ter exatamente 6 caracteres. Você digitou 5 caracteres."); + } + + [Fact] + public async Task CountedCopy_UsesTheArgumentsFromTheRealValidator() + { + var request = new ResolveClarificationRequest(new string('x', AppConstants.MaxClarificationValueLength + 1)); + var result = new ResolveClarificationRequestValidator().Validate(request); + var body = await HandleAsync(result, "pt-BR"); + + body.GetProperty("errors").GetProperty("Value")[0].GetString() + .Should().Contain(AppConstants.MaxClarificationValueLength.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .And.NotContain("{0}"); + body.GetProperty("errorDetails").GetProperty("Value")[0].GetProperty("code").GetString() + .Should().Be(ErrorMessages.ClarificationValueTooLong.Code); + } + + private static (ValidationResult Result, string Property, string Code, string Portuguese) Case( + IValidator validator, T request, string property, string code, string portuguese) => + (validator.Validate(request), property, code, portuguese); + + private static (ValidationResult Result, string Property, string Code, string Portuguese) FailureFor(string family) + { + var id = Guid.NewGuid(); + return family switch + { + "Accountability" => Case(new SetAccountabilityHabitsCommandValidator(), new SetAccountabilityHabitsCommand(id, id, [Guid.Empty]), "HabitIds", ValidationErrorCodes.HabitIdsRequired, "Os IDs dos hábitos não podem estar vazios."), + "ApiKeys" => Case(new CreateApiKeyValidator(), new CreateApiKeyCommand(id, ""), "Name", ValidationErrorCodes.ApiKeyNameRequired, "O nome da chave de API é obrigatório."), + "Auth" => Case(new VerifyCodeCommandValidator(), new VerifyCodeCommand("reader@example.com", "12345"), "Code", ValidationErrorCodes.VerificationCodeFormat, "O código deve ter 6 dígitos"), + "Calendar" => Case(new SetSelectedCalendarsCommandValidator(), new SetSelectedCalendarsCommand(id, [""]), "CalendarIds[0]", ValidationErrorCodes.CalendarIdsRequired, "Os IDs das agendas não podem estar vazios."), + "Challenges" => Case(new SetChallengeHabitsCommandValidator(), new SetChallengeHabitsCommand(id, id, []), "HabitIds", ValidationErrorCodes.ChallengeHabitsRequired, "Vincule pelo menos um dos seus hábitos ao desafio."), + "Chat" => Case(new TranscribeAudioCommandValidator(), new TranscribeAudioCommand(id, [1], "voice.xyz"), "FileName", ValidationErrorCodes.AudioFormat, "O formato de áudio '.xyz' não é suportado."), + "ChecklistTemplates" => Case(new CreateChecklistTemplateCommandValidator(), new CreateChecklistTemplateCommand(id, "List", [""]), "Items[0]", ValidationErrorCodes.TemplateItemRequired, "Os itens do modelo de checklist não podem estar vazios."), + "Email" => Case(new ProcessSesEventCommandValidator(), new ProcessSesEventCommand(""), "Payload", "NotEmptyValidator", "'Payload' deve ser informado."), + "Gamification" => Case(new ReportEventCommandValidator(), new ReportEventCommand(id, "unknown"), "EventKey", ValidationErrorCodes.EventKeyKnown, "A chave do evento é desconhecida."), + "Goals" => Case(new LinkHabitsToGoalCommandValidator(), new LinkHabitsToGoalCommand(id, id, Enumerable.Repeat(id, AppConstants.MaxHabitsPerGoal + 1).ToArray()), "HabitIds", ValidationErrorCodes.GoalHabitLimit, $"Uma meta pode ter no máximo {AppConstants.MaxHabitsPerGoal} hábitos vinculados."), + "Habits" => Case(new CreateHabitCommandValidator(), new CreateHabitCommand(id, "Read", null, null, null, IsBadHabit: true, IsGeneral: true), "IsBadHabit", ValidationErrorCodes.GeneralHabitNotBad, "Hábitos gerais não podem ser hábitos ruins"), + "Marketing" => Case(new SendMarketingBroadcastCommandValidator(), new SendMarketingBroadcastCommand("", "Subject", "Body", "Body", null), "SubjectEn", "NotEmptyValidator", "'Subject En' deve ser informado."), + "Notifications" => Case(new UnsubscribePushCommandValidator(), new UnsubscribePushCommand(id, ""), "Endpoint", "NotEmptyValidator", "'Endpoint' deve ser informado."), + "Profile" => Case(new SetLanguageCommandValidator(), new SetLanguageCommand(id, "fr"), "Language", ValidationErrorCodes.LanguageSupported, "O idioma deve ser um destes: en, pt-BR"), + "Referrals" => Case(new ProcessReferralCodeCommandValidator(), new ProcessReferralCodeCommand(id, ""), "ReferralCode", "NotEmptyValidator", "'Referral Code' deve ser informado."), + "Social" => Case(new GetCheersQueryValidator(), new GetCheersQuery(id, "unknown"), "Direction", ValidationErrorCodes.CheersDirection, "A direção deve ser 'received' ou 'sent'."), + "Subscriptions" => Case(new CreateCheckoutCommandValidator(), new CreateCheckoutCommand(id, "weekly", null, null), "Interval", ValidationErrorCodes.BillingIntervalSupported, "O intervalo de cobrança deve ser 'monthly' ou 'yearly'."), + "Support" => Case(new SendSupportCommandValidator(), new SendSupportCommand(id, "Reader", "reader@example.com", "", "Help"), "Subject", "NotEmptyValidator", "'Subject' deve ser informado."), + "Tags" => Case(new CreateTagCommandValidator(), new CreateTagCommand(id, "Read", "invalid"), "Color", ValidationErrorCodes.TagColorFormat, "A cor deve ser um código hexadecimal válido (por exemplo, #FF5733)"), + "Uploads" => Case(new SignUploadValidator(), new SignUploadCommand(id, "invalid", 1), "ContentType", ValidationErrorCodes.UploadContentTypeSupported, $"O tipo de conteúdo deve ser um destes: {string.Join(", ", UploadContentTypes.Allowed)}."), + "UserFacts" => Case(new BulkDeleteUserFactsCommandValidator(), new BulkDeleteUserFactsCommand(id, []), "FactIds", ValidationErrorCodes.FactIdsRequired, "A lista de IDs dos fatos não pode estar vazia"), + "Waitlist" => Case(new JoinWaitlistCommandValidator(), new JoinWaitlistCommand("reader@example.com", "fr"), "Language", ValidationErrorCodes.WaitlistLanguageSupported, "O idioma deve ser um destes: en, pt-BR."), + _ => throw new ArgumentOutOfRangeException(nameof(family)) + }; + } + + private static async Task HandleAsync( + ValidationResult result, string? header, string? storedLanguage = null, bool authenticated = false) + { + var repository = Substitute.For>(); + var context = new DefaultHttpContext { TraceIdentifier = "validation-localization" }; + context.Response.Body = new MemoryStream(); + if (header is not null) + context.Request.Headers.AcceptLanguage = header; + if (authenticated) + { + var user = User.Create("Reader", "reader@example.com").Value; + user.SetLanguage(storedLanguage); + repository.GetByIdAsync(user.Id, Arg.Any()).Returns(user); + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], "Test")); + } + + using var services = new ServiceCollection() + .AddSingleton(new RequestLanguageResolver(repository)) + .BuildServiceProvider(); + context.RequestServices = services; + + await new ValidationExceptionHandler(NullLogger.Instance) + .TryHandleAsync(context, new ValidationException(result.Errors), CancellationToken.None); + + context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + context.Response.Body.Position = 0; + using var document = await JsonDocument.ParseAsync(context.Response.Body); + return document.RootElement.Clone(); + } +}